1use std::collections::BTreeMap;
33use std::io::{PipeReader, PipeWriter, Read};
34#[cfg(windows)]
35use std::io::{Seek, SeekFrom};
36use std::path::{Path, PathBuf};
37use std::process::{Child, Stdio};
38use std::sync::{Arc, LazyLock};
39use std::time::Duration;
40
41use agent_bridle_core::{
42 best_available_sandbox, confinement_unenforceable, effective_sandbox_kind, enforcement_report,
43 human_gate, is_unbridled, loopback_fenced_caveats, net_egress_proxy_hosts, Caveats, Denial,
44 DenialKind, Disclosure, EnforcementReport, LimitsPolicy, SandboxKind, SandboxPolicy, Tool,
45 ToolContext, ToolEnvelope, ToolError, ToolResult,
46};
47use async_trait::async_trait;
48
49use crate::net_proxy;
50use crate::output_observer::{output_session, OutputEmitter};
51use crate::parse::{
52 classify, seg_literal, Arg, Command, Redirect, Refusal, Script, ScriptItem, Seg, Sep, StderrTo,
53};
54
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
63pub(crate) struct Captured {
64 pub exit_code: i32,
65 pub stdout: String,
66 pub stderr: String,
67 pub stdout_truncated: bool,
69 pub stderr_truncated: bool,
71 pub net_denials: Vec<Denial>,
77}
78
79pub(crate) struct SpawnCfg {
92 pub max_output: usize,
94 pub audit_sink: Option<String>,
96 pub sandbox: Arc<SandboxPolicy>,
98 pub unbridled: bool,
102 pub output: OutputEmitter,
104}
105
106pub(crate) trait Spawner: Send + Sync {
107 fn run(
116 &self,
117 stages: &[Command],
118 cwd: Option<&str>,
119 caveats: &Caveats,
120 env: &BTreeMap<String, String>,
121 cfg: &SpawnCfg,
122 ) -> ToolResult<Captured>;
123}
124
125struct OsSpawner;
128
129impl Spawner for OsSpawner {
130 fn run(
131 &self,
132 stages: &[Command],
133 cwd: Option<&str>,
134 caveats: &Caveats,
135 env: &BTreeMap<String, String>,
136 cfg: &SpawnCfg,
137 ) -> ToolResult<Captured> {
138 if cfg.unbridled {
142 return run_pipeline(stages, cwd, &[], env, cfg.max_output, cfg.output.clone());
143 }
144 if let Some((allow_hosts, fenced)) = egress_proxy_plan(caveats, &cfg.sandbox) {
149 return run_with_egress_proxy(stages, cwd, &fenced, env, allow_hosts, cfg);
150 }
151 if intended_sandbox_kind(caveats, &cfg.sandbox) == SandboxKind::None {
155 run_pipeline(stages, cwd, &[], env, cfg.max_output, cfg.output.clone())
156 } else {
157 run_confined(stages, cwd, caveats, env, cfg)
158 }
159 }
160}
161
162fn egress_proxy_plan(
171 caveats: &Caveats,
172 sandbox: &Arc<SandboxPolicy>,
173) -> Option<(Vec<String>, Caveats)> {
174 let allow_hosts = net_egress_proxy_hosts(caveats)?;
175 let fenced = loopback_fenced_caveats(caveats);
176 if intended_sandbox_kind(&fenced, sandbox) == SandboxKind::None {
177 return None; }
179 Some((allow_hosts, fenced))
180}
181
182fn run_with_egress_proxy(
191 stages: &[Command],
192 cwd: Option<&str>,
193 fenced: &Caveats,
194 env: &BTreeMap<String, String>,
195 allow_hosts: Vec<String>,
196 cfg: &SpawnCfg,
197) -> ToolResult<Captured> {
198 let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(fenced)?;
200 let proxy = net_proxy::start(
204 allow_hosts,
205 Arc::new(net_proxy::StdResolver),
206 net_audit_sink(cfg.audit_sink.as_deref()),
207 )
208 .map_err(ToolError::Exec)?;
209 let mut env = env.clone();
212 for (k, v) in proxy.proxy_env() {
213 env.insert(k, v);
214 }
215
216 let stages = stages.to_vec();
217 let cwd = cwd.map(str::to_string);
218 let fenced = fenced.clone();
219 let max_output = cfg.max_output;
220 let output = cfg.output.clone();
221 let sandbox = cfg.sandbox.clone();
222 let captured = std::thread::Builder::new()
223 .name("agent-bridle-confined".to_string())
224 .spawn(move || {
225 best_available_sandbox(&sandbox).apply(&fenced)?;
226 run_pipeline(&stages, cwd.as_deref(), &prefix, &env, max_output, output)
227 })
228 .map_err(ToolError::Exec)?
229 .join()
230 .map_err(|_| {
231 ToolError::Exec(std::io::Error::other("confined execution thread panicked"))
232 })?;
233 let refused = proxy.refused_hosts();
237 drop(proxy); let mut captured = captured?;
239 captured.net_denials = refused
240 .into_iter()
241 .map(|host| Denial {
242 kind: DenialKind::Net,
243 reason: format!("net does not permit '{host}'"),
244 target: host,
245 })
246 .collect();
247 Ok(captured)
248}
249
250fn net_audit_sink(configured: Option<&str>) -> Arc<dyn net_proxy::AuditSink> {
259 match configured {
260 Some(path) if !path.is_empty() => std::fs::OpenOptions::new()
261 .create(true)
262 .append(true)
263 .open(path)
264 .map(|f| Arc::new(net_proxy::JsonlSink::new(f)) as Arc<dyn net_proxy::AuditSink>)
265 .unwrap_or_else(|_| Arc::new(net_proxy::NullSink)),
266 _ => Arc::new(net_proxy::NullSink),
267 }
268}
269
270fn intended_sandbox_kind(caveats: &Caveats, sandbox: &Arc<SandboxPolicy>) -> SandboxKind {
277 effective_sandbox_kind(best_available_sandbox(sandbox).kind(), caveats)
278}
279
280fn run_confined(
291 stages: &[Command],
292 cwd: Option<&str>,
293 caveats: &Caveats,
294 env: &BTreeMap<String, String>,
295 cfg: &SpawnCfg,
296) -> ToolResult<Captured> {
297 let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(caveats)?;
299 let stages = stages.to_vec();
300 let cwd = cwd.map(str::to_string);
301 let caveats = caveats.clone();
302 let env = env.clone();
303 let max_output = cfg.max_output;
304 let output = cfg.output.clone();
305 let sandbox = cfg.sandbox.clone();
306 std::thread::Builder::new()
307 .name("agent-bridle-confined".to_string())
308 .spawn(move || {
309 best_available_sandbox(&sandbox).apply(&caveats)?;
310 run_pipeline(&stages, cwd.as_deref(), &prefix, &env, max_output, output)
311 })
312 .map_err(ToolError::Exec)?
313 .join()
314 .map_err(|_| ToolError::Exec(std::io::Error::other("confined execution thread panicked")))?
315}
316
317static SHELL_SCHEMA: LazyLock<serde_json::Value> = LazyLock::new(|| {
324 serde_json::from_str(include_str!("shell_tool.schema.json"))
325 .expect("embedded shell_tool.schema.json must be valid JSON")
326});
327
328#[derive(Clone)]
335pub struct ShellTool {
336 spawner: Arc<dyn Spawner>,
337 env: Arc<dyn EnvProvider>,
338 lister: Arc<dyn DirLister>,
339 limits: LimitsPolicy,
340 sandbox: Arc<SandboxPolicy>,
343 output_observer: Option<Arc<dyn crate::ShellOutputObserver>>,
344}
345
346impl std::fmt::Debug for ShellTool {
347 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348 f.write_str("ShellTool")
349 }
350}
351
352impl ShellTool {
353 #[must_use]
356 pub fn new() -> Self {
357 Self::with_config(LimitsPolicy::default())
358 }
359
360 #[must_use]
363 pub fn with_config(limits: LimitsPolicy) -> Self {
364 Self {
365 spawner: Arc::new(OsSpawner),
366 env: Arc::new(RealEnv),
367 lister: Arc::new(RealDirLister),
368 limits,
369 sandbox: Arc::new(SandboxPolicy::default()),
370 output_observer: None,
371 }
372 }
373
374 #[must_use]
381 pub fn with_output_observer(mut self, observer: Arc<dyn crate::ShellOutputObserver>) -> Self {
382 self.output_observer = Some(observer);
383 self
384 }
385
386 #[must_use]
389 pub fn with_sandbox_policy(mut self, sandbox: SandboxPolicy) -> Self {
390 self.sandbox = Arc::new(sandbox);
391 self
392 }
393
394 #[cfg(test)]
396 fn with_spawner(spawner: Arc<dyn Spawner>) -> Self {
397 Self {
398 spawner,
399 env: Arc::new(RealEnv),
400 lister: Arc::new(RealDirLister),
401 limits: LimitsPolicy::default(),
402 sandbox: Arc::new(SandboxPolicy::default()),
403 output_observer: None,
404 }
405 }
406
407 #[cfg(test)]
411 fn with_spawner_and_env(spawner: Arc<dyn Spawner>, env: Arc<dyn EnvProvider>) -> Self {
412 Self {
413 spawner,
414 env,
415 lister: Arc::new(RealDirLister),
416 limits: LimitsPolicy::default(),
417 sandbox: Arc::new(SandboxPolicy::default()),
418 output_observer: None,
419 }
420 }
421
422 #[cfg(test)]
426 fn with_seams(
427 spawner: Arc<dyn Spawner>,
428 env: Arc<dyn EnvProvider>,
429 lister: Arc<dyn DirLister>,
430 ) -> Self {
431 Self {
432 spawner,
433 env,
434 lister,
435 limits: LimitsPolicy::default(),
436 sandbox: Arc::new(SandboxPolicy::default()),
437 output_observer: None,
438 }
439 }
440}
441
442impl Default for ShellTool {
443 fn default() -> Self {
444 Self::new()
445 }
446}
447
448#[async_trait]
449impl Tool for ShellTool {
450 fn name(&self) -> &str {
451 "shell"
452 }
453
454 fn schema(&self) -> serde_json::Value {
455 let mut schema = SHELL_SCHEMA.clone();
461 schema["properties"]["timeout_secs"]["maximum"] =
462 serde_json::Value::from(self.limits.max_timeout_secs);
463 schema
464 }
465
466 async fn invoke(
467 &self,
468 args: serde_json::Value,
469 cx: &ToolContext,
470 ) -> ToolResult<serde_json::Value> {
471 let parsed = ShellArgs::parse(&args, &self.limits)?;
472 let unbridled = is_unbridled();
478 let sandbox_kind = if unbridled {
491 SandboxKind::None
492 } else {
493 match egress_proxy_plan(cx.caveats(), &self.sandbox) {
494 Some((_, fenced)) => intended_sandbox_kind(&fenced, &self.sandbox),
495 None => intended_sandbox_kind(cx.caveats(), &self.sandbox),
496 }
497 };
498 let enforcement = enforcement_report(cx.caveats(), sandbox_kind);
501
502 let mut script = match parsed.script() {
504 Ok(s) => s,
505 Err(refusal) => return Ok(refused_envelope(sandbox_kind, enforcement, &refusal)),
506 };
507
508 for item in &mut script {
514 for stage in &mut item.pipeline {
515 let mut new_argv: Vec<Arg> = Vec::with_capacity(stage.argv.len());
521 for (i, arg) in stage.argv.drain(..).enumerate() {
522 let pattern: Option<String> = if i == 0 {
523 None
524 } else {
525 match &arg {
526 Arg::Glob(p) => Some(p.clone()),
527 Arg::VarGlob(segs) => {
528 match expand_varglob(segs, &*self.env, &self.limits.var_allowlist) {
529 Ok(p) => Some(p),
530 Err((target, e)) => {
531 return Ok(deny(
532 sandbox_kind,
533 enforcement,
534 DenialKind::Exec,
535 &target,
536 &e,
537 ))
538 }
539 }
540 }
541 _ => None,
542 }
543 };
544 match pattern {
545 Some(p) => {
546 let mut leash = |dir: &Path| cx.check_path_read(dir);
547 match expand_glob_walk(
548 &p,
549 parsed.cwd.as_deref(),
550 &*self.lister,
551 &mut leash,
552 self.limits.max_glob_depth,
553 self.limits.max_glob_matches,
554 ) {
555 Ok(ms) => new_argv.extend(ms.into_iter().map(Arg::Lit)),
556 Err(e) => {
557 return Ok(deny(
558 sandbox_kind,
559 enforcement,
560 DenialKind::Open,
561 &p,
562 &e,
563 ))
564 }
565 }
566 }
567 None => new_argv.push(arg),
568 }
569 }
570 stage.argv = new_argv;
571 for redirect in &mut stage.redirects {
572 let segs = match redirect {
573 Redirect::Stdout { path, .. }
574 | Redirect::Stderr { path, .. }
575 | Redirect::Stdin { path } => path,
576 Redirect::StderrToStdout => continue,
577 };
578 match expand_redirect_target(segs, &*self.env, &self.limits.var_allowlist) {
579 Ok(resolved) => *segs = vec![Seg::Lit(resolved)],
580 Err((target, e)) => {
581 return Ok(deny(
582 sandbox_kind,
583 enforcement,
584 DenialKind::Open,
585 &target,
586 &e,
587 ))
588 }
589 }
590 }
591 }
592 }
593
594 for item in &script {
600 for stage in &item.pipeline {
601 match stage.argv.first() {
602 Some(Arg::Lit(program)) => {
603 if let Err(e) = cx.check_exec(program) {
604 return Ok(deny(
605 sandbox_kind,
606 enforcement,
607 DenialKind::Exec,
608 program,
609 &e,
610 ));
611 }
612 }
613 Some(Arg::Glob(pattern)) => {
614 return Ok(deny(
615 sandbox_kind,
616 enforcement,
617 DenialKind::Exec,
618 pattern,
619 &ToolError::denied("a glob pattern is not allowed as a program name"),
620 ));
621 }
622 Some(Arg::Var(_segs)) => {
623 return Ok(deny(
624 sandbox_kind,
625 enforcement,
626 DenialKind::Exec,
627 "$VAR",
628 &ToolError::denied("a variable is not allowed as a program name"),
629 ));
630 }
631 Some(Arg::VarGlob(_)) => {
634 return Ok(deny(
635 sandbox_kind,
636 enforcement,
637 DenialKind::Exec,
638 "$VAR/glob",
639 &ToolError::denied("a glob pattern is not allowed as a program name"),
640 ));
641 }
642 None => {} }
644 for arg in &stage.argv {
645 match arg {
646 Arg::Var(segs) => {
649 for seg in segs {
650 if let Seg::Var(name) = seg {
651 if !is_allowed_var(name, &self.limits.var_allowlist) {
652 return Ok(deny(
653 sandbox_kind,
654 enforcement,
655 DenialKind::Exec,
656 &format!("${name}"),
657 &ToolError::denied(format!(
658 "variable ${name} is not in the confined shell's allowlist"
659 )),
660 ));
661 }
662 }
663 }
664 }
665 Arg::Glob(_) => unreachable!("glob expanded at admission"),
668 Arg::VarGlob(_) => unreachable!("VarGlob expanded at admission"),
669 Arg::Lit(_) => {}
670 }
671 }
672 for redirect in &stage.redirects {
673 let (path, checked) = match redirect {
676 Redirect::Stdout { path, .. } | Redirect::Stderr { path, .. } => {
677 let p = seg_literal(path).expect("redirect target lowered");
678 (p, cx.check_path_write(Path::new(p)))
679 }
680 Redirect::Stdin { path } => {
681 let p = seg_literal(path).expect("redirect target lowered");
682 (p, cx.check_path_read(Path::new(p)))
683 }
684 Redirect::StderrToStdout => continue,
686 };
687 if let Err(e) = checked {
688 return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, path, &e));
689 }
690 }
691 }
692 }
693 if let Some(cwd) = &parsed.cwd {
695 if let Err(e) = cx.check_path_read(Path::new(cwd)) {
696 return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, cwd, &e));
697 }
698 }
699
700 if !unbridled && confinement_unenforceable(sandbox_kind, cx.caveats(), cx.strength_floor())
718 {
719 return Ok(deny(
720 sandbox_kind,
721 enforcement,
722 DenialKind::Exec,
723 "confinement",
724 &ToolError::denied(format!(
725 "a restricted filesystem/exec/net axis cannot be enforced on this host \
726 at the required strength floor ({:?}); refusing to run unconfined",
727 cx.strength_floor()
728 )),
729 ));
730 }
731
732 let spawner = Arc::clone(&self.spawner);
735 let cwd = parsed.cwd.clone();
736 let timeout = parsed.timeout;
737 let (output_guard, output) =
738 output_session(self.output_observer.clone(), self.limits.max_output_bytes);
739 let cfg = SpawnCfg {
740 max_output: self.limits.max_output_bytes,
741 audit_sink: self.limits.audit_sink.clone(),
742 sandbox: Arc::clone(&self.sandbox),
743 unbridled,
744 output,
745 };
746 let disclosure = Disclosure {
748 unbridled,
749 human_gate: human_gate(),
750 ..Disclosure::default()
751 };
752 let env = parsed.env;
755 let caveats = cx.caveats().clone();
756 let run = tokio::task::spawn_blocking(move || {
757 run_script(&*spawner, &script, cwd.as_deref(), &caveats, &env, &cfg)
758 });
759 match tokio::time::timeout(timeout, run).await {
760 Ok(joined) => {
761 let captured = joined
762 .map_err(|e| ToolError::Other(anyhow::anyhow!("shell task panicked: {e}")))??;
763 let envelope = ToolEnvelope::new(sandbox_kind)
767 .with_enforcement(enforcement)
768 .with_disclosure(disclosure)
769 .with_exit_code(captured.exit_code)
770 .with_truncation(captured.stdout_truncated, captured.stderr_truncated)
771 .with_stdout(captured.stdout)
772 .with_stderr(captured.stderr)
773 .with_denials(captured.net_denials)
774 .with_timed_out(false)
775 .into_json();
776 output_guard.finish();
777 Ok(envelope)
778 }
779 Err(_elapsed) => {
780 drop(output_guard);
783 Ok(ToolEnvelope::new(sandbox_kind)
784 .with_enforcement(enforcement)
785 .with_disclosure(disclosure)
786 .with_stderr(format!("command timed out after {}s", timeout.as_secs()))
787 .with_timed_out(true)
788 .into_json())
789 }
790 }
791 }
792}
793
794fn run_script(
798 spawner: &dyn Spawner,
799 script: &[ScriptItem],
800 cwd: Option<&str>,
801 caveats: &Caveats,
802 env: &BTreeMap<String, String>,
803 cfg: &SpawnCfg,
804) -> ToolResult<Captured> {
805 let mut stdout = String::new();
806 let mut stderr = String::new();
807 let mut status: i32 = 0;
808 let mut stdout_truncated = false;
809 let mut stderr_truncated = false;
810 let mut net_denials: Vec<Denial> = Vec::new();
812
813 for item in script {
814 let run_it = match item.sep {
815 Sep::Seq => true,
816 Sep::And => status == 0,
817 Sep::Or => status != 0,
818 };
819 if run_it {
820 let captured = spawner.run(&item.pipeline, cwd, caveats, env, cfg)?;
821 stdout.push_str(&captured.stdout);
822 stderr.push_str(&captured.stderr);
823 stdout_truncated |= captured.stdout_truncated;
824 stderr_truncated |= captured.stderr_truncated;
825 net_denials.extend(captured.net_denials);
826 status = captured.exit_code;
827 }
828 }
829
830 let stdout_truncated = stdout_truncated || stdout.len() > cfg.max_output;
832 let stderr_truncated = stderr_truncated || stderr.len() > cfg.max_output;
833
834 Ok(Captured {
835 exit_code: status,
836 stdout: cap_string(stdout, cfg.max_output),
837 stderr: cap_string(stderr, cfg.max_output),
838 net_denials,
839 stdout_truncated,
840 stderr_truncated,
841 })
842}
843
844fn deny(
846 sandbox_kind: SandboxKind,
847 enforcement: EnforcementReport,
848 kind: DenialKind,
849 target: &str,
850 err: &ToolError,
851) -> serde_json::Value {
852 ToolEnvelope::new(sandbox_kind)
853 .with_enforcement(enforcement)
854 .with_disclosure(unbridle_disclosure())
855 .with_denials(vec![Denial {
856 kind,
857 target: target.to_string(),
858 reason: err.to_string(),
859 }])
860 .into_json()
861}
862
863fn unbridle_disclosure() -> Disclosure {
867 Disclosure {
868 unbridled: is_unbridled(),
869 human_gate: human_gate(),
870 ..Disclosure::default()
871 }
872}
873
874fn refused_envelope(
876 sandbox_kind: SandboxKind,
877 enforcement: EnforcementReport,
878 refusal: &Refusal,
879) -> serde_json::Value {
880 ToolEnvelope::new(sandbox_kind)
881 .with_enforcement(enforcement)
882 .with_disclosure(unbridle_disclosure())
883 .with_denials(vec![Denial {
884 kind: DenialKind::Exec,
885 target: refusal.construct(),
886 reason: refusal.to_string(),
887 }])
888 .into_json()
889}
890
891struct ShellArgs {
893 program: Option<String>,
894 args: Vec<String>,
895 cmd: Option<String>,
896 cwd: Option<String>,
897 env: BTreeMap<String, String>,
901 timeout: Duration,
902}
903
904impl ShellArgs {
905 fn parse(v: &serde_json::Value, limits: &LimitsPolicy) -> ToolResult<Self> {
906 let obj = v
907 .as_object()
908 .ok_or_else(|| ToolError::denied("shell args must be a JSON object"))?;
909
910 let program = obj
911 .get("program")
912 .and_then(|x| x.as_str())
913 .map(String::from);
914 let cmd = obj.get("cmd").and_then(|x| x.as_str()).map(String::from);
915 let args = obj
916 .get("args")
917 .and_then(|x| x.as_array())
918 .map(|a| {
919 a.iter()
920 .filter_map(|x| x.as_str().map(String::from))
921 .collect::<Vec<_>>()
922 })
923 .unwrap_or_default();
924 let cwd = obj.get("cwd").and_then(|x| x.as_str()).map(String::from);
925 let env = obj
929 .get("env")
930 .and_then(|x| x.as_object())
931 .map(|m| {
932 m.iter()
933 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
934 .collect::<BTreeMap<String, String>>()
935 })
936 .unwrap_or_default();
937 let timeout_secs = obj
938 .get("timeout_secs")
939 .and_then(serde_json::Value::as_u64)
940 .unwrap_or(limits.default_timeout_secs)
941 .clamp(1, limits.max_timeout_secs);
942
943 match (&program, &cmd) {
944 (Some(_), Some(_)) => {
945 return Err(ToolError::denied(
946 "provide exactly one of `program` or `cmd`, not both",
947 ))
948 }
949 (None, None) => return Err(ToolError::denied("provide one of `program` or `cmd`")),
950 _ => {}
951 }
952 if program.is_none() && !args.is_empty() {
953 return Err(ToolError::denied(
954 "`args` may only be used together with `program`",
955 ));
956 }
957
958 Ok(Self {
959 program,
960 args,
961 cmd,
962 cwd,
963 env,
964 timeout: Duration::from_secs(timeout_secs),
965 })
966 }
967
968 fn script(&self) -> Result<Script, Refusal> {
972 if let Some(program) = &self.program {
973 let mut argv = Vec::with_capacity(1 + self.args.len());
974 argv.push(Arg::Lit(program.clone()));
975 argv.extend(self.args.iter().cloned().map(Arg::Lit));
976 Ok(vec![ScriptItem {
977 sep: Sep::Seq,
978 pipeline: vec![Command {
979 argv,
980 redirects: Vec::new(),
981 }],
982 }])
983 } else {
984 classify(self.cmd.as_deref().unwrap_or(""))
985 }
986 }
987}
988
989fn is_allowed_var(name: &str, allowlist: &[String]) -> bool {
998 allowlist.iter().any(|v| v == name)
999}
1000
1001pub(crate) trait EnvProvider: Send + Sync {
1006 fn get(&self, name: &str) -> Option<String>;
1008}
1009
1010pub(crate) struct RealEnv;
1012impl EnvProvider for RealEnv {
1013 fn get(&self, name: &str) -> Option<String> {
1014 std::env::var(name).ok()
1015 }
1016}
1017
1018fn expand_redirect_target(
1023 segs: &[Seg],
1024 env: &dyn EnvProvider,
1025 allowlist: &[String],
1026) -> Result<String, (String, ToolError)> {
1027 let mut out = String::new();
1028 for seg in segs {
1029 match seg {
1030 Seg::Lit(s) => out.push_str(s),
1031 Seg::Var(name) => {
1032 if !is_allowed_var(name, allowlist) {
1033 return Err((
1034 format!("${name}"),
1035 ToolError::denied(format!(
1036 "variable ${name} is not in the confined shell's allowlist"
1037 )),
1038 ));
1039 }
1040 out.push_str(&env.get(name).unwrap_or_default());
1041 }
1042 }
1043 }
1044 Ok(out)
1045}
1046
1047fn expand_varglob(
1057 segs: &[Seg],
1058 env: &dyn EnvProvider,
1059 allowlist: &[String],
1060) -> Result<String, (String, ToolError)> {
1061 let mut out = String::new();
1062 let mut last_var_byte: Option<usize> = None; let mut last_slash_byte: Option<usize> = None; for seg in segs {
1065 match seg {
1066 Seg::Lit(s) => {
1067 for ch in s.chars() {
1068 if ch == '/' {
1069 last_slash_byte = Some(out.len());
1070 }
1071 out.push(ch);
1072 }
1073 }
1074 Seg::Var(name) => {
1075 if !is_allowed_var(name, allowlist) {
1076 return Err((
1077 format!("${name}"),
1078 ToolError::denied(format!(
1079 "variable ${name} is not in the confined shell's allowlist"
1080 )),
1081 ));
1082 }
1083 for ch in env.get(name).unwrap_or_default().chars() {
1084 if ch == '/' {
1085 last_slash_byte = Some(out.len());
1086 }
1087 last_var_byte = Some(out.len());
1088 out.push(ch);
1089 }
1090 }
1091 }
1092 }
1093 let basename_start = last_slash_byte.map_or(0, |i| i + 1);
1096 if last_var_byte.is_some_and(|v| v >= basename_start) {
1097 return Err((
1098 "$VAR".to_string(),
1099 ToolError::denied(
1100 "a variable in a glob's basename is not supported (re-injection guard); \
1101 put the variable in the directory prefix, e.g. $DIR/*.rs",
1102 ),
1103 ));
1104 }
1105 Ok(out)
1106}
1107
1108#[derive(Debug, Clone, PartialEq, Eq)]
1113pub(crate) struct GlobEntry {
1114 pub name: String,
1115 pub is_dir: bool,
1116}
1117
1118pub(crate) trait DirLister: Send + Sync {
1121 fn list(&self, dir: &Path) -> Vec<GlobEntry>;
1123}
1124
1125pub(crate) struct RealDirLister;
1127impl DirLister for RealDirLister {
1128 fn list(&self, dir: &Path) -> Vec<GlobEntry> {
1129 std::fs::read_dir(dir)
1130 .map(|rd| {
1131 rd.filter_map(|e| {
1132 let e = e.ok()?;
1133 let name = e.file_name().into_string().ok()?;
1134 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
1135 Some(GlobEntry { name, is_dir })
1136 })
1137 .collect()
1138 })
1139 .unwrap_or_default()
1140 }
1141}
1142
1143fn join_rel(rel: &str, name: &str) -> String {
1146 if rel.is_empty() {
1147 name.to_string()
1148 } else if rel == "/" {
1149 format!("/{name}")
1150 } else {
1151 format!("{rel}/{name}")
1152 }
1153}
1154
1155fn descend_all(
1159 real: &Path,
1160 rel: &str,
1161 list: &dyn DirLister,
1162 leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
1163 depth: usize,
1164 max_matches: usize,
1165 out: &mut Vec<(PathBuf, String)>,
1166) -> ToolResult<()> {
1167 if depth == 0 || out.len() >= max_matches {
1168 return Ok(());
1169 }
1170 leash(real)?;
1171 let mut entries = list.list(real);
1172 entries.sort_by(|a, b| a.name.cmp(&b.name));
1173 for e in entries {
1174 if e.is_dir && !e.name.starts_with('.') {
1175 let child_real = real.join(&e.name);
1176 let child_rel = join_rel(rel, &e.name);
1177 out.push((child_real.clone(), child_rel.clone()));
1178 if out.len() >= max_matches {
1179 break;
1180 }
1181 descend_all(
1182 &child_real,
1183 &child_rel,
1184 list,
1185 leash,
1186 depth - 1,
1187 max_matches,
1188 out,
1189 )?;
1190 }
1191 }
1192 Ok(())
1193}
1194
1195fn expand_glob_walk(
1203 pattern: &str,
1204 cwd: Option<&str>,
1205 list: &dyn DirLister,
1206 leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
1207 max_depth: usize,
1208 max_matches: usize,
1209) -> ToolResult<Vec<String>> {
1210 let absolute = pattern.starts_with('/');
1211 let segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
1212
1213 let base_real = if absolute {
1214 PathBuf::from("/")
1215 } else {
1216 cwd.map_or_else(|| PathBuf::from("."), PathBuf::from)
1217 };
1218 let base_rel = if absolute {
1219 "/".to_string()
1220 } else {
1221 String::new()
1222 };
1223 let mut frontier: Vec<(PathBuf, String)> = vec![(base_real, base_rel)];
1224
1225 for seg in &segments {
1226 let mut next: Vec<(PathBuf, String)> = Vec::new();
1227 if *seg == "**" {
1228 for (real, rel) in &frontier {
1229 next.push((real.clone(), rel.clone())); descend_all(real, rel, list, leash, max_depth, max_matches, &mut next)?;
1231 }
1232 } else {
1233 let seg_hidden = seg.starts_with('.');
1234 for (real, rel) in &frontier {
1235 leash(real)?;
1236 let mut entries = list.list(real);
1237 entries.sort_by(|a, b| a.name.cmp(&b.name));
1238 for e in entries {
1239 if (seg_hidden || !e.name.starts_with('.')) && fnmatch(seg, &e.name) {
1240 next.push((real.join(&e.name), join_rel(rel, &e.name)));
1241 if next.len() >= max_matches {
1242 break;
1243 }
1244 }
1245 }
1246 }
1247 }
1248 frontier = next;
1249 if frontier.is_empty() {
1250 break;
1251 }
1252 }
1253
1254 let mut matches: Vec<String> = frontier.into_iter().map(|(_, rel)| rel).collect();
1255 matches.retain(|m| !m.is_empty()); matches.sort();
1257 matches.dedup();
1258 if matches.is_empty() {
1259 Ok(vec![pattern.to_string()])
1260 } else {
1261 Ok(matches)
1262 }
1263}
1264
1265fn fnmatch(pattern: &str, name: &str) -> bool {
1268 let p: Vec<char> = pattern.chars().collect();
1269 let n: Vec<char> = name.chars().collect();
1270 fnmatch_inner(&p, &n)
1271}
1272
1273fn fnmatch_inner(p: &[char], n: &[char]) -> bool {
1274 match p.first() {
1275 None => n.is_empty(),
1276 Some('*') => fnmatch_inner(&p[1..], n) || (!n.is_empty() && fnmatch_inner(p, &n[1..])),
1277 Some('?') => !n.is_empty() && fnmatch_inner(&p[1..], &n[1..]),
1278 Some('[') => {
1279 if n.is_empty() {
1280 return false;
1281 }
1282 match match_class(&p[1..], n[0]) {
1283 Some((matched, rest)) => matched && fnmatch_inner(rest, &n[1..]),
1284 None => n[0] == '[' && fnmatch_inner(&p[1..], &n[1..]),
1286 }
1287 }
1288 Some(&c) => !n.is_empty() && c == n[0] && fnmatch_inner(&p[1..], &n[1..]),
1289 }
1290}
1291
1292fn match_class(p: &[char], c: char) -> Option<(bool, &[char])> {
1295 let mut i = 0;
1296 let negate = matches!(p.first(), Some('!' | '^'));
1297 if negate {
1298 i = 1;
1299 }
1300 let mut matched = false;
1301 let mut first = true;
1302 while i < p.len() {
1303 if p[i] == ']' && !first {
1304 return Some((matched ^ negate, &p[i + 1..]));
1305 }
1306 first = false;
1307 if i + 2 < p.len() && p[i + 1] == '-' && p[i + 2] != ']' {
1308 if c >= p[i] && c <= p[i + 2] {
1309 matched = true;
1310 }
1311 i += 3;
1312 } else {
1313 if c == p[i] {
1314 matched = true;
1315 }
1316 i += 1;
1317 }
1318 }
1319 None
1320}
1321
1322fn open_for_write(path: &str, append: bool) -> std::io::Result<std::fs::File> {
1326 #[cfg(windows)]
1327 if append {
1328 let mut file = std::fs::OpenOptions::new()
1329 .write(true)
1330 .create(true)
1331 .truncate(false)
1332 .open(path)?;
1333 file.seek(SeekFrom::End(0))?;
1334 return Ok(file);
1335 }
1336
1337 std::fs::OpenOptions::new()
1338 .write(true)
1339 .create(true)
1340 .truncate(!append)
1341 .append(append)
1342 .open(path)
1343}
1344
1345fn kill_all(children: &mut [Child]) {
1348 for child in children.iter_mut() {
1349 let _ = child.kill();
1350 let _ = child.wait();
1351 }
1352}
1353
1354fn expand_stage_argv(stage: &Command, _cwd: Option<&str>) -> Vec<String> {
1359 let mut argv = Vec::with_capacity(stage.argv.len());
1360 for arg in &stage.argv {
1361 match arg {
1362 Arg::Lit(s) => argv.push(s.clone()),
1363 Arg::Var(segs) => {
1367 let mut word = String::new();
1368 for seg in segs {
1369 match seg {
1370 Seg::Lit(s) => word.push_str(s),
1371 Seg::Var(name) => word.push_str(&std::env::var(name).unwrap_or_default()),
1372 }
1373 }
1374 argv.push(word);
1375 }
1376 Arg::Glob(_) => unreachable!("glob expanded at admission"),
1380 Arg::VarGlob(_) => unreachable!("VarGlob lowered/expanded at admission"),
1381 }
1382 }
1383 argv
1384}
1385
1386fn run_pipeline(
1398 stages: &[Command],
1399 cwd: Option<&str>,
1400 wrap: &[String],
1401 env: &BTreeMap<String, String>,
1402 max_output: usize,
1403 output: OutputEmitter,
1404) -> ToolResult<Captured> {
1405 debug_assert!(!stages.is_empty(), "the parser guarantees ≥1 stage");
1406 let n = stages.len();
1407 let last = n - 1;
1408
1409 let mut children: Vec<Child> = Vec::with_capacity(n);
1410 let mut prev_stdin: Option<PipeReader> = None;
1412 let mut stdout_capture: Option<PipeReader> = None;
1414 let mut stderr_threads: Vec<std::thread::JoinHandle<(Vec<u8>, bool)>> = Vec::new();
1417
1418 for (i, stage) in stages.iter().enumerate() {
1419 let is_last = i == last;
1420 let stage_argv = expand_stage_argv(stage, cwd);
1421 let argv: Vec<String> = if wrap.is_empty() {
1426 stage_argv
1427 } else {
1428 wrap.iter().cloned().chain(stage_argv).collect()
1429 };
1430 let mut cmd = std::process::Command::new(&argv[0]);
1431 cmd.args(&argv[1..]);
1432 if let Some(dir) = cwd {
1433 cmd.current_dir(dir);
1434 }
1435 for (k, v) in env {
1444 cmd.env(k, v);
1445 }
1446
1447 if let Some(path) = stage.stdin_path() {
1449 let file = ok_or_kill(std::fs::File::open(path), &mut children)?;
1450 cmd.stdin(Stdio::from(file));
1451 prev_stdin = None;
1452 } else {
1453 cmd.stdin(match prev_stdin.take() {
1454 Some(reader) => Stdio::from(reader),
1455 None => Stdio::null(),
1456 });
1457 }
1458
1459 let dup_source: DupSource;
1463 if let Some((path, append)) = stage.stdout_redirect() {
1464 let file = ok_or_kill(open_for_write(path, append), &mut children)?;
1465 let clone = ok_or_kill(file.try_clone(), &mut children)?;
1466 cmd.stdout(Stdio::from(file));
1467 dup_source = DupSource::File(clone);
1468 } else {
1469 let (reader, writer) = ok_or_kill(std::io::pipe(), &mut children)?;
1470 let clone = ok_or_kill(writer.try_clone(), &mut children)?;
1471 cmd.stdout(Stdio::from(writer));
1472 if is_last {
1473 stdout_capture = Some(reader);
1474 } else {
1475 prev_stdin = Some(reader);
1476 }
1477 dup_source = DupSource::Pipe(clone);
1478 }
1479
1480 match stage.stderr_disposition() {
1482 StderrTo::Stdout => match dup_source {
1485 DupSource::File(f) => {
1486 cmd.stderr(Stdio::from(f));
1487 }
1488 DupSource::Pipe(w) => {
1489 cmd.stderr(Stdio::from(w));
1490 }
1491 },
1492 StderrTo::File { path, append } => {
1494 let file = ok_or_kill(open_for_write(&path, append), &mut children)?;
1495 cmd.stderr(Stdio::from(file));
1496 }
1498 StderrTo::Capture => {
1500 cmd.stderr(Stdio::piped());
1501 }
1502 }
1503
1504 let mut child = ok_or_kill(cmd.spawn(), &mut children)?;
1505
1506 if matches!(stage.stderr_disposition(), StderrTo::Capture) {
1507 let err = child.stderr.take().expect("stderr is piped");
1508 let output = output.clone();
1509 stderr_threads.push(std::thread::spawn(move || {
1510 read_capped_observed(err, max_output, &output, crate::ShellOutputStream::Stderr)
1511 }));
1512 }
1513 children.push(child);
1514 }
1515
1516 let stdout_thread = stdout_capture.map(|reader| {
1520 std::thread::spawn(move || {
1521 read_capped_observed(
1522 reader,
1523 max_output,
1524 &output,
1525 crate::ShellOutputStream::Stdout,
1526 )
1527 })
1528 });
1529
1530 let mut exit_code = -1;
1532 for (i, child) in children.iter_mut().enumerate() {
1533 let status = child.wait().map_err(ToolError::Exec)?;
1534 if i == last {
1535 exit_code = status.code().unwrap_or(-1);
1536 }
1537 }
1538
1539 let (stdout, stdout_truncated) =
1540 stdout_thread.map_or((Vec::new(), false), |h| h.join().unwrap_or_default());
1541 let mut stderr = Vec::new();
1542 let mut stderr_truncated = false;
1543 for h in stderr_threads {
1544 let (buf, trunc) = h.join().unwrap_or_default();
1545 stderr.extend(buf);
1546 stderr_truncated |= trunc;
1547 }
1548 let stderr_truncated = stderr_truncated || stderr.len() > max_output;
1551
1552 Ok(Captured {
1553 exit_code,
1554 stdout: capped_utf8(&stdout, max_output),
1555 stderr: capped_utf8(&stderr, max_output),
1556 stdout_truncated,
1557 stderr_truncated,
1558 net_denials: Vec::new(),
1561 })
1562}
1563
1564enum DupSource {
1566 File(std::fs::File),
1567 Pipe(PipeWriter),
1568}
1569
1570fn ok_or_kill<T>(result: std::io::Result<T>, children: &mut [Child]) -> ToolResult<T> {
1573 result.map_err(|e| {
1574 kill_all(children);
1575 ToolError::Exec(e)
1576 })
1577}
1578
1579fn read_capped_observed(
1590 mut reader: impl Read,
1591 max_output: usize,
1592 output: &OutputEmitter,
1593 stream: crate::ShellOutputStream,
1594) -> (Vec<u8>, bool) {
1595 let mut buf = Vec::with_capacity(max_output.min(8 * 1024));
1596 let mut chunk = [0u8; 8 * 1024];
1597 while buf.len() < max_output {
1598 let remaining = max_output - buf.len();
1599 let read_len = remaining.min(chunk.len());
1600 match reader.read(&mut chunk[..read_len]) {
1601 Ok(0) => return (buf, false),
1602 Ok(n) => {
1603 output.emit(stream, &chunk[..n]);
1604 buf.extend_from_slice(&chunk[..n]);
1605 }
1606 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1607 Err(_) => return (buf, false),
1608 }
1609 }
1610 let mut probe = [0u8; 1];
1611 let truncated = loop {
1612 match reader.read(&mut probe) {
1613 Ok(n) => break n > 0,
1614 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1615 Err(_) => break false,
1616 }
1617 };
1618 (buf, truncated)
1619}
1620
1621#[cfg(test)]
1622fn read_capped(reader: impl Read, max_output: usize) -> (Vec<u8>, bool) {
1623 read_capped_observed(
1624 reader,
1625 max_output,
1626 &OutputEmitter::default(),
1627 crate::ShellOutputStream::Stdout,
1628 )
1629}
1630
1631fn capped_utf8(bytes: &[u8], max_output: usize) -> String {
1636 let slice = &bytes[..bytes.len().min(max_output)];
1637 String::from_utf8_lossy(slice).into_owned()
1638}
1639
1640fn cap_string(mut s: String, max_output: usize) -> String {
1643 if s.len() > max_output {
1644 let mut end = max_output;
1645 while !s.is_char_boundary(end) {
1646 end -= 1;
1647 }
1648 s.truncate(end);
1649 }
1650 s
1651}
1652
1653#[cfg(test)]
1654mod tests {
1655 use super::*;
1656 use agent_bridle_core::{Caveats, Gate, Scope};
1657 use std::collections::HashMap;
1658 use std::sync::{mpsc, Mutex};
1659
1660 #[test]
1664 fn schema_loads_from_data_file_with_expected_shape() {
1665 let s = ShellTool::new().schema();
1666 assert_eq!(s["type"], "object");
1667 assert_eq!(s["additionalProperties"], false);
1668 for key in ["program", "args", "cmd", "cwd", "env", "timeout_secs"] {
1669 assert!(
1670 s["properties"].get(key).is_some(),
1671 "schema is missing the `{key}` property: {s}"
1672 );
1673 }
1674 }
1675
1676 #[test]
1680 fn schema_timeout_maximum_tracks_the_configured_limits() {
1681 let limits = agent_bridle_core::LimitsPolicy {
1682 max_timeout_secs: 7,
1683 ..agent_bridle_core::LimitsPolicy::default()
1684 };
1685 let s = ShellTool::with_config(limits).schema();
1686 assert_eq!(s["properties"]["timeout_secs"]["maximum"], 7);
1687 assert!(SHELL_SCHEMA["properties"]["timeout_secs"]
1689 .get("maximum")
1690 .is_none());
1691 }
1692
1693 #[derive(Default)]
1696 struct MockSpawner {
1697 calls: Mutex<Vec<Vec<Command>>>,
1698 envs: Mutex<Vec<BTreeMap<String, String>>>,
1701 exit_by_program: HashMap<String, i32>,
1702 block_ms: u64,
1703 net_denials: Vec<Denial>,
1707 }
1708
1709 impl MockSpawner {
1710 fn with_exit(program: &str, code: i32) -> Self {
1711 let mut m = Self::default();
1712 m.exit_by_program.insert(program.to_string(), code);
1713 m
1714 }
1715
1716 fn with_net_denials(denials: Vec<Denial>) -> Self {
1719 Self {
1720 net_denials: denials,
1721 ..Self::default()
1722 }
1723 }
1724 }
1725
1726 fn prog(stage: &Command) -> &str {
1729 match stage.argv.first() {
1730 Some(Arg::Lit(s) | Arg::Glob(s)) => s,
1731 Some(Arg::Var(_) | Arg::VarGlob(_)) | None => "",
1732 }
1733 }
1734
1735 impl Spawner for MockSpawner {
1736 fn run(
1737 &self,
1738 stages: &[Command],
1739 _cwd: Option<&str>,
1740 _caveats: &Caveats,
1741 env: &BTreeMap<String, String>,
1742 _cfg: &SpawnCfg,
1743 ) -> ToolResult<Captured> {
1744 self.calls.lock().unwrap().push(stages.to_vec());
1745 self.envs.lock().unwrap().push(env.clone());
1746 if self.block_ms > 0 {
1747 std::thread::sleep(Duration::from_millis(self.block_ms));
1748 }
1749 Ok(Captured {
1750 exit_code: self
1751 .exit_by_program
1752 .get(prog(&stages[0]))
1753 .copied()
1754 .unwrap_or(0),
1755 stdout: String::new(),
1756 stderr: String::new(),
1757 net_denials: self.net_denials.clone(),
1758 ..Default::default()
1759 })
1760 }
1761 }
1762
1763 struct CoordinatedSpawner {
1764 proceed: Mutex<mpsc::Receiver<()>>,
1765 finished: mpsc::Sender<()>,
1766 }
1767
1768 impl Spawner for CoordinatedSpawner {
1769 fn run(
1770 &self,
1771 _stages: &[Command],
1772 _cwd: Option<&str>,
1773 _caveats: &Caveats,
1774 _env: &BTreeMap<String, String>,
1775 cfg: &SpawnCfg,
1776 ) -> ToolResult<Captured> {
1777 cfg.output.emit(crate::ShellOutputStream::Stdout, b"first");
1778 self.proceed
1779 .lock()
1780 .expect("proceed lock")
1781 .recv()
1782 .expect("test releases spawner");
1783 cfg.output.emit(crate::ShellOutputStream::Stdout, b"second");
1784 self.finished.send(()).expect("test observes completion");
1785 Ok(Captured {
1786 exit_code: 0,
1787 stdout: "firstsecond".to_string(),
1788 ..Default::default()
1789 })
1790 }
1791 }
1792
1793 fn coordinated_spawner() -> (
1794 Arc<CoordinatedSpawner>,
1795 mpsc::Sender<()>,
1796 mpsc::Receiver<()>,
1797 ) {
1798 let (proceed_tx, proceed_rx) = mpsc::channel();
1799 let (finished_tx, finished_rx) = mpsc::channel();
1800 (
1801 Arc::new(CoordinatedSpawner {
1802 proceed: Mutex::new(proceed_rx),
1803 finished: finished_tx,
1804 }),
1805 proceed_tx,
1806 finished_rx,
1807 )
1808 }
1809
1810 struct BlockingObserver {
1811 entered: mpsc::Sender<()>,
1812 release: Mutex<mpsc::Receiver<()>>,
1813 finished: mpsc::Sender<()>,
1814 }
1815
1816 impl crate::ShellOutputObserver for BlockingObserver {
1817 fn on_output(
1818 &self,
1819 _invocation: crate::ShellInvocationId,
1820 _stream: crate::ShellOutputStream,
1821 _chunk: &[u8],
1822 ) {
1823 self.entered.send(()).expect("observer entered callback");
1824 self.release
1825 .lock()
1826 .expect("observer release lock")
1827 .recv()
1828 .expect("test releases blocked observer");
1829 }
1830
1831 fn on_finish(&self, _invocation: crate::ShellInvocationId) {
1832 self.finished.send(()).expect("record unexpected finish");
1833 }
1834 }
1835
1836 struct TemporalPipelineSpawner;
1837
1838 impl Spawner for TemporalPipelineSpawner {
1839 fn run(
1840 &self,
1841 stages: &[Command],
1842 _cwd: Option<&str>,
1843 _caveats: &Caveats,
1844 _env: &BTreeMap<String, String>,
1845 cfg: &SpawnCfg,
1846 ) -> ToolResult<Captured> {
1847 assert_eq!(stages.len(), 2, "the test request is one pipeline");
1848 cfg.output
1851 .emit(crate::ShellOutputStream::Stderr, b"second-stage");
1852 cfg.output
1853 .emit(crate::ShellOutputStream::Stderr, b"first-stage");
1854 Ok(Captured {
1855 exit_code: 0,
1856 stderr: "firs".to_string(),
1857 stderr_truncated: true,
1858 ..Default::default()
1859 })
1860 }
1861 }
1862
1863 #[derive(Debug, PartialEq, Eq)]
1864 enum PipelineObserverEvent {
1865 Output(crate::ShellInvocationId, crate::ShellOutputStream, Vec<u8>),
1866 Finish(crate::ShellInvocationId),
1867 }
1868
1869 struct PipelineObserver(mpsc::Sender<PipelineObserverEvent>);
1870
1871 impl crate::ShellOutputObserver for PipelineObserver {
1872 fn on_output(
1873 &self,
1874 invocation: crate::ShellInvocationId,
1875 stream: crate::ShellOutputStream,
1876 chunk: &[u8],
1877 ) {
1878 self.0
1879 .send(PipelineObserverEvent::Output(
1880 invocation,
1881 stream,
1882 chunk.to_vec(),
1883 ))
1884 .expect("record pipeline output");
1885 }
1886
1887 fn on_finish(&self, invocation: crate::ShellInvocationId) {
1888 self.0
1889 .send(PipelineObserverEvent::Finish(invocation))
1890 .expect("record pipeline finish");
1891 }
1892 }
1893
1894 #[tokio::test]
1895 async fn observer_receives_output_before_invoke_completes() {
1896 let (spawner, proceed, finished) = coordinated_spawner();
1897 let (seen_tx, seen_rx) = mpsc::channel();
1898 let seen_rx = Arc::new(Mutex::new(seen_rx));
1899 let observer = Arc::new(move |invocation, stream, chunk: &[u8]| {
1900 seen_tx
1901 .send((invocation, stream, chunk.to_vec()))
1902 .expect("test receives observer callback");
1903 });
1904 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
1905 let context = ctx(exec_only(&["anything"]));
1906
1907 let invoke = tokio::spawn(async move {
1908 tool.invoke(serde_json::json!({"program": "anything"}), &context)
1909 .await
1910 });
1911 let first_rx = Arc::clone(&seen_rx);
1912 let first = tokio::task::spawn_blocking(move || {
1913 first_rx
1914 .lock()
1915 .expect("observer receiver lock")
1916 .recv_timeout(Duration::from_secs(2))
1917 })
1918 .await
1919 .expect("receiver task")
1920 .expect("live callback before completion");
1921 let invocation = first.0;
1922 assert_eq!(
1923 first,
1924 (
1925 invocation,
1926 crate::ShellOutputStream::Stdout,
1927 b"first".to_vec()
1928 )
1929 );
1930 assert!(!invoke.is_finished(), "the tool must still be running");
1931
1932 proceed.send(()).expect("release spawner");
1933 finished
1934 .recv_timeout(Duration::from_secs(2))
1935 .expect("spawner completion");
1936 let out = invoke.await.expect("invoke task").expect("invoke result");
1937 assert_eq!(out["stdout"], "firstsecond");
1938 assert_eq!(
1939 seen_rx
1940 .lock()
1941 .expect("observer receiver lock")
1942 .recv_timeout(Duration::from_secs(2))
1943 .expect("second callback"),
1944 (
1945 invocation,
1946 crate::ShellOutputStream::Stdout,
1947 b"second".to_vec()
1948 )
1949 );
1950 }
1951
1952 #[tokio::test]
1953 async fn pipeline_stderr_live_cap_is_temporal_but_envelope_is_authoritative() {
1954 let (events_tx, events_rx) = mpsc::channel();
1955 let mut tool = ShellTool::with_spawner(Arc::new(TemporalPipelineSpawner));
1956 tool.limits.max_output_bytes = 4;
1957 let tool = tool.with_output_observer(Arc::new(PipelineObserver(events_tx)));
1958
1959 let out = tool
1960 .invoke(
1961 serde_json::json!({"cmd": "first | second"}),
1962 &ctx(exec_only(&["first", "second"])),
1963 )
1964 .await
1965 .expect("invoke pipeline");
1966
1967 assert_eq!(out["stderr"], "firs");
1968 assert_eq!(out["stderr_truncated"], true);
1969 let first = events_rx
1970 .recv_timeout(Duration::from_secs(2))
1971 .expect("live stderr event");
1972 let invocation = match first {
1973 PipelineObserverEvent::Output(id, crate::ShellOutputStream::Stderr, bytes) => {
1974 assert_eq!(bytes, b"seco", "the live cap follows enqueue order");
1975 id
1976 }
1977 other => panic!("unexpected first observer event: {other:?}"),
1978 };
1979 assert_eq!(
1980 events_rx
1981 .recv_timeout(Duration::from_secs(2))
1982 .expect("queue-drained finish"),
1983 PipelineObserverEvent::Finish(invocation)
1984 );
1985 assert!(
1986 events_rx.try_recv().is_err(),
1987 "the later stage-order bytes are outside the live cap"
1988 );
1989 }
1990
1991 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1992 async fn cancellation_does_not_wait_for_a_blocked_observer_or_deliver_late_output() {
1993 let (spawner, proceed, finished) = coordinated_spawner();
1994 let (seen_tx, seen_rx) = mpsc::channel();
1995 let (entered_tx, entered_rx) = mpsc::channel();
1996 let (release_observer_tx, release_observer_rx) = mpsc::channel();
1997 let release_observer_rx = Mutex::new(release_observer_rx);
1998 let observer = Arc::new(move |invocation, stream, chunk: &[u8]| {
1999 seen_tx
2000 .send((invocation, stream, chunk.to_vec()))
2001 .expect("observer receiver remains alive");
2002 entered_tx.send(()).expect("observer entered callback");
2003 release_observer_rx
2004 .lock()
2005 .expect("observer release lock")
2006 .recv()
2007 .expect("test releases blocked observer");
2008 });
2009 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
2010 let context = ctx(exec_only(&["anything"]));
2011
2012 let mut invoke = tokio::spawn(async move {
2013 tool.invoke(serde_json::json!({"program": "anything"}), &context)
2014 .await
2015 });
2016 entered_rx
2017 .recv_timeout(Duration::from_secs(2))
2018 .expect("observer is blocked in its first callback");
2019 let first = seen_rx
2020 .recv_timeout(Duration::from_secs(2))
2021 .expect("first callback");
2022 assert_eq!(first.1, crate::ShellOutputStream::Stdout);
2023 assert_eq!(first.2, b"first");
2024
2025 invoke.abort();
2026 let cancelled = tokio::time::timeout(Duration::from_millis(500), &mut invoke).await;
2027 proceed.send(()).expect("release detached worker");
2028 release_observer_tx
2029 .send(())
2030 .expect("release presentation callback");
2031 finished
2032 .recv_timeout(Duration::from_secs(2))
2033 .expect("detached worker attempted its late write");
2034 let cancelled = cancelled.expect("cancellation must not wait for observer code");
2035 assert!(
2036 cancelled.expect_err("invoke is cancelled").is_cancelled(),
2037 "the invocation future was cancelled"
2038 );
2039 assert!(
2040 seen_rx.recv_timeout(Duration::from_millis(50)).is_err(),
2041 "output emitted by the detached worker after cancellation is ignored"
2042 );
2043 }
2044
2045 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2046 async fn timeout_does_not_wait_for_a_blocked_observer_or_finish_the_session() {
2047 let (spawner, proceed, worker_finished) = coordinated_spawner();
2048 let (entered_tx, entered_rx) = mpsc::channel();
2049 let (release_tx, release_rx) = mpsc::channel();
2050 let (finish_tx, finish_rx) = mpsc::channel();
2051 let observer = Arc::new(BlockingObserver {
2052 entered: entered_tx,
2053 release: Mutex::new(release_rx),
2054 finished: finish_tx,
2055 });
2056 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
2057 let context = ctx(exec_only(&["anything"]));
2058
2059 let mut invoke = tokio::spawn(async move {
2060 tool.invoke(
2061 serde_json::json!({"program": "anything", "timeout_secs": 1}),
2062 &context,
2063 )
2064 .await
2065 });
2066 entered_rx
2067 .recv_timeout(Duration::from_secs(2))
2068 .expect("observer is blocked in its first callback");
2069
2070 let result = tokio::time::timeout(Duration::from_secs(2), &mut invoke).await;
2071 if result.is_err() {
2072 invoke.abort();
2073 }
2074 proceed.send(()).expect("release detached worker");
2075 release_tx.send(()).expect("release presentation callback");
2076 worker_finished
2077 .recv_timeout(Duration::from_secs(2))
2078 .expect("detached worker attempted its late write");
2079
2080 let output = result
2081 .expect("tool timeout must not wait for observer code")
2082 .expect("invoke task")
2083 .expect("timeout envelope");
2084 assert_eq!(output["timed_out"], true);
2085 assert!(
2086 finish_rx.recv_timeout(Duration::from_millis(50)).is_err(),
2087 "a timed-out observer session must not report ordinary completion"
2088 );
2089 }
2090
2091 fn ctx(granted: Caveats) -> ToolContext {
2092 Gate::new(0)
2093 .authorize(&ShellTool::new(), &granted)
2094 .expect("authorize")
2095 }
2096
2097 fn ctx_strong(granted: Caveats) -> ToolContext {
2100 Gate::new(0)
2101 .with_strength_floor(agent_bridle_core::AxisEnforcement::Kernel)
2102 .authorize(&ShellTool::new(), &granted)
2103 .expect("authorize")
2104 }
2105
2106 fn exec_only(names: &[&str]) -> Caveats {
2107 Caveats {
2108 exec: Scope::only(names.iter().map(|s| (*s).to_string())),
2109 ..Caveats::top()
2110 }
2111 }
2112
2113 fn calls(mock: &Arc<MockSpawner>) -> Vec<Vec<Command>> {
2114 mock.calls.lock().unwrap().clone()
2115 }
2116
2117 fn envs(mock: &Arc<MockSpawner>) -> Vec<BTreeMap<String, String>> {
2119 mock.envs.lock().unwrap().clone()
2120 }
2121
2122 #[tokio::test]
2136 async fn strong_principal_fails_closed_on_unenforceable_exec() {
2137 let granted = exec_only(&["echo"]);
2138 let exec_is_kernel_confined = enforcement_report(
2141 &granted,
2142 intended_sandbox_kind(&granted, &Arc::new(SandboxPolicy::default())),
2143 )
2144 .exec
2145 == Some(agent_bridle_core::AxisEnforcement::Kernel);
2146
2147 let mock = Arc::new(MockSpawner::default());
2148 let out = ShellTool::with_spawner(mock.clone())
2149 .invoke(
2150 serde_json::json!({"cmd": "echo hi"}),
2151 &ctx_strong(granted.clone()),
2152 )
2153 .await
2154 .expect("invoke");
2155 if exec_is_kernel_confined {
2156 assert_ne!(
2159 out["denied"],
2160 serde_json::json!(true),
2161 "kernel-confined exec must run for a strong principal: {out}"
2162 );
2163 assert_eq!(
2164 out["enforcement"]["exec"], "kernel",
2165 "exec is reported kernel-confined: {out}"
2166 );
2167 assert_eq!(ran_programs(&mock), ["echo"], "the program spawned: {out}");
2168 } else {
2169 assert_eq!(
2172 out["denied"], true,
2173 "strong principal must fail closed on unenforceable exec: {out}"
2174 );
2175 assert!(ran_programs(&mock).is_empty(), "nothing may spawn: {out}");
2176 }
2177
2178 let mock = Arc::new(MockSpawner::default());
2181 let out = ShellTool::with_spawner(mock.clone())
2182 .invoke(serde_json::json!({"cmd": "echo hi"}), &ctx(granted))
2183 .await
2184 .expect("invoke");
2185 assert_ne!(
2186 out["denied"],
2187 serde_json::json!(true),
2188 "default principal still runs: {out}"
2189 );
2190 }
2191
2192 #[tokio::test]
2199 async fn net_refusal_surfaces_as_a_net_denial_in_the_envelope() {
2200 let mock = Arc::new(MockSpawner::with_net_denials(vec![Denial {
2201 kind: DenialKind::Net,
2202 target: "github.com".to_string(),
2203 reason: "net does not permit 'github.com'".to_string(),
2204 }]));
2205 let out = ShellTool::with_spawner(mock)
2206 .invoke(
2207 serde_json::json!({ "cmd": "echo hi" }),
2208 &ctx(exec_only(&["echo"])),
2209 )
2210 .await
2211 .expect("invoke");
2212 assert_eq!(
2213 out["denied"],
2214 serde_json::json!(true),
2215 "a net denial sets denied: {out}"
2216 );
2217 assert_eq!(out["denials"][0]["kind"], "net");
2218 assert_eq!(out["denials"][0]["target"], "github.com");
2219 assert!(out.get("exit_code").is_some(), "command still ran: {out}");
2222 }
2223
2224 fn ran_programs(mock: &Arc<MockSpawner>) -> Vec<String> {
2225 calls(mock)
2226 .iter()
2227 .map(|pipeline| prog(&pipeline[0]).to_string())
2228 .collect()
2229 }
2230
2231 #[tokio::test]
2237 async fn env_map_is_passed_to_the_spawner() {
2238 let mock = Arc::new(MockSpawner::default());
2239 let out = ShellTool::with_spawner(mock.clone())
2240 .invoke(
2241 serde_json::json!({
2242 "program": "echo",
2243 "args": ["hi"],
2244 "env": { "FOO": "bar", "VIRTUAL_ENV": "/venv" },
2245 }),
2246 &ctx(exec_only(&["echo"])),
2247 )
2248 .await
2249 .expect("invoke");
2250 assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
2251 let envs = envs(&mock);
2252 assert_eq!(envs.len(), 1, "one pipeline ran");
2253 assert_eq!(envs[0].get("FOO").map(String::as_str), Some("bar"));
2254 assert_eq!(
2255 envs[0].get("VIRTUAL_ENV").map(String::as_str),
2256 Some("/venv"),
2257 "every env entry reaches the child: {:?}",
2258 envs[0]
2259 );
2260 }
2261
2262 #[tokio::test]
2269 async fn env_does_not_change_the_program_the_leash_checks() {
2270 let mock = Arc::new(MockSpawner::default());
2271 let out = ShellTool::with_spawner(mock.clone())
2274 .invoke(
2275 serde_json::json!({
2276 "cmd": "hostname; uname -s",
2277 "env": { "FOO": "bar" },
2278 }),
2279 &ctx(exec_only(&["hostname", "uname"])),
2280 )
2281 .await
2282 .expect("invoke");
2283 assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
2284 let programs = ran_programs(&mock);
2286 assert_eq!(
2287 programs,
2288 vec!["hostname".to_string(), "uname".to_string()],
2289 "the leash/spawner see the real programs, never `export`/env keys: {programs:?}"
2290 );
2291 for e in envs(&mock) {
2293 assert_eq!(e.get("FOO").map(String::as_str), Some("bar"));
2294 }
2295 }
2296
2297 #[test]
2300 fn parse_env_field_present_and_absent() {
2301 let parsed = ShellArgs::parse(
2303 &serde_json::json!({
2304 "program": "echo",
2305 "env": { "FOO": "bar", "BAZ": "qux" },
2306 }),
2307 &agent_bridle_core::LimitsPolicy::default(),
2308 )
2309 .expect("parse");
2310 assert_eq!(parsed.env.get("FOO").map(String::as_str), Some("bar"));
2311 assert_eq!(parsed.env.get("BAZ").map(String::as_str), Some("qux"));
2312 assert_eq!(parsed.env.len(), 2);
2313
2314 let parsed = ShellArgs::parse(
2316 &serde_json::json!({ "program": "echo" }),
2317 &agent_bridle_core::LimitsPolicy::default(),
2318 )
2319 .expect("parse");
2320 assert!(parsed.env.is_empty(), "absent env defaults to empty");
2321 }
2322
2323 #[test]
2327 fn parse_timeout_uses_configured_limits() {
2328 let limits = agent_bridle_core::LimitsPolicy {
2329 max_timeout_secs: 5,
2330 default_timeout_secs: 3,
2331 ..agent_bridle_core::LimitsPolicy::default()
2332 };
2333 let over = ShellArgs::parse(
2335 &serde_json::json!({ "program": "echo", "timeout_secs": 9999 }),
2336 &limits,
2337 )
2338 .expect("parse");
2339 assert_eq!(over.timeout, std::time::Duration::from_secs(5));
2340 let dflt =
2342 ShellArgs::parse(&serde_json::json!({ "program": "echo" }), &limits).expect("parse");
2343 assert_eq!(dflt.timeout, std::time::Duration::from_secs(3));
2344 }
2345
2346 struct FakeEnv(HashMap<String, String>);
2349 impl EnvProvider for FakeEnv {
2350 fn get(&self, name: &str) -> Option<String> {
2351 self.0.get(name).cloned()
2352 }
2353 }
2354 fn fake_env(pairs: &[(&str, &str)]) -> Arc<dyn EnvProvider> {
2355 Arc::new(FakeEnv(
2356 pairs
2357 .iter()
2358 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
2359 .collect(),
2360 ))
2361 }
2362
2363 struct MapLister(HashMap<String, Vec<GlobEntry>>);
2366 impl DirLister for MapLister {
2367 fn list(&self, dir: &Path) -> Vec<GlobEntry> {
2368 let key = dir.to_string_lossy().replace('\\', "/");
2371 self.0.get(&key).cloned().unwrap_or_default()
2372 }
2373 }
2374 fn ent(name: &str, is_dir: bool) -> GlobEntry {
2375 GlobEntry {
2376 name: name.to_string(),
2377 is_dir,
2378 }
2379 }
2380 fn map_lister(dirs: &[(&str, Vec<GlobEntry>)]) -> Arc<dyn DirLister> {
2381 Arc::new(MapLister(
2382 dirs.iter()
2383 .map(|(d, es)| ((*d).to_string(), es.clone()))
2384 .collect(),
2385 ))
2386 }
2387
2388 #[tokio::test]
2394 async fn redirect_var_is_expanded_and_reaches_spawner_resolved() {
2395 let tmp = std::env::temp_dir().to_string_lossy().into_owned();
2396 let mock = Arc::new(MockSpawner::default());
2397 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
2398 let out = tool
2400 .invoke(
2401 serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
2402 &ctx(exec_only(&["echo"])),
2403 )
2404 .await
2405 .expect("invoke");
2406 assert_ne!(
2407 out["denied"],
2408 serde_json::json!(true),
2409 "in-scope var: {out}"
2410 );
2411 let redir = &calls(&mock)[0][0].redirects[0];
2412 assert_eq!(
2413 *redir,
2414 Redirect::Stdout {
2415 path: vec![Seg::Lit(format!("{tmp}/out"))],
2416 append: false,
2417 }
2418 );
2419 }
2420
2421 #[tokio::test]
2423 async fn redirect_var_not_in_allowlist_is_denied() {
2424 let mock = Arc::new(MockSpawner::default());
2425 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/x")]));
2426 let out = tool
2427 .invoke(
2428 serde_json::json!({"cmd": "echo hi > $SECRET"}),
2429 &ctx(exec_only(&["echo"])),
2430 )
2431 .await
2432 .expect("invoke");
2433 assert_eq!(out["denied"], true, "non-allowlisted redirect var: {out}");
2434 assert!(
2435 ran_programs(&mock).is_empty(),
2436 "no spawn on a denied redirect"
2437 );
2438 assert!(out["denials"][0]["reason"]
2439 .as_str()
2440 .unwrap_or_default()
2441 .contains("SECRET"));
2442 }
2443
2444 #[test]
2450 fn expand_varglob_keeps_value_metachars_literal_and_refuses_basename_var() {
2451 let env = FakeEnv(HashMap::from([("TMPDIR".to_string(), "/a*b".to_string())]));
2453 let allow = agent_bridle_core::LimitsPolicy::default().var_allowlist;
2454 let pattern = expand_varglob(
2457 &[Seg::Var("TMPDIR".into()), Seg::Lit("/*.rs".into())],
2458 &env,
2459 &allow,
2460 )
2461 .unwrap();
2462 assert_eq!(pattern, "/a*b/*.rs");
2463 let err = expand_varglob(
2465 &[Seg::Var("TMPDIR".into()), Seg::Lit("*.rs".into())],
2466 &env,
2467 &allow,
2468 );
2469 assert!(err.is_err(), "var in glob basename must be refused");
2470 }
2471
2472 #[tokio::test]
2475 async fn glob_var_expands_to_resolved_matches_before_spawn() {
2476 let mock = Arc::new(MockSpawner::default());
2477 let lister = map_lister(&[
2478 (".", vec![ent("proj", true)]),
2479 ("./proj", vec![ent("a.rs", false), ent("b.rs", false)]),
2480 ]);
2481 let tool = ShellTool::with_seams(mock.clone(), fake_env(&[("TMPDIR", "proj")]), lister);
2482 let out = tool
2483 .invoke(
2484 serde_json::json!({"cmd": "ls $TMPDIR/*.rs"}), &ctx(exec_only(&["ls"])),
2486 )
2487 .await
2488 .expect("invoke");
2489 assert_ne!(
2490 out["denied"],
2491 serde_json::json!(true),
2492 "in-scope glob var: {out}"
2493 );
2494 assert_eq!(
2495 calls(&mock)[0][0].argv,
2496 vec![
2497 Arg::Lit("ls".into()),
2498 Arg::Lit("proj/a.rs".into()),
2499 Arg::Lit("proj/b.rs".into())
2500 ]
2501 );
2502 }
2503
2504 #[tokio::test]
2506 async fn glob_var_in_basename_is_denied() {
2507 let mock = Arc::new(MockSpawner::default());
2508 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("PREFIX", "foo")]));
2509 let out = tool
2510 .invoke(
2511 serde_json::json!({"cmd": "ls $PREFIX*.rs"}),
2512 &ctx(exec_only(&["ls"])),
2513 )
2514 .await
2515 .expect("invoke");
2516 assert_eq!(out["denied"], true, "var in glob basename refused: {out}");
2517 assert!(ran_programs(&mock).is_empty());
2518 }
2519
2520 #[tokio::test]
2522 async fn glob_var_not_in_allowlist_is_denied() {
2523 let mock = Arc::new(MockSpawner::default());
2524 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/s")]));
2525 let out = tool
2526 .invoke(
2527 serde_json::json!({"cmd": "ls $SECRET/*.rs"}),
2528 &ctx(exec_only(&["ls"])),
2529 )
2530 .await
2531 .expect("invoke");
2532 assert_eq!(out["denied"], true, "non-allowlisted glob var: {out}");
2533 assert!(ran_programs(&mock).is_empty());
2534 }
2535
2536 #[tokio::test]
2540 async fn redirect_var_resolved_path_out_of_fs_write_scope_denied() {
2541 let tmp = std::env::temp_dir().to_string_lossy().into_owned();
2542 let mock = Arc::new(MockSpawner::default());
2543 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
2544 let granted = Caveats {
2545 exec: Scope::only(["echo".to_string()]),
2546 fs_write: Scope::only(["/nonexistent-grant-root".to_string()]),
2547 ..Caveats::top()
2548 };
2549 let out = tool
2550 .invoke(
2551 serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
2552 &ctx(granted),
2553 )
2554 .await
2555 .expect("invoke");
2556 assert_eq!(out["denied"], true, "resolved path outside fs_write: {out}");
2557 assert!(ran_programs(&mock).is_empty());
2558 }
2559
2560 #[tokio::test]
2563 async fn and_short_circuits_on_failure() {
2564 let mock = Arc::new(MockSpawner::with_exit("false", 1));
2565 ShellTool::with_spawner(mock.clone())
2566 .invoke(
2567 serde_json::json!({"cmd": "false && echo hi"}),
2568 &ctx(exec_only(&["false", "echo"])),
2569 )
2570 .await
2571 .expect("invoke");
2572 assert_eq!(ran_programs(&mock), vec!["false"], "echo must be skipped");
2573 }
2574
2575 #[tokio::test]
2576 async fn out_of_scope_anywhere_denies_the_whole_script() {
2577 let mock = Arc::new(MockSpawner::default());
2578 let out = ShellTool::with_spawner(mock.clone())
2579 .invoke(
2580 serde_json::json!({"cmd": "echo ok ; rm -rf x"}),
2581 &ctx(exec_only(&["echo"])),
2582 )
2583 .await
2584 .expect("invoke");
2585 assert_eq!(out["denied"], true);
2586 assert!(ran_programs(&mock).is_empty());
2587 }
2588
2589 #[tokio::test]
2594 async fn glob_arg_expanded_to_matches_before_spawn() {
2595 let mock = Arc::new(MockSpawner::default());
2596 let lister = map_lister(&[(
2597 ".",
2598 vec![ent("a.rs", false), ent("b.rs", false), ent("c.txt", false)],
2599 )]);
2600 ShellTool::with_seams(mock.clone(), fake_env(&[]), lister)
2601 .invoke(
2602 serde_json::json!({"cmd": "ls *.rs"}), &ctx(exec_only(&["ls"])),
2604 )
2605 .await
2606 .expect("invoke");
2607 assert_eq!(
2608 calls(&mock)[0][0].argv,
2609 vec![
2610 Arg::Lit("ls".into()),
2611 Arg::Lit("a.rs".into()),
2612 Arg::Lit("b.rs".into())
2613 ]
2614 );
2615 }
2616
2617 #[tokio::test]
2619 async fn glob_as_program_name_denied() {
2620 let mock = Arc::new(MockSpawner::default());
2621 let out = ShellTool::with_spawner(mock.clone())
2622 .invoke(serde_json::json!({"cmd": "*.sh foo"}), &ctx(Caveats::top()))
2623 .await
2624 .expect("invoke");
2625 assert_eq!(out["denied"], true);
2626 assert!(ran_programs(&mock).is_empty());
2627 }
2628
2629 #[tokio::test]
2631 async fn glob_dir_out_of_fs_read_scope_denied() {
2632 let mock = Arc::new(MockSpawner::default());
2633 let granted = Caveats {
2634 exec: Scope::only(["echo".to_string()]),
2635 fs_read: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
2637 ..Caveats::top()
2638 };
2639 let out = ShellTool::with_spawner(mock.clone())
2640 .invoke(serde_json::json!({"cmd": "echo *"}), &ctx(granted))
2641 .await
2642 .expect("invoke");
2643 assert_eq!(out["denied"], true);
2644 assert_eq!(out["denials"][0]["kind"], "open");
2645 assert!(ran_programs(&mock).is_empty());
2646 }
2647
2648 #[tokio::test]
2652 async fn allowlisted_var_reaches_spawner() {
2653 let mock = Arc::new(MockSpawner::default());
2654 ShellTool::with_spawner(mock.clone())
2655 .invoke(
2656 serde_json::json!({"cmd": "echo $HOME"}),
2657 &ctx(exec_only(&["echo"])),
2658 )
2659 .await
2660 .expect("invoke");
2661 let c = calls(&mock);
2662 assert_eq!(
2663 c[0][0].argv,
2664 vec![
2665 Arg::Lit("echo".into()),
2666 Arg::Var(vec![Seg::Var("HOME".into())]),
2667 ]
2668 );
2669 }
2670
2671 #[tokio::test]
2674 async fn non_allowlisted_var_denied() {
2675 let mock = Arc::new(MockSpawner::default());
2676 let out = ShellTool::with_spawner(mock.clone())
2677 .invoke(
2678 serde_json::json!({"cmd": "echo $AWS_SECRET_KEY"}),
2679 &ctx(Caveats::top()),
2680 )
2681 .await
2682 .expect("invoke");
2683 assert_eq!(out["denied"], true);
2684 assert_eq!(out["denials"][0]["target"], "$AWS_SECRET_KEY");
2685 assert!(ran_programs(&mock).is_empty());
2686 }
2687
2688 #[tokio::test]
2690 async fn var_as_program_name_denied() {
2691 let mock = Arc::new(MockSpawner::default());
2692 let out = ShellTool::with_spawner(mock.clone())
2693 .invoke(
2694 serde_json::json!({"cmd": "$HOME foo"}),
2695 &ctx(Caveats::top()),
2696 )
2697 .await
2698 .expect("invoke");
2699 assert_eq!(out["denied"], true);
2700 assert!(ran_programs(&mock).is_empty());
2701 }
2702
2703 #[tokio::test]
2707 async fn stderr_to_file_out_of_scope_denied() {
2708 let mock = Arc::new(MockSpawner::default());
2709 let granted = Caveats {
2710 exec: Scope::only(["cmd".to_string()]),
2711 fs_write: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
2712 ..Caveats::top()
2713 };
2714 let out = ShellTool::with_spawner(mock.clone())
2715 .invoke(
2716 serde_json::json!({"cmd": "cmd 2> /etc/passwd"}),
2717 &ctx(granted),
2718 )
2719 .await
2720 .expect("invoke");
2721 assert_eq!(out["denied"], true);
2722 assert_eq!(out["denials"][0]["kind"], "open");
2723 assert_eq!(out["denials"][0]["target"], "/etc/passwd");
2724 assert!(ran_programs(&mock).is_empty());
2725 }
2726
2727 #[tokio::test]
2729 async fn stderr_merge_reaches_spawner() {
2730 let mock = Arc::new(MockSpawner::default());
2731 ShellTool::with_spawner(mock.clone())
2732 .invoke(
2733 serde_json::json!({"cmd": "cmd 2>&1"}),
2734 &ctx(exec_only(&["cmd"])),
2735 )
2736 .await
2737 .expect("invoke");
2738 let c = calls(&mock);
2739 assert_eq!(c[0][0].stderr_disposition(), StderrTo::Stdout);
2740 }
2741
2742 #[tokio::test]
2743 async fn both_program_and_cmd_is_a_hard_error() {
2744 let res = ShellTool::new()
2745 .invoke(
2746 serde_json::json!({"program": "echo", "cmd": "echo hi"}),
2747 &ctx(Caveats::top()),
2748 )
2749 .await;
2750 assert!(res.is_err());
2751 }
2752
2753 #[tokio::test]
2754 async fn timeout_is_reported() {
2755 let mock = Arc::new(MockSpawner {
2756 block_ms: 1500,
2757 ..Default::default()
2758 });
2759 let out = ShellTool::with_spawner(mock)
2760 .invoke(
2761 serde_json::json!({"program": "anything", "timeout_secs": 1}),
2762 &ctx(exec_only(&["anything"])),
2763 )
2764 .await
2765 .expect("invoke");
2766 assert_eq!(out["timed_out"], true);
2767 }
2768
2769 #[test]
2772 fn fnmatch_basics() {
2773 assert!(fnmatch("*.rs", "a.rs"));
2774 assert!(!fnmatch("*.rs", "a.txt"));
2775 assert!(fnmatch("a?c", "abc"));
2776 assert!(!fnmatch("a?c", "ac"));
2777 assert!(fnmatch("*", ""));
2778 assert!(fnmatch("a*", "a"));
2779 assert!(fnmatch("[abc]x", "bx"));
2780 assert!(!fnmatch("[abc]x", "dx"));
2781 assert!(fnmatch("[!abc]x", "dx"));
2782 assert!(fnmatch("[a-c]", "b"));
2783 assert!(!fnmatch("[a-c]", "d"));
2784 assert!(fnmatch("foo*bar", "fooXYbar"));
2785 }
2786
2787 #[test]
2792 fn read_capped_bounds_buffering_and_flags_truncation() {
2793 const CAP: usize = 1 << 20;
2795 struct Endless {
2798 served: usize,
2799 }
2800 impl Read for Endless {
2801 fn read(&mut self, b: &mut [u8]) -> std::io::Result<usize> {
2802 self.served = self.served.saturating_add(b.len());
2803 assert!(
2804 self.served <= CAP + 64 * 1024,
2805 "read_capped over-read {} bytes (cap {CAP})",
2806 self.served
2807 );
2808 b.fill(b'x');
2809 Ok(b.len())
2810 }
2811 }
2812 let (buf, truncated) = read_capped(Endless { served: 0 }, CAP);
2813 assert_eq!(buf.len(), CAP, "peak buffering bounded by the cap");
2814 assert!(
2815 truncated,
2816 "a source longer than the cap is flagged truncated"
2817 );
2818
2819 let (buf2, trunc2) = read_capped(&b"hello"[..], CAP);
2821 assert_eq!(buf2, b"hello");
2822 assert!(!trunc2, "a sub-cap source is not truncated");
2823 }
2824
2825 #[test]
2826 fn read_capped_retries_an_interrupted_read() {
2827 struct InterruptedOnce {
2828 interrupted: bool,
2829 inner: std::io::Cursor<Vec<u8>>,
2830 }
2831
2832 impl Read for InterruptedOnce {
2833 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2834 if !self.interrupted {
2835 self.interrupted = true;
2836 return Err(std::io::Error::from(std::io::ErrorKind::Interrupted));
2837 }
2838 self.inner.read(buf)
2839 }
2840 }
2841
2842 let reader = InterruptedOnce {
2843 interrupted: false,
2844 inner: std::io::Cursor::new(b"abcdef".to_vec()),
2845 };
2846 let (captured, truncated) = read_capped(reader, 4);
2847
2848 assert_eq!(captured, b"abcd");
2849 assert!(truncated);
2850 }
2851
2852 #[test]
2853 fn glob_walk_single_segment_and_subpath() {
2854 let lister = map_lister(&[
2855 (
2856 ".",
2857 vec![
2858 ent("a.rs", false),
2859 ent("b.rs", false),
2860 ent("c.txt", false),
2861 ent(".hidden.rs", false),
2862 ent("src", true),
2863 ],
2864 ),
2865 ("./src", vec![ent("a.rs", false), ent("b.rs", false)]),
2866 ]);
2867 let mut allow = |_d: &Path| Ok(());
2868 assert_eq!(
2870 expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2871 vec!["a.rs", "b.rs"]
2872 );
2873 assert_eq!(
2875 expand_glob_walk("zzz*", None, &*lister, &mut allow, 64, 4096).unwrap(),
2876 vec!["zzz*"]
2877 );
2878 assert_eq!(
2880 expand_glob_walk("src/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2881 vec!["src/a.rs", "src/b.rs"]
2882 );
2883 }
2884
2885 #[test]
2886 fn glob_walk_multi_segment_and_recursive() {
2887 let lister = map_lister(&[
2888 (
2889 ".",
2890 vec![ent("a", true), ent("b", true), ent("x.rs", false)],
2891 ),
2892 ("./a", vec![ent("foo.rs", false), ent("sub", true)]),
2893 ("./b", vec![ent("bar.rs", false)]),
2894 ("./a/sub", vec![ent("deep.rs", false)]),
2895 ]);
2896 let mut allow = |_d: &Path| Ok(());
2897 assert_eq!(
2899 expand_glob_walk("*/foo.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2900 vec!["a/foo.rs"]
2901 );
2902 assert_eq!(
2904 expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2905 vec!["a/foo.rs", "a/sub/deep.rs", "b/bar.rs", "x.rs"]
2906 );
2907 }
2908
2909 #[test]
2910 fn glob_walk_leashes_every_directory_and_denies_out_of_scope() {
2911 let lister = map_lister(&[
2912 (".", vec![ent("a", true), ent("x.rs", false)]),
2913 ("./a", vec![ent("secret.rs", false)]),
2914 ]);
2915 let mut deny_a = |d: &Path| {
2918 if d.to_string_lossy().contains("a") {
2919 Err(ToolError::denied("out of fs_read scope"))
2920 } else {
2921 Ok(())
2922 }
2923 };
2924 assert!(expand_glob_walk("**/*.rs", None, &*lister, &mut deny_a, 64, 4096).is_err());
2925 }
2926
2927 #[test]
2930 fn glob_walk_respects_configured_match_cap() {
2931 let lister = map_lister(&[(
2932 ".",
2933 vec![
2934 ent("a.rs", false),
2935 ent("b.rs", false),
2936 ent("c.rs", false),
2937 ent("d.rs", false),
2938 ],
2939 )]);
2940 let mut allow = |_d: &Path| Ok(());
2941 let got = expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 2).unwrap();
2942 assert_eq!(got.len(), 2, "match cap of 2 must bound the result set");
2943 }
2944
2945 #[test]
2948 fn glob_walk_respects_configured_depth_cap() {
2949 let lister = map_lister(&[
2950 (".", vec![ent("a", true), ent("x.rs", false)]),
2951 ("./a", vec![ent("foo.rs", false), ent("sub", true)]),
2952 ("./a/sub", vec![ent("deep.rs", false)]),
2953 ]);
2954 let mut allow = |_d: &Path| Ok(());
2955 let got = expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 1, 4096).unwrap();
2957 assert!(
2958 !got.iter().any(|m| m.contains("deep.rs")),
2959 "depth cap of 1 must not reach a/sub/deep.rs; got {got:?}"
2960 );
2961 }
2962
2963 #[test]
2967 fn var_allowlist_is_config_driven() {
2968 let allow_custom = vec!["MY_CUSTOM_VAR".to_string()];
2970 let env = FakeEnv(HashMap::from([(
2971 "MY_CUSTOM_VAR".to_string(),
2972 "/data".to_string(),
2973 )]));
2974 let out = expand_redirect_target(&[Seg::Var("MY_CUSTOM_VAR".into())], &env, &allow_custom)
2975 .unwrap();
2976 assert_eq!(out, "/data");
2977 assert!(!is_allowed_var("HOME", &["PWD".to_string()]));
2979 assert!(is_allowed_var("PWD", &["PWD".to_string()]));
2980 }
2981
2982 #[test]
2987 fn net_audit_sink_is_config_driven() {
2988 use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
2989 let ev = NetAuditEvent {
2990 ts_ms: 0,
2991 host: "example.test".to_string(),
2992 port: 443,
2993 kind: NetKind::Connect,
2994 decision: NetDecision::Allowed,
2995 bytes_up: 1,
2996 bytes_down: 2,
2997 dur_ms: 3,
2998 };
2999 net_audit_sink(None).record(&ev);
3001
3002 let path = std::env::temp_dir().join(format!("ab-audit-{}.jsonl", std::process::id()));
3004 let _ = std::fs::remove_file(&path);
3005 let sink = net_audit_sink(path.to_str());
3006 sink.record(&ev);
3007 drop(sink);
3008 let contents = std::fs::read_to_string(&path).expect("configured audit file written");
3009 assert!(
3010 contents.contains("example.test"),
3011 "the configured sink must write the event: {contents}"
3012 );
3013 let _ = std::fs::remove_file(&path);
3014 }
3015
3016 #[test]
3020 fn net_audit_sink_bad_path_degrades_to_null() {
3021 use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
3022 let ev = NetAuditEvent {
3023 ts_ms: 0,
3024 host: "example.test".to_string(),
3025 port: 443,
3026 kind: NetKind::Http,
3027 decision: NetDecision::Allowed,
3028 bytes_up: 1,
3029 bytes_down: 2,
3030 dur_ms: 3,
3031 };
3032 let bad = std::env::temp_dir()
3034 .join(format!("ab-nope-{}", std::process::id()))
3035 .join("does/not/exist/audit.jsonl");
3036 let sink = net_audit_sink(bad.to_str());
3037 sink.record(&ev); assert!(
3039 !bad.exists(),
3040 "a bad audit path must not create a file (degraded to null): {bad:?}"
3041 );
3042 }
3043}