1use 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#[derive(Debug)]
50pub enum CheckOutcome {
51 Deny { error: Error, audit: AuditMeta },
52 Allow { arguments: Value },
53}
54
55impl CheckOutcome {
56 pub fn is_deny(&self) -> bool {
58 matches!(self, CheckOutcome::Deny { .. })
59 }
60}
61
62pub struct PermissionPipeline<'a> {
68 registry: &'a ToolRegistry,
69}
70
71impl<'a> PermissionPipeline<'a> {
72 pub fn new(registry: &'a ToolRegistry) -> Self {
74 Self { registry }
75 }
76
77 pub async fn check(&self, tool_name: &str, mut arguments: Value) -> CheckOutcome {
85 let safety_content = safety_content_for_tool(tool_name, &arguments);
87
88 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 if let Some(ref sp) = self.registry.permissions {
114 let guard = sp.read().await;
115
116 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 }
143 Err(_e) => {
144 }
146 }
147 }
148 }
150
151 let is_readonly = self.registry.is_readonly(tool_name);
153 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 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 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 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 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 } else {
228 drop(guard);
230 }
231 } else if self.registry.headless && is_interactive_tool {
232 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(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 if perm_is_unknown {
273 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 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 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
334fn 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#[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 #[test]
365 fn recheck_policy_with_no_policy_returns_ok() {
366 let registry = ToolRegistry::local();
367 let pipeline = PermissionPipeline::new(®istry);
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 #[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(®istry);
386 let args = serde_json::json!({"command": "ls -la"});
387 assert!(pipeline.recheck_policy("Bash", &args).is_ok());
388 }
389
390 #[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(®istry);
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 #[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(®istry);
418 let args = serde_json::json!({"path": "/tmp/scratch.txt"});
419 assert!(pipeline.recheck_policy("Write", &args).is_ok());
420 }
421
422 #[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(®istry);
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 #[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(®istry);
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 #[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(®istry);
470 let args = serde_json::json!({"path": "/secret/file.txt"});
471 let err = pipeline.recheck_policy("Read", &args).unwrap_err();
474 assert!(matches!(err, Error::PermissionDenied { .. }));
475 }
476
477 #[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(®istry);
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 #[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(®istry);
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 #[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(®istry);
537 let outcome = pipeline
538 .check("Bash", serde_json::json!({"command": "ls"}))
539 .await;
540 assert!(outcome.is_deny());
541 }
542
543 #[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(®istry);
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 #[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(®istry);
623 let outcome = pipeline
624 .check("Bash", serde_json::json!({"command": "echo hi"}))
625 .await;
626 assert!(outcome.is_deny());
627 }
628
629 #[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(®istry);
652 let outcome = pipeline
653 .check("Bash", serde_json::json!({"command": "dangerous_cmd"}))
654 .await;
655 assert!(outcome.is_deny());
656 }
657
658 #[test]
661 fn safety_content_read_extracts_path_field() {
662 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 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 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 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 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(®istry);
710 let args = serde_json::json!({"path": "/other/data.json"});
711 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#[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 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 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 #[test]
778 fn check_outcome_is_deny_false_for_allow_outcome() {
779 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 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}