Skip to main content

oxi/store/
access_compat.rs

1//! Permission compatibility — partial port of grok-build
2//! `xai-grok-workspace/src/permission/` (Apache-2.0).
3//!
4//! Two pieces are ported at MVP depth:
5//!
6//! 1. **Bash command splitter** — a lightweight, regex-based variant of
7//!    grok's `bash_command_splitting.rs` (which uses tree-sitter-bash).
8//!    Detects `&&`, `||`, `;`, `|` boundaries and splits into individual
9//!    commands.
10//!
11//! 2. **`.claude/settings.json` importer** — translates Claude's
12//!    `permissions.allow/deny/ask` arrays into oxi `AccessGate` rule
13//!    decisions via `ClaudeRuleSet`.
14//!
15//! ## Non-goals
16//!
17//! - grok's `auto_mode.rs` (100 KB learning-based auto-approve) is
18//!   intentionally NOT ported — it requires training data we don't have.
19//! - grok's `manager.rs`/`resolution.rs` (255 KB) are layered on
20//!   workspace hooks that oxi doesn't share.
21
22use std::path::{Path, PathBuf};
23
24use serde::Deserialize;
25
26use oxi_sdk::ports::{AccessDecision, ToolCallRequest};
27
28// ── Bash command splitting ──────────────────────────────────────────
29
30/// A single command extracted from a shell pipeline.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct SplitCommand {
33    /// Original text of this command (leading/trailing whitespace
34    /// stripped).
35    pub text: String,
36    /// Operator that joins this command to the next. `None` on the
37    /// last command.
38    pub separator: Option<CommandSeparator>,
39}
40
41/// Operators that can join commands in a shell pipeline.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum CommandSeparator {
44    /// `cmd1 && cmd2` — run cmd2 only if cmd1 succeeds.
45    And,
46    /// `cmd1 || cmd2` — run cmd2 only if cmd1 fails.
47    Or,
48    /// `cmd1 ; cmd2` — run cmd1 then cmd2 unconditionally.
49    Sequence,
50    /// `cmd1 | cmd2` — pipe cmd1's stdout into cmd2's stdin.
51    Pipe,
52}
53
54impl CommandSeparator {
55    /// The operator string as written in the shell source.
56    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
66/// Split a shell command line on `&&`, `||`, `;`, `|`. Quotes and
67/// backslash escapes are honored. Returns `None` on unbalanced quotes.
68///
69/// The last command always has `separator = None` — operators join a
70/// command to the *next* one, so there is no operator after the final
71/// command.
72pub 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    // Final flush: no separator (no operator after the last command).
128    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// ── Claude settings importer ────────────────────────────────────────
145
146/// Subset of `.claude/settings.json` we read.
147#[derive(Debug, Default, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct ClaudeSettings {
150    /// `permissions.allow/deny/ask` arrays (optional).
151    pub permissions: Option<ParsedPermissions>,
152    /// Canonical `defaultMode` string (e.g. `"acceptEdits"`, `"auto"`).
153    pub default_mode: Option<String>,
154    /// Optional environment variables applied to every session.
155    pub env: Option<std::collections::HashMap<String, String>>,
156}
157
158/// `permissions` block from Claude settings.
159#[derive(Debug, Default, Deserialize)]
160pub struct ParsedPermissions {
161    /// Tools/commands that always pass without prompting.
162    pub allow: Vec<String>,
163    /// Tools/commands that are always denied.
164    pub deny: Vec<String>,
165    /// Tools/commands that always require explicit approval.
166    pub ask: Vec<String>,
167}
168
169/// A rule extracted from a Claude permissions entry.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct ImportedRule {
172    /// Tool name (e.g. `"Bash"`, `"read"`).
173    pub tool_name: String,
174    /// Argument patterns matched as substrings against the request.
175    pub arg_substrings: Vec<String>,
176}
177
178/// Outcome of importing `.claude/settings.json`.
179#[derive(Debug, Clone, Default)]
180pub struct ImportResult {
181    /// Rules extracted from `permissions.allow`.
182    pub allow: Vec<ImportedRule>,
183    /// Rules extracted from `permissions.deny`.
184    pub deny: Vec<ImportedRule>,
185    /// Rules extracted from `permissions.ask`.
186    pub ask: Vec<ImportedRule>,
187    /// `defaultMode` field if present.
188    pub default_mode: Option<String>,
189    /// Environment variables if present.
190    pub env: Option<std::collections::HashMap<String, String>>,
191    /// Rules that could not be parsed. Hosts may surface them as
192    /// warnings.
193    pub warnings: Vec<String>,
194}
195
196/// Read `.claude/settings.json` from `dir/.claude/settings.json`.
197pub 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
203/// Standard location of Claude settings relative to a project root.
204fn claude_settings_path(dir: &Path) -> PathBuf {
205    dir.join(".claude").join("settings.json")
206}
207
208/// Parse the raw text of a `.claude/settings.json` file.
209pub 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
224/// Translate a parsed [`ClaudeSettings`] to the rule-list form.
225fn 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
250/// Parse a single Claude permissions entry string.
251///
252/// Format: `ToolName` (allow all) or `ToolName(arg1, arg2)` (allow only
253/// when the request fields contain those substrings).
254pub 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
283/// Adapter that wires an [`ImportResult`] into an `AccessGate`-like
284/// check.
285///
286/// Substring matching scans `req.action`, `req.subject`, and `req.cwd`
287/// for each needle (any-match).
288pub struct ClaudeRuleSet {
289    allow: Vec<ImportedRule>,
290    deny: Vec<ImportedRule>,
291    ask: Vec<ImportedRule>,
292}
293
294impl ClaudeRuleSet {
295    /// Build from a successful import.
296    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    /// Evaluate a tool call request. Priority: deny > ask > allow >
305    /// default.
306    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}