1use std::collections::BTreeMap;
29use std::io::{PipeReader, PipeWriter, Read};
30#[cfg(windows)]
31use std::io::{Seek, SeekFrom};
32#[cfg(unix)]
33use std::os::unix::process::CommandExt as _;
34use std::path::{Path, PathBuf};
35use std::process::{Child, Stdio};
36use std::sync::{Arc, LazyLock};
37use std::time::Duration;
38
39use agent_bridle_core::{
40 best_available_sandbox, confinement_unenforceable, effective_sandbox_kind, enforcement_report,
41 human_gate, is_unbridled, Caveats, Denial, DenialKind, Disclosure, EnforcementReport,
42 LimitsPolicy, SandboxKind, SandboxPolicy, Tool, ToolContext, ToolEnvelope, ToolError,
43 ToolResult,
44};
45use async_trait::async_trait;
46
47use crate::net_proxy;
48use crate::output_observer::{output_session, OutputEmitter};
49use crate::parse::{
50 classify, seg_literal, Arg, Command, Redirect, Refusal, Script, ScriptItem, Seg, Sep, StderrTo,
51};
52
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub(crate) struct Captured {
62 pub exit_code: i32,
63 pub stdout: String,
64 pub stderr: String,
65 pub stdout_truncated: bool,
67 pub stderr_truncated: bool,
69 pub net_denials: Vec<Denial>,
75 pub timed_out: bool,
79}
80
81pub(crate) struct SpawnCfg {
94 pub max_output: usize,
96 pub audit_sink: Option<String>,
98 pub sandbox: Arc<SandboxPolicy>,
100 pub unbridled: bool,
104 pub output: OutputEmitter,
106 pub timeout: Duration,
110}
111
112pub(crate) trait Spawner: Send + Sync {
113 fn run(
122 &self,
123 stages: &[Command],
124 cwd: Option<&str>,
125 caveats: &Caveats,
126 env: &BTreeMap<String, String>,
127 cfg: &SpawnCfg,
128 ) -> ToolResult<Captured>;
129}
130
131struct OsSpawner;
134
135impl Spawner for OsSpawner {
136 fn run(
137 &self,
138 stages: &[Command],
139 cwd: Option<&str>,
140 caveats: &Caveats,
141 env: &BTreeMap<String, String>,
142 cfg: &SpawnCfg,
143 ) -> ToolResult<Captured> {
144 if cfg.unbridled {
148 return run_pipeline(
149 stages,
150 cwd,
151 &[],
152 env,
153 cfg.max_output,
154 cfg.output.clone(),
155 cfg.timeout,
156 );
157 }
158 if let Some((allow_hosts, fenced)) = egress_proxy_plan(caveats, &cfg.sandbox) {
163 return run_with_egress_proxy(stages, cwd, &fenced, env, allow_hosts, cfg);
164 }
165 if intended_sandbox_kind(caveats, &cfg.sandbox) == SandboxKind::None {
169 run_pipeline(
170 stages,
171 cwd,
172 &[],
173 env,
174 cfg.max_output,
175 cfg.output.clone(),
176 cfg.timeout,
177 )
178 } else {
179 run_confined(stages, cwd, caveats, env, cfg)
180 }
181 }
182}
183
184fn egress_proxy_plan(
191 caveats: &Caveats,
192 sandbox: &Arc<SandboxPolicy>,
193) -> Option<(Vec<String>, Caveats)> {
194 agent_bridle_core::egress_proxy_plan(caveats, sandbox)
195}
196
197fn run_with_egress_proxy(
206 stages: &[Command],
207 cwd: Option<&str>,
208 fenced: &Caveats,
209 env: &BTreeMap<String, String>,
210 allow_hosts: Vec<String>,
211 cfg: &SpawnCfg,
212) -> ToolResult<Captured> {
213 let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(fenced)?;
215 let proxy = net_proxy::start(
219 allow_hosts,
220 Arc::new(net_proxy::StdResolver),
221 net_audit_sink(cfg.audit_sink.as_deref()),
222 )
223 .map_err(ToolError::Exec)?;
224 let mut env = env.clone();
227 for (k, v) in proxy.proxy_env() {
228 env.insert(k, v);
229 }
230
231 let stages = stages.to_vec();
232 let cwd = cwd.map(str::to_string);
233 let fenced = fenced.clone();
234 let max_output = cfg.max_output;
235 let output = cfg.output.clone();
236 let sandbox = cfg.sandbox.clone();
237 let timeout = cfg.timeout;
238 let captured = std::thread::Builder::new()
239 .name("agent-bridle-confined".to_string())
240 .spawn(move || {
241 best_available_sandbox(&sandbox).apply(&fenced)?;
242 run_pipeline(
243 &stages,
244 cwd.as_deref(),
245 &prefix,
246 &env,
247 max_output,
248 output,
249 timeout,
250 )
251 })
252 .map_err(ToolError::Exec)?
253 .join()
254 .map_err(|_| {
255 ToolError::Exec(std::io::Error::other("confined execution thread panicked"))
256 })?;
257 let refused = proxy.refused_hosts();
261 drop(proxy); let mut captured = captured?;
263 captured.net_denials = refused
264 .into_iter()
265 .map(|host| Denial {
266 kind: DenialKind::Net,
267 reason: format!("net does not permit '{host}'"),
268 target: host,
269 })
270 .collect();
271 Ok(captured)
272}
273
274fn net_audit_sink(configured: Option<&str>) -> Arc<dyn net_proxy::AuditSink> {
283 match configured {
284 Some(path) if !path.is_empty() => std::fs::OpenOptions::new()
285 .create(true)
286 .append(true)
287 .open(path)
288 .map(|f| Arc::new(net_proxy::JsonlSink::new(f)) as Arc<dyn net_proxy::AuditSink>)
289 .unwrap_or_else(|_| Arc::new(net_proxy::NullSink)),
290 _ => Arc::new(net_proxy::NullSink),
291 }
292}
293
294fn intended_sandbox_kind(caveats: &Caveats, sandbox: &Arc<SandboxPolicy>) -> SandboxKind {
300 effective_sandbox_kind(best_available_sandbox(sandbox).kind(), caveats)
301}
302
303fn run_confined(
314 stages: &[Command],
315 cwd: Option<&str>,
316 caveats: &Caveats,
317 env: &BTreeMap<String, String>,
318 cfg: &SpawnCfg,
319) -> ToolResult<Captured> {
320 let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(caveats)?;
322 let stages = stages.to_vec();
323 let cwd = cwd.map(str::to_string);
324 let caveats = caveats.clone();
325 let env = env.clone();
326 let max_output = cfg.max_output;
327 let output = cfg.output.clone();
328 let sandbox = cfg.sandbox.clone();
329 let timeout = cfg.timeout;
330 std::thread::Builder::new()
331 .name("agent-bridle-confined".to_string())
332 .spawn(move || {
333 best_available_sandbox(&sandbox).apply(&caveats)?;
334 run_pipeline(
335 &stages,
336 cwd.as_deref(),
337 &prefix,
338 &env,
339 max_output,
340 output,
341 timeout,
342 )
343 })
344 .map_err(ToolError::Exec)?
345 .join()
346 .map_err(|_| ToolError::Exec(std::io::Error::other("confined execution thread panicked")))?
347}
348
349static SHELL_SCHEMA: LazyLock<serde_json::Value> = LazyLock::new(|| {
356 serde_json::from_str(include_str!("shell_tool.schema.json"))
357 .expect("embedded shell_tool.schema.json must be valid JSON")
358});
359
360#[derive(Clone)]
367pub struct ShellTool {
368 spawner: Arc<dyn Spawner>,
369 env: Arc<dyn EnvProvider>,
370 lister: Arc<dyn DirLister>,
371 limits: LimitsPolicy,
372 sandbox: Arc<SandboxPolicy>,
375 output_observer: Option<Arc<dyn crate::ShellOutputObserver>>,
376}
377
378impl std::fmt::Debug for ShellTool {
379 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380 f.write_str("ShellTool")
381 }
382}
383
384impl ShellTool {
385 #[must_use]
388 pub fn new() -> Self {
389 Self::with_config(LimitsPolicy::default())
390 }
391
392 #[must_use]
395 pub fn with_config(limits: LimitsPolicy) -> Self {
396 Self {
397 spawner: Arc::new(OsSpawner),
398 env: Arc::new(RealEnv),
399 lister: Arc::new(RealDirLister),
400 limits,
401 sandbox: Arc::new(SandboxPolicy::default()),
402 output_observer: None,
403 }
404 }
405
406 #[must_use]
413 pub fn with_output_observer(mut self, observer: Arc<dyn crate::ShellOutputObserver>) -> Self {
414 self.output_observer = Some(observer);
415 self
416 }
417
418 #[must_use]
421 pub fn with_sandbox_policy(mut self, sandbox: SandboxPolicy) -> Self {
422 self.sandbox = Arc::new(sandbox);
423 self
424 }
425
426 #[cfg(test)]
428 fn with_spawner(spawner: Arc<dyn Spawner>) -> Self {
429 Self {
430 spawner,
431 env: Arc::new(RealEnv),
432 lister: Arc::new(RealDirLister),
433 limits: LimitsPolicy::default(),
434 sandbox: Arc::new(SandboxPolicy::default()),
435 output_observer: None,
436 }
437 }
438
439 #[cfg(test)]
443 fn with_spawner_and_env(spawner: Arc<dyn Spawner>, env: Arc<dyn EnvProvider>) -> Self {
444 Self {
445 spawner,
446 env,
447 lister: Arc::new(RealDirLister),
448 limits: LimitsPolicy::default(),
449 sandbox: Arc::new(SandboxPolicy::default()),
450 output_observer: None,
451 }
452 }
453
454 #[cfg(test)]
458 fn with_seams(
459 spawner: Arc<dyn Spawner>,
460 env: Arc<dyn EnvProvider>,
461 lister: Arc<dyn DirLister>,
462 ) -> Self {
463 Self {
464 spawner,
465 env,
466 lister,
467 limits: LimitsPolicy::default(),
468 sandbox: Arc::new(SandboxPolicy::default()),
469 output_observer: None,
470 }
471 }
472}
473
474impl Default for ShellTool {
475 fn default() -> Self {
476 Self::new()
477 }
478}
479
480#[async_trait]
481impl Tool for ShellTool {
482 fn name(&self) -> &str {
483 "shell"
484 }
485
486 fn schema(&self) -> serde_json::Value {
487 let mut schema = SHELL_SCHEMA.clone();
493 schema["properties"]["timeout_secs"]["maximum"] =
494 serde_json::Value::from(self.limits.max_timeout_secs);
495 schema
496 }
497
498 async fn invoke(
499 &self,
500 args: serde_json::Value,
501 cx: &ToolContext,
502 ) -> ToolResult<serde_json::Value> {
503 let parsed = ShellArgs::parse(&args, &self.limits)?;
504 let unbridled = is_unbridled();
510 let sandbox_kind = if unbridled {
522 SandboxKind::None
523 } else {
524 match egress_proxy_plan(cx.caveats(), &self.sandbox) {
525 Some((_, fenced)) => intended_sandbox_kind(&fenced, &self.sandbox),
526 None => intended_sandbox_kind(cx.caveats(), &self.sandbox),
527 }
528 };
529 let enforcement = enforcement_report(cx.caveats(), sandbox_kind);
532
533 let mut script = match parsed.script() {
535 Ok(s) => s,
536 Err(refusal) => {
537 return Ok(refused_envelope(
538 sandbox_kind,
539 enforcement,
540 &refusal,
541 parsed.cmd.as_deref(),
542 ))
543 }
544 };
545
546 for item in &mut script {
552 for stage in &mut item.pipeline {
553 let mut new_argv: Vec<Arg> = Vec::with_capacity(stage.argv.len());
559 for (i, arg) in stage.argv.drain(..).enumerate() {
560 let pattern: Option<String> = if i == 0 {
561 None
562 } else {
563 match &arg {
564 Arg::Glob(p) => Some(p.clone()),
565 Arg::VarGlob(segs) => {
566 match expand_varglob(segs, &*self.env, &self.limits.var_allowlist) {
567 Ok(p) => Some(p),
568 Err((target, e)) => {
569 return Ok(deny(
570 sandbox_kind,
571 enforcement,
572 DenialKind::Exec,
573 &target,
574 &e,
575 ))
576 }
577 }
578 }
579 _ => None,
580 }
581 };
582 match pattern {
583 Some(p) => {
584 let mut leash = |dir: &Path| cx.check_path_read(dir);
585 match expand_glob_walk(
586 &p,
587 parsed.cwd.as_deref(),
588 &*self.lister,
589 &mut leash,
590 self.limits.max_glob_depth,
591 self.limits.max_glob_matches,
592 ) {
593 Ok(ms) => new_argv.extend(ms.into_iter().map(Arg::Lit)),
594 Err(e) => {
595 return Ok(deny(
596 sandbox_kind,
597 enforcement,
598 DenialKind::Open,
599 &p,
600 &e,
601 ))
602 }
603 }
604 }
605 None => new_argv.push(arg),
606 }
607 }
608 stage.argv = new_argv;
609 for redirect in &mut stage.redirects {
610 let segs = match redirect {
611 Redirect::Stdout { path, .. }
612 | Redirect::Stderr { path, .. }
613 | Redirect::Stdin { path } => path,
614 Redirect::StderrToStdout => continue,
615 };
616 match expand_redirect_target(segs, &*self.env, &self.limits.var_allowlist) {
617 Ok(resolved) => *segs = vec![Seg::Lit(resolved)],
618 Err((target, e)) => {
619 return Ok(deny(
620 sandbox_kind,
621 enforcement,
622 DenialKind::Open,
623 &target,
624 &e,
625 ))
626 }
627 }
628 }
629 }
630 }
631
632 for item in &script {
638 for stage in &item.pipeline {
639 match stage.argv.first() {
640 Some(Arg::Lit(program)) => {
641 if let Err(e) = cx.check_exec(program) {
642 return Ok(deny(
643 sandbox_kind,
644 enforcement,
645 DenialKind::Exec,
646 program,
647 &e,
648 ));
649 }
650 }
651 Some(Arg::Glob(pattern)) => {
652 return Ok(deny(
653 sandbox_kind,
654 enforcement,
655 DenialKind::Exec,
656 pattern,
657 &ToolError::denied("a glob pattern is not allowed as a program name"),
658 ));
659 }
660 Some(Arg::Var(_segs)) => {
661 return Ok(deny(
662 sandbox_kind,
663 enforcement,
664 DenialKind::Exec,
665 "$VAR",
666 &ToolError::denied("a variable is not allowed as a program name"),
667 ));
668 }
669 Some(Arg::VarGlob(_)) => {
672 return Ok(deny(
673 sandbox_kind,
674 enforcement,
675 DenialKind::Exec,
676 "$VAR/glob",
677 &ToolError::denied("a glob pattern is not allowed as a program name"),
678 ));
679 }
680 None => {} }
682 for arg in &stage.argv {
683 match arg {
684 Arg::Var(segs) => {
687 for seg in segs {
688 if let Seg::Var(name) = seg {
689 if !is_allowed_var(name, &self.limits.var_allowlist) {
690 return Ok(deny(
691 sandbox_kind,
692 enforcement,
693 DenialKind::Exec,
694 &format!("${name}"),
695 &ToolError::denied(format!(
696 "variable ${name} is not in the confined shell's allowlist"
697 )),
698 ));
699 }
700 }
701 }
702 }
703 Arg::Glob(_) => unreachable!("glob expanded at admission"),
706 Arg::VarGlob(_) => unreachable!("VarGlob expanded at admission"),
707 Arg::Lit(_) => {}
708 }
709 }
710 for redirect in &stage.redirects {
711 let (path, checked) = match redirect {
714 Redirect::Stdout { path, .. } | Redirect::Stderr { path, .. } => {
715 let p = seg_literal(path).expect("redirect target lowered");
716 (p, cx.check_path_write(Path::new(p)))
717 }
718 Redirect::Stdin { path } => {
719 let p = seg_literal(path).expect("redirect target lowered");
720 (p, cx.check_path_read(Path::new(p)))
721 }
722 Redirect::StderrToStdout => continue,
724 };
725 if let Err(e) = checked {
726 return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, path, &e));
727 }
728 }
729 }
730 }
731 if let Some(cwd) = &parsed.cwd {
733 if let Err(e) = cx.check_path_read(Path::new(cwd)) {
734 return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, cwd, &e));
735 }
736 }
737
738 if !unbridled && confinement_unenforceable(sandbox_kind, cx.caveats(), cx.strength_floor())
756 {
757 return Ok(deny(
758 sandbox_kind,
759 enforcement,
760 DenialKind::Exec,
761 "confinement",
762 &ToolError::denied(format!(
763 "a restricted filesystem/exec/net axis cannot be enforced on this host \
764 at the required strength floor ({:?}); refusing to run unconfined",
765 cx.strength_floor()
766 )),
767 ));
768 }
769
770 let spawner = Arc::clone(&self.spawner);
773 let cwd = parsed.cwd.clone();
774 let timeout = parsed.timeout;
775 let (output_guard, output) =
776 output_session(self.output_observer.clone(), self.limits.max_output_bytes);
777 let cfg = SpawnCfg {
778 max_output: self.limits.max_output_bytes,
779 audit_sink: self.limits.audit_sink.clone(),
780 sandbox: Arc::clone(&self.sandbox),
781 unbridled,
782 output,
783 timeout,
784 };
785 let disclosure = Disclosure {
787 unbridled,
788 human_gate: human_gate(),
789 ..Disclosure::default()
790 };
791 let (env, _dropped_env) =
798 agent_bridle_core::fence_env(&parsed.env, &self.limits.env_denylist);
799 let caveats = cx.caveats().clone();
800 let run = tokio::task::spawn_blocking(move || {
801 run_script(&*spawner, &script, cwd.as_deref(), &caveats, &env, &cfg)
802 });
803 match tokio::time::timeout(timeout, run).await {
811 Ok(joined) => {
812 let captured = joined
813 .map_err(|e| ToolError::Other(anyhow::anyhow!("shell task panicked: {e}")))??;
814 let envelope = ToolEnvelope::new(sandbox_kind)
818 .with_enforcement(enforcement)
819 .with_disclosure(disclosure)
820 .with_exit_code(captured.exit_code)
821 .with_truncation(captured.stdout_truncated, captured.stderr_truncated)
822 .with_stdout(captured.stdout)
823 .with_stderr(captured.stderr)
824 .with_denials(captured.net_denials)
825 .with_timed_out(captured.timed_out)
826 .into_json();
827 output_guard.finish();
828 Ok(envelope)
829 }
830 Err(_elapsed) => {
831 drop(output_guard);
834 Ok(ToolEnvelope::new(sandbox_kind)
835 .with_enforcement(enforcement)
836 .with_disclosure(disclosure)
837 .with_stderr(format!("command timed out after {}s", timeout.as_secs()))
838 .with_timed_out(true)
839 .into_json())
840 }
841 }
842 }
843}
844
845fn run_script(
849 spawner: &dyn Spawner,
850 script: &[ScriptItem],
851 cwd: Option<&str>,
852 caveats: &Caveats,
853 env: &BTreeMap<String, String>,
854 cfg: &SpawnCfg,
855) -> ToolResult<Captured> {
856 let mut stdout = String::new();
857 let mut stderr = String::new();
858 let mut status: i32 = 0;
859 let mut stdout_truncated = false;
860 let mut stderr_truncated = false;
861 let mut net_denials: Vec<Denial> = Vec::new();
863 let mut timed_out = false;
864
865 for item in script {
866 let run_it = match item.sep {
867 Sep::Seq => true,
868 Sep::And => status == 0,
869 Sep::Or => status != 0,
870 };
871 if run_it {
872 let captured = spawner.run(&item.pipeline, cwd, caveats, env, cfg)?;
873 stdout.push_str(&captured.stdout);
874 stderr.push_str(&captured.stderr);
875 stdout_truncated |= captured.stdout_truncated;
876 stderr_truncated |= captured.stderr_truncated;
877 net_denials.extend(captured.net_denials);
878 status = captured.exit_code;
879 if captured.timed_out {
882 timed_out = true;
883 break;
884 }
885 }
886 }
887
888 let stdout_truncated = stdout_truncated || stdout.len() > cfg.max_output;
890 let stderr_truncated = stderr_truncated || stderr.len() > cfg.max_output;
891
892 Ok(Captured {
893 exit_code: status,
894 stdout: cap_string(stdout, cfg.max_output),
895 stderr: cap_string(stderr, cfg.max_output),
896 net_denials,
897 stdout_truncated,
898 stderr_truncated,
899 timed_out,
900 })
901}
902
903fn deny(
905 sandbox_kind: SandboxKind,
906 enforcement: EnforcementReport,
907 kind: DenialKind,
908 target: &str,
909 err: &ToolError,
910) -> serde_json::Value {
911 ToolEnvelope::new(sandbox_kind)
912 .with_enforcement(enforcement)
913 .with_disclosure(unbridle_disclosure())
914 .with_denials(vec![Denial {
915 kind,
916 target: target.to_string(),
917 reason: err.to_string(),
918 }])
919 .into_json()
920}
921
922fn unbridle_disclosure() -> Disclosure {
926 Disclosure {
927 unbridled: is_unbridled(),
928 human_gate: human_gate(),
929 ..Disclosure::default()
930 }
931}
932
933fn refused_envelope(
935 sandbox_kind: SandboxKind,
936 enforcement: EnforcementReport,
937 refusal: &Refusal,
938 cmd: Option<&str>,
939) -> serde_json::Value {
940 let envelope = ToolEnvelope::new(sandbox_kind)
941 .with_enforcement(enforcement)
942 .with_disclosure(unbridle_disclosure())
943 .with_denials(vec![Denial {
944 kind: DenialKind::Exec,
945 target: refusal.construct(),
946 reason: refusal.to_string(),
947 }])
948 .into_json();
949
950 #[cfg(feature = "brush")]
957 {
958 let mut envelope = envelope;
959 if matches!(refusal, Refusal::Dynamic(_)) {
960 if let Some(cmd) = cmd {
961 if let Ok(inspection) = crate::inspect_shell(cmd) {
962 if let Ok(value) = serde_json::to_value(inspection) {
963 envelope["shell_inspection"] = value;
964 }
965 }
966 }
967 }
968 envelope
969 }
970 #[cfg(not(feature = "brush"))]
971 {
972 let _ = cmd;
973 envelope
974 }
975}
976
977struct ShellArgs {
979 program: Option<String>,
980 args: Vec<String>,
981 cmd: Option<String>,
982 cwd: Option<String>,
983 env: BTreeMap<String, String>,
987 timeout: Duration,
988}
989
990impl ShellArgs {
991 fn parse(v: &serde_json::Value, limits: &LimitsPolicy) -> ToolResult<Self> {
992 let obj = v
993 .as_object()
994 .ok_or_else(|| ToolError::denied("shell args must be a JSON object"))?;
995
996 let program = obj
997 .get("program")
998 .and_then(|x| x.as_str())
999 .map(String::from);
1000 let cmd = obj.get("cmd").and_then(|x| x.as_str()).map(String::from);
1001 let args = obj
1002 .get("args")
1003 .and_then(|x| x.as_array())
1004 .map(|a| {
1005 a.iter()
1006 .filter_map(|x| x.as_str().map(String::from))
1007 .collect::<Vec<_>>()
1008 })
1009 .unwrap_or_default();
1010 let cwd = obj.get("cwd").and_then(|x| x.as_str()).map(String::from);
1011 let env = obj
1015 .get("env")
1016 .and_then(|x| x.as_object())
1017 .map(|m| {
1018 m.iter()
1019 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1020 .collect::<BTreeMap<String, String>>()
1021 })
1022 .unwrap_or_default();
1023 let timeout_secs = obj
1024 .get("timeout_secs")
1025 .and_then(serde_json::Value::as_u64)
1026 .unwrap_or(limits.default_timeout_secs)
1027 .clamp(1, limits.max_timeout_secs);
1028
1029 match (&program, &cmd) {
1030 (Some(_), Some(_)) => {
1031 return Err(ToolError::denied(
1032 "provide exactly one of `program` or `cmd`, not both",
1033 ))
1034 }
1035 (None, None) => return Err(ToolError::denied("provide one of `program` or `cmd`")),
1036 _ => {}
1037 }
1038 if program.is_none() && !args.is_empty() {
1039 return Err(ToolError::denied(
1040 "`args` may only be used together with `program`",
1041 ));
1042 }
1043
1044 Ok(Self {
1045 program,
1046 args,
1047 cmd,
1048 cwd,
1049 env,
1050 timeout: Duration::from_secs(timeout_secs),
1051 })
1052 }
1053
1054 fn script(&self) -> Result<Script, Refusal> {
1058 if let Some(program) = &self.program {
1059 let mut argv = Vec::with_capacity(1 + self.args.len());
1060 argv.push(Arg::Lit(program.clone()));
1061 argv.extend(self.args.iter().cloned().map(Arg::Lit));
1062 Ok(vec![ScriptItem {
1063 sep: Sep::Seq,
1064 pipeline: vec![Command {
1065 argv,
1066 redirects: Vec::new(),
1067 }],
1068 }])
1069 } else {
1070 classify(self.cmd.as_deref().unwrap_or(""))
1071 }
1072 }
1073}
1074
1075fn is_allowed_var(name: &str, allowlist: &[String]) -> bool {
1084 allowlist.iter().any(|v| v == name)
1085}
1086
1087pub(crate) trait EnvProvider: Send + Sync {
1092 fn get(&self, name: &str) -> Option<String>;
1094}
1095
1096pub(crate) struct RealEnv;
1098impl EnvProvider for RealEnv {
1099 fn get(&self, name: &str) -> Option<String> {
1100 std::env::var(name).ok()
1101 }
1102}
1103
1104fn expand_redirect_target(
1109 segs: &[Seg],
1110 env: &dyn EnvProvider,
1111 allowlist: &[String],
1112) -> Result<String, (String, ToolError)> {
1113 let mut out = String::new();
1114 for seg in segs {
1115 match seg {
1116 Seg::Lit(s) => out.push_str(s),
1117 Seg::Var(name) => {
1118 if !is_allowed_var(name, allowlist) {
1119 return Err((
1120 format!("${name}"),
1121 ToolError::denied(format!(
1122 "variable ${name} is not in the confined shell's allowlist"
1123 )),
1124 ));
1125 }
1126 out.push_str(&env.get(name).unwrap_or_default());
1127 }
1128 }
1129 }
1130 Ok(out)
1131}
1132
1133fn expand_varglob(
1143 segs: &[Seg],
1144 env: &dyn EnvProvider,
1145 allowlist: &[String],
1146) -> Result<String, (String, ToolError)> {
1147 let mut out = String::new();
1148 let mut last_var_byte: Option<usize> = None; let mut last_slash_byte: Option<usize> = None; for seg in segs {
1151 match seg {
1152 Seg::Lit(s) => {
1153 for ch in s.chars() {
1154 if ch == '/' {
1155 last_slash_byte = Some(out.len());
1156 }
1157 out.push(ch);
1158 }
1159 }
1160 Seg::Var(name) => {
1161 if !is_allowed_var(name, allowlist) {
1162 return Err((
1163 format!("${name}"),
1164 ToolError::denied(format!(
1165 "variable ${name} is not in the confined shell's allowlist"
1166 )),
1167 ));
1168 }
1169 for ch in env.get(name).unwrap_or_default().chars() {
1170 if ch == '/' {
1171 last_slash_byte = Some(out.len());
1172 }
1173 last_var_byte = Some(out.len());
1174 out.push(ch);
1175 }
1176 }
1177 }
1178 }
1179 let basename_start = last_slash_byte.map_or(0, |i| i + 1);
1182 if last_var_byte.is_some_and(|v| v >= basename_start) {
1183 return Err((
1184 "$VAR".to_string(),
1185 ToolError::denied(
1186 "a variable in a glob's basename is not supported (re-injection guard); \
1187 put the variable in the directory prefix, e.g. $DIR/*.rs",
1188 ),
1189 ));
1190 }
1191 Ok(out)
1192}
1193
1194#[derive(Debug, Clone, PartialEq, Eq)]
1199pub(crate) struct GlobEntry {
1200 pub name: String,
1201 pub is_dir: bool,
1202}
1203
1204pub(crate) trait DirLister: Send + Sync {
1207 fn list(&self, dir: &Path) -> Vec<GlobEntry>;
1209}
1210
1211pub(crate) struct RealDirLister;
1213impl DirLister for RealDirLister {
1214 fn list(&self, dir: &Path) -> Vec<GlobEntry> {
1215 std::fs::read_dir(dir)
1216 .map(|rd| {
1217 rd.filter_map(|e| {
1218 let e = e.ok()?;
1219 let name = e.file_name().into_string().ok()?;
1220 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
1221 Some(GlobEntry { name, is_dir })
1222 })
1223 .collect()
1224 })
1225 .unwrap_or_default()
1226 }
1227}
1228
1229fn join_rel(rel: &str, name: &str) -> String {
1232 if rel.is_empty() {
1233 name.to_string()
1234 } else if rel == "/" {
1235 format!("/{name}")
1236 } else {
1237 format!("{rel}/{name}")
1238 }
1239}
1240
1241fn descend_all(
1245 real: &Path,
1246 rel: &str,
1247 list: &dyn DirLister,
1248 leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
1249 depth: usize,
1250 max_matches: usize,
1251 out: &mut Vec<(PathBuf, String)>,
1252) -> ToolResult<()> {
1253 if depth == 0 || out.len() >= max_matches {
1254 return Ok(());
1255 }
1256 leash(real)?;
1257 let mut entries = list.list(real);
1258 entries.sort_by(|a, b| a.name.cmp(&b.name));
1259 for e in entries {
1260 if e.is_dir && !e.name.starts_with('.') {
1261 let child_real = real.join(&e.name);
1262 let child_rel = join_rel(rel, &e.name);
1263 out.push((child_real.clone(), child_rel.clone()));
1264 if out.len() >= max_matches {
1265 break;
1266 }
1267 descend_all(
1268 &child_real,
1269 &child_rel,
1270 list,
1271 leash,
1272 depth - 1,
1273 max_matches,
1274 out,
1275 )?;
1276 }
1277 }
1278 Ok(())
1279}
1280
1281fn expand_glob_walk(
1289 pattern: &str,
1290 cwd: Option<&str>,
1291 list: &dyn DirLister,
1292 leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
1293 max_depth: usize,
1294 max_matches: usize,
1295) -> ToolResult<Vec<String>> {
1296 let absolute = pattern.starts_with('/');
1297 let segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
1298
1299 let base_real = if absolute {
1300 PathBuf::from("/")
1301 } else {
1302 cwd.map_or_else(|| PathBuf::from("."), PathBuf::from)
1303 };
1304 let base_rel = if absolute {
1305 "/".to_string()
1306 } else {
1307 String::new()
1308 };
1309 let mut frontier: Vec<(PathBuf, String)> = vec![(base_real, base_rel)];
1310
1311 for seg in &segments {
1312 let mut next: Vec<(PathBuf, String)> = Vec::new();
1313 if *seg == "**" {
1314 for (real, rel) in &frontier {
1315 next.push((real.clone(), rel.clone())); descend_all(real, rel, list, leash, max_depth, max_matches, &mut next)?;
1317 }
1318 } else {
1319 let seg_hidden = seg.starts_with('.');
1320 for (real, rel) in &frontier {
1321 leash(real)?;
1322 let mut entries = list.list(real);
1323 entries.sort_by(|a, b| a.name.cmp(&b.name));
1324 for e in entries {
1325 if (seg_hidden || !e.name.starts_with('.')) && fnmatch(seg, &e.name) {
1326 next.push((real.join(&e.name), join_rel(rel, &e.name)));
1327 if next.len() >= max_matches {
1328 break;
1329 }
1330 }
1331 }
1332 }
1333 }
1334 frontier = next;
1335 if frontier.is_empty() {
1336 break;
1337 }
1338 }
1339
1340 let mut matches: Vec<String> = frontier.into_iter().map(|(_, rel)| rel).collect();
1341 matches.retain(|m| !m.is_empty()); matches.sort();
1343 matches.dedup();
1344 if matches.is_empty() {
1345 Ok(vec![pattern.to_string()])
1346 } else {
1347 Ok(matches)
1348 }
1349}
1350
1351fn fnmatch(pattern: &str, name: &str) -> bool {
1354 let p: Vec<char> = pattern.chars().collect();
1355 let n: Vec<char> = name.chars().collect();
1356 fnmatch_inner(&p, &n)
1357}
1358
1359fn fnmatch_inner(p: &[char], n: &[char]) -> bool {
1360 match p.first() {
1361 None => n.is_empty(),
1362 Some('*') => fnmatch_inner(&p[1..], n) || (!n.is_empty() && fnmatch_inner(p, &n[1..])),
1363 Some('?') => !n.is_empty() && fnmatch_inner(&p[1..], &n[1..]),
1364 Some('[') => {
1365 if n.is_empty() {
1366 return false;
1367 }
1368 match match_class(&p[1..], n[0]) {
1369 Some((matched, rest)) => matched && fnmatch_inner(rest, &n[1..]),
1370 None => n[0] == '[' && fnmatch_inner(&p[1..], &n[1..]),
1372 }
1373 }
1374 Some(&c) => !n.is_empty() && c == n[0] && fnmatch_inner(&p[1..], &n[1..]),
1375 }
1376}
1377
1378fn match_class(p: &[char], c: char) -> Option<(bool, &[char])> {
1381 let mut i = 0;
1382 let negate = matches!(p.first(), Some('!' | '^'));
1383 if negate {
1384 i = 1;
1385 }
1386 let mut matched = false;
1387 let mut first = true;
1388 while i < p.len() {
1389 if p[i] == ']' && !first {
1390 return Some((matched ^ negate, &p[i + 1..]));
1391 }
1392 first = false;
1393 if i + 2 < p.len() && p[i + 1] == '-' && p[i + 2] != ']' {
1394 if c >= p[i] && c <= p[i + 2] {
1395 matched = true;
1396 }
1397 i += 3;
1398 } else {
1399 if c == p[i] {
1400 matched = true;
1401 }
1402 i += 1;
1403 }
1404 }
1405 None
1406}
1407
1408fn open_for_write(path: &str, append: bool) -> std::io::Result<std::fs::File> {
1412 #[cfg(windows)]
1413 if append {
1414 let mut file = std::fs::OpenOptions::new()
1415 .write(true)
1416 .create(true)
1417 .truncate(false)
1418 .open(path)?;
1419 file.seek(SeekFrom::End(0))?;
1420 return Ok(file);
1421 }
1422
1423 std::fs::OpenOptions::new()
1424 .write(true)
1425 .create(true)
1426 .truncate(!append)
1427 .append(append)
1428 .open(path)
1429}
1430
1431fn kill_all(children: &mut [Child]) {
1434 for child in children.iter_mut() {
1435 let _ = child.kill();
1436 let _ = child.wait();
1437 }
1438}
1439
1440fn expand_stage_argv(stage: &Command, _cwd: Option<&str>) -> Vec<String> {
1445 let mut argv = Vec::with_capacity(stage.argv.len());
1446 for arg in &stage.argv {
1447 match arg {
1448 Arg::Lit(s) => argv.push(s.clone()),
1449 Arg::Var(segs) => {
1453 let mut word = String::new();
1454 for seg in segs {
1455 match seg {
1456 Seg::Lit(s) => word.push_str(s),
1457 Seg::Var(name) => word.push_str(&std::env::var(name).unwrap_or_default()),
1458 }
1459 }
1460 argv.push(word);
1461 }
1462 Arg::Glob(_) => unreachable!("glob expanded at admission"),
1466 Arg::VarGlob(_) => unreachable!("VarGlob lowered/expanded at admission"),
1467 }
1468 }
1469 argv
1470}
1471
1472fn kill_pipeline_stage(child: &mut Child) {
1488 #[cfg(unix)]
1492 if let Some(pid) = rustix::process::Pid::from_raw(child.id() as i32) {
1493 let _ = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
1494 }
1495 let _ = child.kill();
1496}
1497
1498fn run_pipeline(
1499 stages: &[Command],
1500 cwd: Option<&str>,
1501 wrap: &[String],
1502 env: &BTreeMap<String, String>,
1503 max_output: usize,
1504 output: OutputEmitter,
1505 timeout: Duration,
1506) -> ToolResult<Captured> {
1507 debug_assert!(!stages.is_empty(), "the parser guarantees ≥1 stage");
1508 let n = stages.len();
1509 let last = n - 1;
1510
1511 let mut children: Vec<Child> = Vec::with_capacity(n);
1512 let mut prev_stdin: Option<PipeReader> = None;
1514 let mut stdout_capture: Option<PipeReader> = None;
1516 let mut stderr_threads: Vec<std::thread::JoinHandle<(Vec<u8>, bool)>> = Vec::new();
1519
1520 for (i, stage) in stages.iter().enumerate() {
1521 let is_last = i == last;
1522 let stage_argv = expand_stage_argv(stage, cwd);
1523 let argv: Vec<String> = if wrap.is_empty() {
1528 stage_argv
1529 } else {
1530 wrap.iter().cloned().chain(stage_argv).collect()
1531 };
1532 let mut cmd = std::process::Command::new(&argv[0]);
1533 cmd.args(&argv[1..]);
1534 #[cfg(unix)]
1537 cmd.process_group(0);
1538 if let Some(dir) = cwd {
1539 cmd.current_dir(dir);
1540 }
1541 #[cfg(unix)]
1555 {
1556 cmd.env_clear();
1557 cmd.env("PATH", agent_bridle_core::default_exec_path());
1558 cmd.env("LC_ALL", "C");
1559 }
1560 for (k, v) in env {
1561 cmd.env(k, v);
1562 }
1563
1564 if let Some(path) = stage.stdin_path() {
1566 let file = ok_or_kill(std::fs::File::open(path), &mut children)?;
1567 cmd.stdin(Stdio::from(file));
1568 prev_stdin = None;
1569 } else {
1570 cmd.stdin(match prev_stdin.take() {
1571 Some(reader) => Stdio::from(reader),
1572 None => Stdio::null(),
1573 });
1574 }
1575
1576 let dup_source: DupSource;
1580 if let Some((path, append)) = stage.stdout_redirect() {
1581 let file = ok_or_kill(open_for_write(path, append), &mut children)?;
1582 let clone = ok_or_kill(file.try_clone(), &mut children)?;
1583 cmd.stdout(Stdio::from(file));
1584 dup_source = DupSource::File(clone);
1585 } else {
1586 let (reader, writer) = ok_or_kill(std::io::pipe(), &mut children)?;
1587 let clone = ok_or_kill(writer.try_clone(), &mut children)?;
1588 cmd.stdout(Stdio::from(writer));
1589 if is_last {
1590 stdout_capture = Some(reader);
1591 } else {
1592 prev_stdin = Some(reader);
1593 }
1594 dup_source = DupSource::Pipe(clone);
1595 }
1596
1597 match stage.stderr_disposition() {
1599 StderrTo::Stdout => match dup_source {
1602 DupSource::File(f) => {
1603 cmd.stderr(Stdio::from(f));
1604 }
1605 DupSource::Pipe(w) => {
1606 cmd.stderr(Stdio::from(w));
1607 }
1608 },
1609 StderrTo::File { path, append } => {
1611 let file = ok_or_kill(open_for_write(&path, append), &mut children)?;
1612 cmd.stderr(Stdio::from(file));
1613 }
1615 StderrTo::Capture => {
1617 cmd.stderr(Stdio::piped());
1618 }
1619 }
1620
1621 let mut child = ok_or_kill(cmd.spawn(), &mut children)?;
1622
1623 if matches!(stage.stderr_disposition(), StderrTo::Capture) {
1624 let err = child.stderr.take().expect("stderr is piped");
1625 let output = output.clone();
1626 stderr_threads.push(std::thread::spawn(move || {
1627 read_capped_observed(err, max_output, &output, crate::ShellOutputStream::Stderr)
1628 }));
1629 }
1630 children.push(child);
1631 }
1632
1633 let stdout_thread = stdout_capture.map(|reader| {
1637 std::thread::spawn(move || {
1638 read_capped_observed(
1639 reader,
1640 max_output,
1641 &output,
1642 crate::ShellOutputStream::Stdout,
1643 )
1644 })
1645 });
1646
1647 let deadline = std::time::Instant::now() + timeout;
1653 let mut exit_code = -1;
1654 let mut timed_out = false;
1655 let mut done = vec![false; children.len()];
1656 loop {
1657 let mut all_done = true;
1658 for (i, child) in children.iter_mut().enumerate() {
1659 if done[i] {
1660 continue;
1661 }
1662 match child.try_wait().map_err(ToolError::Exec)? {
1663 Some(status) => {
1664 done[i] = true;
1665 if i == last {
1666 exit_code = status.code().unwrap_or(-1);
1667 }
1668 }
1669 None => all_done = false,
1670 }
1671 }
1672 if all_done {
1673 break;
1674 }
1675 if std::time::Instant::now() >= deadline {
1676 timed_out = true;
1677 for child in children.iter_mut() {
1678 kill_pipeline_stage(child);
1679 }
1680 for child in children.iter_mut() {
1682 let _ = child.wait();
1683 }
1684 break;
1685 }
1686 std::thread::sleep(Duration::from_millis(15));
1687 }
1688
1689 let (stdout, stdout_truncated) =
1690 stdout_thread.map_or((Vec::new(), false), |h| h.join().unwrap_or_default());
1691 let mut stderr = Vec::new();
1692 let mut stderr_truncated = false;
1693 for h in stderr_threads {
1694 let (buf, trunc) = h.join().unwrap_or_default();
1695 stderr.extend(buf);
1696 stderr_truncated |= trunc;
1697 }
1698 let stderr_truncated = stderr_truncated || stderr.len() > max_output;
1701
1702 Ok(Captured {
1703 exit_code,
1704 stdout: capped_utf8(&stdout, max_output),
1705 stderr: capped_utf8(&stderr, max_output),
1706 stdout_truncated,
1707 stderr_truncated,
1708 net_denials: Vec::new(),
1711 timed_out,
1712 })
1713}
1714
1715enum DupSource {
1717 File(std::fs::File),
1718 Pipe(PipeWriter),
1719}
1720
1721fn ok_or_kill<T>(result: std::io::Result<T>, children: &mut [Child]) -> ToolResult<T> {
1724 result.map_err(|e| {
1725 kill_all(children);
1726 ToolError::Exec(e)
1727 })
1728}
1729
1730fn read_capped_observed(
1741 mut reader: impl Read,
1742 max_output: usize,
1743 output: &OutputEmitter,
1744 stream: crate::ShellOutputStream,
1745) -> (Vec<u8>, bool) {
1746 let mut buf = Vec::with_capacity(max_output.min(8 * 1024));
1747 let mut chunk = [0u8; 8 * 1024];
1748 while buf.len() < max_output {
1749 let remaining = max_output - buf.len();
1750 let read_len = remaining.min(chunk.len());
1751 match reader.read(&mut chunk[..read_len]) {
1752 Ok(0) => return (buf, false),
1753 Ok(n) => {
1754 output.emit(stream, &chunk[..n]);
1755 buf.extend_from_slice(&chunk[..n]);
1756 }
1757 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1758 Err(_) => return (buf, false),
1759 }
1760 }
1761 let mut probe = [0u8; 1];
1762 let truncated = loop {
1763 match reader.read(&mut probe) {
1764 Ok(n) => break n > 0,
1765 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1766 Err(_) => break false,
1767 }
1768 };
1769 (buf, truncated)
1770}
1771
1772#[cfg(test)]
1773fn read_capped(reader: impl Read, max_output: usize) -> (Vec<u8>, bool) {
1774 read_capped_observed(
1775 reader,
1776 max_output,
1777 &OutputEmitter::default(),
1778 crate::ShellOutputStream::Stdout,
1779 )
1780}
1781
1782fn capped_utf8(bytes: &[u8], max_output: usize) -> String {
1787 let slice = &bytes[..bytes.len().min(max_output)];
1788 String::from_utf8_lossy(slice).into_owned()
1789}
1790
1791fn cap_string(mut s: String, max_output: usize) -> String {
1794 if s.len() > max_output {
1795 let mut end = max_output;
1796 while !s.is_char_boundary(end) {
1797 end -= 1;
1798 }
1799 s.truncate(end);
1800 }
1801 s
1802}
1803
1804#[cfg(test)]
1805mod tests {
1806 use super::*;
1807 use agent_bridle_core::{Caveats, Gate, Scope};
1808 use std::collections::HashMap;
1809 use std::sync::{mpsc, Mutex};
1810
1811 #[test]
1815 fn schema_loads_from_data_file_with_expected_shape() {
1816 let s = ShellTool::new().schema();
1817 assert_eq!(s["type"], "object");
1818 assert_eq!(s["additionalProperties"], false);
1819 for key in ["program", "args", "cmd", "cwd", "env", "timeout_secs"] {
1820 assert!(
1821 s["properties"].get(key).is_some(),
1822 "schema is missing the `{key}` property: {s}"
1823 );
1824 }
1825 }
1826
1827 #[test]
1831 fn schema_timeout_maximum_tracks_the_configured_limits() {
1832 let limits = agent_bridle_core::LimitsPolicy {
1833 max_timeout_secs: 7,
1834 ..agent_bridle_core::LimitsPolicy::default()
1835 };
1836 let s = ShellTool::with_config(limits).schema();
1837 assert_eq!(s["properties"]["timeout_secs"]["maximum"], 7);
1838 assert!(SHELL_SCHEMA["properties"]["timeout_secs"]
1840 .get("maximum")
1841 .is_none());
1842 }
1843
1844 #[cfg(feature = "brush")]
1848 #[tokio::test]
1849 async fn dynamic_refusal_attaches_non_executing_shell_inspection() {
1850 let cmd = r#"ls -1 $(find . -name "*.rs" -type f -exec wc -l {} + 2>/dev/null | sort -nr | head -10)"#;
1851 let mock = Arc::new(MockSpawner::default());
1852 let out = ShellTool::with_spawner(mock.clone())
1853 .invoke(serde_json::json!({"cmd": cmd}), &ctx(Caveats::top()))
1854 .await
1855 .expect("structured refusal");
1856
1857 assert_eq!(out["denied"], true);
1858 assert_eq!(out["denials"][0]["target"], "command substitution `$(`");
1859 assert_eq!(out["shell_inspection"]["source"], cmd);
1860 assert_eq!(
1861 out["shell_inspection"]["constructs"][0]["kind"],
1862 "command_substitution"
1863 );
1864 assert_eq!(
1865 out["shell_inspection"]["constructs"][0]["inspection"]["commands"][0]
1866 ["descendant_execs"][0]["program"],
1867 "wc"
1868 );
1869 assert!(
1870 calls(&mock).is_empty(),
1871 "inspection must not execute any stage: {out}"
1872 );
1873
1874 let arithmetic_cmd = r#"echo "$((1 + 2))""#;
1875 let arithmetic = ShellTool::with_spawner(mock.clone())
1876 .invoke(
1877 serde_json::json!({"cmd": arithmetic_cmd}),
1878 &ctx(Caveats::top()),
1879 )
1880 .await
1881 .expect("structured arithmetic refusal");
1882
1883 assert_eq!(
1884 arithmetic["denials"][0]["target"],
1885 "arithmetic expansion `$((`"
1886 );
1887 assert_eq!(
1888 arithmetic["shell_inspection"]["constructs"][0]["kind"],
1889 "arithmetic_expansion"
1890 );
1891 assert!(
1892 calls(&mock).is_empty(),
1893 "arithmetic inspection must not execute any stage: {arithmetic}"
1894 );
1895
1896 let runtime_arithmetic = ShellTool::with_spawner(mock.clone())
1897 .invoke(
1898 serde_json::json!({"cmd": "echo $((runtime_value))"}),
1899 &ctx(Caveats::top()),
1900 )
1901 .await
1902 .expect("structured runtime arithmetic refusal");
1903 assert_eq!(
1904 runtime_arithmetic["denials"][0]["target"],
1905 "arithmetic expansion `$((`"
1906 );
1907 assert!(
1908 runtime_arithmetic.get("shell_inspection").is_none(),
1909 "an incomplete runtime-state projection must not be attached: {runtime_arithmetic}"
1910 );
1911 assert!(
1912 calls(&mock).is_empty(),
1913 "runtime arithmetic inspection must not execute any stage: {runtime_arithmetic}"
1914 );
1915 }
1916
1917 #[derive(Default)]
1920 struct MockSpawner {
1921 calls: Mutex<Vec<Vec<Command>>>,
1922 envs: Mutex<Vec<BTreeMap<String, String>>>,
1925 exit_by_program: HashMap<String, i32>,
1926 block_ms: u64,
1927 net_denials: Vec<Denial>,
1931 }
1932
1933 impl MockSpawner {
1934 fn with_exit(program: &str, code: i32) -> Self {
1935 let mut m = Self::default();
1936 m.exit_by_program.insert(program.to_string(), code);
1937 m
1938 }
1939
1940 fn with_net_denials(denials: Vec<Denial>) -> Self {
1943 Self {
1944 net_denials: denials,
1945 ..Self::default()
1946 }
1947 }
1948 }
1949
1950 fn prog(stage: &Command) -> &str {
1953 match stage.argv.first() {
1954 Some(Arg::Lit(s) | Arg::Glob(s)) => s,
1955 Some(Arg::Var(_) | Arg::VarGlob(_)) | None => "",
1956 }
1957 }
1958
1959 impl Spawner for MockSpawner {
1960 fn run(
1961 &self,
1962 stages: &[Command],
1963 _cwd: Option<&str>,
1964 _caveats: &Caveats,
1965 env: &BTreeMap<String, String>,
1966 _cfg: &SpawnCfg,
1967 ) -> ToolResult<Captured> {
1968 self.calls.lock().unwrap().push(stages.to_vec());
1969 self.envs.lock().unwrap().push(env.clone());
1970 if self.block_ms > 0 {
1971 std::thread::sleep(Duration::from_millis(self.block_ms));
1972 }
1973 Ok(Captured {
1974 exit_code: self
1975 .exit_by_program
1976 .get(prog(&stages[0]))
1977 .copied()
1978 .unwrap_or(0),
1979 stdout: String::new(),
1980 stderr: String::new(),
1981 net_denials: self.net_denials.clone(),
1982 ..Default::default()
1983 })
1984 }
1985 }
1986
1987 struct CoordinatedSpawner {
1988 proceed: Mutex<mpsc::Receiver<()>>,
1989 finished: mpsc::Sender<()>,
1990 }
1991
1992 impl Spawner for CoordinatedSpawner {
1993 fn run(
1994 &self,
1995 _stages: &[Command],
1996 _cwd: Option<&str>,
1997 _caveats: &Caveats,
1998 _env: &BTreeMap<String, String>,
1999 cfg: &SpawnCfg,
2000 ) -> ToolResult<Captured> {
2001 cfg.output.emit(crate::ShellOutputStream::Stdout, b"first");
2002 self.proceed
2003 .lock()
2004 .expect("proceed lock")
2005 .recv()
2006 .expect("test releases spawner");
2007 cfg.output.emit(crate::ShellOutputStream::Stdout, b"second");
2008 self.finished.send(()).expect("test observes completion");
2009 Ok(Captured {
2010 exit_code: 0,
2011 stdout: "firstsecond".to_string(),
2012 ..Default::default()
2013 })
2014 }
2015 }
2016
2017 fn coordinated_spawner() -> (
2018 Arc<CoordinatedSpawner>,
2019 mpsc::Sender<()>,
2020 mpsc::Receiver<()>,
2021 ) {
2022 let (proceed_tx, proceed_rx) = mpsc::channel();
2023 let (finished_tx, finished_rx) = mpsc::channel();
2024 (
2025 Arc::new(CoordinatedSpawner {
2026 proceed: Mutex::new(proceed_rx),
2027 finished: finished_tx,
2028 }),
2029 proceed_tx,
2030 finished_rx,
2031 )
2032 }
2033
2034 struct BlockingObserver {
2035 entered: mpsc::Sender<()>,
2036 release: Mutex<mpsc::Receiver<()>>,
2037 finished: mpsc::Sender<()>,
2038 }
2039
2040 impl crate::ShellOutputObserver for BlockingObserver {
2041 fn on_output(
2042 &self,
2043 _invocation: crate::ShellInvocationId,
2044 _stream: crate::ShellOutputStream,
2045 _chunk: &[u8],
2046 ) {
2047 self.entered.send(()).expect("observer entered callback");
2048 self.release
2049 .lock()
2050 .expect("observer release lock")
2051 .recv()
2052 .expect("test releases blocked observer");
2053 }
2054
2055 fn on_finish(&self, _invocation: crate::ShellInvocationId) {
2056 self.finished.send(()).expect("record unexpected finish");
2057 }
2058 }
2059
2060 struct TemporalPipelineSpawner;
2061
2062 impl Spawner for TemporalPipelineSpawner {
2063 fn run(
2064 &self,
2065 stages: &[Command],
2066 _cwd: Option<&str>,
2067 _caveats: &Caveats,
2068 _env: &BTreeMap<String, String>,
2069 cfg: &SpawnCfg,
2070 ) -> ToolResult<Captured> {
2071 assert_eq!(stages.len(), 2, "the test request is one pipeline");
2072 cfg.output
2075 .emit(crate::ShellOutputStream::Stderr, b"second-stage");
2076 cfg.output
2077 .emit(crate::ShellOutputStream::Stderr, b"first-stage");
2078 Ok(Captured {
2079 exit_code: 0,
2080 stderr: "firs".to_string(),
2081 stderr_truncated: true,
2082 ..Default::default()
2083 })
2084 }
2085 }
2086
2087 #[derive(Debug, PartialEq, Eq)]
2088 enum PipelineObserverEvent {
2089 Output(crate::ShellInvocationId, crate::ShellOutputStream, Vec<u8>),
2090 Finish(crate::ShellInvocationId),
2091 }
2092
2093 struct PipelineObserver(mpsc::Sender<PipelineObserverEvent>);
2094
2095 impl crate::ShellOutputObserver for PipelineObserver {
2096 fn on_output(
2097 &self,
2098 invocation: crate::ShellInvocationId,
2099 stream: crate::ShellOutputStream,
2100 chunk: &[u8],
2101 ) {
2102 self.0
2103 .send(PipelineObserverEvent::Output(
2104 invocation,
2105 stream,
2106 chunk.to_vec(),
2107 ))
2108 .expect("record pipeline output");
2109 }
2110
2111 fn on_finish(&self, invocation: crate::ShellInvocationId) {
2112 self.0
2113 .send(PipelineObserverEvent::Finish(invocation))
2114 .expect("record pipeline finish");
2115 }
2116 }
2117
2118 #[tokio::test]
2119 async fn observer_receives_output_before_invoke_completes() {
2120 let (spawner, proceed, finished) = coordinated_spawner();
2121 let (seen_tx, seen_rx) = mpsc::channel();
2122 let seen_rx = Arc::new(Mutex::new(seen_rx));
2123 let observer = Arc::new(move |invocation, stream, chunk: &[u8]| {
2124 seen_tx
2125 .send((invocation, stream, chunk.to_vec()))
2126 .expect("test receives observer callback");
2127 });
2128 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
2129 let context = ctx(exec_only(&["anything"]));
2130
2131 let invoke = tokio::spawn(async move {
2132 tool.invoke(serde_json::json!({"program": "anything"}), &context)
2133 .await
2134 });
2135 let first_rx = Arc::clone(&seen_rx);
2136 let first = tokio::task::spawn_blocking(move || {
2137 first_rx
2138 .lock()
2139 .expect("observer receiver lock")
2140 .recv_timeout(Duration::from_secs(2))
2141 })
2142 .await
2143 .expect("receiver task")
2144 .expect("live callback before completion");
2145 let invocation = first.0;
2146 assert_eq!(
2147 first,
2148 (
2149 invocation,
2150 crate::ShellOutputStream::Stdout,
2151 b"first".to_vec()
2152 )
2153 );
2154 assert!(!invoke.is_finished(), "the tool must still be running");
2155
2156 proceed.send(()).expect("release spawner");
2157 finished
2158 .recv_timeout(Duration::from_secs(2))
2159 .expect("spawner completion");
2160 let out = invoke.await.expect("invoke task").expect("invoke result");
2161 assert_eq!(out["stdout"], "firstsecond");
2162 assert_eq!(
2163 seen_rx
2164 .lock()
2165 .expect("observer receiver lock")
2166 .recv_timeout(Duration::from_secs(2))
2167 .expect("second callback"),
2168 (
2169 invocation,
2170 crate::ShellOutputStream::Stdout,
2171 b"second".to_vec()
2172 )
2173 );
2174 }
2175
2176 #[tokio::test]
2177 async fn pipeline_stderr_live_cap_is_temporal_but_envelope_is_authoritative() {
2178 let (events_tx, events_rx) = mpsc::channel();
2179 let mut tool = ShellTool::with_spawner(Arc::new(TemporalPipelineSpawner));
2180 tool.limits.max_output_bytes = 4;
2181 let tool = tool.with_output_observer(Arc::new(PipelineObserver(events_tx)));
2182
2183 let out = tool
2184 .invoke(
2185 serde_json::json!({"cmd": "first | second"}),
2186 &ctx(exec_only(&["first", "second"])),
2187 )
2188 .await
2189 .expect("invoke pipeline");
2190
2191 assert_eq!(out["stderr"], "firs");
2192 assert_eq!(out["stderr_truncated"], true);
2193 let first = events_rx
2194 .recv_timeout(Duration::from_secs(2))
2195 .expect("live stderr event");
2196 let invocation = match first {
2197 PipelineObserverEvent::Output(id, crate::ShellOutputStream::Stderr, bytes) => {
2198 assert_eq!(bytes, b"seco", "the live cap follows enqueue order");
2199 id
2200 }
2201 other => panic!("unexpected first observer event: {other:?}"),
2202 };
2203 assert_eq!(
2204 events_rx
2205 .recv_timeout(Duration::from_secs(2))
2206 .expect("queue-drained finish"),
2207 PipelineObserverEvent::Finish(invocation)
2208 );
2209 assert!(
2210 events_rx.try_recv().is_err(),
2211 "the later stage-order bytes are outside the live cap"
2212 );
2213 }
2214
2215 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2216 async fn cancellation_does_not_wait_for_a_blocked_observer_or_deliver_late_output() {
2217 let (spawner, proceed, finished) = coordinated_spawner();
2218 let (seen_tx, seen_rx) = mpsc::channel();
2219 let (entered_tx, entered_rx) = mpsc::channel();
2220 let (release_observer_tx, release_observer_rx) = mpsc::channel();
2221 let release_observer_rx = Mutex::new(release_observer_rx);
2222 let observer = Arc::new(move |invocation, stream, chunk: &[u8]| {
2223 seen_tx
2224 .send((invocation, stream, chunk.to_vec()))
2225 .expect("observer receiver remains alive");
2226 entered_tx.send(()).expect("observer entered callback");
2227 release_observer_rx
2228 .lock()
2229 .expect("observer release lock")
2230 .recv()
2231 .expect("test releases blocked observer");
2232 });
2233 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
2234 let context = ctx(exec_only(&["anything"]));
2235
2236 let mut invoke = tokio::spawn(async move {
2237 tool.invoke(serde_json::json!({"program": "anything"}), &context)
2238 .await
2239 });
2240 entered_rx
2241 .recv_timeout(Duration::from_secs(2))
2242 .expect("observer is blocked in its first callback");
2243 let first = seen_rx
2244 .recv_timeout(Duration::from_secs(2))
2245 .expect("first callback");
2246 assert_eq!(first.1, crate::ShellOutputStream::Stdout);
2247 assert_eq!(first.2, b"first");
2248
2249 invoke.abort();
2250 let cancelled = tokio::time::timeout(Duration::from_millis(500), &mut invoke).await;
2251 proceed.send(()).expect("release detached worker");
2252 release_observer_tx
2253 .send(())
2254 .expect("release presentation callback");
2255 finished
2256 .recv_timeout(Duration::from_secs(2))
2257 .expect("detached worker attempted its late write");
2258 let cancelled = cancelled.expect("cancellation must not wait for observer code");
2259 assert!(
2260 cancelled.expect_err("invoke is cancelled").is_cancelled(),
2261 "the invocation future was cancelled"
2262 );
2263 assert!(
2264 seen_rx.recv_timeout(Duration::from_millis(50)).is_err(),
2265 "output emitted by the detached worker after cancellation is ignored"
2266 );
2267 }
2268
2269 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2270 async fn timeout_does_not_wait_for_a_blocked_observer_or_finish_the_session() {
2271 let (spawner, proceed, worker_finished) = coordinated_spawner();
2272 let (entered_tx, entered_rx) = mpsc::channel();
2273 let (release_tx, release_rx) = mpsc::channel();
2274 let (finish_tx, finish_rx) = mpsc::channel();
2275 let observer = Arc::new(BlockingObserver {
2276 entered: entered_tx,
2277 release: Mutex::new(release_rx),
2278 finished: finish_tx,
2279 });
2280 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
2281 let context = ctx(exec_only(&["anything"]));
2282
2283 let mut invoke = tokio::spawn(async move {
2284 tool.invoke(
2285 serde_json::json!({"program": "anything", "timeout_secs": 1}),
2286 &context,
2287 )
2288 .await
2289 });
2290 entered_rx
2291 .recv_timeout(Duration::from_secs(2))
2292 .expect("observer is blocked in its first callback");
2293
2294 let result = tokio::time::timeout(Duration::from_secs(2), &mut invoke).await;
2295 if result.is_err() {
2296 invoke.abort();
2297 }
2298 proceed.send(()).expect("release detached worker");
2299 release_tx.send(()).expect("release presentation callback");
2300 worker_finished
2301 .recv_timeout(Duration::from_secs(2))
2302 .expect("detached worker attempted its late write");
2303
2304 let output = result
2305 .expect("tool timeout must not wait for observer code")
2306 .expect("invoke task")
2307 .expect("timeout envelope");
2308 assert_eq!(output["timed_out"], true);
2309 assert!(
2310 finish_rx.recv_timeout(Duration::from_millis(50)).is_err(),
2311 "a timed-out observer session must not report ordinary completion"
2312 );
2313 }
2314
2315 fn ctx(granted: Caveats) -> ToolContext {
2316 Gate::new(0)
2317 .authorize(&ShellTool::new(), &granted)
2318 .expect("authorize")
2319 }
2320
2321 fn ctx_strong(granted: Caveats) -> ToolContext {
2324 Gate::new(0)
2325 .with_strength_floor(agent_bridle_core::AxisEnforcement::Kernel)
2326 .authorize(&ShellTool::new(), &granted)
2327 .expect("authorize")
2328 }
2329
2330 fn exec_only(names: &[&str]) -> Caveats {
2331 Caveats {
2332 exec: Scope::only(names.iter().map(|s| (*s).to_string())),
2333 ..Caveats::top()
2334 }
2335 }
2336
2337 fn calls(mock: &Arc<MockSpawner>) -> Vec<Vec<Command>> {
2338 mock.calls.lock().unwrap().clone()
2339 }
2340
2341 fn envs(mock: &Arc<MockSpawner>) -> Vec<BTreeMap<String, String>> {
2343 mock.envs.lock().unwrap().clone()
2344 }
2345
2346 #[tokio::test]
2360 async fn strong_principal_fails_closed_on_unenforceable_exec() {
2361 let granted = exec_only(&["echo"]);
2362 let exec_is_kernel_confined = enforcement_report(
2365 &granted,
2366 intended_sandbox_kind(&granted, &Arc::new(SandboxPolicy::default())),
2367 )
2368 .exec
2369 == Some(agent_bridle_core::AxisEnforcement::Kernel);
2370
2371 let mock = Arc::new(MockSpawner::default());
2372 let out = ShellTool::with_spawner(mock.clone())
2373 .invoke(
2374 serde_json::json!({"cmd": "echo hi"}),
2375 &ctx_strong(granted.clone()),
2376 )
2377 .await
2378 .expect("invoke");
2379 if exec_is_kernel_confined {
2380 assert_ne!(
2383 out["denied"],
2384 serde_json::json!(true),
2385 "kernel-confined exec must run for a strong principal: {out}"
2386 );
2387 assert_eq!(
2388 out["enforcement"]["exec"], "kernel",
2389 "exec is reported kernel-confined: {out}"
2390 );
2391 assert_eq!(ran_programs(&mock), ["echo"], "the program spawned: {out}");
2392 } else {
2393 assert_eq!(
2396 out["denied"], true,
2397 "strong principal must fail closed on unenforceable exec: {out}"
2398 );
2399 assert!(ran_programs(&mock).is_empty(), "nothing may spawn: {out}");
2400 }
2401
2402 let mock = Arc::new(MockSpawner::default());
2405 let out = ShellTool::with_spawner(mock.clone())
2406 .invoke(serde_json::json!({"cmd": "echo hi"}), &ctx(granted))
2407 .await
2408 .expect("invoke");
2409 assert_ne!(
2410 out["denied"],
2411 serde_json::json!(true),
2412 "default principal still runs: {out}"
2413 );
2414 }
2415
2416 #[tokio::test]
2423 async fn net_refusal_surfaces_as_a_net_denial_in_the_envelope() {
2424 let mock = Arc::new(MockSpawner::with_net_denials(vec![Denial {
2425 kind: DenialKind::Net,
2426 target: "github.com".to_string(),
2427 reason: "net does not permit 'github.com'".to_string(),
2428 }]));
2429 let out = ShellTool::with_spawner(mock)
2430 .invoke(
2431 serde_json::json!({ "cmd": "echo hi" }),
2432 &ctx(exec_only(&["echo"])),
2433 )
2434 .await
2435 .expect("invoke");
2436 assert_eq!(
2437 out["denied"],
2438 serde_json::json!(true),
2439 "a net denial sets denied: {out}"
2440 );
2441 assert_eq!(out["denials"][0]["kind"], "net");
2442 assert_eq!(out["denials"][0]["target"], "github.com");
2443 assert!(out.get("exit_code").is_some(), "command still ran: {out}");
2446 }
2447
2448 fn ran_programs(mock: &Arc<MockSpawner>) -> Vec<String> {
2449 calls(mock)
2450 .iter()
2451 .map(|pipeline| prog(&pipeline[0]).to_string())
2452 .collect()
2453 }
2454
2455 #[tokio::test]
2461 async fn env_map_is_passed_to_the_spawner() {
2462 let mock = Arc::new(MockSpawner::default());
2463 let out = ShellTool::with_spawner(mock.clone())
2464 .invoke(
2465 serde_json::json!({
2466 "program": "echo",
2467 "args": ["hi"],
2468 "env": { "FOO": "bar", "VIRTUAL_ENV": "/venv" },
2469 }),
2470 &ctx(exec_only(&["echo"])),
2471 )
2472 .await
2473 .expect("invoke");
2474 assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
2475 let envs = envs(&mock);
2476 assert_eq!(envs.len(), 1, "one pipeline ran");
2477 assert_eq!(envs[0].get("FOO").map(String::as_str), Some("bar"));
2478 assert_eq!(
2479 envs[0].get("VIRTUAL_ENV").map(String::as_str),
2480 Some("/venv"),
2481 "every env entry reaches the child: {:?}",
2482 envs[0]
2483 );
2484 }
2485
2486 #[tokio::test]
2493 async fn env_does_not_change_the_program_the_leash_checks() {
2494 let mock = Arc::new(MockSpawner::default());
2495 let out = ShellTool::with_spawner(mock.clone())
2498 .invoke(
2499 serde_json::json!({
2500 "cmd": "hostname; uname -s",
2501 "env": { "FOO": "bar" },
2502 }),
2503 &ctx(exec_only(&["hostname", "uname"])),
2504 )
2505 .await
2506 .expect("invoke");
2507 assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
2508 let programs = ran_programs(&mock);
2510 assert_eq!(
2511 programs,
2512 vec!["hostname".to_string(), "uname".to_string()],
2513 "the leash/spawner see the real programs, never `export`/env keys: {programs:?}"
2514 );
2515 for e in envs(&mock) {
2517 assert_eq!(e.get("FOO").map(String::as_str), Some("bar"));
2518 }
2519 }
2520
2521 #[test]
2524 fn parse_env_field_present_and_absent() {
2525 let parsed = ShellArgs::parse(
2527 &serde_json::json!({
2528 "program": "echo",
2529 "env": { "FOO": "bar", "BAZ": "qux" },
2530 }),
2531 &agent_bridle_core::LimitsPolicy::default(),
2532 )
2533 .expect("parse");
2534 assert_eq!(parsed.env.get("FOO").map(String::as_str), Some("bar"));
2535 assert_eq!(parsed.env.get("BAZ").map(String::as_str), Some("qux"));
2536 assert_eq!(parsed.env.len(), 2);
2537
2538 let parsed = ShellArgs::parse(
2540 &serde_json::json!({ "program": "echo" }),
2541 &agent_bridle_core::LimitsPolicy::default(),
2542 )
2543 .expect("parse");
2544 assert!(parsed.env.is_empty(), "absent env defaults to empty");
2545 }
2546
2547 #[test]
2551 fn parse_timeout_uses_configured_limits() {
2552 let limits = agent_bridle_core::LimitsPolicy {
2553 max_timeout_secs: 5,
2554 default_timeout_secs: 3,
2555 ..agent_bridle_core::LimitsPolicy::default()
2556 };
2557 let over = ShellArgs::parse(
2559 &serde_json::json!({ "program": "echo", "timeout_secs": 9999 }),
2560 &limits,
2561 )
2562 .expect("parse");
2563 assert_eq!(over.timeout, std::time::Duration::from_secs(5));
2564 let dflt =
2566 ShellArgs::parse(&serde_json::json!({ "program": "echo" }), &limits).expect("parse");
2567 assert_eq!(dflt.timeout, std::time::Duration::from_secs(3));
2568 }
2569
2570 struct FakeEnv(HashMap<String, String>);
2573 impl EnvProvider for FakeEnv {
2574 fn get(&self, name: &str) -> Option<String> {
2575 self.0.get(name).cloned()
2576 }
2577 }
2578 fn fake_env(pairs: &[(&str, &str)]) -> Arc<dyn EnvProvider> {
2579 Arc::new(FakeEnv(
2580 pairs
2581 .iter()
2582 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
2583 .collect(),
2584 ))
2585 }
2586
2587 struct MapLister(HashMap<String, Vec<GlobEntry>>);
2590 impl DirLister for MapLister {
2591 fn list(&self, dir: &Path) -> Vec<GlobEntry> {
2592 let key = dir.to_string_lossy().replace('\\', "/");
2595 self.0.get(&key).cloned().unwrap_or_default()
2596 }
2597 }
2598 fn ent(name: &str, is_dir: bool) -> GlobEntry {
2599 GlobEntry {
2600 name: name.to_string(),
2601 is_dir,
2602 }
2603 }
2604 fn map_lister(dirs: &[(&str, Vec<GlobEntry>)]) -> Arc<dyn DirLister> {
2605 Arc::new(MapLister(
2606 dirs.iter()
2607 .map(|(d, es)| ((*d).to_string(), es.clone()))
2608 .collect(),
2609 ))
2610 }
2611
2612 #[tokio::test]
2618 async fn redirect_var_is_expanded_and_reaches_spawner_resolved() {
2619 let tmp = std::env::temp_dir().to_string_lossy().into_owned();
2620 let mock = Arc::new(MockSpawner::default());
2621 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
2622 let out = tool
2624 .invoke(
2625 serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
2626 &ctx(exec_only(&["echo"])),
2627 )
2628 .await
2629 .expect("invoke");
2630 assert_ne!(
2631 out["denied"],
2632 serde_json::json!(true),
2633 "in-scope var: {out}"
2634 );
2635 let redir = &calls(&mock)[0][0].redirects[0];
2636 assert_eq!(
2637 *redir,
2638 Redirect::Stdout {
2639 path: vec![Seg::Lit(format!("{tmp}/out"))],
2640 append: false,
2641 }
2642 );
2643 }
2644
2645 #[tokio::test]
2647 async fn redirect_var_not_in_allowlist_is_denied() {
2648 let mock = Arc::new(MockSpawner::default());
2649 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/x")]));
2650 let out = tool
2651 .invoke(
2652 serde_json::json!({"cmd": "echo hi > $SECRET"}),
2653 &ctx(exec_only(&["echo"])),
2654 )
2655 .await
2656 .expect("invoke");
2657 assert_eq!(out["denied"], true, "non-allowlisted redirect var: {out}");
2658 assert!(
2659 ran_programs(&mock).is_empty(),
2660 "no spawn on a denied redirect"
2661 );
2662 assert!(out["denials"][0]["reason"]
2663 .as_str()
2664 .unwrap_or_default()
2665 .contains("SECRET"));
2666 }
2667
2668 #[test]
2674 fn expand_varglob_keeps_value_metachars_literal_and_refuses_basename_var() {
2675 let env = FakeEnv(HashMap::from([("TMPDIR".to_string(), "/a*b".to_string())]));
2677 let allow = agent_bridle_core::LimitsPolicy::default().var_allowlist;
2678 let pattern = expand_varglob(
2681 &[Seg::Var("TMPDIR".into()), Seg::Lit("/*.rs".into())],
2682 &env,
2683 &allow,
2684 )
2685 .unwrap();
2686 assert_eq!(pattern, "/a*b/*.rs");
2687 let err = expand_varglob(
2689 &[Seg::Var("TMPDIR".into()), Seg::Lit("*.rs".into())],
2690 &env,
2691 &allow,
2692 );
2693 assert!(err.is_err(), "var in glob basename must be refused");
2694 }
2695
2696 #[tokio::test]
2699 async fn glob_var_expands_to_resolved_matches_before_spawn() {
2700 let mock = Arc::new(MockSpawner::default());
2701 let lister = map_lister(&[
2702 (".", vec![ent("proj", true)]),
2703 ("./proj", vec![ent("a.rs", false), ent("b.rs", false)]),
2704 ]);
2705 let tool = ShellTool::with_seams(mock.clone(), fake_env(&[("TMPDIR", "proj")]), lister);
2706 let out = tool
2707 .invoke(
2708 serde_json::json!({"cmd": "ls $TMPDIR/*.rs"}), &ctx(exec_only(&["ls"])),
2710 )
2711 .await
2712 .expect("invoke");
2713 assert_ne!(
2714 out["denied"],
2715 serde_json::json!(true),
2716 "in-scope glob var: {out}"
2717 );
2718 assert_eq!(
2719 calls(&mock)[0][0].argv,
2720 vec![
2721 Arg::Lit("ls".into()),
2722 Arg::Lit("proj/a.rs".into()),
2723 Arg::Lit("proj/b.rs".into())
2724 ]
2725 );
2726 }
2727
2728 #[tokio::test]
2730 async fn glob_var_in_basename_is_denied() {
2731 let mock = Arc::new(MockSpawner::default());
2732 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("PREFIX", "foo")]));
2733 let out = tool
2734 .invoke(
2735 serde_json::json!({"cmd": "ls $PREFIX*.rs"}),
2736 &ctx(exec_only(&["ls"])),
2737 )
2738 .await
2739 .expect("invoke");
2740 assert_eq!(out["denied"], true, "var in glob basename refused: {out}");
2741 assert!(ran_programs(&mock).is_empty());
2742 }
2743
2744 #[tokio::test]
2746 async fn glob_var_not_in_allowlist_is_denied() {
2747 let mock = Arc::new(MockSpawner::default());
2748 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/s")]));
2749 let out = tool
2750 .invoke(
2751 serde_json::json!({"cmd": "ls $SECRET/*.rs"}),
2752 &ctx(exec_only(&["ls"])),
2753 )
2754 .await
2755 .expect("invoke");
2756 assert_eq!(out["denied"], true, "non-allowlisted glob var: {out}");
2757 assert!(ran_programs(&mock).is_empty());
2758 }
2759
2760 #[tokio::test]
2764 async fn redirect_var_resolved_path_out_of_fs_write_scope_denied() {
2765 let tmp = std::env::temp_dir().to_string_lossy().into_owned();
2766 let mock = Arc::new(MockSpawner::default());
2767 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
2768 let granted = Caveats {
2769 exec: Scope::only(["echo".to_string()]),
2770 fs_write: Scope::only(["/nonexistent-grant-root".to_string()]),
2771 ..Caveats::top()
2772 };
2773 let out = tool
2774 .invoke(
2775 serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
2776 &ctx(granted),
2777 )
2778 .await
2779 .expect("invoke");
2780 assert_eq!(out["denied"], true, "resolved path outside fs_write: {out}");
2781 assert!(ran_programs(&mock).is_empty());
2782 }
2783
2784 #[tokio::test]
2787 async fn and_short_circuits_on_failure() {
2788 let mock = Arc::new(MockSpawner::with_exit("false", 1));
2789 ShellTool::with_spawner(mock.clone())
2790 .invoke(
2791 serde_json::json!({"cmd": "false && echo hi"}),
2792 &ctx(exec_only(&["false", "echo"])),
2793 )
2794 .await
2795 .expect("invoke");
2796 assert_eq!(ran_programs(&mock), vec!["false"], "echo must be skipped");
2797 }
2798
2799 #[tokio::test]
2800 async fn out_of_scope_anywhere_denies_the_whole_script() {
2801 let mock = Arc::new(MockSpawner::default());
2802 let out = ShellTool::with_spawner(mock.clone())
2803 .invoke(
2804 serde_json::json!({"cmd": "echo ok ; rm -rf x"}),
2805 &ctx(exec_only(&["echo"])),
2806 )
2807 .await
2808 .expect("invoke");
2809 assert_eq!(out["denied"], true);
2810 assert!(ran_programs(&mock).is_empty());
2811 }
2812
2813 #[tokio::test]
2818 async fn glob_arg_expanded_to_matches_before_spawn() {
2819 let mock = Arc::new(MockSpawner::default());
2820 let lister = map_lister(&[(
2821 ".",
2822 vec![ent("a.rs", false), ent("b.rs", false), ent("c.txt", false)],
2823 )]);
2824 ShellTool::with_seams(mock.clone(), fake_env(&[]), lister)
2825 .invoke(
2826 serde_json::json!({"cmd": "ls *.rs"}), &ctx(exec_only(&["ls"])),
2828 )
2829 .await
2830 .expect("invoke");
2831 assert_eq!(
2832 calls(&mock)[0][0].argv,
2833 vec![
2834 Arg::Lit("ls".into()),
2835 Arg::Lit("a.rs".into()),
2836 Arg::Lit("b.rs".into())
2837 ]
2838 );
2839 }
2840
2841 #[tokio::test]
2843 async fn glob_as_program_name_denied() {
2844 let mock = Arc::new(MockSpawner::default());
2845 let out = ShellTool::with_spawner(mock.clone())
2846 .invoke(serde_json::json!({"cmd": "*.sh foo"}), &ctx(Caveats::top()))
2847 .await
2848 .expect("invoke");
2849 assert_eq!(out["denied"], true);
2850 assert!(ran_programs(&mock).is_empty());
2851 }
2852
2853 #[tokio::test]
2855 async fn glob_dir_out_of_fs_read_scope_denied() {
2856 let mock = Arc::new(MockSpawner::default());
2857 let granted = Caveats {
2858 exec: Scope::only(["echo".to_string()]),
2859 fs_read: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
2861 ..Caveats::top()
2862 };
2863 let out = ShellTool::with_spawner(mock.clone())
2864 .invoke(serde_json::json!({"cmd": "echo *"}), &ctx(granted))
2865 .await
2866 .expect("invoke");
2867 assert_eq!(out["denied"], true);
2868 assert_eq!(out["denials"][0]["kind"], "open");
2869 assert!(ran_programs(&mock).is_empty());
2870 }
2871
2872 #[tokio::test]
2876 async fn allowlisted_var_reaches_spawner() {
2877 let mock = Arc::new(MockSpawner::default());
2878 ShellTool::with_spawner(mock.clone())
2879 .invoke(
2880 serde_json::json!({"cmd": "echo $HOME"}),
2881 &ctx(exec_only(&["echo"])),
2882 )
2883 .await
2884 .expect("invoke");
2885 let c = calls(&mock);
2886 assert_eq!(
2887 c[0][0].argv,
2888 vec![
2889 Arg::Lit("echo".into()),
2890 Arg::Var(vec![Seg::Var("HOME".into())]),
2891 ]
2892 );
2893 }
2894
2895 #[tokio::test]
2898 async fn non_allowlisted_var_denied() {
2899 let mock = Arc::new(MockSpawner::default());
2900 let out = ShellTool::with_spawner(mock.clone())
2901 .invoke(
2902 serde_json::json!({"cmd": "echo $AWS_SECRET_KEY"}),
2903 &ctx(Caveats::top()),
2904 )
2905 .await
2906 .expect("invoke");
2907 assert_eq!(out["denied"], true);
2908 assert_eq!(out["denials"][0]["target"], "$AWS_SECRET_KEY");
2909 assert!(ran_programs(&mock).is_empty());
2910 }
2911
2912 #[tokio::test]
2914 async fn var_as_program_name_denied() {
2915 let mock = Arc::new(MockSpawner::default());
2916 let out = ShellTool::with_spawner(mock.clone())
2917 .invoke(
2918 serde_json::json!({"cmd": "$HOME foo"}),
2919 &ctx(Caveats::top()),
2920 )
2921 .await
2922 .expect("invoke");
2923 assert_eq!(out["denied"], true);
2924 assert!(ran_programs(&mock).is_empty());
2925 }
2926
2927 #[tokio::test]
2931 async fn stderr_to_file_out_of_scope_denied() {
2932 let mock = Arc::new(MockSpawner::default());
2933 let granted = Caveats {
2934 exec: Scope::only(["cmd".to_string()]),
2935 fs_write: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
2936 ..Caveats::top()
2937 };
2938 let out = ShellTool::with_spawner(mock.clone())
2939 .invoke(
2940 serde_json::json!({"cmd": "cmd 2> /etc/passwd"}),
2941 &ctx(granted),
2942 )
2943 .await
2944 .expect("invoke");
2945 assert_eq!(out["denied"], true);
2946 assert_eq!(out["denials"][0]["kind"], "open");
2947 assert_eq!(out["denials"][0]["target"], "/etc/passwd");
2948 assert!(ran_programs(&mock).is_empty());
2949 }
2950
2951 #[tokio::test]
2953 async fn stderr_merge_reaches_spawner() {
2954 let mock = Arc::new(MockSpawner::default());
2955 ShellTool::with_spawner(mock.clone())
2956 .invoke(
2957 serde_json::json!({"cmd": "cmd 2>&1"}),
2958 &ctx(exec_only(&["cmd"])),
2959 )
2960 .await
2961 .expect("invoke");
2962 let c = calls(&mock);
2963 assert_eq!(c[0][0].stderr_disposition(), StderrTo::Stdout);
2964 }
2965
2966 #[tokio::test]
2967 async fn both_program_and_cmd_is_a_hard_error() {
2968 let res = ShellTool::new()
2969 .invoke(
2970 serde_json::json!({"program": "echo", "cmd": "echo hi"}),
2971 &ctx(Caveats::top()),
2972 )
2973 .await;
2974 assert!(res.is_err());
2975 }
2976
2977 #[tokio::test]
2978 async fn timeout_is_reported() {
2979 let mock = Arc::new(MockSpawner {
2980 block_ms: 1500,
2981 ..Default::default()
2982 });
2983 let out = ShellTool::with_spawner(mock)
2984 .invoke(
2985 serde_json::json!({"program": "anything", "timeout_secs": 1}),
2986 &ctx(exec_only(&["anything"])),
2987 )
2988 .await
2989 .expect("invoke");
2990 assert_eq!(out["timed_out"], true);
2991 }
2992
2993 #[test]
2996 fn fnmatch_basics() {
2997 assert!(fnmatch("*.rs", "a.rs"));
2998 assert!(!fnmatch("*.rs", "a.txt"));
2999 assert!(fnmatch("a?c", "abc"));
3000 assert!(!fnmatch("a?c", "ac"));
3001 assert!(fnmatch("*", ""));
3002 assert!(fnmatch("a*", "a"));
3003 assert!(fnmatch("[abc]x", "bx"));
3004 assert!(!fnmatch("[abc]x", "dx"));
3005 assert!(fnmatch("[!abc]x", "dx"));
3006 assert!(fnmatch("[a-c]", "b"));
3007 assert!(!fnmatch("[a-c]", "d"));
3008 assert!(fnmatch("foo*bar", "fooXYbar"));
3009 }
3010
3011 #[test]
3016 fn read_capped_bounds_buffering_and_flags_truncation() {
3017 const CAP: usize = 1 << 20;
3019 struct Endless {
3022 served: usize,
3023 }
3024 impl Read for Endless {
3025 fn read(&mut self, b: &mut [u8]) -> std::io::Result<usize> {
3026 self.served = self.served.saturating_add(b.len());
3027 assert!(
3028 self.served <= CAP + 64 * 1024,
3029 "read_capped over-read {} bytes (cap {CAP})",
3030 self.served
3031 );
3032 b.fill(b'x');
3033 Ok(b.len())
3034 }
3035 }
3036 let (buf, truncated) = read_capped(Endless { served: 0 }, CAP);
3037 assert_eq!(buf.len(), CAP, "peak buffering bounded by the cap");
3038 assert!(
3039 truncated,
3040 "a source longer than the cap is flagged truncated"
3041 );
3042
3043 let (buf2, trunc2) = read_capped(&b"hello"[..], CAP);
3045 assert_eq!(buf2, b"hello");
3046 assert!(!trunc2, "a sub-cap source is not truncated");
3047 }
3048
3049 #[test]
3050 fn read_capped_retries_an_interrupted_read() {
3051 struct InterruptedOnce {
3052 interrupted: bool,
3053 inner: std::io::Cursor<Vec<u8>>,
3054 }
3055
3056 impl Read for InterruptedOnce {
3057 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3058 if !self.interrupted {
3059 self.interrupted = true;
3060 return Err(std::io::Error::from(std::io::ErrorKind::Interrupted));
3061 }
3062 self.inner.read(buf)
3063 }
3064 }
3065
3066 let reader = InterruptedOnce {
3067 interrupted: false,
3068 inner: std::io::Cursor::new(b"abcdef".to_vec()),
3069 };
3070 let (captured, truncated) = read_capped(reader, 4);
3071
3072 assert_eq!(captured, b"abcd");
3073 assert!(truncated);
3074 }
3075
3076 #[test]
3077 fn glob_walk_single_segment_and_subpath() {
3078 let lister = map_lister(&[
3079 (
3080 ".",
3081 vec![
3082 ent("a.rs", false),
3083 ent("b.rs", false),
3084 ent("c.txt", false),
3085 ent(".hidden.rs", false),
3086 ent("src", true),
3087 ],
3088 ),
3089 ("./src", vec![ent("a.rs", false), ent("b.rs", false)]),
3090 ]);
3091 let mut allow = |_d: &Path| Ok(());
3092 assert_eq!(
3094 expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
3095 vec!["a.rs", "b.rs"]
3096 );
3097 assert_eq!(
3099 expand_glob_walk("zzz*", None, &*lister, &mut allow, 64, 4096).unwrap(),
3100 vec!["zzz*"]
3101 );
3102 assert_eq!(
3104 expand_glob_walk("src/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
3105 vec!["src/a.rs", "src/b.rs"]
3106 );
3107 }
3108
3109 #[test]
3110 fn glob_walk_multi_segment_and_recursive() {
3111 let lister = map_lister(&[
3112 (
3113 ".",
3114 vec![ent("a", true), ent("b", true), ent("x.rs", false)],
3115 ),
3116 ("./a", vec![ent("foo.rs", false), ent("sub", true)]),
3117 ("./b", vec![ent("bar.rs", false)]),
3118 ("./a/sub", vec![ent("deep.rs", false)]),
3119 ]);
3120 let mut allow = |_d: &Path| Ok(());
3121 assert_eq!(
3123 expand_glob_walk("*/foo.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
3124 vec!["a/foo.rs"]
3125 );
3126 assert_eq!(
3128 expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
3129 vec!["a/foo.rs", "a/sub/deep.rs", "b/bar.rs", "x.rs"]
3130 );
3131 }
3132
3133 #[test]
3134 fn glob_walk_leashes_every_directory_and_denies_out_of_scope() {
3135 let lister = map_lister(&[
3136 (".", vec![ent("a", true), ent("x.rs", false)]),
3137 ("./a", vec![ent("secret.rs", false)]),
3138 ]);
3139 let mut deny_a = |d: &Path| {
3142 if d.to_string_lossy().contains("a") {
3143 Err(ToolError::denied("out of fs_read scope"))
3144 } else {
3145 Ok(())
3146 }
3147 };
3148 assert!(expand_glob_walk("**/*.rs", None, &*lister, &mut deny_a, 64, 4096).is_err());
3149 }
3150
3151 #[test]
3154 fn glob_walk_respects_configured_match_cap() {
3155 let lister = map_lister(&[(
3156 ".",
3157 vec![
3158 ent("a.rs", false),
3159 ent("b.rs", false),
3160 ent("c.rs", false),
3161 ent("d.rs", false),
3162 ],
3163 )]);
3164 let mut allow = |_d: &Path| Ok(());
3165 let got = expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 2).unwrap();
3166 assert_eq!(got.len(), 2, "match cap of 2 must bound the result set");
3167 }
3168
3169 #[test]
3172 fn glob_walk_respects_configured_depth_cap() {
3173 let lister = map_lister(&[
3174 (".", vec![ent("a", true), ent("x.rs", false)]),
3175 ("./a", vec![ent("foo.rs", false), ent("sub", true)]),
3176 ("./a/sub", vec![ent("deep.rs", false)]),
3177 ]);
3178 let mut allow = |_d: &Path| Ok(());
3179 let got = expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 1, 4096).unwrap();
3181 assert!(
3182 !got.iter().any(|m| m.contains("deep.rs")),
3183 "depth cap of 1 must not reach a/sub/deep.rs; got {got:?}"
3184 );
3185 }
3186
3187 #[test]
3191 fn var_allowlist_is_config_driven() {
3192 let allow_custom = vec!["MY_CUSTOM_VAR".to_string()];
3194 let env = FakeEnv(HashMap::from([(
3195 "MY_CUSTOM_VAR".to_string(),
3196 "/data".to_string(),
3197 )]));
3198 let out = expand_redirect_target(&[Seg::Var("MY_CUSTOM_VAR".into())], &env, &allow_custom)
3199 .unwrap();
3200 assert_eq!(out, "/data");
3201 assert!(!is_allowed_var("HOME", &["PWD".to_string()]));
3203 assert!(is_allowed_var("PWD", &["PWD".to_string()]));
3204 }
3205
3206 #[test]
3211 fn net_audit_sink_is_config_driven() {
3212 use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
3213 let ev = NetAuditEvent {
3214 ts_ms: 0,
3215 host: "example.test".to_string(),
3216 port: 443,
3217 kind: NetKind::Connect,
3218 decision: NetDecision::Allowed,
3219 bytes_up: 1,
3220 bytes_down: 2,
3221 dur_ms: 3,
3222 };
3223 net_audit_sink(None).record(&ev);
3225
3226 let path = std::env::temp_dir().join(format!("ab-audit-{}.jsonl", std::process::id()));
3228 let _ = std::fs::remove_file(&path);
3229 let sink = net_audit_sink(path.to_str());
3230 sink.record(&ev);
3231 drop(sink);
3232 let contents = std::fs::read_to_string(&path).expect("configured audit file written");
3233 assert!(
3234 contents.contains("example.test"),
3235 "the configured sink must write the event: {contents}"
3236 );
3237 let _ = std::fs::remove_file(&path);
3238 }
3239
3240 #[test]
3244 fn net_audit_sink_bad_path_degrades_to_null() {
3245 use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
3246 let ev = NetAuditEvent {
3247 ts_ms: 0,
3248 host: "example.test".to_string(),
3249 port: 443,
3250 kind: NetKind::Http,
3251 decision: NetDecision::Allowed,
3252 bytes_up: 1,
3253 bytes_down: 2,
3254 dur_ms: 3,
3255 };
3256 let bad = std::env::temp_dir()
3258 .join(format!("ab-nope-{}", std::process::id()))
3259 .join("does/not/exist/audit.jsonl");
3260 let sink = net_audit_sink(bad.to_str());
3261 sink.record(&ev); assert!(
3263 !bad.exists(),
3264 "a bad audit path must not create a file (degraded to null): {bad:?}"
3265 );
3266 }
3267}