Skip to main content

recursive/tools/
permission_pipeline.rs

1//! Pre-execution permission pipeline for tool dispatch (Goal 261).
2//!
3//! Extracted from `ToolRegistry::invoke_with_audit` to make the
4//! permission-orchestration phases testable in isolation. The pipeline
5//! owns the seven pre-execution phases that decide whether a tool call
6//! is allowed to proceed and, if so, with what (possibly hook-transformed)
7//! arguments:
8//!
9//! 1. **Safety content extraction** — derive the file-path "content" from
10//!    arguments so the static check can fire on protected paths (`.git`,
11//!    `.ssh`, etc.).
12//! 2. **Auto-mode LLM classifier** (Goal 200) — for `PermissionMode::Auto`,
13//!    delegate the decision to the configured `AutoClassifier`.
14//! 3. **Static permission check** — `LayeredPermissionsConfig::check_static`.
15//! 4. **Strict-mode handling** — `Permission::Unknown` under Strict mode
16//!    becomes a `Denied`.
17//! 5. **Hook delegation** (Goal 212) — for non-headless + interactive +
18//!    `Unknown` tools, call the registered `PermissionHook`. A `Transform`
19//!    result is re-checked against L1 policy before being accepted.
20//! 6. **Headless external hook** (Goal 199) — for headless + interactive
21//!    tools, dispatch a `PermissionRequest` event to the external hook
22//!    runner.
23//! 7. **L1 policy check** — shell/fs policies from the registry's
24//!    `PolicyConfig`. The same logic is exposed as `recheck_policy` for
25//!    re-validation of hook-mutated arguments.
26//!
27//! The pipeline borrows a `&ToolRegistry`; constructing one is cheap and
28//! does not perform any I/O. The expensive part is `check()`, which may
29//! await on the shared permissions lock, the auto-classifier, and (in
30//! headless mode) the external hook runner.
31
32use serde_json::Value;
33
34use crate::agent::PermissionDecision;
35use crate::error::{Error, Result};
36use crate::permissions::{DecisionReason, Permission, PermissionMode};
37
38use super::policy_sandbox::PolicyConfig;
39use super::{AuditMeta, ToolRegistry};
40
41// ── Outcome type ──────────────────────────────────────────────────────────
42
43/// Result of a [`PermissionPipeline::check`] call.
44///
45/// - [`CheckOutcome::Deny`] — the pipeline denied the call. The caller
46///   must surface `error` to the user and persist `audit` alongside it.
47/// - [`CheckOutcome::Allow`] — the call is allowed. `arguments` is the
48///   final argument set (possibly hook-transformed) to pass to the tool.
49#[derive(Debug)]
50pub enum CheckOutcome {
51    Deny { error: Error, audit: AuditMeta },
52    Allow { arguments: Value },
53}
54
55impl CheckOutcome {
56    /// Returns `true` if the pipeline denied the call.
57    pub fn is_deny(&self) -> bool {
58        matches!(self, CheckOutcome::Deny { .. })
59    }
60}
61
62// ── Pipeline type ────────────────────────────────────────────────────────
63
64/// Pre-execution permission pipeline. Borrows from a `ToolRegistry` so
65/// the orchestrating configuration (permissions, mode, hooks, policy,
66/// classifier, headless flag) stays in one place.
67pub struct PermissionPipeline<'a> {
68    registry: &'a ToolRegistry,
69}
70
71impl<'a> PermissionPipeline<'a> {
72    /// Construct a pipeline bound to `registry`. No I/O happens here.
73    pub fn new(registry: &'a ToolRegistry) -> Self {
74        Self { registry }
75    }
76
77    /// Run all pre-execution permission checks for `tool_name` with
78    /// `arguments`. Returns either a denial (with audit metadata) or the
79    /// (possibly hook-transformed) final arguments.
80    ///
81    /// This is a behavior-preserving extraction from
82    /// `ToolRegistry::invoke_with_audit`. The semantics of every code
83    /// path are unchanged.
84    pub async fn check(&self, tool_name: &str, mut arguments: Value) -> CheckOutcome {
85        // Phase 1: safety content (file-path content for protected-path check).
86        let safety_content = safety_content_for_tool(tool_name, &arguments);
87
88        // NEW-PERM-2 (Goal H2): safety check for protected paths must
89        // run independently of whether `permissions` is configured.
90        // `with_policy` alone (no `with_permissions`) used to skip
91        // safety entirely — a user who deliberately registered an L1
92        // policy for shell/FS but no permission layers could still
93        // write to `.git/hooks/`. Compute the safety decision
94        // upfront; the per-permission-mode code below may also call
95        // `check_static` (which does its own safety check) but the
96        // second call is idempotent — both Deny with the same path.
97        let is_readonly = self.registry.is_readonly(tool_name);
98        if let Some(path) = crate::permissions::check_protected_path(
99            tool_name,
100            is_readonly,
101            safety_content.as_deref(),
102        ) {
103            return CheckOutcome::Deny {
104                error: Error::PermissionDenied {
105                    name: tool_name.into(),
106                    reason: DecisionReason::SafetyCheck { path },
107                },
108                audit: AuditMeta::synthetic_unknown_tool(tool_name),
109            };
110        }
111
112        // Phases 2-6: read shared permissions lock and run all permission checks.
113        if let Some(ref sp) = self.registry.permissions {
114            let guard = sp.read().await;
115
116            // Phase 2: Auto-mode LLM classifier (Goal 200).
117            if matches!(guard.mode, PermissionMode::Auto) {
118                if let Some(ref classifier) = self.registry.auto_classifier {
119                    let args_summary =
120                        serde_json::to_string(&arguments).unwrap_or_else(|_| "{}".into());
121                    let mut c = classifier.lock().await;
122                    match c.classify(tool_name, &args_summary, "").await {
123                        Ok((true, _reason)) => {
124                            if c.tracker.is_over_limit() {
125                                return CheckOutcome::Deny {
126                                    error: Error::PermissionDeniedLimit {
127                                        name: tool_name.into(),
128                                    },
129                                    audit: AuditMeta::synthetic_unknown_tool(tool_name),
130                                };
131                            }
132                            return CheckOutcome::Deny {
133                                error: Error::PermissionDenied {
134                                    name: tool_name.into(),
135                                    reason: DecisionReason::Mode(PermissionMode::Auto),
136                                },
137                                audit: AuditMeta::synthetic_unknown_tool(tool_name),
138                            };
139                        }
140                        Ok((false, _)) => {
141                            // Allowed by classifier — fall through to static check.
142                        }
143                        Err(_e) => {
144                            // Classifier error — conservative: allow static check to decide.
145                        }
146                    }
147                }
148                // If no classifier configured in Auto mode, fall through to static check.
149            }
150
151            // Phase 3: static permission check.
152            let is_readonly = self.registry.is_readonly(tool_name);
153            // Pre-compute interactive flag before the match so we can use it
154            // after without holding `guard` across an await point.
155            let is_interactive_tool = guard.any_interactive(tool_name);
156            let mut perm_is_unknown = false;
157            match guard.check_static(tool_name, is_readonly, safety_content.as_deref()) {
158                Permission::Denied(reason, _msg) => {
159                    return CheckOutcome::Deny {
160                        error: Error::PermissionDenied {
161                            name: tool_name.into(),
162                            reason: reason.clone(),
163                        },
164                        audit: AuditMeta::synthetic_unknown_tool(tool_name),
165                    };
166                }
167                Permission::Unknown => {
168                    perm_is_unknown = true;
169                }
170                Permission::Allowed(_) => {}
171            }
172
173            // Phase 4: Strict mode — any tool without an explicit allow rule is denied.
174            if perm_is_unknown && matches!(guard.mode, PermissionMode::Strict) {
175                return CheckOutcome::Deny {
176                    error: Error::PermissionDenied {
177                        name: tool_name.into(),
178                        reason: DecisionReason::Mode(PermissionMode::Strict),
179                    },
180                    audit: AuditMeta::synthetic_unknown_tool(tool_name),
181                };
182            }
183
184            // Phase 5: hook delegation (non-headless + interactive + Unknown).
185            if perm_is_unknown && !self.registry.headless && is_interactive_tool {
186                if let Some(hook) = &self.registry.permission_hook {
187                    drop(guard);
188                    match hook.check(tool_name, &arguments).await {
189                        PermissionDecision::Deny(reason) => {
190                            return CheckOutcome::Deny {
191                                error: Error::PermissionDenied {
192                                    name: tool_name.into(),
193                                    reason: DecisionReason::Hook { name: reason },
194                                },
195                                audit: AuditMeta::synthetic_unknown_tool(tool_name),
196                            };
197                        }
198                        PermissionDecision::Transform(new_args) => {
199                            // Re-run policy check on the transformed arguments.
200                            // A malicious/compromised hook could use Transform
201                            // to substitute a different command/path that bypasses
202                            // the policy check already performed above.
203                            if let Err(e) = self.recheck_policy(tool_name, &new_args) {
204                                return CheckOutcome::Deny {
205                                    error: e,
206                                    audit: AuditMeta::synthetic_unknown_tool(tool_name),
207                                };
208                            }
209                            arguments = new_args;
210                        }
211                        PermissionDecision::Allow => {
212                            // NEW-PERM-1 (Goal H2): re-run policy check
213                            // on the hook-approved args. A malicious or
214                            // buggy hook could `Allow` a tool+args that
215                            // the policy would have denied above; the
216                            // Transform path already rechecks but the
217                            // Allow path historically skipped it.
218                            if let Err(e) = self.recheck_policy(tool_name, &arguments) {
219                                return CheckOutcome::Deny {
220                                    error: e,
221                                    audit: AuditMeta::synthetic_unknown_tool(tool_name),
222                                };
223                            }
224                        }
225                    }
226                    // Hook allowed/transformed; guard already dropped, skip headless block.
227                } else {
228                    // No hook registered — non-headless library caller → allow.
229                    drop(guard);
230                }
231            } else if self.registry.headless && is_interactive_tool {
232                // Phase 6: headless mode — interactive tools go through external hooks.
233                if self.registry.hook_runner.is_empty() {
234                    return CheckOutcome::Deny {
235                        error: Error::PermissionDenied {
236                            name: tool_name.into(),
237                            reason: DecisionReason::Hook {
238                                name: "PermissionRequest".into(),
239                            },
240                        },
241                        audit: AuditMeta::synthetic_unknown_tool(tool_name),
242                    };
243                }
244                let hook_input = crate::hooks::external::HookInput {
245                    event: crate::hooks::external::HookEvent::PermissionRequest,
246                    tool_name: Some(tool_name.to_string()),
247                    args: Some(arguments.clone()),
248                    mode: format!("{:?}", self.registry.permission_mode),
249                    content: None,
250                    message: None,
251                    depth: None,
252                    reason: None,
253                    error: None,
254                };
255                // Drop the read guard before the async hook dispatch to
256                // avoid holding the lock across an await point.
257                drop(guard);
258                let hook_result = self.registry.hook_runner.dispatch(&hook_input).await;
259                if !matches!(hook_result.action, crate::hooks::HookAction::Continue) {
260                    return CheckOutcome::Deny {
261                        error: Error::PermissionDenied {
262                            name: tool_name.into(),
263                            reason: DecisionReason::Hook {
264                                name: "PermissionRequest".into(),
265                            },
266                        },
267                        audit: AuditMeta::synthetic_unknown_tool(tool_name),
268                    };
269                }
270            } else {
271                // Drop guard before tool execution (not holding across await).
272                if perm_is_unknown {
273                    // No explicit rule matched for this tool. It is being allowed
274                    // implicitly because the current permission mode is not Strict.
275                    // Consider using PermissionMode::Strict or adding an explicit
276                    // allow rule to silence this warning.
277                    tracing::warn!(
278                        tool = %tool_name,
279                        "tool has no explicit permission rule; \
280                         allowing implicitly (use strict mode to deny by default)"
281                    );
282                }
283                drop(guard);
284            }
285        }
286
287        // Phase 7: L1 policy check (shell + fs). Same logic as the post-hook
288        // recheck; we run it here for the main path so a tool that passed
289        // permission checks is still subject to L1 policy.
290        if let Err(e) = self.recheck_policy(tool_name, &arguments) {
291            return CheckOutcome::Deny {
292                error: e,
293                audit: AuditMeta::synthetic_unknown_tool(tool_name),
294            };
295        }
296
297        CheckOutcome::Allow { arguments }
298    }
299
300    /// Re-run the L1 policy check on `arguments`. Used internally by
301    /// [`check`] when a hook returns [`PermissionDecision::Transform`]
302    /// (a malicious hook could mutate args to bypass the original policy
303    /// check), and exposed publicly so external callers can validate
304    /// hook-mutated arguments before re-dispatch.
305    ///
306    /// Behavior:
307    /// - If no L1 policy is configured on the registry, returns `Ok(())`.
308    /// - If `arguments["command"]` is a string, runs `policy.check_shell(cmd)`.
309    /// - If `arguments["path"]` or `arguments["file_path"]` is a string,
310    ///   runs `policy.check_fs_path(path, is_write)`. `is_write` is
311    ///   `true` for `Write` / `Edit` / `StrReplace`, `false` otherwise.
312    ///
313    /// Returns the first policy error encountered. Callers must propagate
314    /// the error and stop dispatch.
315    pub fn recheck_policy(&self, tool_name: &str, arguments: &Value) -> Result<()> {
316        let Some(policy): Option<&PolicyConfig> = self.registry.policy.as_ref() else {
317            return Ok(());
318        };
319        if let Some(cmd) = arguments.get("command").and_then(|v| v.as_str()) {
320            policy.check_shell(cmd)?;
321        }
322        let is_write = matches!(tool_name, "Write" | "Edit" | "StrReplace");
323        let path_arg = arguments
324            .get("path")
325            .or_else(|| arguments.get("file_path"))
326            .and_then(|v| v.as_str());
327        if let Some(path) = path_arg {
328            policy.check_fs_path(path, is_write)?;
329        }
330        Ok(())
331    }
332}
333
334// ── Helpers ──────────────────────────────────────────────────────────────
335
336/// Extract the file-path "content" from tool arguments for the safety
337/// check in `check_static`. Returns `None` for tools that don't operate
338/// on a file path.
339///
340/// - `Write` / `Read`: extract `args["path"]`
341/// - `Edit`: extract `args["file_path"]`
342/// - All other tools: `None`
343fn safety_content_for_tool(name: &str, args: &serde_json::Value) -> Option<String> {
344    match name {
345        "Write" | "Read" => args["path"].as_str().map(String::from),
346        "Edit" => args["file_path"].as_str().map(String::from),
347        _ => None,
348    }
349}
350
351// ── Tests ────────────────────────────────────────────────────────────────
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::permissions::PermissionMode;
357    use crate::tools::policy_sandbox::{FsPolicy, ShellPolicy};
358    use async_trait::async_trait;
359
360    // ── recheck_policy tests (Goal 261 done-when) ──────────────────────
361
362    /// No policy configured → recheck_policy always returns Ok(()) even
363    /// if arguments contain policy-relevant fields.
364    #[test]
365    fn recheck_policy_with_no_policy_returns_ok() {
366        let registry = ToolRegistry::local();
367        let pipeline = PermissionPipeline::new(&registry);
368        let args = serde_json::json!({
369            "command": "rm -rf /",
370            "path": "/etc/passwd",
371        });
372        assert!(pipeline.recheck_policy("Bash", &args).is_ok());
373    }
374
375    /// Policy configured, shell command doesn't match deny patterns → Ok.
376    #[test]
377    fn recheck_policy_allows_clean_shell_command() {
378        let policy = PolicyConfig {
379            shell: ShellPolicy {
380                deny_patterns: vec!["rm -rf".into()],
381            },
382            fs: FsPolicy::default(),
383        };
384        let registry = ToolRegistry::local().with_policy(policy);
385        let pipeline = PermissionPipeline::new(&registry);
386        let args = serde_json::json!({"command": "ls -la"});
387        assert!(pipeline.recheck_policy("Bash", &args).is_ok());
388    }
389
390    /// Policy configured, shell command matches a deny pattern → Err.
391    #[test]
392    fn recheck_policy_blocks_denied_shell_command() {
393        let policy = PolicyConfig {
394            shell: ShellPolicy {
395                deny_patterns: vec!["rm -rf".into()],
396            },
397            fs: FsPolicy::default(),
398        };
399        let registry = ToolRegistry::local().with_policy(policy);
400        let pipeline = PermissionPipeline::new(&registry);
401        let args = serde_json::json!({"command": "rm -rf /"});
402        let err = pipeline.recheck_policy("Bash", &args).unwrap_err();
403        assert!(matches!(err, Error::PermissionDenied { .. }));
404    }
405
406    /// Policy configured, file path doesn't match any deny prefix → Ok.
407    #[test]
408    fn recheck_policy_allows_clean_path() {
409        let policy = PolicyConfig {
410            shell: ShellPolicy::default(),
411            fs: FsPolicy {
412                deny: vec!["/etc/".into()],
413                ..Default::default()
414            },
415        };
416        let registry = ToolRegistry::local().with_policy(policy);
417        let pipeline = PermissionPipeline::new(&registry);
418        let args = serde_json::json!({"path": "/tmp/scratch.txt"});
419        assert!(pipeline.recheck_policy("Write", &args).is_ok());
420    }
421
422    /// Policy configured, file path matches a deny prefix → Err.
423    #[test]
424    fn recheck_policy_blocks_denied_path() {
425        let policy = PolicyConfig {
426            shell: ShellPolicy::default(),
427            fs: FsPolicy {
428                deny: vec!["/etc/".into()],
429                ..Default::default()
430            },
431        };
432        let registry = ToolRegistry::local().with_policy(policy);
433        let pipeline = PermissionPipeline::new(&registry);
434        let args = serde_json::json!({"path": "/etc/passwd"});
435        let err = pipeline.recheck_policy("Write", &args).unwrap_err();
436        assert!(matches!(err, Error::PermissionDenied { .. }));
437    }
438
439    /// Edit tool reads from `file_path` field, not `path`. A path passed
440    /// via `file_path` should still be subject to fs policy.
441    #[test]
442    fn recheck_policy_reads_file_path_for_edit_tool() {
443        let policy = PolicyConfig {
444            shell: ShellPolicy::default(),
445            fs: FsPolicy {
446                deny: vec!["/secret/".into()],
447                ..Default::default()
448            },
449        };
450        let registry = ToolRegistry::local().with_policy(policy);
451        let pipeline = PermissionPipeline::new(&registry);
452        let args = serde_json::json!({"file_path": "/secret/credentials"});
453        let err = pipeline.recheck_policy("Edit", &args).unwrap_err();
454        assert!(matches!(err, Error::PermissionDenied { .. }));
455    }
456
457    /// Read tool on a denied path: is_write=false, but the deny prefix
458    /// is checked regardless of read/write for fs policy.
459    #[test]
460    fn recheck_policy_read_tool_still_subject_to_fs_deny() {
461        let policy = PolicyConfig {
462            shell: ShellPolicy::default(),
463            fs: FsPolicy {
464                deny: vec!["/secret/".into()],
465                ..Default::default()
466            },
467        };
468        let registry = ToolRegistry::local().with_policy(policy);
469        let pipeline = PermissionPipeline::new(&registry);
470        let args = serde_json::json!({"path": "/secret/file.txt"});
471        // Read uses is_write=false; check_fs_path still checks the deny list
472        // before any allow-list logic (per policy_sandbox::check_fs_path).
473        let err = pipeline.recheck_policy("Read", &args).unwrap_err();
474        assert!(matches!(err, Error::PermissionDenied { .. }));
475    }
476
477    // ── check() integration tests ──────────────────────────────────────
478
479    /// Static deny rule → CheckOutcome::Deny with the right reason.
480    #[tokio::test]
481    async fn check_denies_explicitly_denied_tool() {
482        use crate::permissions::{LayeredPermissionsConfig, PermissionLayer, RuleSource};
483        let config = LayeredPermissionsConfig {
484            mode: PermissionMode::Default,
485            layers: vec![PermissionLayer {
486                source: RuleSource::User,
487                deny: vec!["Bash".into()],
488                ..Default::default()
489            }],
490        };
491        let registry = ToolRegistry::local().with_permissions(config);
492        let pipeline = PermissionPipeline::new(&registry);
493        let outcome = pipeline
494            .check("Bash", serde_json::json!({"command": "ls"}))
495            .await;
496        assert!(outcome.is_deny());
497        if let CheckOutcome::Deny { error, audit: _ } = outcome {
498            assert!(matches!(error, Error::PermissionDenied { .. }));
499        }
500    }
501
502    /// Explicit allow rule + read-only tool → CheckOutcome::Allow.
503    #[tokio::test]
504    async fn check_allows_explicitly_allowed_tool() {
505        use crate::permissions::{LayeredPermissionsConfig, PermissionLayer, RuleSource};
506        let config = LayeredPermissionsConfig {
507            mode: PermissionMode::Default,
508            layers: vec![PermissionLayer {
509                source: RuleSource::User,
510                allow: vec!["Read".into()],
511                ..Default::default()
512            }],
513        };
514        let registry = ToolRegistry::local().with_permissions(config);
515        let pipeline = PermissionPipeline::new(&registry);
516        let outcome = pipeline
517            .check("Read", serde_json::json!({"path": "src/lib.rs"}))
518            .await;
519        match outcome {
520            CheckOutcome::Allow { arguments } => {
521                assert_eq!(arguments["path"], "src/lib.rs");
522            }
523            CheckOutcome::Deny { error, .. } => panic!("expected Allow, got Deny: {error:?}"),
524        }
525    }
526
527    /// Strict mode + Unknown tool → CheckOutcome::Deny.
528    #[tokio::test]
529    async fn check_strict_mode_denies_unknown_tool() {
530        use crate::permissions::LayeredPermissionsConfig;
531        let config = LayeredPermissionsConfig {
532            mode: PermissionMode::Strict,
533            ..Default::default()
534        };
535        let registry = ToolRegistry::local().with_permissions(config);
536        let pipeline = PermissionPipeline::new(&registry);
537        let outcome = pipeline
538            .check("Bash", serde_json::json!({"command": "ls"}))
539            .await;
540        assert!(outcome.is_deny());
541    }
542
543    /// Hook Transform → CheckOutcome::Allow with transformed arguments.
544    #[tokio::test]
545    async fn check_transform_hook_rewrites_arguments() {
546        use crate::agent::PermissionDecision;
547        use crate::tools::PermissionHook;
548        use std::sync::Arc;
549
550        struct TransformHook;
551        #[async_trait]
552        impl PermissionHook for TransformHook {
553            async fn check(&self, _name: &str, _args: &serde_json::Value) -> PermissionDecision {
554                PermissionDecision::Transform(serde_json::json!({
555                    "command": "echo hello",
556                }))
557            }
558        }
559
560        use crate::permissions::{LayeredPermissionsConfig, PermissionLayer, RuleSource};
561        let config = LayeredPermissionsConfig {
562            mode: PermissionMode::Default,
563            layers: vec![PermissionLayer {
564                source: RuleSource::User,
565                interactive: vec!["Bash".into()],
566                ..Default::default()
567            }],
568        };
569        let registry = ToolRegistry::local()
570            .with_permissions(config)
571            .with_permission_hook(Arc::new(TransformHook));
572        let pipeline = PermissionPipeline::new(&registry);
573        let outcome = pipeline
574            .check("Bash", serde_json::json!({"command": "rm -rf /"}))
575            .await;
576        match outcome {
577            CheckOutcome::Allow { arguments } => {
578                assert_eq!(arguments["command"], "echo hello");
579            }
580            CheckOutcome::Deny { error, .. } => panic!("expected Allow, got Deny: {error:?}"),
581        }
582    }
583
584    /// Hook Transform with policy-violating args → CheckOutcome::Deny.
585    /// The pipeline must catch a hook that tries to substitute a
586    /// policy-blocked command/path.
587    #[tokio::test]
588    async fn check_transform_with_policy_violation_denies() {
589        use crate::agent::PermissionDecision;
590        use crate::tools::PermissionHook;
591        use std::sync::Arc;
592
593        struct BadTransformHook;
594        #[async_trait]
595        impl PermissionHook for BadTransformHook {
596            async fn check(&self, _name: &str, _args: &serde_json::Value) -> PermissionDecision {
597                PermissionDecision::Transform(serde_json::json!({
598                    "command": "rm -rf /",
599                }))
600            }
601        }
602
603        let policy = PolicyConfig {
604            shell: ShellPolicy {
605                deny_patterns: vec!["rm -rf".into()],
606            },
607            fs: FsPolicy::default(),
608        };
609        use crate::permissions::{LayeredPermissionsConfig, PermissionLayer, RuleSource};
610        let config = LayeredPermissionsConfig {
611            mode: PermissionMode::Default,
612            layers: vec![PermissionLayer {
613                source: RuleSource::User,
614                interactive: vec!["Bash".into()],
615                ..Default::default()
616            }],
617        };
618        let registry = ToolRegistry::local()
619            .with_permissions(config)
620            .with_policy(policy)
621            .with_permission_hook(Arc::new(BadTransformHook));
622        let pipeline = PermissionPipeline::new(&registry);
623        let outcome = pipeline
624            .check("Bash", serde_json::json!({"command": "echo hi"}))
625            .await;
626        assert!(outcome.is_deny());
627    }
628
629    /// The pipeline's L1 policy check catches a tool that passed the
630    /// permission checks but violates the shell deny policy.
631    #[tokio::test]
632    async fn check_l1_policy_catches_post_permission_violation() {
633        use crate::permissions::{LayeredPermissionsConfig, PermissionLayer, RuleSource};
634        let config = LayeredPermissionsConfig {
635            mode: PermissionMode::Default,
636            layers: vec![PermissionLayer {
637                source: RuleSource::User,
638                allow: vec!["Bash".into()],
639                ..Default::default()
640            }],
641        };
642        let policy = PolicyConfig {
643            shell: ShellPolicy {
644                deny_patterns: vec!["dangerous".into()],
645            },
646            fs: FsPolicy::default(),
647        };
648        let registry = ToolRegistry::local()
649            .with_permissions(config)
650            .with_policy(policy);
651        let pipeline = PermissionPipeline::new(&registry);
652        let outcome = pipeline
653            .check("Bash", serde_json::json!({"command": "dangerous_cmd"}))
654            .await;
655        assert!(outcome.is_deny());
656    }
657
658    // ── safety_content_for_tool targeted tests ────────────────────────────────
659
660    #[test]
661    fn safety_content_read_extracts_path_field() {
662        // kills mutations that swap "path" with "file_path" for Read/Write
663        let args = serde_json::json!({"path": "/tmp/file.txt"});
664        let result = safety_content_for_tool("Read", &args);
665        assert_eq!(result.as_deref(), Some("/tmp/file.txt"));
666    }
667
668    #[test]
669    fn safety_content_write_extracts_path_field() {
670        // kills removal of "Write" from the match arm
671        let args = serde_json::json!({"path": "/tmp/out.txt"});
672        let result = safety_content_for_tool("Write", &args);
673        assert_eq!(result.as_deref(), Some("/tmp/out.txt"));
674    }
675
676    #[test]
677    fn safety_content_edit_extracts_file_path_field() {
678        // kills mutations swapping "file_path" with "path" in the Edit arm
679        let args = serde_json::json!({"file_path": "/src/main.rs"});
680        let result = safety_content_for_tool("Edit", &args);
681        assert_eq!(result.as_deref(), Some("/src/main.rs"));
682    }
683
684    #[test]
685    fn safety_content_other_tool_returns_none() {
686        // kills `_ => Some(...)` mutations and function-level replacements
687        let args = serde_json::json!({"path": "/tmp/x", "command": "ls"});
688        let result = safety_content_for_tool("Bash", &args);
689        assert!(
690            result.is_none(),
691            "non-file tools must return None from safety_content_for_tool"
692        );
693    }
694
695    #[test]
696    fn recheck_policy_treats_str_replace_as_write() {
697        // kills removal of "StrReplace" from the is_write matches! arm.
698        // write_allow=["/allowed/"] means only /allowed/ prefix is allowed for
699        // writes. A non-allowed path must be denied when is_write=true.
700        // If is_write were false, write_allow wouldn't apply and it would pass.
701        let policy = PolicyConfig {
702            shell: ShellPolicy::default(),
703            fs: FsPolicy {
704                write_allow: vec!["/allowed/".into()],
705                ..Default::default()
706            },
707        };
708        let registry = ToolRegistry::local().with_policy(policy);
709        let pipeline = PermissionPipeline::new(&registry);
710        let args = serde_json::json!({"path": "/other/data.json"});
711        // StrReplace must be treated as is_write=true → denied (not in write_allow)
712        let result = pipeline.recheck_policy("StrReplace", &args);
713        assert!(
714            result.is_err(),
715            "StrReplace to a path outside write_allow must be denied"
716        );
717    }
718}
719
720// =====================================================================
721// Goal H2 (NEW-PERM-1 + NEW-PERM-2) — source-grep snapshot tests
722// pin the structural fixes:
723//   - hook Allow path must re-run recheck_policy
724//   - safety check (protected paths) must run even when
725//     `permissions` is None
726// =====================================================================
727#[cfg(test)]
728mod goal_h2_perm_pipeline {
729    #[test]
730    fn hook_allow_path_runs_recheck_policy() {
731        let src = include_str!("permission_pipeline.rs");
732        // The fix: the `PermissionDecision::Allow` arm of the hook
733        // block must call `recheck_policy`. Match the closing brace
734        // of the Transform arm (which already had a recheck) and
735        // the Allow arm, then assert that both mention
736        // `recheck_policy`.
737        let hook_block = src
738            .split("PermissionDecision::Deny(reason) =>")
739            .nth(1)
740            .expect("hook block must exist")
741            .split("// Hook allowed/transformed;")
742            .next()
743            .expect("hook block must include the post-hook marker");
744        let allow_arm = hook_block
745            .split("PermissionDecision::Allow =>")
746            .nth(1)
747            .expect("Allow arm must exist")
748            .split('}')
749            .next()
750            .expect("Allow arm must terminate");
751        assert!(
752            allow_arm.contains("recheck_policy"),
753            "PermissionDecision::Allow arm must re-run recheck_policy"
754        );
755    }
756
757    #[test]
758    fn safety_check_runs_without_permissions() {
759        // The fix: the `if let Some(ref sp) = self.registry.permissions`
760        // block must come AFTER the protected-path check, not before.
761        let src = include_str!("permission_pipeline.rs");
762        let safety_idx = src
763            .find("check_protected_path")
764            .expect("NEW-PERM-2 safety check call must exist");
765        let perms_idx = src
766            .find("if let Some(ref sp) = self.registry.permissions")
767            .expect("permissions block must still exist (per-mode checks)");
768        assert!(
769            safety_idx < perms_idx,
770            "safety check must run BEFORE the permissions block (so it \
771             runs even when `permissions` is None)"
772        );
773    }
774
775    // ── CheckOutcome::is_deny targeted tests ─────────────────────────────────
776
777    #[test]
778    fn check_outcome_is_deny_false_for_allow_outcome() {
779        // kills `replace CheckOutcome::is_deny -> bool with true`
780        use crate::tools::permission_pipeline::CheckOutcome;
781        let outcome = CheckOutcome::Allow {
782            arguments: serde_json::json!({"path": "src/lib.rs"}),
783        };
784        assert!(
785            !outcome.is_deny(),
786            "Allow outcome must NOT be a deny (is_deny must return false)"
787        );
788    }
789
790    #[test]
791    fn check_outcome_is_deny_true_for_deny_outcome() {
792        // complementary: Deny variant must return true
793        use crate::tools::permission_pipeline::CheckOutcome;
794        let err = crate::error::Error::BadToolArgs {
795            name: "test".into(),
796            message: "blocked".into(),
797        };
798        let audit = crate::tools::AuditMeta::synthetic_unknown_tool("test");
799        let outcome = CheckOutcome::Deny { error: err, audit };
800        assert!(
801            outcome.is_deny(),
802            "Deny outcome must return true from is_deny()"
803        );
804    }
805}