1use std::path::{Path, PathBuf};
23
24use serde::Deserialize;
25
26use oxi_sdk::ports::{AccessDecision, ToolCallRequest};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct SplitCommand {
33 pub text: String,
36 pub separator: Option<CommandSeparator>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum CommandSeparator {
44 And,
46 Or,
48 Sequence,
50 Pipe,
52}
53
54impl CommandSeparator {
55 pub fn as_str(&self) -> &'static str {
57 match self {
58 CommandSeparator::And => "&&",
59 CommandSeparator::Or => "||",
60 CommandSeparator::Sequence => ";",
61 CommandSeparator::Pipe => "|",
62 }
63 }
64}
65
66pub fn split_bash(input: &str) -> Option<Vec<SplitCommand>> {
73 let mut out = Vec::new();
74 let mut current = String::new();
75 let mut chars = input.chars().peekable();
76 let mut in_single = false;
77 let mut in_double = false;
78
79 while let Some(c) = chars.next() {
80 if c == '\\' && !in_single {
81 if let Some(next) = chars.next() {
82 current.push('\\');
83 current.push(next);
84 } else {
85 current.push('\\');
86 }
87 continue;
88 }
89 if c == '\'' && !in_double {
90 in_single = !in_single;
91 current.push(c);
92 continue;
93 }
94 if c == '"' && !in_single {
95 in_double = !in_double;
96 current.push(c);
97 continue;
98 }
99
100 if !in_single && !in_double {
101 if c == '&' && chars.peek() == Some(&'&') {
102 chars.next();
103 flush_command(&mut current, &mut out, Some(CommandSeparator::And));
104 continue;
105 }
106 if c == '|' {
107 if chars.peek() == Some(&'|') {
108 chars.next();
109 flush_command(&mut current, &mut out, Some(CommandSeparator::Or));
110 } else {
111 flush_command(&mut current, &mut out, Some(CommandSeparator::Pipe));
112 }
113 continue;
114 }
115 if c == ';' {
116 flush_command(&mut current, &mut out, Some(CommandSeparator::Sequence));
117 continue;
118 }
119 }
120 current.push(c);
121 }
122
123 if in_single || in_double {
124 return None;
125 }
126
127 flush_command(&mut current, &mut out, None);
129 Some(out)
130}
131
132fn flush_command(
133 current: &mut String,
134 out: &mut Vec<SplitCommand>,
135 separator: Option<CommandSeparator>,
136) {
137 let text = current.trim().to_string();
138 if !text.is_empty() {
139 out.push(SplitCommand { text, separator });
140 }
141 current.clear();
142}
143
144#[derive(Debug, Default, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct ClaudeSettings {
150 pub permissions: Option<ParsedPermissions>,
152 pub default_mode: Option<String>,
154 pub env: Option<std::collections::HashMap<String, String>>,
156}
157
158#[derive(Debug, Default, Deserialize)]
160pub struct ParsedPermissions {
161 pub allow: Vec<String>,
163 pub deny: Vec<String>,
165 pub ask: Vec<String>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct ImportedRule {
172 pub tool_name: String,
174 pub arg_substrings: Vec<String>,
176}
177
178#[derive(Debug, Clone, Default)]
180pub struct ImportResult {
181 pub allow: Vec<ImportedRule>,
183 pub deny: Vec<ImportedRule>,
185 pub ask: Vec<ImportedRule>,
187 pub default_mode: Option<String>,
189 pub env: Option<std::collections::HashMap<String, String>>,
191 pub warnings: Vec<String>,
194}
195
196pub fn load_claude_settings(dir: &Path) -> Option<ImportResult> {
198 let path = claude_settings_path(dir);
199 let raw = std::fs::read_to_string(&path).ok()?;
200 parse_claude_settings(&raw, &path)
201}
202
203fn claude_settings_path(dir: &Path) -> PathBuf {
205 dir.join(".claude").join("settings.json")
206}
207
208pub fn parse_claude_settings(raw: &str, path: &Path) -> Option<ImportResult> {
210 let settings: ClaudeSettings = match serde_json::from_str(raw) {
211 Ok(s) => s,
212 Err(e) => {
213 tracing::warn!(
214 path = %path.display(),
215 error = %e,
216 "failed to parse Claude settings; skipping"
217 );
218 return None;
219 }
220 };
221 Some(translate(settings))
222}
223
224fn translate(settings: ClaudeSettings) -> ImportResult {
226 let mut out = ImportResult {
227 default_mode: settings.default_mode,
228 env: settings.env,
229 ..Default::default()
230 };
231 if let Some(perms) = settings.permissions {
232 for (action, entries, target) in [
233 ("allow", perms.allow, &mut out.allow),
234 ("deny", perms.deny, &mut out.deny),
235 ("ask", perms.ask, &mut out.ask),
236 ] {
237 for entry in entries {
238 match parse_rule_entry(&entry) {
239 Some(rule) => target.push(rule),
240 None => out
241 .warnings
242 .push(format!("{action}: could not parse rule {entry:?}")),
243 }
244 }
245 }
246 }
247 out
248}
249
250pub fn parse_rule_entry(entry: &str) -> Option<ImportedRule> {
255 let entry = entry.trim();
256 if entry.is_empty() {
257 return None;
258 }
259 if let Some(open) = entry.find('(') {
260 let close = entry.rfind(')')?;
261 if close <= open {
262 return None;
263 }
264 let tool_name = entry[..open].trim().to_string();
265 let args_str = &entry[open + 1..close];
266 let arg_substrings: Vec<String> = args_str
267 .split(',')
268 .map(|s| s.trim().to_string())
269 .filter(|s| !s.is_empty())
270 .collect();
271 Some(ImportedRule {
272 tool_name,
273 arg_substrings,
274 })
275 } else {
276 Some(ImportedRule {
277 tool_name: entry.to_string(),
278 arg_substrings: Vec::new(),
279 })
280 }
281}
282
283pub struct ClaudeRuleSet {
289 allow: Vec<ImportedRule>,
290 deny: Vec<ImportedRule>,
291 ask: Vec<ImportedRule>,
292}
293
294impl ClaudeRuleSet {
295 pub fn from_import(import: ImportResult) -> Self {
297 Self {
298 allow: import.allow,
299 deny: import.deny,
300 ask: import.ask,
301 }
302 }
303
304 pub fn evaluate(&self, req: &ToolCallRequest) -> AccessDecision {
307 if self.matches(&self.deny, req) {
308 return AccessDecision::Deny {
309 reason: format!("denied by .claude/settings.json rule (tool {})", req.tool),
310 };
311 }
312 if self.matches(&self.ask, req) {
313 return AccessDecision::RequireApproval {
314 reason: format!(
315 "requires approval per .claude/settings.json rule (tool {})",
316 req.tool
317 ),
318 };
319 }
320 if self.matches(&self.allow, req) {
321 return AccessDecision::Allow;
322 }
323 if self.allow.is_empty() {
324 AccessDecision::Deny {
325 reason: "no allow rule matches and allow-list is empty".into(),
326 }
327 } else {
328 AccessDecision::Allow
329 }
330 }
331
332 fn matches(&self, rules: &[ImportedRule], req: &ToolCallRequest) -> bool {
333 rules.iter().any(|r| {
334 r.tool_name == req.tool
335 && r.arg_substrings.iter().all(|needle| {
336 let needle = needle.as_str();
337 needle.is_empty()
338 || req.action.contains(needle)
339 || req.subject.contains(needle)
340 || req.cwd.to_string_lossy().contains(needle)
341 })
342 })
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 fn req(tool: &str, action: &str) -> ToolCallRequest {
351 ToolCallRequest {
352 tool: tool.to_string(),
353 action: action.to_string(),
354 cwd: PathBuf::from("/tmp"),
355 subject: "test".to_string(),
356 }
357 }
358
359 #[test]
360 fn split_single_command() {
361 let cmds = split_bash("ls -la").unwrap();
362 assert_eq!(cmds.len(), 1);
363 assert_eq!(cmds[0].text, "ls -la");
364 assert_eq!(cmds[0].separator, None);
365 }
366
367 #[test]
368 fn split_and_chain() {
369 let cmds = split_bash("cargo build && cargo test").unwrap();
370 assert_eq!(cmds.len(), 2);
371 assert_eq!(cmds[0].text, "cargo build");
372 assert_eq!(cmds[0].separator, Some(CommandSeparator::And));
373 assert_eq!(cmds[1].text, "cargo test");
374 assert_eq!(cmds[1].separator, None);
375 }
376
377 #[test]
378 fn split_or_sequence_pipe() {
379 let cmds = split_bash("a || b ; c | d").unwrap();
380 assert_eq!(cmds.len(), 4);
381 assert_eq!(cmds[0].separator, Some(CommandSeparator::Or));
382 assert_eq!(cmds[1].separator, Some(CommandSeparator::Sequence));
383 assert_eq!(cmds[2].separator, Some(CommandSeparator::Pipe));
384 assert_eq!(cmds[3].separator, None);
385 }
386
387 #[test]
388 fn split_preserves_quoted_operators() {
389 let cmds = split_bash(r#"echo "a | b" && ls"#).unwrap();
390 assert_eq!(cmds.len(), 2);
391 assert_eq!(cmds[0].text, r#"echo "a | b""#);
392 }
393
394 #[test]
395 fn split_preserves_single_quoted_operators() {
396 let cmds = split_bash("echo 'a && b' && ls").unwrap();
397 assert_eq!(cmds.len(), 2);
398 assert_eq!(cmds[0].text, "echo 'a && b'");
399 }
400
401 #[test]
402 fn split_handles_escape() {
403 let cmds = split_bash(r"a \|\| b").unwrap();
404 assert_eq!(cmds.len(), 1, "escaped || must not split");
405 assert_eq!(cmds[0].text, r"a \|\| b");
406 }
407
408 #[test]
409 fn split_unbalanced_quote_returns_none() {
410 assert!(split_bash("echo 'unbalanced").is_none());
411 }
412
413 #[test]
414 fn split_empty_input_yields_empty() {
415 let cmds = split_bash("").unwrap();
416 assert!(cmds.is_empty());
417 }
418
419 #[test]
420 fn split_whitespace_only_yields_empty() {
421 let cmds = split_bash(" \n ").unwrap();
422 assert!(cmds.is_empty());
423 }
424
425 #[test]
426 fn parse_rule_bare_tool() {
427 let r = parse_rule_entry("read").unwrap();
428 assert_eq!(r.tool_name, "read");
429 assert!(r.arg_substrings.is_empty());
430 }
431
432 #[test]
433 fn parse_rule_with_args() {
434 let r = parse_rule_entry(r#"Bash(npm install:*)"#).unwrap();
435 assert_eq!(r.tool_name, "Bash");
436 assert_eq!(r.arg_substrings, vec!["npm install:*"]);
437 }
438
439 #[test]
440 fn parse_rule_multiple_args() {
441 let r = parse_rule_entry("Bash(git, push)").unwrap();
442 assert_eq!(r.tool_name, "Bash");
443 assert_eq!(r.arg_substrings, vec!["git", "push"]);
444 }
445
446 #[test]
447 fn parse_rule_unbalanced_returns_none() {
448 assert!(parse_rule_entry("Bash(git").is_none());
449 }
450
451 #[test]
452 fn parse_claude_settings_full() {
453 let raw = r#"{
454 "permissions": {
455 "allow": ["read", "Bash(npm test)"],
456 "deny": ["Bash(rm -rf)"],
457 "ask": ["write(/etc/*)"]
458 },
459 "defaultMode": "default",
460 "env": {"FOO": "bar"}
461 }"#;
462 let import = parse_claude_settings(raw, Path::new("/test/.claude/settings.json")).unwrap();
463 assert_eq!(import.allow.len(), 2);
464 assert_eq!(import.allow[0].tool_name, "read");
465 assert_eq!(import.allow[1].tool_name, "Bash");
466 assert_eq!(import.deny.len(), 1);
467 assert_eq!(import.ask.len(), 1);
468 assert_eq!(import.default_mode.as_deref(), Some("default"));
469 assert_eq!(
470 import.env.as_ref().unwrap().get("FOO").map(|s| s.as_str()),
471 Some("bar")
472 );
473 }
474
475 #[test]
476 fn parse_claude_settings_invalid_json_returns_none() {
477 let raw = "not json at all";
478 assert!(parse_claude_settings(raw, Path::new("/test/settings.json")).is_none());
479 }
480
481 #[test]
482 fn parse_claude_settings_empty() {
483 let raw = "{}";
484 let import = parse_claude_settings(raw, Path::new("/test/settings.json")).unwrap();
485 assert!(import.allow.is_empty());
486 assert!(import.deny.is_empty());
487 assert_eq!(import.warnings.len(), 0);
488 }
489
490 #[test]
491 fn evaluate_deny_wins() {
492 let import = ImportResult {
493 deny: vec![ImportedRule {
494 tool_name: "Bash".into(),
495 arg_substrings: vec!["rm -rf".into()],
496 }],
497 ..Default::default()
498 };
499 let set = ClaudeRuleSet::from_import(import);
500 let decision = set.evaluate(&req("Bash", "rm -rf /tmp/x"));
501 assert!(matches!(decision, AccessDecision::Deny { .. }));
502 }
503
504 #[test]
505 fn evaluate_ask_then_allow() {
506 let import = ImportResult {
507 ask: vec![ImportedRule {
508 tool_name: "write".into(),
509 arg_substrings: vec!["/etc/".into()],
510 }],
511 allow: vec![ImportedRule {
512 tool_name: "write".into(),
513 arg_substrings: vec!["/tmp/".into()],
514 }],
515 ..Default::default()
516 };
517 let set = ClaudeRuleSet::from_import(import);
518 let ask = set.evaluate(&req("write", "edit /etc/passwd"));
519 assert!(matches!(ask, AccessDecision::RequireApproval { .. }));
520 let allow = set.evaluate(&req("write", "edit /tmp/x"));
521 assert!(matches!(allow, AccessDecision::Allow));
522 }
523
524 #[test]
525 fn evaluate_empty_allow_list_denies() {
526 let set = ClaudeRuleSet::from_import(ImportResult::default());
527 let decision = set.evaluate(&req("read", "open README"));
528 assert!(matches!(decision, AccessDecision::Deny { .. }));
529 }
530
531 #[test]
532 fn evaluate_nonempty_allow_default_allows() {
533 let import = ImportResult {
534 allow: vec![ImportedRule {
535 tool_name: "read".into(),
536 arg_substrings: vec!["/tmp/".into()],
537 }],
538 ..Default::default()
539 };
540 let set = ClaudeRuleSet::from_import(import);
541 let decision = set.evaluate(&req("read", "open /etc/x"));
542 assert!(matches!(decision, AccessDecision::Allow));
543 }
544}