1use std::collections::{BTreeMap, HashSet};
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 private_hosts: HashSet<String>,
103 pub unbridled: bool,
107 pub output: OutputEmitter,
109 pub timeout: Duration,
113}
114
115pub(crate) trait Spawner: Send + Sync {
116 fn run(
125 &self,
126 stages: &[Command],
127 cwd: Option<&str>,
128 caveats: &Caveats,
129 env: &BTreeMap<String, String>,
130 cfg: &SpawnCfg,
131 ) -> ToolResult<Captured>;
132}
133
134struct OsSpawner;
137
138impl Spawner for OsSpawner {
139 fn run(
140 &self,
141 stages: &[Command],
142 cwd: Option<&str>,
143 caveats: &Caveats,
144 env: &BTreeMap<String, String>,
145 cfg: &SpawnCfg,
146 ) -> ToolResult<Captured> {
147 if cfg.unbridled {
151 return run_pipeline(
152 stages,
153 cwd,
154 &[],
155 env,
156 cfg.max_output,
157 cfg.output.clone(),
158 cfg.timeout,
159 );
160 }
161 if let Some((allow_hosts, fenced)) = egress_proxy_plan(caveats, &cfg.sandbox) {
166 return run_with_egress_proxy(stages, cwd, &fenced, env, allow_hosts, cfg);
167 }
168 if intended_sandbox_kind(caveats, &cfg.sandbox) == SandboxKind::None {
172 run_pipeline(
173 stages,
174 cwd,
175 &[],
176 env,
177 cfg.max_output,
178 cfg.output.clone(),
179 cfg.timeout,
180 )
181 } else {
182 run_confined(stages, cwd, caveats, env, cfg)
183 }
184 }
185}
186
187fn egress_proxy_plan(
194 caveats: &Caveats,
195 sandbox: &Arc<SandboxPolicy>,
196) -> Option<(Vec<String>, Caveats)> {
197 agent_bridle_core::egress_proxy_plan(caveats, sandbox)
198}
199
200fn run_with_egress_proxy(
209 stages: &[Command],
210 cwd: Option<&str>,
211 fenced: &Caveats,
212 env: &BTreeMap<String, String>,
213 allow_hosts: Vec<String>,
214 cfg: &SpawnCfg,
215) -> ToolResult<Captured> {
216 let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(fenced)?;
218 let proxy = net_proxy::start_with_private_hosts(
222 allow_hosts,
223 cfg.private_hosts.iter().cloned(),
224 Arc::new(net_proxy::StdResolver),
225 net_audit_sink(cfg.audit_sink.as_deref()),
226 )
227 .map_err(ToolError::Exec)?;
228 let mut env = env.clone();
231 for (k, v) in proxy.proxy_env() {
232 env.insert(k, v);
233 }
234
235 let stages = stages.to_vec();
236 let cwd = cwd.map(str::to_string);
237 let fenced = fenced.clone();
238 let max_output = cfg.max_output;
239 let output = cfg.output.clone();
240 let sandbox = cfg.sandbox.clone();
241 let timeout = cfg.timeout;
242 let captured = std::thread::Builder::new()
243 .name("agent-bridle-confined".to_string())
244 .spawn(move || {
245 best_available_sandbox(&sandbox).apply(&fenced)?;
246 run_pipeline(
247 &stages,
248 cwd.as_deref(),
249 &prefix,
250 &env,
251 max_output,
252 output,
253 timeout,
254 )
255 })
256 .map_err(ToolError::Exec)?
257 .join()
258 .map_err(|_| {
259 ToolError::Exec(std::io::Error::other("confined execution thread panicked"))
260 })?;
261 let refused = proxy.refused_hosts();
265 drop(proxy); let mut captured = captured?;
267 captured.net_denials = refused
268 .into_iter()
269 .map(|host| Denial {
270 kind: DenialKind::Net,
271 reason: format!("net does not permit '{host}'"),
272 target: host,
273 })
274 .collect();
275 Ok(captured)
276}
277
278fn net_audit_sink(configured: Option<&str>) -> Arc<dyn net_proxy::AuditSink> {
287 match configured {
288 Some(path) if !path.is_empty() => std::fs::OpenOptions::new()
289 .create(true)
290 .append(true)
291 .open(path)
292 .map(|f| Arc::new(net_proxy::JsonlSink::new(f)) as Arc<dyn net_proxy::AuditSink>)
293 .unwrap_or_else(|_| Arc::new(net_proxy::NullSink)),
294 _ => Arc::new(net_proxy::NullSink),
295 }
296}
297
298fn intended_sandbox_kind(caveats: &Caveats, sandbox: &Arc<SandboxPolicy>) -> SandboxKind {
304 effective_sandbox_kind(best_available_sandbox(sandbox).kind(), caveats)
305}
306
307fn run_confined(
318 stages: &[Command],
319 cwd: Option<&str>,
320 caveats: &Caveats,
321 env: &BTreeMap<String, String>,
322 cfg: &SpawnCfg,
323) -> ToolResult<Captured> {
324 let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(caveats)?;
326 let stages = stages.to_vec();
327 let cwd = cwd.map(str::to_string);
328 let caveats = caveats.clone();
329 let env = env.clone();
330 let max_output = cfg.max_output;
331 let output = cfg.output.clone();
332 let sandbox = cfg.sandbox.clone();
333 let timeout = cfg.timeout;
334 std::thread::Builder::new()
335 .name("agent-bridle-confined".to_string())
336 .spawn(move || {
337 best_available_sandbox(&sandbox).apply(&caveats)?;
338 run_pipeline(
339 &stages,
340 cwd.as_deref(),
341 &prefix,
342 &env,
343 max_output,
344 output,
345 timeout,
346 )
347 })
348 .map_err(ToolError::Exec)?
349 .join()
350 .map_err(|_| ToolError::Exec(std::io::Error::other("confined execution thread panicked")))?
351}
352
353static SHELL_SCHEMA: LazyLock<serde_json::Value> = LazyLock::new(|| {
360 serde_json::from_str(include_str!("shell_tool.schema.json"))
361 .expect("embedded shell_tool.schema.json must be valid JSON")
362});
363
364#[derive(Clone)]
371pub struct ShellTool {
372 spawner: Arc<dyn Spawner>,
373 env: Arc<dyn EnvProvider>,
374 lister: Arc<dyn DirLister>,
375 limits: LimitsPolicy,
376 sandbox: Arc<SandboxPolicy>,
379 private_hosts: HashSet<String>,
380 output_observer: Option<Arc<dyn crate::ShellOutputObserver>>,
381}
382
383impl std::fmt::Debug for ShellTool {
384 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385 f.write_str("ShellTool")
386 }
387}
388
389impl ShellTool {
390 #[must_use]
393 pub fn new() -> Self {
394 Self::with_config(LimitsPolicy::default())
395 }
396
397 #[must_use]
400 pub fn with_config(limits: LimitsPolicy) -> Self {
401 Self {
402 spawner: Arc::new(OsSpawner),
403 env: Arc::new(RealEnv),
404 lister: Arc::new(RealDirLister),
405 limits,
406 sandbox: Arc::new(SandboxPolicy::default()),
407 private_hosts: HashSet::new(),
408 output_observer: None,
409 }
410 }
411
412 #[must_use]
419 pub fn with_output_observer(mut self, observer: Arc<dyn crate::ShellOutputObserver>) -> Self {
420 self.output_observer = Some(observer);
421 self
422 }
423
424 #[must_use]
427 pub fn with_sandbox_policy(mut self, sandbox: SandboxPolicy) -> Self {
428 self.sandbox = Arc::new(sandbox);
429 self
430 }
431
432 pub fn with_private_hosts(
440 mut self,
441 hosts: impl IntoIterator<Item = String>,
442 ) -> std::io::Result<Self> {
443 self.private_hosts = net_proxy::canonical_private_hosts(hosts)?;
444 Ok(self)
445 }
446
447 #[cfg(test)]
449 fn with_spawner(spawner: Arc<dyn Spawner>) -> Self {
450 Self {
451 spawner,
452 env: Arc::new(RealEnv),
453 lister: Arc::new(RealDirLister),
454 limits: LimitsPolicy::default(),
455 sandbox: Arc::new(SandboxPolicy::default()),
456 private_hosts: HashSet::new(),
457 output_observer: None,
458 }
459 }
460
461 #[cfg(test)]
465 fn with_spawner_and_env(spawner: Arc<dyn Spawner>, env: Arc<dyn EnvProvider>) -> Self {
466 Self {
467 spawner,
468 env,
469 lister: Arc::new(RealDirLister),
470 limits: LimitsPolicy::default(),
471 sandbox: Arc::new(SandboxPolicy::default()),
472 private_hosts: HashSet::new(),
473 output_observer: None,
474 }
475 }
476
477 #[cfg(test)]
481 fn with_seams(
482 spawner: Arc<dyn Spawner>,
483 env: Arc<dyn EnvProvider>,
484 lister: Arc<dyn DirLister>,
485 ) -> Self {
486 Self {
487 spawner,
488 env,
489 lister,
490 limits: LimitsPolicy::default(),
491 sandbox: Arc::new(SandboxPolicy::default()),
492 private_hosts: HashSet::new(),
493 output_observer: None,
494 }
495 }
496}
497
498impl Default for ShellTool {
499 fn default() -> Self {
500 Self::new()
501 }
502}
503
504#[async_trait]
505impl Tool for ShellTool {
506 fn name(&self) -> &str {
507 "shell"
508 }
509
510 fn schema(&self) -> serde_json::Value {
511 let mut schema = SHELL_SCHEMA.clone();
517 schema["properties"]["timeout_secs"]["maximum"] =
518 serde_json::Value::from(self.limits.max_timeout_secs);
519 schema
520 }
521
522 async fn invoke(
523 &self,
524 args: serde_json::Value,
525 cx: &ToolContext,
526 ) -> ToolResult<serde_json::Value> {
527 let parsed = ShellArgs::parse(&args, &self.limits)?;
528 let unbridled = is_unbridled();
534 let sandbox_kind = if unbridled {
546 SandboxKind::None
547 } else {
548 match egress_proxy_plan(cx.caveats(), &self.sandbox) {
549 Some((_, fenced)) => intended_sandbox_kind(&fenced, &self.sandbox),
550 None => intended_sandbox_kind(cx.caveats(), &self.sandbox),
551 }
552 };
553 let enforcement = enforcement_report(cx.caveats(), sandbox_kind);
556
557 let mut script = match parsed.script() {
559 Ok(s) => s,
560 Err(refusal) => {
561 return Ok(refused_envelope(
562 sandbox_kind,
563 enforcement,
564 &refusal,
565 parsed.cmd.as_deref(),
566 ))
567 }
568 };
569
570 for item in &mut script {
576 for stage in &mut item.pipeline {
577 let mut new_argv: Vec<Arg> = Vec::with_capacity(stage.argv.len());
583 for (i, arg) in stage.argv.drain(..).enumerate() {
584 let pattern: Option<String> = if i == 0 {
585 None
586 } else {
587 match &arg {
588 Arg::Glob(p) => Some(p.clone()),
589 Arg::VarGlob(segs) => {
590 match expand_varglob(segs, &*self.env, &self.limits.var_allowlist) {
591 Ok(p) => Some(p),
592 Err((target, e)) => {
593 return Ok(deny(
594 sandbox_kind,
595 enforcement,
596 DenialKind::Exec,
597 &target,
598 &e,
599 ))
600 }
601 }
602 }
603 _ => None,
604 }
605 };
606 match pattern {
607 Some(p) => {
608 let mut leash = |dir: &Path| cx.check_path_read(dir);
609 match expand_glob_walk(
610 &p,
611 parsed.cwd.as_deref(),
612 &*self.lister,
613 &mut leash,
614 self.limits.max_glob_depth,
615 self.limits.max_glob_matches,
616 ) {
617 Ok(ms) => new_argv.extend(ms.into_iter().map(Arg::Lit)),
618 Err(e) => {
619 return Ok(deny(
620 sandbox_kind,
621 enforcement,
622 DenialKind::Open,
623 &p,
624 &e,
625 ))
626 }
627 }
628 }
629 None => new_argv.push(arg),
630 }
631 }
632 stage.argv = new_argv;
633 for redirect in &mut stage.redirects {
634 let segs = match redirect {
635 Redirect::Stdout { path, .. }
636 | Redirect::Stderr { path, .. }
637 | Redirect::Stdin { path } => path,
638 Redirect::StderrToStdout => continue,
639 };
640 match expand_redirect_target(segs, &*self.env, &self.limits.var_allowlist) {
641 Ok(resolved) => *segs = vec![Seg::Lit(resolved)],
642 Err((target, e)) => {
643 return Ok(deny(
644 sandbox_kind,
645 enforcement,
646 DenialKind::Open,
647 &target,
648 &e,
649 ))
650 }
651 }
652 }
653 }
654 }
655
656 for item in &script {
662 for stage in &item.pipeline {
663 match stage.argv.first() {
664 Some(Arg::Lit(program)) => {
665 if let Err(e) = cx.check_exec(program) {
666 return Ok(deny(
667 sandbox_kind,
668 enforcement,
669 DenialKind::Exec,
670 program,
671 &e,
672 ));
673 }
674 }
675 Some(Arg::Glob(pattern)) => {
676 return Ok(deny(
677 sandbox_kind,
678 enforcement,
679 DenialKind::Exec,
680 pattern,
681 &ToolError::denied("a glob pattern is not allowed as a program name"),
682 ));
683 }
684 Some(Arg::Var(_segs)) => {
685 return Ok(deny(
686 sandbox_kind,
687 enforcement,
688 DenialKind::Exec,
689 "$VAR",
690 &ToolError::denied("a variable is not allowed as a program name"),
691 ));
692 }
693 Some(Arg::VarGlob(_)) => {
696 return Ok(deny(
697 sandbox_kind,
698 enforcement,
699 DenialKind::Exec,
700 "$VAR/glob",
701 &ToolError::denied("a glob pattern is not allowed as a program name"),
702 ));
703 }
704 None => {} }
706 for arg in &stage.argv {
707 match arg {
708 Arg::Var(segs) => {
711 for seg in segs {
712 if let Seg::Var(name) = seg {
713 if !is_allowed_var(name, &self.limits.var_allowlist) {
714 return Ok(deny(
715 sandbox_kind,
716 enforcement,
717 DenialKind::Exec,
718 &format!("${name}"),
719 &ToolError::denied(format!(
720 "variable ${name} is not in the confined shell's allowlist"
721 )),
722 ));
723 }
724 }
725 }
726 }
727 Arg::Glob(_) => unreachable!("glob expanded at admission"),
730 Arg::VarGlob(_) => unreachable!("VarGlob expanded at admission"),
731 Arg::Lit(_) => {}
732 }
733 }
734 for redirect in &stage.redirects {
735 let (path, checked) = match redirect {
738 Redirect::Stdout { path, .. } | Redirect::Stderr { path, .. } => {
739 let p = seg_literal(path).expect("redirect target lowered");
740 (p, cx.check_path_write(Path::new(p)))
741 }
742 Redirect::Stdin { path } => {
743 let p = seg_literal(path).expect("redirect target lowered");
744 (p, cx.check_path_read(Path::new(p)))
745 }
746 Redirect::StderrToStdout => continue,
748 };
749 if let Err(e) = checked {
750 return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, path, &e));
751 }
752 }
753 }
754 }
755 if let Some(cwd) = &parsed.cwd {
757 if let Err(e) = cx.check_path_read(Path::new(cwd)) {
758 return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, cwd, &e));
759 }
760 }
761
762 if !unbridled && confinement_unenforceable(sandbox_kind, cx.caveats(), cx.strength_floor())
780 {
781 return Ok(deny(
782 sandbox_kind,
783 enforcement,
784 DenialKind::Exec,
785 "confinement",
786 &ToolError::denied(format!(
787 "a restricted filesystem/exec/net axis cannot be enforced on this host \
788 at the required strength floor ({:?}); refusing to run unconfined",
789 cx.strength_floor()
790 )),
791 ));
792 }
793
794 let spawner = Arc::clone(&self.spawner);
797 let cwd = parsed.cwd.clone();
798 let timeout = parsed.timeout;
799 let (output_guard, output) =
800 output_session(self.output_observer.clone(), self.limits.max_output_bytes);
801 let cfg = SpawnCfg {
802 max_output: self.limits.max_output_bytes,
803 audit_sink: self.limits.audit_sink.clone(),
804 sandbox: Arc::clone(&self.sandbox),
805 private_hosts: self.private_hosts.clone(),
806 unbridled,
807 output,
808 timeout,
809 };
810 let disclosure = Disclosure {
812 unbridled,
813 human_gate: human_gate(),
814 ..Disclosure::default()
815 };
816 let (env, _dropped_env) =
823 agent_bridle_core::fence_env(&parsed.env, &self.limits.env_denylist);
824 let caveats = cx.caveats().clone();
825 let run = tokio::task::spawn_blocking(move || {
826 run_script(&*spawner, &script, cwd.as_deref(), &caveats, &env, &cfg)
827 });
828 match tokio::time::timeout(timeout, run).await {
836 Ok(joined) => {
837 let captured = joined
838 .map_err(|e| ToolError::Other(anyhow::anyhow!("shell task panicked: {e}")))??;
839 let envelope = ToolEnvelope::new(sandbox_kind)
843 .with_enforcement(enforcement)
844 .with_disclosure(disclosure)
845 .with_exit_code(captured.exit_code)
846 .with_truncation(captured.stdout_truncated, captured.stderr_truncated)
847 .with_stdout(captured.stdout)
848 .with_stderr(captured.stderr)
849 .with_denials(captured.net_denials)
850 .with_timed_out(captured.timed_out)
851 .into_json();
852 output_guard.finish();
853 Ok(envelope)
854 }
855 Err(_elapsed) => {
856 drop(output_guard);
859 Ok(ToolEnvelope::new(sandbox_kind)
860 .with_enforcement(enforcement)
861 .with_disclosure(disclosure)
862 .with_stderr(format!("command timed out after {}s", timeout.as_secs()))
863 .with_timed_out(true)
864 .into_json())
865 }
866 }
867 }
868}
869
870fn run_script(
874 spawner: &dyn Spawner,
875 script: &[ScriptItem],
876 cwd: Option<&str>,
877 caveats: &Caveats,
878 env: &BTreeMap<String, String>,
879 cfg: &SpawnCfg,
880) -> ToolResult<Captured> {
881 let mut stdout = String::new();
882 let mut stderr = String::new();
883 let mut status: i32 = 0;
884 let mut stdout_truncated = false;
885 let mut stderr_truncated = false;
886 let mut net_denials: Vec<Denial> = Vec::new();
888 let mut timed_out = false;
889
890 for item in script {
891 let run_it = match item.sep {
892 Sep::Seq => true,
893 Sep::And => status == 0,
894 Sep::Or => status != 0,
895 };
896 if run_it {
897 let captured = spawner.run(&item.pipeline, cwd, caveats, env, cfg)?;
898 stdout.push_str(&captured.stdout);
899 stderr.push_str(&captured.stderr);
900 stdout_truncated |= captured.stdout_truncated;
901 stderr_truncated |= captured.stderr_truncated;
902 net_denials.extend(captured.net_denials);
903 status = captured.exit_code;
904 if captured.timed_out {
907 timed_out = true;
908 break;
909 }
910 }
911 }
912
913 let stdout_truncated = stdout_truncated || stdout.len() > cfg.max_output;
915 let stderr_truncated = stderr_truncated || stderr.len() > cfg.max_output;
916
917 Ok(Captured {
918 exit_code: status,
919 stdout: cap_string(stdout, cfg.max_output),
920 stderr: cap_string(stderr, cfg.max_output),
921 net_denials,
922 stdout_truncated,
923 stderr_truncated,
924 timed_out,
925 })
926}
927
928fn deny(
930 sandbox_kind: SandboxKind,
931 enforcement: EnforcementReport,
932 kind: DenialKind,
933 target: &str,
934 err: &ToolError,
935) -> serde_json::Value {
936 ToolEnvelope::new(sandbox_kind)
937 .with_enforcement(enforcement)
938 .with_disclosure(unbridle_disclosure())
939 .with_denials(vec![Denial {
940 kind,
941 target: target.to_string(),
942 reason: err.to_string(),
943 }])
944 .into_json()
945}
946
947fn unbridle_disclosure() -> Disclosure {
951 Disclosure {
952 unbridled: is_unbridled(),
953 human_gate: human_gate(),
954 ..Disclosure::default()
955 }
956}
957
958fn refused_envelope(
960 sandbox_kind: SandboxKind,
961 enforcement: EnforcementReport,
962 refusal: &Refusal,
963 cmd: Option<&str>,
964) -> serde_json::Value {
965 let envelope = ToolEnvelope::new(sandbox_kind)
966 .with_enforcement(enforcement)
967 .with_disclosure(unbridle_disclosure())
968 .with_denials(vec![Denial {
969 kind: DenialKind::Exec,
970 target: refusal.construct(),
971 reason: refusal.to_string(),
972 }])
973 .into_json();
974
975 #[cfg(feature = "brush")]
982 {
983 let mut envelope = envelope;
984 if matches!(refusal, Refusal::Dynamic(_)) {
985 if let Some(cmd) = cmd {
986 if let Ok(inspection) = crate::inspect_shell(cmd) {
987 if let Ok(value) = serde_json::to_value(inspection) {
988 envelope["shell_inspection"] = value;
989 }
990 }
991 }
992 }
993 envelope
994 }
995 #[cfg(not(feature = "brush"))]
996 {
997 let _ = cmd;
998 envelope
999 }
1000}
1001
1002struct ShellArgs {
1004 program: Option<String>,
1005 args: Vec<String>,
1006 cmd: Option<String>,
1007 cwd: Option<String>,
1008 env: BTreeMap<String, String>,
1012 timeout: Duration,
1013}
1014
1015impl ShellArgs {
1016 fn parse(v: &serde_json::Value, limits: &LimitsPolicy) -> ToolResult<Self> {
1017 let obj = v
1018 .as_object()
1019 .ok_or_else(|| ToolError::denied("shell args must be a JSON object"))?;
1020
1021 let program = obj
1022 .get("program")
1023 .and_then(|x| x.as_str())
1024 .map(String::from);
1025 let cmd = obj.get("cmd").and_then(|x| x.as_str()).map(String::from);
1026 let args = obj
1027 .get("args")
1028 .and_then(|x| x.as_array())
1029 .map(|a| {
1030 a.iter()
1031 .filter_map(|x| x.as_str().map(String::from))
1032 .collect::<Vec<_>>()
1033 })
1034 .unwrap_or_default();
1035 let cwd = obj.get("cwd").and_then(|x| x.as_str()).map(String::from);
1036 let env = obj
1040 .get("env")
1041 .and_then(|x| x.as_object())
1042 .map(|m| {
1043 m.iter()
1044 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1045 .collect::<BTreeMap<String, String>>()
1046 })
1047 .unwrap_or_default();
1048 let timeout_secs = obj
1049 .get("timeout_secs")
1050 .and_then(serde_json::Value::as_u64)
1051 .unwrap_or(limits.default_timeout_secs)
1052 .clamp(1, limits.max_timeout_secs);
1053
1054 match (&program, &cmd) {
1055 (Some(_), Some(_)) => {
1056 return Err(ToolError::denied(
1057 "provide exactly one of `program` or `cmd`, not both",
1058 ))
1059 }
1060 (None, None) => return Err(ToolError::denied("provide one of `program` or `cmd`")),
1061 _ => {}
1062 }
1063 if program.is_none() && !args.is_empty() {
1064 return Err(ToolError::denied(
1065 "`args` may only be used together with `program`",
1066 ));
1067 }
1068
1069 Ok(Self {
1070 program,
1071 args,
1072 cmd,
1073 cwd,
1074 env,
1075 timeout: Duration::from_secs(timeout_secs),
1076 })
1077 }
1078
1079 fn script(&self) -> Result<Script, Refusal> {
1083 if let Some(program) = &self.program {
1084 let mut argv = Vec::with_capacity(1 + self.args.len());
1085 argv.push(Arg::Lit(program.clone()));
1086 argv.extend(self.args.iter().cloned().map(Arg::Lit));
1087 Ok(vec![ScriptItem {
1088 sep: Sep::Seq,
1089 pipeline: vec![Command {
1090 argv,
1091 redirects: Vec::new(),
1092 }],
1093 }])
1094 } else {
1095 classify(self.cmd.as_deref().unwrap_or(""))
1096 }
1097 }
1098}
1099
1100fn is_allowed_var(name: &str, allowlist: &[String]) -> bool {
1109 allowlist.iter().any(|v| v == name)
1110}
1111
1112pub(crate) trait EnvProvider: Send + Sync {
1117 fn get(&self, name: &str) -> Option<String>;
1119}
1120
1121pub(crate) struct RealEnv;
1123impl EnvProvider for RealEnv {
1124 fn get(&self, name: &str) -> Option<String> {
1125 std::env::var(name).ok()
1126 }
1127}
1128
1129fn expand_redirect_target(
1134 segs: &[Seg],
1135 env: &dyn EnvProvider,
1136 allowlist: &[String],
1137) -> Result<String, (String, ToolError)> {
1138 let mut out = String::new();
1139 for seg in segs {
1140 match seg {
1141 Seg::Lit(s) => out.push_str(s),
1142 Seg::Var(name) => {
1143 if !is_allowed_var(name, allowlist) {
1144 return Err((
1145 format!("${name}"),
1146 ToolError::denied(format!(
1147 "variable ${name} is not in the confined shell's allowlist"
1148 )),
1149 ));
1150 }
1151 out.push_str(&env.get(name).unwrap_or_default());
1152 }
1153 }
1154 }
1155 Ok(out)
1156}
1157
1158fn expand_varglob(
1168 segs: &[Seg],
1169 env: &dyn EnvProvider,
1170 allowlist: &[String],
1171) -> Result<String, (String, ToolError)> {
1172 let mut out = String::new();
1173 let mut last_var_byte: Option<usize> = None; let mut last_slash_byte: Option<usize> = None; for seg in segs {
1176 match seg {
1177 Seg::Lit(s) => {
1178 for ch in s.chars() {
1179 if ch == '/' {
1180 last_slash_byte = Some(out.len());
1181 }
1182 out.push(ch);
1183 }
1184 }
1185 Seg::Var(name) => {
1186 if !is_allowed_var(name, allowlist) {
1187 return Err((
1188 format!("${name}"),
1189 ToolError::denied(format!(
1190 "variable ${name} is not in the confined shell's allowlist"
1191 )),
1192 ));
1193 }
1194 for ch in env.get(name).unwrap_or_default().chars() {
1195 if ch == '/' {
1196 last_slash_byte = Some(out.len());
1197 }
1198 last_var_byte = Some(out.len());
1199 out.push(ch);
1200 }
1201 }
1202 }
1203 }
1204 let basename_start = last_slash_byte.map_or(0, |i| i + 1);
1207 if last_var_byte.is_some_and(|v| v >= basename_start) {
1208 return Err((
1209 "$VAR".to_string(),
1210 ToolError::denied(
1211 "a variable in a glob's basename is not supported (re-injection guard); \
1212 put the variable in the directory prefix, e.g. $DIR/*.rs",
1213 ),
1214 ));
1215 }
1216 Ok(out)
1217}
1218
1219#[derive(Debug, Clone, PartialEq, Eq)]
1224pub(crate) struct GlobEntry {
1225 pub name: String,
1226 pub is_dir: bool,
1227}
1228
1229pub(crate) trait DirLister: Send + Sync {
1232 fn list(&self, dir: &Path) -> Vec<GlobEntry>;
1234}
1235
1236pub(crate) struct RealDirLister;
1238impl DirLister for RealDirLister {
1239 fn list(&self, dir: &Path) -> Vec<GlobEntry> {
1240 std::fs::read_dir(dir)
1241 .map(|rd| {
1242 rd.filter_map(|e| {
1243 let e = e.ok()?;
1244 let name = e.file_name().into_string().ok()?;
1245 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
1246 Some(GlobEntry { name, is_dir })
1247 })
1248 .collect()
1249 })
1250 .unwrap_or_default()
1251 }
1252}
1253
1254fn join_rel(rel: &str, name: &str) -> String {
1257 if rel.is_empty() {
1258 name.to_string()
1259 } else if rel == "/" {
1260 format!("/{name}")
1261 } else {
1262 format!("{rel}/{name}")
1263 }
1264}
1265
1266fn descend_all(
1270 real: &Path,
1271 rel: &str,
1272 list: &dyn DirLister,
1273 leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
1274 depth: usize,
1275 max_matches: usize,
1276 out: &mut Vec<(PathBuf, String)>,
1277) -> ToolResult<()> {
1278 if depth == 0 || out.len() >= max_matches {
1279 return Ok(());
1280 }
1281 leash(real)?;
1282 let mut entries = list.list(real);
1283 entries.sort_by(|a, b| a.name.cmp(&b.name));
1284 for e in entries {
1285 if e.is_dir && !e.name.starts_with('.') {
1286 let child_real = real.join(&e.name);
1287 let child_rel = join_rel(rel, &e.name);
1288 out.push((child_real.clone(), child_rel.clone()));
1289 if out.len() >= max_matches {
1290 break;
1291 }
1292 descend_all(
1293 &child_real,
1294 &child_rel,
1295 list,
1296 leash,
1297 depth - 1,
1298 max_matches,
1299 out,
1300 )?;
1301 }
1302 }
1303 Ok(())
1304}
1305
1306fn expand_glob_walk(
1314 pattern: &str,
1315 cwd: Option<&str>,
1316 list: &dyn DirLister,
1317 leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
1318 max_depth: usize,
1319 max_matches: usize,
1320) -> ToolResult<Vec<String>> {
1321 let absolute = pattern.starts_with('/');
1322 let segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
1323
1324 let base_real = if absolute {
1325 PathBuf::from("/")
1326 } else {
1327 cwd.map_or_else(|| PathBuf::from("."), PathBuf::from)
1328 };
1329 let base_rel = if absolute {
1330 "/".to_string()
1331 } else {
1332 String::new()
1333 };
1334 let mut frontier: Vec<(PathBuf, String)> = vec![(base_real, base_rel)];
1335
1336 for seg in &segments {
1337 let mut next: Vec<(PathBuf, String)> = Vec::new();
1338 if *seg == "**" {
1339 for (real, rel) in &frontier {
1340 next.push((real.clone(), rel.clone())); descend_all(real, rel, list, leash, max_depth, max_matches, &mut next)?;
1342 }
1343 } else {
1344 let seg_hidden = seg.starts_with('.');
1345 for (real, rel) in &frontier {
1346 leash(real)?;
1347 let mut entries = list.list(real);
1348 entries.sort_by(|a, b| a.name.cmp(&b.name));
1349 for e in entries {
1350 if (seg_hidden || !e.name.starts_with('.')) && fnmatch(seg, &e.name) {
1351 next.push((real.join(&e.name), join_rel(rel, &e.name)));
1352 if next.len() >= max_matches {
1353 break;
1354 }
1355 }
1356 }
1357 }
1358 }
1359 frontier = next;
1360 if frontier.is_empty() {
1361 break;
1362 }
1363 }
1364
1365 let mut matches: Vec<String> = frontier.into_iter().map(|(_, rel)| rel).collect();
1366 matches.retain(|m| !m.is_empty()); matches.sort();
1368 matches.dedup();
1369 if matches.is_empty() {
1370 Ok(vec![pattern.to_string()])
1371 } else {
1372 Ok(matches)
1373 }
1374}
1375
1376fn fnmatch(pattern: &str, name: &str) -> bool {
1379 let p: Vec<char> = pattern.chars().collect();
1380 let n: Vec<char> = name.chars().collect();
1381 fnmatch_inner(&p, &n)
1382}
1383
1384fn fnmatch_inner(p: &[char], n: &[char]) -> bool {
1385 match p.first() {
1386 None => n.is_empty(),
1387 Some('*') => fnmatch_inner(&p[1..], n) || (!n.is_empty() && fnmatch_inner(p, &n[1..])),
1388 Some('?') => !n.is_empty() && fnmatch_inner(&p[1..], &n[1..]),
1389 Some('[') => {
1390 if n.is_empty() {
1391 return false;
1392 }
1393 match match_class(&p[1..], n[0]) {
1394 Some((matched, rest)) => matched && fnmatch_inner(rest, &n[1..]),
1395 None => n[0] == '[' && fnmatch_inner(&p[1..], &n[1..]),
1397 }
1398 }
1399 Some(&c) => !n.is_empty() && c == n[0] && fnmatch_inner(&p[1..], &n[1..]),
1400 }
1401}
1402
1403fn match_class(p: &[char], c: char) -> Option<(bool, &[char])> {
1406 let mut i = 0;
1407 let negate = matches!(p.first(), Some('!' | '^'));
1408 if negate {
1409 i = 1;
1410 }
1411 let mut matched = false;
1412 let mut first = true;
1413 while i < p.len() {
1414 if p[i] == ']' && !first {
1415 return Some((matched ^ negate, &p[i + 1..]));
1416 }
1417 first = false;
1418 if i + 2 < p.len() && p[i + 1] == '-' && p[i + 2] != ']' {
1419 if c >= p[i] && c <= p[i + 2] {
1420 matched = true;
1421 }
1422 i += 3;
1423 } else {
1424 if c == p[i] {
1425 matched = true;
1426 }
1427 i += 1;
1428 }
1429 }
1430 None
1431}
1432
1433fn open_for_write(path: &str, append: bool) -> std::io::Result<std::fs::File> {
1437 #[cfg(windows)]
1438 if append {
1439 let mut file = std::fs::OpenOptions::new()
1440 .write(true)
1441 .create(true)
1442 .truncate(false)
1443 .open(path)?;
1444 file.seek(SeekFrom::End(0))?;
1445 return Ok(file);
1446 }
1447
1448 std::fs::OpenOptions::new()
1449 .write(true)
1450 .create(true)
1451 .truncate(!append)
1452 .append(append)
1453 .open(path)
1454}
1455
1456fn kill_all(children: &mut [Child]) {
1459 for child in children.iter_mut() {
1460 let _ = child.kill();
1461 let _ = child.wait();
1462 }
1463}
1464
1465fn expand_stage_argv(stage: &Command, _cwd: Option<&str>) -> Vec<String> {
1470 let mut argv = Vec::with_capacity(stage.argv.len());
1471 for arg in &stage.argv {
1472 match arg {
1473 Arg::Lit(s) => argv.push(s.clone()),
1474 Arg::Var(segs) => {
1478 let mut word = String::new();
1479 for seg in segs {
1480 match seg {
1481 Seg::Lit(s) => word.push_str(s),
1482 Seg::Var(name) => word.push_str(&std::env::var(name).unwrap_or_default()),
1483 }
1484 }
1485 argv.push(word);
1486 }
1487 Arg::Glob(_) => unreachable!("glob expanded at admission"),
1491 Arg::VarGlob(_) => unreachable!("VarGlob lowered/expanded at admission"),
1492 }
1493 }
1494 argv
1495}
1496
1497fn run_pipeline(
1509 stages: &[Command],
1510 cwd: Option<&str>,
1511 wrap: &[String],
1512 env: &BTreeMap<String, String>,
1513 max_output: usize,
1514 output: OutputEmitter,
1515 timeout: Duration,
1516) -> ToolResult<Captured> {
1517 debug_assert!(!stages.is_empty(), "the parser guarantees ≥1 stage");
1518 let n = stages.len();
1519 let last = n - 1;
1520
1521 let mut children: Vec<Child> = Vec::with_capacity(n);
1522 let mut prev_stdin: Option<PipeReader> = None;
1524 let mut stdout_capture: Option<PipeReader> = None;
1526 let mut stderr_threads: Vec<std::thread::JoinHandle<(Vec<u8>, bool)>> = Vec::new();
1529
1530 for (i, stage) in stages.iter().enumerate() {
1531 let is_last = i == last;
1532 let stage_argv = expand_stage_argv(stage, cwd);
1533 let argv: Vec<String> = if wrap.is_empty() {
1538 stage_argv
1539 } else {
1540 wrap.iter().cloned().chain(stage_argv).collect()
1541 };
1542 let mut cmd = std::process::Command::new(&argv[0]);
1543 cmd.args(&argv[1..]);
1544 #[cfg(unix)]
1547 cmd.process_group(0);
1548 if let Some(dir) = cwd {
1549 cmd.current_dir(dir);
1550 }
1551 #[cfg(unix)]
1565 {
1566 cmd.env_clear();
1567 cmd.env("PATH", agent_bridle_core::default_exec_path());
1568 cmd.env("LC_ALL", "C");
1569 }
1570 for (k, v) in env {
1571 cmd.env(k, v);
1572 }
1573
1574 if let Some(path) = stage.stdin_path() {
1576 let file = ok_or_kill(std::fs::File::open(path), &mut children)?;
1577 cmd.stdin(Stdio::from(file));
1578 prev_stdin = None;
1579 } else {
1580 cmd.stdin(match prev_stdin.take() {
1581 Some(reader) => Stdio::from(reader),
1582 None => Stdio::null(),
1583 });
1584 }
1585
1586 let dup_source: DupSource;
1590 if let Some((path, append)) = stage.stdout_redirect() {
1591 let file = ok_or_kill(open_for_write(path, append), &mut children)?;
1592 let clone = ok_or_kill(file.try_clone(), &mut children)?;
1593 cmd.stdout(Stdio::from(file));
1594 dup_source = DupSource::File(clone);
1595 } else {
1596 let (reader, writer) = ok_or_kill(std::io::pipe(), &mut children)?;
1597 let clone = ok_or_kill(writer.try_clone(), &mut children)?;
1598 cmd.stdout(Stdio::from(writer));
1599 if is_last {
1600 stdout_capture = Some(reader);
1601 } else {
1602 prev_stdin = Some(reader);
1603 }
1604 dup_source = DupSource::Pipe(clone);
1605 }
1606
1607 match stage.stderr_disposition() {
1609 StderrTo::Stdout => match dup_source {
1612 DupSource::File(f) => {
1613 cmd.stderr(Stdio::from(f));
1614 }
1615 DupSource::Pipe(w) => {
1616 cmd.stderr(Stdio::from(w));
1617 }
1618 },
1619 StderrTo::File { path, append } => {
1621 let file = ok_or_kill(open_for_write(&path, append), &mut children)?;
1622 cmd.stderr(Stdio::from(file));
1623 }
1625 StderrTo::Capture => {
1627 cmd.stderr(Stdio::piped());
1628 }
1629 }
1630
1631 let mut child = ok_or_kill(cmd.spawn(), &mut children)?;
1632
1633 if matches!(stage.stderr_disposition(), StderrTo::Capture) {
1634 let err = child.stderr.take().expect("stderr is piped");
1635 let output = output.clone();
1636 stderr_threads.push(std::thread::spawn(move || {
1637 read_capped_observed(err, max_output, &output, crate::ShellOutputStream::Stderr)
1638 }));
1639 }
1640 children.push(child);
1641 }
1642
1643 let stdout_thread = stdout_capture.map(|reader| {
1647 std::thread::spawn(move || {
1648 read_capped_observed(
1649 reader,
1650 max_output,
1651 &output,
1652 crate::ShellOutputStream::Stdout,
1653 )
1654 })
1655 });
1656
1657 let deadline = std::time::Instant::now() + timeout;
1663 let mut exit_code = -1;
1664 let mut timed_out = false;
1665 let mut done = vec![false; children.len()];
1666 loop {
1667 let mut all_done = true;
1668 for (i, child) in children.iter_mut().enumerate() {
1669 if done[i] {
1670 continue;
1671 }
1672 match child.try_wait().map_err(ToolError::Exec)? {
1673 Some(status) => {
1674 done[i] = true;
1675 if i == last {
1676 exit_code = status.code().unwrap_or(-1);
1677 }
1678 }
1679 None => all_done = false,
1680 }
1681 }
1682 if all_done {
1683 break;
1684 }
1685 if std::time::Instant::now() >= deadline {
1686 timed_out = true;
1687 for child in children.iter_mut() {
1688 crate::kill_child_tree(child);
1689 }
1690 for child in children.iter_mut() {
1692 let _ = child.wait();
1693 }
1694 break;
1695 }
1696 std::thread::sleep(Duration::from_millis(15));
1697 }
1698
1699 let (stdout, stdout_truncated) =
1700 stdout_thread.map_or((Vec::new(), false), |h| h.join().unwrap_or_default());
1701 let mut stderr = Vec::new();
1702 let mut stderr_truncated = false;
1703 for h in stderr_threads {
1704 let (buf, trunc) = h.join().unwrap_or_default();
1705 stderr.extend(buf);
1706 stderr_truncated |= trunc;
1707 }
1708 let stderr_truncated = stderr_truncated || stderr.len() > max_output;
1711
1712 Ok(Captured {
1713 exit_code,
1714 stdout: capped_utf8(&stdout, max_output),
1715 stderr: capped_utf8(&stderr, max_output),
1716 stdout_truncated,
1717 stderr_truncated,
1718 net_denials: Vec::new(),
1721 timed_out,
1722 })
1723}
1724
1725enum DupSource {
1727 File(std::fs::File),
1728 Pipe(PipeWriter),
1729}
1730
1731fn ok_or_kill<T>(result: std::io::Result<T>, children: &mut [Child]) -> ToolResult<T> {
1734 result.map_err(|e| {
1735 kill_all(children);
1736 ToolError::Exec(e)
1737 })
1738}
1739
1740fn read_capped_observed(
1751 mut reader: impl Read,
1752 max_output: usize,
1753 output: &OutputEmitter,
1754 stream: crate::ShellOutputStream,
1755) -> (Vec<u8>, bool) {
1756 let mut buf = Vec::with_capacity(max_output.min(8 * 1024));
1757 let mut chunk = [0u8; 8 * 1024];
1758 while buf.len() < max_output {
1759 let remaining = max_output - buf.len();
1760 let read_len = remaining.min(chunk.len());
1761 match reader.read(&mut chunk[..read_len]) {
1762 Ok(0) => return (buf, false),
1763 Ok(n) => {
1764 output.emit(stream, &chunk[..n]);
1765 buf.extend_from_slice(&chunk[..n]);
1766 }
1767 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1768 Err(_) => return (buf, false),
1769 }
1770 }
1771 let mut probe = [0u8; 1];
1772 let truncated = loop {
1773 match reader.read(&mut probe) {
1774 Ok(n) => break n > 0,
1775 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1776 Err(_) => break false,
1777 }
1778 };
1779 (buf, truncated)
1780}
1781
1782#[cfg(test)]
1783fn read_capped(reader: impl Read, max_output: usize) -> (Vec<u8>, bool) {
1784 read_capped_observed(
1785 reader,
1786 max_output,
1787 &OutputEmitter::default(),
1788 crate::ShellOutputStream::Stdout,
1789 )
1790}
1791
1792fn capped_utf8(bytes: &[u8], max_output: usize) -> String {
1797 let slice = &bytes[..bytes.len().min(max_output)];
1798 String::from_utf8_lossy(slice).into_owned()
1799}
1800
1801fn cap_string(mut s: String, max_output: usize) -> String {
1804 if s.len() > max_output {
1805 let mut end = max_output;
1806 while !s.is_char_boundary(end) {
1807 end -= 1;
1808 }
1809 s.truncate(end);
1810 }
1811 s
1812}
1813
1814#[cfg(test)]
1815mod tests {
1816 use super::*;
1817 use agent_bridle_core::{Caveats, Gate, Scope};
1818 use std::collections::HashMap;
1819 use std::sync::{mpsc, Mutex};
1820
1821 #[test]
1825 fn schema_loads_from_data_file_with_expected_shape() {
1826 let s = ShellTool::new().schema();
1827 assert_eq!(s["type"], "object");
1828 assert_eq!(s["additionalProperties"], false);
1829 for key in ["program", "args", "cmd", "cwd", "env", "timeout_secs"] {
1830 assert!(
1831 s["properties"].get(key).is_some(),
1832 "schema is missing the `{key}` property: {s}"
1833 );
1834 }
1835 }
1836
1837 #[test]
1841 fn schema_timeout_maximum_tracks_the_configured_limits() {
1842 let limits = agent_bridle_core::LimitsPolicy {
1843 max_timeout_secs: 7,
1844 ..agent_bridle_core::LimitsPolicy::default()
1845 };
1846 let s = ShellTool::with_config(limits).schema();
1847 assert_eq!(s["properties"]["timeout_secs"]["maximum"], 7);
1848 assert!(SHELL_SCHEMA["properties"]["timeout_secs"]
1850 .get("maximum")
1851 .is_none());
1852 }
1853
1854 #[cfg(feature = "brush")]
1858 #[tokio::test]
1859 async fn dynamic_refusal_attaches_non_executing_shell_inspection() {
1860 let cmd = r#"ls -1 $(find . -name "*.rs" -type f -exec wc -l {} + 2>/dev/null | sort -nr | head -10)"#;
1861 let mock = Arc::new(MockSpawner::default());
1862 let out = ShellTool::with_spawner(mock.clone())
1863 .invoke(serde_json::json!({"cmd": cmd}), &ctx(Caveats::top()))
1864 .await
1865 .expect("structured refusal");
1866
1867 assert_eq!(out["denied"], true);
1868 assert_eq!(out["denials"][0]["target"], "command substitution `$(`");
1869 assert_eq!(out["shell_inspection"]["source"], cmd);
1870 assert_eq!(
1871 out["shell_inspection"]["constructs"][0]["kind"],
1872 "command_substitution"
1873 );
1874 assert_eq!(
1875 out["shell_inspection"]["constructs"][0]["inspection"]["commands"][0]
1876 ["descendant_execs"][0]["program"],
1877 "wc"
1878 );
1879 assert!(
1880 calls(&mock).is_empty(),
1881 "inspection must not execute any stage: {out}"
1882 );
1883
1884 let arithmetic_cmd = r#"echo "$((1 + 2))""#;
1885 let arithmetic = ShellTool::with_spawner(mock.clone())
1886 .invoke(
1887 serde_json::json!({"cmd": arithmetic_cmd}),
1888 &ctx(Caveats::top()),
1889 )
1890 .await
1891 .expect("structured arithmetic refusal");
1892
1893 assert_eq!(
1894 arithmetic["denials"][0]["target"],
1895 "arithmetic expansion `$((`"
1896 );
1897 assert_eq!(
1898 arithmetic["shell_inspection"]["constructs"][0]["kind"],
1899 "arithmetic_expansion"
1900 );
1901 assert!(
1902 calls(&mock).is_empty(),
1903 "arithmetic inspection must not execute any stage: {arithmetic}"
1904 );
1905
1906 let runtime_arithmetic = ShellTool::with_spawner(mock.clone())
1907 .invoke(
1908 serde_json::json!({"cmd": "echo $((runtime_value))"}),
1909 &ctx(Caveats::top()),
1910 )
1911 .await
1912 .expect("structured runtime arithmetic refusal");
1913 assert_eq!(
1914 runtime_arithmetic["denials"][0]["target"],
1915 "arithmetic expansion `$((`"
1916 );
1917 assert!(
1918 runtime_arithmetic.get("shell_inspection").is_none(),
1919 "an incomplete runtime-state projection must not be attached: {runtime_arithmetic}"
1920 );
1921 assert!(
1922 calls(&mock).is_empty(),
1923 "runtime arithmetic inspection must not execute any stage: {runtime_arithmetic}"
1924 );
1925 }
1926
1927 #[derive(Default)]
1930 struct MockSpawner {
1931 calls: Mutex<Vec<Vec<Command>>>,
1932 envs: Mutex<Vec<BTreeMap<String, String>>>,
1935 private_hosts: Mutex<Vec<Vec<String>>>,
1936 net_scopes: Mutex<Vec<Scope<String>>>,
1937 exit_by_program: HashMap<String, i32>,
1938 block_ms: u64,
1939 net_denials: Vec<Denial>,
1943 }
1944
1945 impl MockSpawner {
1946 fn with_exit(program: &str, code: i32) -> Self {
1947 let mut m = Self::default();
1948 m.exit_by_program.insert(program.to_string(), code);
1949 m
1950 }
1951
1952 fn with_net_denials(denials: Vec<Denial>) -> Self {
1955 Self {
1956 net_denials: denials,
1957 ..Self::default()
1958 }
1959 }
1960 }
1961
1962 fn prog(stage: &Command) -> &str {
1965 match stage.argv.first() {
1966 Some(Arg::Lit(s) | Arg::Glob(s)) => s,
1967 Some(Arg::Var(_) | Arg::VarGlob(_)) | None => "",
1968 }
1969 }
1970
1971 impl Spawner for MockSpawner {
1972 fn run(
1973 &self,
1974 stages: &[Command],
1975 _cwd: Option<&str>,
1976 caveats: &Caveats,
1977 env: &BTreeMap<String, String>,
1978 cfg: &SpawnCfg,
1979 ) -> ToolResult<Captured> {
1980 self.calls.lock().unwrap().push(stages.to_vec());
1981 self.envs.lock().unwrap().push(env.clone());
1982 let mut hosts: Vec<_> = cfg.private_hosts.iter().cloned().collect();
1983 hosts.sort();
1984 self.private_hosts.lock().unwrap().push(hosts);
1985 self.net_scopes.lock().unwrap().push(caveats.net.clone());
1986 if self.block_ms > 0 {
1987 std::thread::sleep(Duration::from_millis(self.block_ms));
1988 }
1989 Ok(Captured {
1990 exit_code: self
1991 .exit_by_program
1992 .get(prog(&stages[0]))
1993 .copied()
1994 .unwrap_or(0),
1995 stdout: String::new(),
1996 stderr: String::new(),
1997 net_denials: self.net_denials.clone(),
1998 ..Default::default()
1999 })
2000 }
2001 }
2002
2003 struct CoordinatedSpawner {
2004 proceed: Mutex<mpsc::Receiver<()>>,
2005 finished: mpsc::Sender<()>,
2006 }
2007
2008 impl Spawner for CoordinatedSpawner {
2009 fn run(
2010 &self,
2011 _stages: &[Command],
2012 _cwd: Option<&str>,
2013 _caveats: &Caveats,
2014 _env: &BTreeMap<String, String>,
2015 cfg: &SpawnCfg,
2016 ) -> ToolResult<Captured> {
2017 cfg.output.emit(crate::ShellOutputStream::Stdout, b"first");
2018 self.proceed
2019 .lock()
2020 .expect("proceed lock")
2021 .recv()
2022 .expect("test releases spawner");
2023 cfg.output.emit(crate::ShellOutputStream::Stdout, b"second");
2024 self.finished.send(()).expect("test observes completion");
2025 Ok(Captured {
2026 exit_code: 0,
2027 stdout: "firstsecond".to_string(),
2028 ..Default::default()
2029 })
2030 }
2031 }
2032
2033 fn coordinated_spawner() -> (
2034 Arc<CoordinatedSpawner>,
2035 mpsc::Sender<()>,
2036 mpsc::Receiver<()>,
2037 ) {
2038 let (proceed_tx, proceed_rx) = mpsc::channel();
2039 let (finished_tx, finished_rx) = mpsc::channel();
2040 (
2041 Arc::new(CoordinatedSpawner {
2042 proceed: Mutex::new(proceed_rx),
2043 finished: finished_tx,
2044 }),
2045 proceed_tx,
2046 finished_rx,
2047 )
2048 }
2049
2050 struct BlockingObserver {
2051 entered: mpsc::Sender<()>,
2052 release: Mutex<mpsc::Receiver<()>>,
2053 finished: mpsc::Sender<()>,
2054 }
2055
2056 impl crate::ShellOutputObserver for BlockingObserver {
2057 fn on_output(
2058 &self,
2059 _invocation: crate::ShellInvocationId,
2060 _stream: crate::ShellOutputStream,
2061 _chunk: &[u8],
2062 ) {
2063 self.entered.send(()).expect("observer entered callback");
2064 self.release
2065 .lock()
2066 .expect("observer release lock")
2067 .recv()
2068 .expect("test releases blocked observer");
2069 }
2070
2071 fn on_finish(&self, _invocation: crate::ShellInvocationId) {
2072 self.finished.send(()).expect("record unexpected finish");
2073 }
2074 }
2075
2076 struct TemporalPipelineSpawner;
2077
2078 impl Spawner for TemporalPipelineSpawner {
2079 fn run(
2080 &self,
2081 stages: &[Command],
2082 _cwd: Option<&str>,
2083 _caveats: &Caveats,
2084 _env: &BTreeMap<String, String>,
2085 cfg: &SpawnCfg,
2086 ) -> ToolResult<Captured> {
2087 assert_eq!(stages.len(), 2, "the test request is one pipeline");
2088 cfg.output
2091 .emit(crate::ShellOutputStream::Stderr, b"second-stage");
2092 cfg.output
2093 .emit(crate::ShellOutputStream::Stderr, b"first-stage");
2094 Ok(Captured {
2095 exit_code: 0,
2096 stderr: "firs".to_string(),
2097 stderr_truncated: true,
2098 ..Default::default()
2099 })
2100 }
2101 }
2102
2103 #[derive(Debug, PartialEq, Eq)]
2104 enum PipelineObserverEvent {
2105 Output(crate::ShellInvocationId, crate::ShellOutputStream, Vec<u8>),
2106 Finish(crate::ShellInvocationId),
2107 }
2108
2109 struct PipelineObserver(mpsc::Sender<PipelineObserverEvent>);
2110
2111 impl crate::ShellOutputObserver for PipelineObserver {
2112 fn on_output(
2113 &self,
2114 invocation: crate::ShellInvocationId,
2115 stream: crate::ShellOutputStream,
2116 chunk: &[u8],
2117 ) {
2118 self.0
2119 .send(PipelineObserverEvent::Output(
2120 invocation,
2121 stream,
2122 chunk.to_vec(),
2123 ))
2124 .expect("record pipeline output");
2125 }
2126
2127 fn on_finish(&self, invocation: crate::ShellInvocationId) {
2128 self.0
2129 .send(PipelineObserverEvent::Finish(invocation))
2130 .expect("record pipeline finish");
2131 }
2132 }
2133
2134 #[tokio::test]
2135 async fn observer_receives_output_before_invoke_completes() {
2136 let (spawner, proceed, finished) = coordinated_spawner();
2137 let (seen_tx, seen_rx) = mpsc::channel();
2138 let seen_rx = Arc::new(Mutex::new(seen_rx));
2139 let observer = Arc::new(move |invocation, stream, chunk: &[u8]| {
2140 seen_tx
2141 .send((invocation, stream, chunk.to_vec()))
2142 .expect("test receives observer callback");
2143 });
2144 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
2145 let context = ctx(exec_only(&["anything"]));
2146
2147 let invoke = tokio::spawn(async move {
2148 tool.invoke(serde_json::json!({"program": "anything"}), &context)
2149 .await
2150 });
2151 let first_rx = Arc::clone(&seen_rx);
2152 let first = tokio::task::spawn_blocking(move || {
2153 first_rx
2154 .lock()
2155 .expect("observer receiver lock")
2156 .recv_timeout(Duration::from_secs(2))
2157 })
2158 .await
2159 .expect("receiver task")
2160 .expect("live callback before completion");
2161 let invocation = first.0;
2162 assert_eq!(
2163 first,
2164 (
2165 invocation,
2166 crate::ShellOutputStream::Stdout,
2167 b"first".to_vec()
2168 )
2169 );
2170 assert!(!invoke.is_finished(), "the tool must still be running");
2171
2172 proceed.send(()).expect("release spawner");
2173 finished
2174 .recv_timeout(Duration::from_secs(2))
2175 .expect("spawner completion");
2176 let out = invoke.await.expect("invoke task").expect("invoke result");
2177 assert_eq!(out["stdout"], "firstsecond");
2178 assert_eq!(
2179 seen_rx
2180 .lock()
2181 .expect("observer receiver lock")
2182 .recv_timeout(Duration::from_secs(2))
2183 .expect("second callback"),
2184 (
2185 invocation,
2186 crate::ShellOutputStream::Stdout,
2187 b"second".to_vec()
2188 )
2189 );
2190 }
2191
2192 #[tokio::test]
2193 async fn pipeline_stderr_live_cap_is_temporal_but_envelope_is_authoritative() {
2194 let (events_tx, events_rx) = mpsc::channel();
2195 let mut tool = ShellTool::with_spawner(Arc::new(TemporalPipelineSpawner));
2196 tool.limits.max_output_bytes = 4;
2197 let tool = tool.with_output_observer(Arc::new(PipelineObserver(events_tx)));
2198
2199 let out = tool
2200 .invoke(
2201 serde_json::json!({"cmd": "first | second"}),
2202 &ctx(exec_only(&["first", "second"])),
2203 )
2204 .await
2205 .expect("invoke pipeline");
2206
2207 assert_eq!(out["stderr"], "firs");
2208 assert_eq!(out["stderr_truncated"], true);
2209 let first = events_rx
2210 .recv_timeout(Duration::from_secs(2))
2211 .expect("live stderr event");
2212 let invocation = match first {
2213 PipelineObserverEvent::Output(id, crate::ShellOutputStream::Stderr, bytes) => {
2214 assert_eq!(bytes, b"seco", "the live cap follows enqueue order");
2215 id
2216 }
2217 other => panic!("unexpected first observer event: {other:?}"),
2218 };
2219 assert_eq!(
2220 events_rx
2221 .recv_timeout(Duration::from_secs(2))
2222 .expect("queue-drained finish"),
2223 PipelineObserverEvent::Finish(invocation)
2224 );
2225 assert!(
2226 events_rx.try_recv().is_err(),
2227 "the later stage-order bytes are outside the live cap"
2228 );
2229 }
2230
2231 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2232 async fn cancellation_does_not_wait_for_a_blocked_observer_or_deliver_late_output() {
2233 let (spawner, proceed, finished) = coordinated_spawner();
2234 let (seen_tx, seen_rx) = mpsc::channel();
2235 let (entered_tx, entered_rx) = mpsc::channel();
2236 let (release_observer_tx, release_observer_rx) = mpsc::channel();
2237 let release_observer_rx = Mutex::new(release_observer_rx);
2238 let observer = Arc::new(move |invocation, stream, chunk: &[u8]| {
2239 seen_tx
2240 .send((invocation, stream, chunk.to_vec()))
2241 .expect("observer receiver remains alive");
2242 entered_tx.send(()).expect("observer entered callback");
2243 release_observer_rx
2244 .lock()
2245 .expect("observer release lock")
2246 .recv()
2247 .expect("test releases blocked observer");
2248 });
2249 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
2250 let context = ctx(exec_only(&["anything"]));
2251
2252 let mut invoke = tokio::spawn(async move {
2253 tool.invoke(serde_json::json!({"program": "anything"}), &context)
2254 .await
2255 });
2256 entered_rx
2257 .recv_timeout(Duration::from_secs(2))
2258 .expect("observer is blocked in its first callback");
2259 let first = seen_rx
2260 .recv_timeout(Duration::from_secs(2))
2261 .expect("first callback");
2262 assert_eq!(first.1, crate::ShellOutputStream::Stdout);
2263 assert_eq!(first.2, b"first");
2264
2265 invoke.abort();
2266 let cancelled = tokio::time::timeout(Duration::from_millis(500), &mut invoke).await;
2267 proceed.send(()).expect("release detached worker");
2268 release_observer_tx
2269 .send(())
2270 .expect("release presentation callback");
2271 finished
2272 .recv_timeout(Duration::from_secs(2))
2273 .expect("detached worker attempted its late write");
2274 let cancelled = cancelled.expect("cancellation must not wait for observer code");
2275 assert!(
2276 cancelled.expect_err("invoke is cancelled").is_cancelled(),
2277 "the invocation future was cancelled"
2278 );
2279 assert!(
2280 seen_rx.recv_timeout(Duration::from_millis(50)).is_err(),
2281 "output emitted by the detached worker after cancellation is ignored"
2282 );
2283 }
2284
2285 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2286 async fn timeout_does_not_wait_for_a_blocked_observer_or_finish_the_session() {
2287 let (spawner, proceed, worker_finished) = coordinated_spawner();
2288 let (entered_tx, entered_rx) = mpsc::channel();
2289 let (release_tx, release_rx) = mpsc::channel();
2290 let (finish_tx, finish_rx) = mpsc::channel();
2291 let observer = Arc::new(BlockingObserver {
2292 entered: entered_tx,
2293 release: Mutex::new(release_rx),
2294 finished: finish_tx,
2295 });
2296 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
2297 let context = ctx(exec_only(&["anything"]));
2298
2299 let mut invoke = tokio::spawn(async move {
2300 tool.invoke(
2301 serde_json::json!({"program": "anything", "timeout_secs": 1}),
2302 &context,
2303 )
2304 .await
2305 });
2306 entered_rx
2307 .recv_timeout(Duration::from_secs(2))
2308 .expect("observer is blocked in its first callback");
2309
2310 let result = tokio::time::timeout(Duration::from_secs(2), &mut invoke).await;
2311 if result.is_err() {
2312 invoke.abort();
2313 }
2314 proceed.send(()).expect("release detached worker");
2315 release_tx.send(()).expect("release presentation callback");
2316 worker_finished
2317 .recv_timeout(Duration::from_secs(2))
2318 .expect("detached worker attempted its late write");
2319
2320 let output = result
2321 .expect("tool timeout must not wait for observer code")
2322 .expect("invoke task")
2323 .expect("timeout envelope");
2324 assert_eq!(output["timed_out"], true);
2325 assert!(
2326 finish_rx.recv_timeout(Duration::from_millis(50)).is_err(),
2327 "a timed-out observer session must not report ordinary completion"
2328 );
2329 }
2330
2331 fn ctx(granted: Caveats) -> ToolContext {
2332 Gate::new(0)
2333 .authorize(&ShellTool::new(), &granted)
2334 .expect("authorize")
2335 }
2336
2337 fn ctx_strong(granted: Caveats) -> ToolContext {
2340 Gate::new(0)
2341 .with_strength_floor(agent_bridle_core::AxisEnforcement::Kernel)
2342 .authorize(&ShellTool::new(), &granted)
2343 .expect("authorize")
2344 }
2345
2346 fn exec_only(names: &[&str]) -> Caveats {
2347 Caveats {
2348 exec: Scope::only(names.iter().map(|s| (*s).to_string())),
2349 ..Caveats::top()
2350 }
2351 }
2352
2353 fn calls(mock: &Arc<MockSpawner>) -> Vec<Vec<Command>> {
2354 mock.calls.lock().unwrap().clone()
2355 }
2356
2357 fn envs(mock: &Arc<MockSpawner>) -> Vec<BTreeMap<String, String>> {
2359 mock.envs.lock().unwrap().clone()
2360 }
2361
2362 #[test]
2363 fn private_hosts_are_empty_by_default_and_reject_nonexact_names() {
2364 assert!(ShellTool::new().private_hosts.is_empty());
2365 assert!(ShellTool::default().private_hosts.is_empty());
2366 for invalid in [
2367 "*",
2368 "*.example.test",
2369 "https://service.test",
2370 "service.test:443",
2371 "unix:/tmp/service.sock",
2372 ] {
2373 let error = ShellTool::new()
2374 .with_private_hosts([invalid.to_string()])
2375 .expect_err("private-host approval must be an exact host");
2376 assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
2377 }
2378 }
2379
2380 #[tokio::test]
2381 async fn private_hosts_reach_spawner_without_changing_net_authority() {
2382 let mock = Arc::new(MockSpawner::default());
2383 let granted = Caveats {
2384 net: Scope::only(["other.test".to_string()]),
2385 ..Caveats::top()
2386 };
2387 let out = ShellTool::with_spawner(mock.clone())
2388 .with_private_hosts(["Service.Test.".to_string(), "service.test".to_string()])
2389 .expect("canonical exact approval")
2390 .invoke(serde_json::json!({"cmd": "echo hi"}), &ctx(granted.clone()))
2391 .await
2392 .expect("invoke");
2393 assert_eq!(out["exit_code"], 0);
2394 assert_eq!(
2395 *mock.private_hosts.lock().unwrap(),
2396 vec![vec!["service.test".to_string()]]
2397 );
2398 assert_eq!(*mock.net_scopes.lock().unwrap(), vec![granted.net]);
2399 }
2400
2401 #[tokio::test]
2402 async fn private_hosts_are_not_inferred_from_net_authority() {
2403 let mock = Arc::new(MockSpawner::default());
2404 let granted = Caveats {
2405 net: Scope::only(["service.test".to_string()]),
2406 ..Caveats::top()
2407 };
2408 ShellTool::with_spawner(mock.clone())
2409 .invoke(serde_json::json!({"cmd": "echo hi"}), &ctx(granted))
2410 .await
2411 .expect("invoke");
2412 assert_eq!(
2413 *mock.private_hosts.lock().unwrap(),
2414 vec![Vec::<String>::new()]
2415 );
2416 }
2417
2418 #[tokio::test]
2432 async fn strong_principal_fails_closed_on_unenforceable_exec() {
2433 let granted = exec_only(&["echo"]);
2434 let exec_is_kernel_confined = enforcement_report(
2437 &granted,
2438 intended_sandbox_kind(&granted, &Arc::new(SandboxPolicy::default())),
2439 )
2440 .exec
2441 == Some(agent_bridle_core::AxisEnforcement::Kernel);
2442
2443 let mock = Arc::new(MockSpawner::default());
2444 let out = ShellTool::with_spawner(mock.clone())
2445 .invoke(
2446 serde_json::json!({"cmd": "echo hi"}),
2447 &ctx_strong(granted.clone()),
2448 )
2449 .await
2450 .expect("invoke");
2451 if exec_is_kernel_confined {
2452 assert_ne!(
2455 out["denied"],
2456 serde_json::json!(true),
2457 "kernel-confined exec must run for a strong principal: {out}"
2458 );
2459 assert_eq!(
2460 out["enforcement"]["exec"], "kernel",
2461 "exec is reported kernel-confined: {out}"
2462 );
2463 assert_eq!(ran_programs(&mock), ["echo"], "the program spawned: {out}");
2464 } else {
2465 assert_eq!(
2468 out["denied"], true,
2469 "strong principal must fail closed on unenforceable exec: {out}"
2470 );
2471 assert!(ran_programs(&mock).is_empty(), "nothing may spawn: {out}");
2472 }
2473
2474 let mock = Arc::new(MockSpawner::default());
2477 let out = ShellTool::with_spawner(mock.clone())
2478 .invoke(serde_json::json!({"cmd": "echo hi"}), &ctx(granted))
2479 .await
2480 .expect("invoke");
2481 assert_ne!(
2482 out["denied"],
2483 serde_json::json!(true),
2484 "default principal still runs: {out}"
2485 );
2486 }
2487
2488 #[tokio::test]
2495 async fn net_refusal_surfaces_as_a_net_denial_in_the_envelope() {
2496 let mock = Arc::new(MockSpawner::with_net_denials(vec![Denial {
2497 kind: DenialKind::Net,
2498 target: "github.com".to_string(),
2499 reason: "net does not permit 'github.com'".to_string(),
2500 }]));
2501 let out = ShellTool::with_spawner(mock)
2502 .invoke(
2503 serde_json::json!({ "cmd": "echo hi" }),
2504 &ctx(exec_only(&["echo"])),
2505 )
2506 .await
2507 .expect("invoke");
2508 assert_eq!(
2509 out["denied"],
2510 serde_json::json!(true),
2511 "a net denial sets denied: {out}"
2512 );
2513 assert_eq!(out["denials"][0]["kind"], "net");
2514 assert_eq!(out["denials"][0]["target"], "github.com");
2515 assert!(out.get("exit_code").is_some(), "command still ran: {out}");
2518 }
2519
2520 fn ran_programs(mock: &Arc<MockSpawner>) -> Vec<String> {
2521 calls(mock)
2522 .iter()
2523 .map(|pipeline| prog(&pipeline[0]).to_string())
2524 .collect()
2525 }
2526
2527 #[tokio::test]
2533 async fn env_map_is_passed_to_the_spawner() {
2534 let mock = Arc::new(MockSpawner::default());
2535 let out = ShellTool::with_spawner(mock.clone())
2536 .invoke(
2537 serde_json::json!({
2538 "program": "echo",
2539 "args": ["hi"],
2540 "env": { "FOO": "bar", "VIRTUAL_ENV": "/venv" },
2541 }),
2542 &ctx(exec_only(&["echo"])),
2543 )
2544 .await
2545 .expect("invoke");
2546 assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
2547 let envs = envs(&mock);
2548 assert_eq!(envs.len(), 1, "one pipeline ran");
2549 assert_eq!(envs[0].get("FOO").map(String::as_str), Some("bar"));
2550 assert_eq!(
2551 envs[0].get("VIRTUAL_ENV").map(String::as_str),
2552 Some("/venv"),
2553 "every env entry reaches the child: {:?}",
2554 envs[0]
2555 );
2556 }
2557
2558 #[tokio::test]
2565 async fn env_does_not_change_the_program_the_leash_checks() {
2566 let mock = Arc::new(MockSpawner::default());
2567 let out = ShellTool::with_spawner(mock.clone())
2570 .invoke(
2571 serde_json::json!({
2572 "cmd": "hostname; uname -s",
2573 "env": { "FOO": "bar" },
2574 }),
2575 &ctx(exec_only(&["hostname", "uname"])),
2576 )
2577 .await
2578 .expect("invoke");
2579 assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
2580 let programs = ran_programs(&mock);
2582 assert_eq!(
2583 programs,
2584 vec!["hostname".to_string(), "uname".to_string()],
2585 "the leash/spawner see the real programs, never `export`/env keys: {programs:?}"
2586 );
2587 for e in envs(&mock) {
2589 assert_eq!(e.get("FOO").map(String::as_str), Some("bar"));
2590 }
2591 }
2592
2593 #[test]
2596 fn parse_env_field_present_and_absent() {
2597 let parsed = ShellArgs::parse(
2599 &serde_json::json!({
2600 "program": "echo",
2601 "env": { "FOO": "bar", "BAZ": "qux" },
2602 }),
2603 &agent_bridle_core::LimitsPolicy::default(),
2604 )
2605 .expect("parse");
2606 assert_eq!(parsed.env.get("FOO").map(String::as_str), Some("bar"));
2607 assert_eq!(parsed.env.get("BAZ").map(String::as_str), Some("qux"));
2608 assert_eq!(parsed.env.len(), 2);
2609
2610 let parsed = ShellArgs::parse(
2612 &serde_json::json!({ "program": "echo" }),
2613 &agent_bridle_core::LimitsPolicy::default(),
2614 )
2615 .expect("parse");
2616 assert!(parsed.env.is_empty(), "absent env defaults to empty");
2617 }
2618
2619 #[test]
2623 fn parse_timeout_uses_configured_limits() {
2624 let limits = agent_bridle_core::LimitsPolicy {
2625 max_timeout_secs: 5,
2626 default_timeout_secs: 3,
2627 ..agent_bridle_core::LimitsPolicy::default()
2628 };
2629 let over = ShellArgs::parse(
2631 &serde_json::json!({ "program": "echo", "timeout_secs": 9999 }),
2632 &limits,
2633 )
2634 .expect("parse");
2635 assert_eq!(over.timeout, std::time::Duration::from_secs(5));
2636 let dflt =
2638 ShellArgs::parse(&serde_json::json!({ "program": "echo" }), &limits).expect("parse");
2639 assert_eq!(dflt.timeout, std::time::Duration::from_secs(3));
2640 }
2641
2642 struct FakeEnv(HashMap<String, String>);
2645 impl EnvProvider for FakeEnv {
2646 fn get(&self, name: &str) -> Option<String> {
2647 self.0.get(name).cloned()
2648 }
2649 }
2650 fn fake_env(pairs: &[(&str, &str)]) -> Arc<dyn EnvProvider> {
2651 Arc::new(FakeEnv(
2652 pairs
2653 .iter()
2654 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
2655 .collect(),
2656 ))
2657 }
2658
2659 struct MapLister(HashMap<String, Vec<GlobEntry>>);
2662 impl DirLister for MapLister {
2663 fn list(&self, dir: &Path) -> Vec<GlobEntry> {
2664 let key = dir.to_string_lossy().replace('\\', "/");
2667 self.0.get(&key).cloned().unwrap_or_default()
2668 }
2669 }
2670 fn ent(name: &str, is_dir: bool) -> GlobEntry {
2671 GlobEntry {
2672 name: name.to_string(),
2673 is_dir,
2674 }
2675 }
2676 fn map_lister(dirs: &[(&str, Vec<GlobEntry>)]) -> Arc<dyn DirLister> {
2677 Arc::new(MapLister(
2678 dirs.iter()
2679 .map(|(d, es)| ((*d).to_string(), es.clone()))
2680 .collect(),
2681 ))
2682 }
2683
2684 #[tokio::test]
2690 async fn redirect_var_is_expanded_and_reaches_spawner_resolved() {
2691 let tmp = std::env::temp_dir().to_string_lossy().into_owned();
2692 let mock = Arc::new(MockSpawner::default());
2693 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
2694 let out = tool
2696 .invoke(
2697 serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
2698 &ctx(exec_only(&["echo"])),
2699 )
2700 .await
2701 .expect("invoke");
2702 assert_ne!(
2703 out["denied"],
2704 serde_json::json!(true),
2705 "in-scope var: {out}"
2706 );
2707 let redir = &calls(&mock)[0][0].redirects[0];
2708 assert_eq!(
2709 *redir,
2710 Redirect::Stdout {
2711 path: vec![Seg::Lit(format!("{tmp}/out"))],
2712 append: false,
2713 }
2714 );
2715 }
2716
2717 #[tokio::test]
2719 async fn redirect_var_not_in_allowlist_is_denied() {
2720 let mock = Arc::new(MockSpawner::default());
2721 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/x")]));
2722 let out = tool
2723 .invoke(
2724 serde_json::json!({"cmd": "echo hi > $SECRET"}),
2725 &ctx(exec_only(&["echo"])),
2726 )
2727 .await
2728 .expect("invoke");
2729 assert_eq!(out["denied"], true, "non-allowlisted redirect var: {out}");
2730 assert!(
2731 ran_programs(&mock).is_empty(),
2732 "no spawn on a denied redirect"
2733 );
2734 assert!(out["denials"][0]["reason"]
2735 .as_str()
2736 .unwrap_or_default()
2737 .contains("SECRET"));
2738 }
2739
2740 #[test]
2746 fn expand_varglob_keeps_value_metachars_literal_and_refuses_basename_var() {
2747 let env = FakeEnv(HashMap::from([("TMPDIR".to_string(), "/a*b".to_string())]));
2749 let allow = agent_bridle_core::LimitsPolicy::default().var_allowlist;
2750 let pattern = expand_varglob(
2753 &[Seg::Var("TMPDIR".into()), Seg::Lit("/*.rs".into())],
2754 &env,
2755 &allow,
2756 )
2757 .unwrap();
2758 assert_eq!(pattern, "/a*b/*.rs");
2759 let err = expand_varglob(
2761 &[Seg::Var("TMPDIR".into()), Seg::Lit("*.rs".into())],
2762 &env,
2763 &allow,
2764 );
2765 assert!(err.is_err(), "var in glob basename must be refused");
2766 }
2767
2768 #[tokio::test]
2771 async fn glob_var_expands_to_resolved_matches_before_spawn() {
2772 let mock = Arc::new(MockSpawner::default());
2773 let lister = map_lister(&[
2774 (".", vec![ent("proj", true)]),
2775 ("./proj", vec![ent("a.rs", false), ent("b.rs", false)]),
2776 ]);
2777 let tool = ShellTool::with_seams(mock.clone(), fake_env(&[("TMPDIR", "proj")]), lister);
2778 let out = tool
2779 .invoke(
2780 serde_json::json!({"cmd": "ls $TMPDIR/*.rs"}), &ctx(exec_only(&["ls"])),
2782 )
2783 .await
2784 .expect("invoke");
2785 assert_ne!(
2786 out["denied"],
2787 serde_json::json!(true),
2788 "in-scope glob var: {out}"
2789 );
2790 assert_eq!(
2791 calls(&mock)[0][0].argv,
2792 vec![
2793 Arg::Lit("ls".into()),
2794 Arg::Lit("proj/a.rs".into()),
2795 Arg::Lit("proj/b.rs".into())
2796 ]
2797 );
2798 }
2799
2800 #[tokio::test]
2802 async fn glob_var_in_basename_is_denied() {
2803 let mock = Arc::new(MockSpawner::default());
2804 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("PREFIX", "foo")]));
2805 let out = tool
2806 .invoke(
2807 serde_json::json!({"cmd": "ls $PREFIX*.rs"}),
2808 &ctx(exec_only(&["ls"])),
2809 )
2810 .await
2811 .expect("invoke");
2812 assert_eq!(out["denied"], true, "var in glob basename refused: {out}");
2813 assert!(ran_programs(&mock).is_empty());
2814 }
2815
2816 #[tokio::test]
2818 async fn glob_var_not_in_allowlist_is_denied() {
2819 let mock = Arc::new(MockSpawner::default());
2820 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/s")]));
2821 let out = tool
2822 .invoke(
2823 serde_json::json!({"cmd": "ls $SECRET/*.rs"}),
2824 &ctx(exec_only(&["ls"])),
2825 )
2826 .await
2827 .expect("invoke");
2828 assert_eq!(out["denied"], true, "non-allowlisted glob var: {out}");
2829 assert!(ran_programs(&mock).is_empty());
2830 }
2831
2832 #[tokio::test]
2836 async fn redirect_var_resolved_path_out_of_fs_write_scope_denied() {
2837 let tmp = std::env::temp_dir().to_string_lossy().into_owned();
2838 let mock = Arc::new(MockSpawner::default());
2839 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
2840 let granted = Caveats {
2841 exec: Scope::only(["echo".to_string()]),
2842 fs_write: Scope::only(["/nonexistent-grant-root".to_string()]),
2843 ..Caveats::top()
2844 };
2845 let out = tool
2846 .invoke(
2847 serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
2848 &ctx(granted),
2849 )
2850 .await
2851 .expect("invoke");
2852 assert_eq!(out["denied"], true, "resolved path outside fs_write: {out}");
2853 assert!(ran_programs(&mock).is_empty());
2854 }
2855
2856 #[tokio::test]
2859 async fn and_short_circuits_on_failure() {
2860 let mock = Arc::new(MockSpawner::with_exit("false", 1));
2861 ShellTool::with_spawner(mock.clone())
2862 .invoke(
2863 serde_json::json!({"cmd": "false && echo hi"}),
2864 &ctx(exec_only(&["false", "echo"])),
2865 )
2866 .await
2867 .expect("invoke");
2868 assert_eq!(ran_programs(&mock), vec!["false"], "echo must be skipped");
2869 }
2870
2871 #[tokio::test]
2872 async fn out_of_scope_anywhere_denies_the_whole_script() {
2873 let mock = Arc::new(MockSpawner::default());
2874 let out = ShellTool::with_spawner(mock.clone())
2875 .invoke(
2876 serde_json::json!({"cmd": "echo ok ; rm -rf x"}),
2877 &ctx(exec_only(&["echo"])),
2878 )
2879 .await
2880 .expect("invoke");
2881 assert_eq!(out["denied"], true);
2882 assert!(ran_programs(&mock).is_empty());
2883 }
2884
2885 #[tokio::test]
2890 async fn glob_arg_expanded_to_matches_before_spawn() {
2891 let mock = Arc::new(MockSpawner::default());
2892 let lister = map_lister(&[(
2893 ".",
2894 vec![ent("a.rs", false), ent("b.rs", false), ent("c.txt", false)],
2895 )]);
2896 ShellTool::with_seams(mock.clone(), fake_env(&[]), lister)
2897 .invoke(
2898 serde_json::json!({"cmd": "ls *.rs"}), &ctx(exec_only(&["ls"])),
2900 )
2901 .await
2902 .expect("invoke");
2903 assert_eq!(
2904 calls(&mock)[0][0].argv,
2905 vec![
2906 Arg::Lit("ls".into()),
2907 Arg::Lit("a.rs".into()),
2908 Arg::Lit("b.rs".into())
2909 ]
2910 );
2911 }
2912
2913 #[tokio::test]
2915 async fn glob_as_program_name_denied() {
2916 let mock = Arc::new(MockSpawner::default());
2917 let out = ShellTool::with_spawner(mock.clone())
2918 .invoke(serde_json::json!({"cmd": "*.sh foo"}), &ctx(Caveats::top()))
2919 .await
2920 .expect("invoke");
2921 assert_eq!(out["denied"], true);
2922 assert!(ran_programs(&mock).is_empty());
2923 }
2924
2925 #[tokio::test]
2927 async fn glob_dir_out_of_fs_read_scope_denied() {
2928 let mock = Arc::new(MockSpawner::default());
2929 let granted = Caveats {
2930 exec: Scope::only(["echo".to_string()]),
2931 fs_read: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
2933 ..Caveats::top()
2934 };
2935 let out = ShellTool::with_spawner(mock.clone())
2936 .invoke(serde_json::json!({"cmd": "echo *"}), &ctx(granted))
2937 .await
2938 .expect("invoke");
2939 assert_eq!(out["denied"], true);
2940 assert_eq!(out["denials"][0]["kind"], "open");
2941 assert!(ran_programs(&mock).is_empty());
2942 }
2943
2944 #[tokio::test]
2948 async fn allowlisted_var_reaches_spawner() {
2949 let mock = Arc::new(MockSpawner::default());
2950 ShellTool::with_spawner(mock.clone())
2951 .invoke(
2952 serde_json::json!({"cmd": "echo $HOME"}),
2953 &ctx(exec_only(&["echo"])),
2954 )
2955 .await
2956 .expect("invoke");
2957 let c = calls(&mock);
2958 assert_eq!(
2959 c[0][0].argv,
2960 vec![
2961 Arg::Lit("echo".into()),
2962 Arg::Var(vec![Seg::Var("HOME".into())]),
2963 ]
2964 );
2965 }
2966
2967 #[tokio::test]
2970 async fn non_allowlisted_var_denied() {
2971 let mock = Arc::new(MockSpawner::default());
2972 let out = ShellTool::with_spawner(mock.clone())
2973 .invoke(
2974 serde_json::json!({"cmd": "echo $AWS_SECRET_KEY"}),
2975 &ctx(Caveats::top()),
2976 )
2977 .await
2978 .expect("invoke");
2979 assert_eq!(out["denied"], true);
2980 assert_eq!(out["denials"][0]["target"], "$AWS_SECRET_KEY");
2981 assert!(ran_programs(&mock).is_empty());
2982 }
2983
2984 #[tokio::test]
2986 async fn var_as_program_name_denied() {
2987 let mock = Arc::new(MockSpawner::default());
2988 let out = ShellTool::with_spawner(mock.clone())
2989 .invoke(
2990 serde_json::json!({"cmd": "$HOME foo"}),
2991 &ctx(Caveats::top()),
2992 )
2993 .await
2994 .expect("invoke");
2995 assert_eq!(out["denied"], true);
2996 assert!(ran_programs(&mock).is_empty());
2997 }
2998
2999 #[tokio::test]
3003 async fn stderr_to_file_out_of_scope_denied() {
3004 let mock = Arc::new(MockSpawner::default());
3005 let granted = Caveats {
3006 exec: Scope::only(["cmd".to_string()]),
3007 fs_write: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
3008 ..Caveats::top()
3009 };
3010 let out = ShellTool::with_spawner(mock.clone())
3011 .invoke(
3012 serde_json::json!({"cmd": "cmd 2> /etc/passwd"}),
3013 &ctx(granted),
3014 )
3015 .await
3016 .expect("invoke");
3017 assert_eq!(out["denied"], true);
3018 assert_eq!(out["denials"][0]["kind"], "open");
3019 assert_eq!(out["denials"][0]["target"], "/etc/passwd");
3020 assert!(ran_programs(&mock).is_empty());
3021 }
3022
3023 #[tokio::test]
3025 async fn stderr_merge_reaches_spawner() {
3026 let mock = Arc::new(MockSpawner::default());
3027 ShellTool::with_spawner(mock.clone())
3028 .invoke(
3029 serde_json::json!({"cmd": "cmd 2>&1"}),
3030 &ctx(exec_only(&["cmd"])),
3031 )
3032 .await
3033 .expect("invoke");
3034 let c = calls(&mock);
3035 assert_eq!(c[0][0].stderr_disposition(), StderrTo::Stdout);
3036 }
3037
3038 #[tokio::test]
3039 async fn both_program_and_cmd_is_a_hard_error() {
3040 let res = ShellTool::new()
3041 .invoke(
3042 serde_json::json!({"program": "echo", "cmd": "echo hi"}),
3043 &ctx(Caveats::top()),
3044 )
3045 .await;
3046 assert!(res.is_err());
3047 }
3048
3049 #[tokio::test]
3050 async fn timeout_is_reported() {
3051 let mock = Arc::new(MockSpawner {
3052 block_ms: 1500,
3053 ..Default::default()
3054 });
3055 let out = ShellTool::with_spawner(mock)
3056 .invoke(
3057 serde_json::json!({"program": "anything", "timeout_secs": 1}),
3058 &ctx(exec_only(&["anything"])),
3059 )
3060 .await
3061 .expect("invoke");
3062 assert_eq!(out["timed_out"], true);
3063 }
3064
3065 #[test]
3068 fn fnmatch_basics() {
3069 assert!(fnmatch("*.rs", "a.rs"));
3070 assert!(!fnmatch("*.rs", "a.txt"));
3071 assert!(fnmatch("a?c", "abc"));
3072 assert!(!fnmatch("a?c", "ac"));
3073 assert!(fnmatch("*", ""));
3074 assert!(fnmatch("a*", "a"));
3075 assert!(fnmatch("[abc]x", "bx"));
3076 assert!(!fnmatch("[abc]x", "dx"));
3077 assert!(fnmatch("[!abc]x", "dx"));
3078 assert!(fnmatch("[a-c]", "b"));
3079 assert!(!fnmatch("[a-c]", "d"));
3080 assert!(fnmatch("foo*bar", "fooXYbar"));
3081 }
3082
3083 #[test]
3088 fn read_capped_bounds_buffering_and_flags_truncation() {
3089 const CAP: usize = 1 << 20;
3091 struct Endless {
3094 served: usize,
3095 }
3096 impl Read for Endless {
3097 fn read(&mut self, b: &mut [u8]) -> std::io::Result<usize> {
3098 self.served = self.served.saturating_add(b.len());
3099 assert!(
3100 self.served <= CAP + 64 * 1024,
3101 "read_capped over-read {} bytes (cap {CAP})",
3102 self.served
3103 );
3104 b.fill(b'x');
3105 Ok(b.len())
3106 }
3107 }
3108 let (buf, truncated) = read_capped(Endless { served: 0 }, CAP);
3109 assert_eq!(buf.len(), CAP, "peak buffering bounded by the cap");
3110 assert!(
3111 truncated,
3112 "a source longer than the cap is flagged truncated"
3113 );
3114
3115 let (buf2, trunc2) = read_capped(&b"hello"[..], CAP);
3117 assert_eq!(buf2, b"hello");
3118 assert!(!trunc2, "a sub-cap source is not truncated");
3119 }
3120
3121 #[test]
3122 fn read_capped_retries_an_interrupted_read() {
3123 struct InterruptedOnce {
3124 interrupted: bool,
3125 inner: std::io::Cursor<Vec<u8>>,
3126 }
3127
3128 impl Read for InterruptedOnce {
3129 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3130 if !self.interrupted {
3131 self.interrupted = true;
3132 return Err(std::io::Error::from(std::io::ErrorKind::Interrupted));
3133 }
3134 self.inner.read(buf)
3135 }
3136 }
3137
3138 let reader = InterruptedOnce {
3139 interrupted: false,
3140 inner: std::io::Cursor::new(b"abcdef".to_vec()),
3141 };
3142 let (captured, truncated) = read_capped(reader, 4);
3143
3144 assert_eq!(captured, b"abcd");
3145 assert!(truncated);
3146 }
3147
3148 #[test]
3149 fn glob_walk_single_segment_and_subpath() {
3150 let lister = map_lister(&[
3151 (
3152 ".",
3153 vec![
3154 ent("a.rs", false),
3155 ent("b.rs", false),
3156 ent("c.txt", false),
3157 ent(".hidden.rs", false),
3158 ent("src", true),
3159 ],
3160 ),
3161 ("./src", vec![ent("a.rs", false), ent("b.rs", false)]),
3162 ]);
3163 let mut allow = |_d: &Path| Ok(());
3164 assert_eq!(
3166 expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
3167 vec!["a.rs", "b.rs"]
3168 );
3169 assert_eq!(
3171 expand_glob_walk("zzz*", None, &*lister, &mut allow, 64, 4096).unwrap(),
3172 vec!["zzz*"]
3173 );
3174 assert_eq!(
3176 expand_glob_walk("src/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
3177 vec!["src/a.rs", "src/b.rs"]
3178 );
3179 }
3180
3181 #[test]
3182 fn glob_walk_multi_segment_and_recursive() {
3183 let lister = map_lister(&[
3184 (
3185 ".",
3186 vec![ent("a", true), ent("b", true), ent("x.rs", false)],
3187 ),
3188 ("./a", vec![ent("foo.rs", false), ent("sub", true)]),
3189 ("./b", vec![ent("bar.rs", false)]),
3190 ("./a/sub", vec![ent("deep.rs", false)]),
3191 ]);
3192 let mut allow = |_d: &Path| Ok(());
3193 assert_eq!(
3195 expand_glob_walk("*/foo.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
3196 vec!["a/foo.rs"]
3197 );
3198 assert_eq!(
3200 expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
3201 vec!["a/foo.rs", "a/sub/deep.rs", "b/bar.rs", "x.rs"]
3202 );
3203 }
3204
3205 #[test]
3206 fn glob_walk_leashes_every_directory_and_denies_out_of_scope() {
3207 let lister = map_lister(&[
3208 (".", vec![ent("a", true), ent("x.rs", false)]),
3209 ("./a", vec![ent("secret.rs", false)]),
3210 ]);
3211 let mut deny_a = |d: &Path| {
3214 if d.to_string_lossy().contains("a") {
3215 Err(ToolError::denied("out of fs_read scope"))
3216 } else {
3217 Ok(())
3218 }
3219 };
3220 assert!(expand_glob_walk("**/*.rs", None, &*lister, &mut deny_a, 64, 4096).is_err());
3221 }
3222
3223 #[test]
3226 fn glob_walk_respects_configured_match_cap() {
3227 let lister = map_lister(&[(
3228 ".",
3229 vec![
3230 ent("a.rs", false),
3231 ent("b.rs", false),
3232 ent("c.rs", false),
3233 ent("d.rs", false),
3234 ],
3235 )]);
3236 let mut allow = |_d: &Path| Ok(());
3237 let got = expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 2).unwrap();
3238 assert_eq!(got.len(), 2, "match cap of 2 must bound the result set");
3239 }
3240
3241 #[test]
3244 fn glob_walk_respects_configured_depth_cap() {
3245 let lister = map_lister(&[
3246 (".", vec![ent("a", true), ent("x.rs", false)]),
3247 ("./a", vec![ent("foo.rs", false), ent("sub", true)]),
3248 ("./a/sub", vec![ent("deep.rs", false)]),
3249 ]);
3250 let mut allow = |_d: &Path| Ok(());
3251 let got = expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 1, 4096).unwrap();
3253 assert!(
3254 !got.iter().any(|m| m.contains("deep.rs")),
3255 "depth cap of 1 must not reach a/sub/deep.rs; got {got:?}"
3256 );
3257 }
3258
3259 #[test]
3263 fn var_allowlist_is_config_driven() {
3264 let allow_custom = vec!["MY_CUSTOM_VAR".to_string()];
3266 let env = FakeEnv(HashMap::from([(
3267 "MY_CUSTOM_VAR".to_string(),
3268 "/data".to_string(),
3269 )]));
3270 let out = expand_redirect_target(&[Seg::Var("MY_CUSTOM_VAR".into())], &env, &allow_custom)
3271 .unwrap();
3272 assert_eq!(out, "/data");
3273 assert!(!is_allowed_var("HOME", &["PWD".to_string()]));
3275 assert!(is_allowed_var("PWD", &["PWD".to_string()]));
3276 }
3277
3278 #[test]
3283 fn net_audit_sink_is_config_driven() {
3284 use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
3285 let ev = NetAuditEvent {
3286 ts_ms: 0,
3287 host: "example.test".to_string(),
3288 port: 443,
3289 kind: NetKind::Connect,
3290 decision: NetDecision::Allowed,
3291 bytes_up: 1,
3292 bytes_down: 2,
3293 dur_ms: 3,
3294 };
3295 net_audit_sink(None).record(&ev);
3297
3298 let path = std::env::temp_dir().join(format!("ab-audit-{}.jsonl", std::process::id()));
3300 let _ = std::fs::remove_file(&path);
3301 let sink = net_audit_sink(path.to_str());
3302 sink.record(&ev);
3303 drop(sink);
3304 let contents = std::fs::read_to_string(&path).expect("configured audit file written");
3305 assert!(
3306 contents.contains("example.test"),
3307 "the configured sink must write the event: {contents}"
3308 );
3309 let _ = std::fs::remove_file(&path);
3310 }
3311
3312 #[test]
3316 fn net_audit_sink_bad_path_degrades_to_null() {
3317 use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
3318 let ev = NetAuditEvent {
3319 ts_ms: 0,
3320 host: "example.test".to_string(),
3321 port: 443,
3322 kind: NetKind::Http,
3323 decision: NetDecision::Allowed,
3324 bytes_up: 1,
3325 bytes_down: 2,
3326 dur_ms: 3,
3327 };
3328 let bad = std::env::temp_dir()
3330 .join(format!("ab-nope-{}", std::process::id()))
3331 .join("does/not/exist/audit.jsonl");
3332 let sink = net_audit_sink(bad.to_str());
3333 sink.record(&ev); assert!(
3335 !bad.exists(),
3336 "a bad audit path must not create a file (degraded to null): {bad:?}"
3337 );
3338 }
3339}