1use std::path::Path;
9use std::sync::LazyLock;
10
11use regex::Regex;
12use serde_json::Value;
13
14pub const WRITE_TOOLS: [&str; 4] = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
16
17pub fn is_write_tool(tool_name: &str) -> bool {
19 WRITE_TOOLS.contains(&tool_name)
20}
21
22static BASH_MUTATION_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
30 [
31 (
32 r"\b(npm|pnpm|yarn|bun)\s+(install|add|ci|i)\b",
33 "package install/add",
34 ),
35 (r"\bpip3?\s+install\b", "pip install"),
36 (r"\bsed\s+-i\b", "in-place file edit (sed -i)"),
37 (
38 r"\bgit\s+(commit|add|push|checkout|reset|restore|merge|rebase)\b",
39 "git mutation",
40 ),
41 (
42 r"\bgit\s+worktree\s+add\b",
43 "git worktree add (working tree outside the sandbox)",
44 ),
45 (
50 r"\b(cp|mv|mkdir|touch|ln|rsync|install)\b[^|;&\n]*\.claude(/|\b)",
51 "path under .claude",
52 ),
53 (
58 r#"\b(cp|mv|mkdir|touch|ln|rsync)\b[^|;&\n]*[\s'"=/]\.{0,2}/?skills(/|\s|$)"#,
59 "creates a bare skills/ dir",
60 ),
61 (r"(^|\s)(>>?|tee)\s", "output redirection to a file"),
62 ]
63 .into_iter()
64 .map(|(re, reason)| {
65 (
66 Regex::new(re)
67 .unwrap_or_else(|e| panic!("bundled bash pattern {re:?} is invalid: {e}")),
68 reason,
69 )
70 })
71 .collect()
72});
73
74pub fn path_arg(args: &Value) -> Option<&str> {
78 let obj = args.as_object()?;
79 ["file_path", "notebook_path", "path"]
80 .iter()
81 .find_map(|k| obj.get(*k).and_then(Value::as_str))
82}
83
84pub fn apply_patch_paths(args: &Value) -> Vec<String> {
88 let mut out = Vec::new();
89 let Some(obj) = args.as_object() else {
90 return out;
91 };
92
93 if let Some(files) = obj.get("files") {
94 collect_file_values(files, &mut out);
95 }
96
97 for key in ["patch", "input", "content"] {
98 if let Some(text) = obj.get(key).and_then(Value::as_str) {
99 collect_patch_header_paths(text, &mut out);
100 }
101 }
102
103 out.sort();
104 out.dedup();
105 out
106}
107
108fn collect_file_values(value: &Value, out: &mut Vec<String>) {
109 match value {
110 Value::String(path) => out.push(path.to_string()),
111 Value::Array(items) => {
112 for item in items {
113 collect_file_values(item, out);
114 }
115 }
116 Value::Object(obj) => {
117 for key in ["file_path", "path", "absolute_file_path", "move_path"] {
118 if let Some(path) = obj.get(key).and_then(Value::as_str) {
119 out.push(path.to_string());
120 }
121 }
122 }
123 _ => {}
124 }
125}
126
127fn collect_patch_header_paths(text: &str, out: &mut Vec<String>) {
128 for line in text.lines() {
129 for prefix in [
130 "*** Add File: ",
131 "*** Update File: ",
132 "*** Delete File: ",
133 "*** Move to: ",
134 ] {
135 if let Some(path) = line.strip_prefix(prefix) {
136 let path = path.trim();
137 if !path.is_empty() {
138 out.push(path.to_string());
139 }
140 }
141 }
142 }
143}
144
145fn absolutize(target: &str, repo_root: &Path) -> std::path::PathBuf {
148 let joined = if Path::new(target).is_absolute() {
149 std::path::PathBuf::from(target)
150 } else {
151 repo_root.join(target)
152 };
153 std::path::absolute(&joined).unwrap_or(joined)
155}
156
157pub fn is_under(target: &str, dir: &str, repo_root: &Path) -> bool {
161 let base = absolutize(dir, repo_root);
162 let abs = absolutize(target, repo_root);
163 abs.starts_with(&base)
164}
165
166pub fn is_under_any(target: &str, dirs: &[String], repo_root: &Path) -> bool {
168 dirs.iter().any(|d| is_under(target, d, repo_root))
169}
170
171pub fn classify_bash(command: &str, allowed_roots: &[String]) -> Option<&'static str> {
175 if command.is_empty() {
176 return None;
177 }
178 if allowed_roots.iter().any(|r| command.contains(r)) {
179 return None;
180 }
181 BASH_MUTATION_PATTERNS
182 .iter()
183 .find(|(re, _)| re.is_match(command))
184 .map(|(_, reason)| *reason)
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use serde_json::json;
191
192 const ROOTS: [&str; 2] = ["/work/skills-workspace", "/work/.claude/skills"];
193
194 fn roots() -> Vec<String> {
195 ROOTS.iter().map(|s| s.to_string()).collect()
196 }
197
198 #[test]
199 fn is_write_tool_matches_the_four_write_tools() {
200 for t in ["Write", "Edit", "MultiEdit", "NotebookEdit"] {
201 assert!(is_write_tool(t), "{t} should be a write tool");
202 }
203 for t in ["Read", "Bash", "Grep", ""] {
204 assert!(!is_write_tool(t), "{t} should not be a write tool");
205 }
206 }
207
208 #[test]
209 fn path_arg_prefers_file_path_then_notebook_then_path() {
210 assert_eq!(path_arg(&json!({ "file_path": "/a" })), Some("/a"));
211 assert_eq!(path_arg(&json!({ "notebook_path": "/b" })), Some("/b"));
212 assert_eq!(path_arg(&json!({ "path": "/c" })), Some("/c"));
213 assert_eq!(
214 path_arg(&json!({ "file_path": "/a", "path": "/c" })),
215 Some("/a")
216 );
217 assert_eq!(path_arg(&json!({ "command": "ls" })), None);
218 assert_eq!(path_arg(&json!("not an object")), None);
219 }
220
221 #[test]
222 fn apply_patch_paths_collects_structured_and_freeform_targets() {
223 let paths = apply_patch_paths(&json!({
224 "files": [
225 "/tmp/out.md",
226 { "path": "src/lib.rs" },
227 { "move_path": "src/new.rs" }
228 ],
229 "patch": "*** Begin Patch\n*** Update File: docs/a.md\n*** Move to: docs/b.md\n*** End Patch\n"
230 }));
231 assert_eq!(
232 paths,
233 vec![
234 "/tmp/out.md".to_string(),
235 "docs/a.md".to_string(),
236 "docs/b.md".to_string(),
237 "src/lib.rs".to_string(),
238 "src/new.rs".to_string(),
239 ]
240 );
241 }
242
243 #[test]
244 fn is_under_matches_dir_and_descendants() {
245 let repo = Path::new("/work");
246 assert!(is_under(
247 "/work/skills-workspace",
248 "/work/skills-workspace",
249 repo
250 ));
251 assert!(is_under(
252 "/work/skills-workspace/x/out.md",
253 "/work/skills-workspace",
254 repo
255 ));
256 assert!(!is_under(
257 "/work/runner/run.ts",
258 "/work/skills-workspace",
259 repo
260 ));
261 assert!(!is_under(
263 "/work/skills-workspace2/x",
264 "/work/skills-workspace",
265 repo
266 ));
267 }
268
269 #[test]
270 fn is_under_resolves_relative_targets_against_repo_root() {
271 let repo = Path::new("/work");
272 assert!(is_under(
273 "skills-workspace/x",
274 "/work/skills-workspace",
275 repo
276 ));
277 }
278
279 #[test]
280 fn is_under_any_checks_every_root() {
281 let repo = Path::new("/work");
282 assert!(is_under_any("/work/.claude/skills/s", &roots(), repo));
283 assert!(!is_under_any("/etc/passwd", &roots(), repo));
284 }
285
286 #[test]
287 fn classify_bash_flags_install_and_git_mutations() {
288 assert_eq!(
289 classify_bash("npm install left-pad", &roots()),
290 Some("package install/add")
291 );
292 assert_eq!(
293 classify_bash("git worktree add ../wt -b scratch", &roots()),
294 Some("git worktree add (working tree outside the sandbox)")
295 );
296 assert_eq!(
297 classify_bash("echo hi > out.log", &roots()),
298 Some("output redirection to a file")
299 );
300 }
301
302 #[test]
303 fn classify_bash_allows_scoped_and_readonly_commands() {
304 assert_eq!(
306 classify_bash("echo hi > /work/skills-workspace/x/log", &roots()),
307 None
308 );
309 assert_eq!(classify_bash("ls -la /", &roots()), None);
310 assert_eq!(classify_bash("", &roots()), None);
311 }
312}