use std::path::{Component, Path};
use serde_json::Value;
use crate::DispatchServiceError;
const WRITE_TOOLS: &[&str] = &["Write", "Edit", "apply_patch"];
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct WriteTarget {
pub(crate) path: String,
pub(crate) inside_workspace: bool,
}
pub(crate) fn derive_write_paths(
workspace_root: &Path,
tool_name: Option<&str>,
tool_input: Option<&Value>,
exact_root: bool,
) -> Result<Vec<WriteTarget>, DispatchServiceError> {
let Some(tool_name) = tool_name else {
if tool_input.is_some() {
return Err(invalid("tool_input requires tool_name"));
}
return Ok(Vec::new());
};
if tool_name == "Bash" {
if exact_root {
return Ok(Vec::new());
}
return Err(invalid(
"opaque Bash effects cannot receive native write-path authority without shell-text inference",
));
}
if !WRITE_TOOLS.contains(&tool_name) {
if tool_input.and_then(Value::as_object).is_some_and(|input| {
["file_path", "path", "command"]
.iter()
.any(|key| input.contains_key(*key))
}) {
return Err(invalid(format!(
"cannot classify write targets for unknown tool `{tool_name}`"
)));
}
return Ok(Vec::new());
}
let input = tool_input.ok_or_else(|| invalid("write tool input is required"))?;
let freeform_patch = if tool_name == "apply_patch" {
input.as_str()
} else {
None
};
let input = match input.as_object() {
Some(input) => {
if tool_name == "apply_patch" {
const APPLY_PATCH_FIELDS: &[&str] =
&["file_path", "path", "patch", "input", "operation"];
if let Some(field) = input
.keys()
.find(|field| !APPLY_PATCH_FIELDS.contains(&field.as_str()))
{
return Err(invalid(format!(
"apply_patch input contains unknown field `{field}`"
)));
}
if input.contains_key("patch") && input.contains_key("input") {
return Err(invalid(
"apply_patch input must not contain both `patch` and `input`",
));
}
}
Some(input)
}
None if freeform_patch.is_some() => None,
None => return Err(invalid("write tool input must be an object")),
};
let mut raw = Vec::new();
if let Some(input) = input {
for key in ["file_path", "path"] {
if let Some(value) = input.get(key) {
raw.push(
value
.as_str()
.filter(|value| !value.is_empty())
.ok_or_else(|| invalid(format!("tool_input.{key} must be a string")))?,
);
}
}
}
let object_patch = input
.and_then(|input| input.get("patch").or_else(|| input.get("input")))
.map(|value| {
value
.as_str()
.ok_or_else(|| invalid("apply_patch input must be a string"))
})
.transpose()?;
if let Some(patch) = freeform_patch.or(object_patch) {
raw.extend(extract_patch_paths(patch)?);
}
raw.sort_unstable();
raw.dedup();
if raw.is_empty() {
return Err(invalid(format!(
"cannot derive native write path from `{tool_name}` input"
)));
}
let mut paths = Vec::with_capacity(raw.len());
for candidate in raw {
match normalize_relative(workspace_root, candidate)? {
Some(relative) => {
verify_nofollow(workspace_root, &relative)?;
paths.push(WriteTarget {
path: relative,
inside_workspace: true,
});
}
None => paths.push(WriteTarget {
path: candidate.to_owned(),
inside_workspace: false,
}),
}
}
paths.sort_by(|left, right| left.path.cmp(&right.path));
paths.dedup();
Ok(paths)
}
fn extract_patch_paths(patch: &str) -> Result<Vec<&str>, DispatchServiceError> {
if patch.len() > 1_048_576 || patch.contains('\0') {
return Err(invalid("apply_patch input is unsafe or too large"));
}
let mut paths = Vec::new();
for line in patch.lines() {
for prefix in [
"*** Add File: ",
"*** Update File: ",
"*** Delete File: ",
"*** Move to: ",
] {
if let Some(path) = line.strip_prefix(prefix) {
if path.is_empty() || path.trim() != path {
return Err(invalid("apply_patch contains an invalid path header"));
}
paths.push(path);
}
}
}
if paths.is_empty() {
return Err(invalid("apply_patch contains no canonical file headers"));
}
Ok(paths)
}
fn normalize_relative(
workspace_root: &Path,
candidate: &str,
) -> Result<Option<String>, DispatchServiceError> {
let normalized;
let candidate = if cfg!(windows) {
normalized = candidate.replace('\\', "/");
normalized.as_str()
} else {
candidate
};
if candidate.len() > 4_096
|| (!cfg!(windows) && candidate.contains('\\'))
|| candidate.contains('\0')
|| candidate.chars().any(char::is_control)
{
return Err(invalid("write path is unsafe"));
}
let candidate = Path::new(candidate);
let resolved;
let relative = if candidate.is_absolute() {
resolved = crate::interface::canonical_identity(candidate);
let root = crate::interface::canonical_identity(workspace_root);
match resolved.strip_prefix(&root) {
Ok(relative) => relative,
Err(_) => return Ok(None),
}
} else {
candidate
};
let mut parts = Vec::new();
for component in relative.components() {
match component {
Component::Normal(part) => {
let value = part
.to_str()
.ok_or_else(|| invalid("write path must be UTF-8"))?;
if value.is_empty() || value == "." || value == ".." {
return Err(invalid("write path has an unsafe component"));
}
parts.push(value);
}
_ => {
return Err(invalid(
"write path must be normalized and repository-relative",
));
}
}
}
if parts.is_empty() {
return Err(invalid("write path cannot name the repository root"));
}
Ok(Some(parts.join("/")))
}
#[cfg(unix)]
fn verify_nofollow(workspace_root: &Path, relative: &str) -> Result<(), DispatchServiceError> {
use rustix::fs::{FileType, Mode, OFlags, open, openat};
let mut directory = open(
workspace_root,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(|error| {
invalid(format!(
"cannot open bound workspace without following links: {error}"
))
})?;
let parts: Vec<&str> = relative.split('/').collect();
for (index, part) in parts.iter().enumerate() {
let final_component = index + 1 == parts.len();
let flags = if final_component {
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW
} else {
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW
};
match openat(&directory, *part, flags, Mode::empty()) {
Ok(next) if final_component => {
let stat = rustix::fs::fstat(&next)
.map_err(|error| invalid(format!("cannot inspect write target: {error}")))?;
if !FileType::from_raw_mode(stat.st_mode).is_file() {
return Err(invalid("existing write target is not a regular file"));
}
}
Ok(next) => directory = next,
Err(error) if final_component && error == rustix::io::Errno::NOENT => return Ok(()),
Err(error) => {
return Err(invalid(format!(
"write target is not safely contained without following links: {error}"
)));
}
}
}
Ok(())
}
#[cfg(not(unix))]
fn verify_nofollow(workspace_root: &Path, relative: &str) -> Result<(), DispatchServiceError> {
if crate::safe_fs::is_link(workspace_root)
.map_err(|error| invalid(format!("cannot inspect bound workspace: {error}")))?
{
return Err(invalid(
"cannot open bound workspace without following links: it is a symlink",
));
}
let mut walked = workspace_root.to_path_buf();
let parts: Vec<&str> = relative.split('/').collect();
for (index, part) in parts.iter().enumerate() {
walked.push(part);
let final_component = index + 1 == parts.len();
let metadata = match std::fs::symlink_metadata(&walked) {
Ok(metadata) => metadata,
Err(error) if final_component && error.kind() == std::io::ErrorKind::NotFound => {
return Ok(());
}
Err(error) => {
return Err(invalid(format!(
"write target is not safely contained without following links at {}: {error}",
walked.display()
)));
}
};
if metadata.file_type().is_symlink() {
return Err(invalid(
"write target is not safely contained without following links: it traverses a symlink",
));
}
if final_component {
if !metadata.is_file() {
return Err(invalid("existing write target is not a regular file"));
}
} else if !metadata.is_dir() {
return Err(invalid(
"write target is not safely contained without following links: an intermediate component is not a directory",
));
}
}
Ok(())
}
fn invalid(reason: impl Into<String>) -> DispatchServiceError {
DispatchServiceError::InvalidRequest(reason.into())
}
#[cfg(test)]
mod tests {
use super::*;
fn inside(targets: Vec<WriteTarget>) -> Vec<String> {
targets
.into_iter()
.map(|target| {
assert!(
target.inside_workspace,
"unexpected out-of-workspace target"
);
target.path
})
.collect()
}
#[test]
fn apply_patch_preserves_object_and_freeform_payloads_but_rejects_unknown_fields() {
let root = std::env::temp_dir();
let path = format!("shepherd-apply-patch-{}.md", std::process::id());
let patch = format!("*** Begin Patch\n*** Update File: {path}\n*** End Patch");
assert_eq!(
derive_write_paths(
&root,
Some("apply_patch"),
Some(&serde_json::json!({"patch": patch})),
false,
)
.map(inside)
.expect("object payload"),
vec![path.clone()]
);
assert_eq!(
derive_write_paths(
&root,
Some("apply_patch"),
Some(&serde_json::Value::String(patch.clone())),
false,
)
.map(inside)
.expect("freeform payload"),
vec![path]
);
assert!(
derive_write_paths(
&root,
Some("apply_patch"),
Some(&serde_json::json!({"patch": patch, "untrusted": true})),
false,
)
.is_err(),
"unknown apply_patch fields must fail closed"
);
}
#[test]
fn opaque_bash_and_unclassifiable_write_tools_fail_closed() {
let root = std::env::temp_dir().join("shepherd-dispatch-scope-red");
assert!(
derive_write_paths(
&root,
Some("Bash"),
Some(&serde_json::json!({
"command": "printf unsafe"
})),
false,
)
.is_err()
);
assert!(
derive_write_paths(
&root,
Some("UnknownWrite"),
Some(&serde_json::json!({
"path": "docs/report.md"
})),
false,
)
.is_err()
);
}
#[test]
fn exact_root_bash_has_no_derived_paths_and_unknown_tools_still_fail_closed() {
let root = std::env::temp_dir().join("shepherd-dispatch-scope-root");
assert_eq!(
derive_write_paths(
&root,
Some("Bash"),
Some(&serde_json::json!({
"command": "printf text > docs/report.md && git status --short --branch"
})),
true,
)
.map(inside)
.expect("exact root Bash is authorized by native identity, not inferred shell paths"),
Vec::<String>::new(),
);
assert!(
derive_write_paths(
&root,
Some("UnknownWrite"),
Some(&serde_json::json!({"command": "true"})),
true,
)
.is_err(),
"exact root authority must not classify unknown tools"
);
}
#[test]
fn an_absolute_path_outside_the_workspace_is_reported_not_refused() {
let root = std::env::temp_dir().join("shepherd-dispatch-scope-outside");
let outside = if cfg!(windows) {
r"C:\Users\nobody\.claude\memory\note.md"
} else {
"/Users/nobody/.claude/memory/note.md"
};
let targets = derive_write_paths(
&root,
Some("Write"),
Some(&serde_json::json!({"file_path": outside})),
false,
)
.expect("an out-of-workspace write resolves rather than aborting the call");
assert_eq!(
targets,
vec![WriteTarget {
path: outside.to_owned(),
inside_workspace: false,
}],
"the evidence names the path that was actually requested"
);
}
#[test]
fn a_relative_path_that_climbs_out_of_the_workspace_still_fails_closed() {
let root = std::env::temp_dir().join("shepherd-dispatch-scope-climb");
for candidate in ["../escape.md", "docs/../../escape.md", "./../escape.md"] {
assert!(
derive_write_paths(
&root,
Some("Write"),
Some(&serde_json::json!({"file_path": candidate})),
false,
)
.is_err(),
"{candidate} must not resolve"
);
}
}
}