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, Caveats, Denial, DenialKind, Disclosure, EnforcementReport,
44 LimitsPolicy, SandboxKind, SandboxPolicy, Tool, ToolContext, ToolEnvelope, ToolError,
45 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(
169 caveats: &Caveats,
170 sandbox: &Arc<SandboxPolicy>,
171) -> Option<(Vec<String>, Caveats)> {
172 agent_bridle_core::egress_proxy_plan(caveats, sandbox)
173}
174
175fn run_with_egress_proxy(
184 stages: &[Command],
185 cwd: Option<&str>,
186 fenced: &Caveats,
187 env: &BTreeMap<String, String>,
188 allow_hosts: Vec<String>,
189 cfg: &SpawnCfg,
190) -> ToolResult<Captured> {
191 let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(fenced)?;
193 let proxy = net_proxy::start(
197 allow_hosts,
198 Arc::new(net_proxy::StdResolver),
199 net_audit_sink(cfg.audit_sink.as_deref()),
200 )
201 .map_err(ToolError::Exec)?;
202 let mut env = env.clone();
205 for (k, v) in proxy.proxy_env() {
206 env.insert(k, v);
207 }
208
209 let stages = stages.to_vec();
210 let cwd = cwd.map(str::to_string);
211 let fenced = fenced.clone();
212 let max_output = cfg.max_output;
213 let output = cfg.output.clone();
214 let sandbox = cfg.sandbox.clone();
215 let captured = std::thread::Builder::new()
216 .name("agent-bridle-confined".to_string())
217 .spawn(move || {
218 best_available_sandbox(&sandbox).apply(&fenced)?;
219 run_pipeline(&stages, cwd.as_deref(), &prefix, &env, max_output, output)
220 })
221 .map_err(ToolError::Exec)?
222 .join()
223 .map_err(|_| {
224 ToolError::Exec(std::io::Error::other("confined execution thread panicked"))
225 })?;
226 let refused = proxy.refused_hosts();
230 drop(proxy); let mut captured = captured?;
232 captured.net_denials = refused
233 .into_iter()
234 .map(|host| Denial {
235 kind: DenialKind::Net,
236 reason: format!("net does not permit '{host}'"),
237 target: host,
238 })
239 .collect();
240 Ok(captured)
241}
242
243fn net_audit_sink(configured: Option<&str>) -> Arc<dyn net_proxy::AuditSink> {
252 match configured {
253 Some(path) if !path.is_empty() => std::fs::OpenOptions::new()
254 .create(true)
255 .append(true)
256 .open(path)
257 .map(|f| Arc::new(net_proxy::JsonlSink::new(f)) as Arc<dyn net_proxy::AuditSink>)
258 .unwrap_or_else(|_| Arc::new(net_proxy::NullSink)),
259 _ => Arc::new(net_proxy::NullSink),
260 }
261}
262
263fn intended_sandbox_kind(caveats: &Caveats, sandbox: &Arc<SandboxPolicy>) -> SandboxKind {
270 effective_sandbox_kind(best_available_sandbox(sandbox).kind(), caveats)
271}
272
273fn run_confined(
284 stages: &[Command],
285 cwd: Option<&str>,
286 caveats: &Caveats,
287 env: &BTreeMap<String, String>,
288 cfg: &SpawnCfg,
289) -> ToolResult<Captured> {
290 let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(caveats)?;
292 let stages = stages.to_vec();
293 let cwd = cwd.map(str::to_string);
294 let caveats = caveats.clone();
295 let env = env.clone();
296 let max_output = cfg.max_output;
297 let output = cfg.output.clone();
298 let sandbox = cfg.sandbox.clone();
299 std::thread::Builder::new()
300 .name("agent-bridle-confined".to_string())
301 .spawn(move || {
302 best_available_sandbox(&sandbox).apply(&caveats)?;
303 run_pipeline(&stages, cwd.as_deref(), &prefix, &env, max_output, output)
304 })
305 .map_err(ToolError::Exec)?
306 .join()
307 .map_err(|_| ToolError::Exec(std::io::Error::other("confined execution thread panicked")))?
308}
309
310static SHELL_SCHEMA: LazyLock<serde_json::Value> = LazyLock::new(|| {
317 serde_json::from_str(include_str!("shell_tool.schema.json"))
318 .expect("embedded shell_tool.schema.json must be valid JSON")
319});
320
321#[derive(Clone)]
328pub struct ShellTool {
329 spawner: Arc<dyn Spawner>,
330 env: Arc<dyn EnvProvider>,
331 lister: Arc<dyn DirLister>,
332 limits: LimitsPolicy,
333 sandbox: Arc<SandboxPolicy>,
336 output_observer: Option<Arc<dyn crate::ShellOutputObserver>>,
337}
338
339impl std::fmt::Debug for ShellTool {
340 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341 f.write_str("ShellTool")
342 }
343}
344
345impl ShellTool {
346 #[must_use]
349 pub fn new() -> Self {
350 Self::with_config(LimitsPolicy::default())
351 }
352
353 #[must_use]
356 pub fn with_config(limits: LimitsPolicy) -> Self {
357 Self {
358 spawner: Arc::new(OsSpawner),
359 env: Arc::new(RealEnv),
360 lister: Arc::new(RealDirLister),
361 limits,
362 sandbox: Arc::new(SandboxPolicy::default()),
363 output_observer: None,
364 }
365 }
366
367 #[must_use]
374 pub fn with_output_observer(mut self, observer: Arc<dyn crate::ShellOutputObserver>) -> Self {
375 self.output_observer = Some(observer);
376 self
377 }
378
379 #[must_use]
382 pub fn with_sandbox_policy(mut self, sandbox: SandboxPolicy) -> Self {
383 self.sandbox = Arc::new(sandbox);
384 self
385 }
386
387 #[cfg(test)]
389 fn with_spawner(spawner: Arc<dyn Spawner>) -> Self {
390 Self {
391 spawner,
392 env: Arc::new(RealEnv),
393 lister: Arc::new(RealDirLister),
394 limits: LimitsPolicy::default(),
395 sandbox: Arc::new(SandboxPolicy::default()),
396 output_observer: None,
397 }
398 }
399
400 #[cfg(test)]
404 fn with_spawner_and_env(spawner: Arc<dyn Spawner>, env: Arc<dyn EnvProvider>) -> Self {
405 Self {
406 spawner,
407 env,
408 lister: Arc::new(RealDirLister),
409 limits: LimitsPolicy::default(),
410 sandbox: Arc::new(SandboxPolicy::default()),
411 output_observer: None,
412 }
413 }
414
415 #[cfg(test)]
419 fn with_seams(
420 spawner: Arc<dyn Spawner>,
421 env: Arc<dyn EnvProvider>,
422 lister: Arc<dyn DirLister>,
423 ) -> Self {
424 Self {
425 spawner,
426 env,
427 lister,
428 limits: LimitsPolicy::default(),
429 sandbox: Arc::new(SandboxPolicy::default()),
430 output_observer: None,
431 }
432 }
433}
434
435impl Default for ShellTool {
436 fn default() -> Self {
437 Self::new()
438 }
439}
440
441#[async_trait]
442impl Tool for ShellTool {
443 fn name(&self) -> &str {
444 "shell"
445 }
446
447 fn schema(&self) -> serde_json::Value {
448 let mut schema = SHELL_SCHEMA.clone();
454 schema["properties"]["timeout_secs"]["maximum"] =
455 serde_json::Value::from(self.limits.max_timeout_secs);
456 schema
457 }
458
459 async fn invoke(
460 &self,
461 args: serde_json::Value,
462 cx: &ToolContext,
463 ) -> ToolResult<serde_json::Value> {
464 let parsed = ShellArgs::parse(&args, &self.limits)?;
465 let unbridled = is_unbridled();
471 let sandbox_kind = if unbridled {
484 SandboxKind::None
485 } else {
486 match egress_proxy_plan(cx.caveats(), &self.sandbox) {
487 Some((_, fenced)) => intended_sandbox_kind(&fenced, &self.sandbox),
488 None => intended_sandbox_kind(cx.caveats(), &self.sandbox),
489 }
490 };
491 let enforcement = enforcement_report(cx.caveats(), sandbox_kind);
494
495 let mut script = match parsed.script() {
497 Ok(s) => s,
498 Err(refusal) => return Ok(refused_envelope(sandbox_kind, enforcement, &refusal)),
499 };
500
501 for item in &mut script {
507 for stage in &mut item.pipeline {
508 let mut new_argv: Vec<Arg> = Vec::with_capacity(stage.argv.len());
514 for (i, arg) in stage.argv.drain(..).enumerate() {
515 let pattern: Option<String> = if i == 0 {
516 None
517 } else {
518 match &arg {
519 Arg::Glob(p) => Some(p.clone()),
520 Arg::VarGlob(segs) => {
521 match expand_varglob(segs, &*self.env, &self.limits.var_allowlist) {
522 Ok(p) => Some(p),
523 Err((target, e)) => {
524 return Ok(deny(
525 sandbox_kind,
526 enforcement,
527 DenialKind::Exec,
528 &target,
529 &e,
530 ))
531 }
532 }
533 }
534 _ => None,
535 }
536 };
537 match pattern {
538 Some(p) => {
539 let mut leash = |dir: &Path| cx.check_path_read(dir);
540 match expand_glob_walk(
541 &p,
542 parsed.cwd.as_deref(),
543 &*self.lister,
544 &mut leash,
545 self.limits.max_glob_depth,
546 self.limits.max_glob_matches,
547 ) {
548 Ok(ms) => new_argv.extend(ms.into_iter().map(Arg::Lit)),
549 Err(e) => {
550 return Ok(deny(
551 sandbox_kind,
552 enforcement,
553 DenialKind::Open,
554 &p,
555 &e,
556 ))
557 }
558 }
559 }
560 None => new_argv.push(arg),
561 }
562 }
563 stage.argv = new_argv;
564 for redirect in &mut stage.redirects {
565 let segs = match redirect {
566 Redirect::Stdout { path, .. }
567 | Redirect::Stderr { path, .. }
568 | Redirect::Stdin { path } => path,
569 Redirect::StderrToStdout => continue,
570 };
571 match expand_redirect_target(segs, &*self.env, &self.limits.var_allowlist) {
572 Ok(resolved) => *segs = vec![Seg::Lit(resolved)],
573 Err((target, e)) => {
574 return Ok(deny(
575 sandbox_kind,
576 enforcement,
577 DenialKind::Open,
578 &target,
579 &e,
580 ))
581 }
582 }
583 }
584 }
585 }
586
587 for item in &script {
593 for stage in &item.pipeline {
594 match stage.argv.first() {
595 Some(Arg::Lit(program)) => {
596 if let Err(e) = cx.check_exec(program) {
597 return Ok(deny(
598 sandbox_kind,
599 enforcement,
600 DenialKind::Exec,
601 program,
602 &e,
603 ));
604 }
605 }
606 Some(Arg::Glob(pattern)) => {
607 return Ok(deny(
608 sandbox_kind,
609 enforcement,
610 DenialKind::Exec,
611 pattern,
612 &ToolError::denied("a glob pattern is not allowed as a program name"),
613 ));
614 }
615 Some(Arg::Var(_segs)) => {
616 return Ok(deny(
617 sandbox_kind,
618 enforcement,
619 DenialKind::Exec,
620 "$VAR",
621 &ToolError::denied("a variable is not allowed as a program name"),
622 ));
623 }
624 Some(Arg::VarGlob(_)) => {
627 return Ok(deny(
628 sandbox_kind,
629 enforcement,
630 DenialKind::Exec,
631 "$VAR/glob",
632 &ToolError::denied("a glob pattern is not allowed as a program name"),
633 ));
634 }
635 None => {} }
637 for arg in &stage.argv {
638 match arg {
639 Arg::Var(segs) => {
642 for seg in segs {
643 if let Seg::Var(name) = seg {
644 if !is_allowed_var(name, &self.limits.var_allowlist) {
645 return Ok(deny(
646 sandbox_kind,
647 enforcement,
648 DenialKind::Exec,
649 &format!("${name}"),
650 &ToolError::denied(format!(
651 "variable ${name} is not in the confined shell's allowlist"
652 )),
653 ));
654 }
655 }
656 }
657 }
658 Arg::Glob(_) => unreachable!("glob expanded at admission"),
661 Arg::VarGlob(_) => unreachable!("VarGlob expanded at admission"),
662 Arg::Lit(_) => {}
663 }
664 }
665 for redirect in &stage.redirects {
666 let (path, checked) = match redirect {
669 Redirect::Stdout { path, .. } | Redirect::Stderr { path, .. } => {
670 let p = seg_literal(path).expect("redirect target lowered");
671 (p, cx.check_path_write(Path::new(p)))
672 }
673 Redirect::Stdin { path } => {
674 let p = seg_literal(path).expect("redirect target lowered");
675 (p, cx.check_path_read(Path::new(p)))
676 }
677 Redirect::StderrToStdout => continue,
679 };
680 if let Err(e) = checked {
681 return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, path, &e));
682 }
683 }
684 }
685 }
686 if let Some(cwd) = &parsed.cwd {
688 if let Err(e) = cx.check_path_read(Path::new(cwd)) {
689 return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, cwd, &e));
690 }
691 }
692
693 if !unbridled && confinement_unenforceable(sandbox_kind, cx.caveats(), cx.strength_floor())
711 {
712 return Ok(deny(
713 sandbox_kind,
714 enforcement,
715 DenialKind::Exec,
716 "confinement",
717 &ToolError::denied(format!(
718 "a restricted filesystem/exec/net axis cannot be enforced on this host \
719 at the required strength floor ({:?}); refusing to run unconfined",
720 cx.strength_floor()
721 )),
722 ));
723 }
724
725 let spawner = Arc::clone(&self.spawner);
728 let cwd = parsed.cwd.clone();
729 let timeout = parsed.timeout;
730 let (output_guard, output) =
731 output_session(self.output_observer.clone(), self.limits.max_output_bytes);
732 let cfg = SpawnCfg {
733 max_output: self.limits.max_output_bytes,
734 audit_sink: self.limits.audit_sink.clone(),
735 sandbox: Arc::clone(&self.sandbox),
736 unbridled,
737 output,
738 };
739 let disclosure = Disclosure {
741 unbridled,
742 human_gate: human_gate(),
743 ..Disclosure::default()
744 };
745 let env = parsed.env;
748 let caveats = cx.caveats().clone();
749 let run = tokio::task::spawn_blocking(move || {
750 run_script(&*spawner, &script, cwd.as_deref(), &caveats, &env, &cfg)
751 });
752 match tokio::time::timeout(timeout, run).await {
753 Ok(joined) => {
754 let captured = joined
755 .map_err(|e| ToolError::Other(anyhow::anyhow!("shell task panicked: {e}")))??;
756 let envelope = ToolEnvelope::new(sandbox_kind)
760 .with_enforcement(enforcement)
761 .with_disclosure(disclosure)
762 .with_exit_code(captured.exit_code)
763 .with_truncation(captured.stdout_truncated, captured.stderr_truncated)
764 .with_stdout(captured.stdout)
765 .with_stderr(captured.stderr)
766 .with_denials(captured.net_denials)
767 .with_timed_out(false)
768 .into_json();
769 output_guard.finish();
770 Ok(envelope)
771 }
772 Err(_elapsed) => {
773 drop(output_guard);
776 Ok(ToolEnvelope::new(sandbox_kind)
777 .with_enforcement(enforcement)
778 .with_disclosure(disclosure)
779 .with_stderr(format!("command timed out after {}s", timeout.as_secs()))
780 .with_timed_out(true)
781 .into_json())
782 }
783 }
784 }
785}
786
787fn run_script(
791 spawner: &dyn Spawner,
792 script: &[ScriptItem],
793 cwd: Option<&str>,
794 caveats: &Caveats,
795 env: &BTreeMap<String, String>,
796 cfg: &SpawnCfg,
797) -> ToolResult<Captured> {
798 let mut stdout = String::new();
799 let mut stderr = String::new();
800 let mut status: i32 = 0;
801 let mut stdout_truncated = false;
802 let mut stderr_truncated = false;
803 let mut net_denials: Vec<Denial> = Vec::new();
805
806 for item in script {
807 let run_it = match item.sep {
808 Sep::Seq => true,
809 Sep::And => status == 0,
810 Sep::Or => status != 0,
811 };
812 if run_it {
813 let captured = spawner.run(&item.pipeline, cwd, caveats, env, cfg)?;
814 stdout.push_str(&captured.stdout);
815 stderr.push_str(&captured.stderr);
816 stdout_truncated |= captured.stdout_truncated;
817 stderr_truncated |= captured.stderr_truncated;
818 net_denials.extend(captured.net_denials);
819 status = captured.exit_code;
820 }
821 }
822
823 let stdout_truncated = stdout_truncated || stdout.len() > cfg.max_output;
825 let stderr_truncated = stderr_truncated || stderr.len() > cfg.max_output;
826
827 Ok(Captured {
828 exit_code: status,
829 stdout: cap_string(stdout, cfg.max_output),
830 stderr: cap_string(stderr, cfg.max_output),
831 net_denials,
832 stdout_truncated,
833 stderr_truncated,
834 })
835}
836
837fn deny(
839 sandbox_kind: SandboxKind,
840 enforcement: EnforcementReport,
841 kind: DenialKind,
842 target: &str,
843 err: &ToolError,
844) -> serde_json::Value {
845 ToolEnvelope::new(sandbox_kind)
846 .with_enforcement(enforcement)
847 .with_disclosure(unbridle_disclosure())
848 .with_denials(vec![Denial {
849 kind,
850 target: target.to_string(),
851 reason: err.to_string(),
852 }])
853 .into_json()
854}
855
856fn unbridle_disclosure() -> Disclosure {
860 Disclosure {
861 unbridled: is_unbridled(),
862 human_gate: human_gate(),
863 ..Disclosure::default()
864 }
865}
866
867fn refused_envelope(
869 sandbox_kind: SandboxKind,
870 enforcement: EnforcementReport,
871 refusal: &Refusal,
872) -> serde_json::Value {
873 ToolEnvelope::new(sandbox_kind)
874 .with_enforcement(enforcement)
875 .with_disclosure(unbridle_disclosure())
876 .with_denials(vec![Denial {
877 kind: DenialKind::Exec,
878 target: refusal.construct(),
879 reason: refusal.to_string(),
880 }])
881 .into_json()
882}
883
884struct ShellArgs {
886 program: Option<String>,
887 args: Vec<String>,
888 cmd: Option<String>,
889 cwd: Option<String>,
890 env: BTreeMap<String, String>,
894 timeout: Duration,
895}
896
897impl ShellArgs {
898 fn parse(v: &serde_json::Value, limits: &LimitsPolicy) -> ToolResult<Self> {
899 let obj = v
900 .as_object()
901 .ok_or_else(|| ToolError::denied("shell args must be a JSON object"))?;
902
903 let program = obj
904 .get("program")
905 .and_then(|x| x.as_str())
906 .map(String::from);
907 let cmd = obj.get("cmd").and_then(|x| x.as_str()).map(String::from);
908 let args = obj
909 .get("args")
910 .and_then(|x| x.as_array())
911 .map(|a| {
912 a.iter()
913 .filter_map(|x| x.as_str().map(String::from))
914 .collect::<Vec<_>>()
915 })
916 .unwrap_or_default();
917 let cwd = obj.get("cwd").and_then(|x| x.as_str()).map(String::from);
918 let env = obj
922 .get("env")
923 .and_then(|x| x.as_object())
924 .map(|m| {
925 m.iter()
926 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
927 .collect::<BTreeMap<String, String>>()
928 })
929 .unwrap_or_default();
930 let timeout_secs = obj
931 .get("timeout_secs")
932 .and_then(serde_json::Value::as_u64)
933 .unwrap_or(limits.default_timeout_secs)
934 .clamp(1, limits.max_timeout_secs);
935
936 match (&program, &cmd) {
937 (Some(_), Some(_)) => {
938 return Err(ToolError::denied(
939 "provide exactly one of `program` or `cmd`, not both",
940 ))
941 }
942 (None, None) => return Err(ToolError::denied("provide one of `program` or `cmd`")),
943 _ => {}
944 }
945 if program.is_none() && !args.is_empty() {
946 return Err(ToolError::denied(
947 "`args` may only be used together with `program`",
948 ));
949 }
950
951 Ok(Self {
952 program,
953 args,
954 cmd,
955 cwd,
956 env,
957 timeout: Duration::from_secs(timeout_secs),
958 })
959 }
960
961 fn script(&self) -> Result<Script, Refusal> {
965 if let Some(program) = &self.program {
966 let mut argv = Vec::with_capacity(1 + self.args.len());
967 argv.push(Arg::Lit(program.clone()));
968 argv.extend(self.args.iter().cloned().map(Arg::Lit));
969 Ok(vec![ScriptItem {
970 sep: Sep::Seq,
971 pipeline: vec![Command {
972 argv,
973 redirects: Vec::new(),
974 }],
975 }])
976 } else {
977 classify(self.cmd.as_deref().unwrap_or(""))
978 }
979 }
980}
981
982fn is_allowed_var(name: &str, allowlist: &[String]) -> bool {
991 allowlist.iter().any(|v| v == name)
992}
993
994pub(crate) trait EnvProvider: Send + Sync {
999 fn get(&self, name: &str) -> Option<String>;
1001}
1002
1003pub(crate) struct RealEnv;
1005impl EnvProvider for RealEnv {
1006 fn get(&self, name: &str) -> Option<String> {
1007 std::env::var(name).ok()
1008 }
1009}
1010
1011fn expand_redirect_target(
1016 segs: &[Seg],
1017 env: &dyn EnvProvider,
1018 allowlist: &[String],
1019) -> Result<String, (String, ToolError)> {
1020 let mut out = String::new();
1021 for seg in segs {
1022 match seg {
1023 Seg::Lit(s) => out.push_str(s),
1024 Seg::Var(name) => {
1025 if !is_allowed_var(name, allowlist) {
1026 return Err((
1027 format!("${name}"),
1028 ToolError::denied(format!(
1029 "variable ${name} is not in the confined shell's allowlist"
1030 )),
1031 ));
1032 }
1033 out.push_str(&env.get(name).unwrap_or_default());
1034 }
1035 }
1036 }
1037 Ok(out)
1038}
1039
1040fn expand_varglob(
1050 segs: &[Seg],
1051 env: &dyn EnvProvider,
1052 allowlist: &[String],
1053) -> Result<String, (String, ToolError)> {
1054 let mut out = String::new();
1055 let mut last_var_byte: Option<usize> = None; let mut last_slash_byte: Option<usize> = None; for seg in segs {
1058 match seg {
1059 Seg::Lit(s) => {
1060 for ch in s.chars() {
1061 if ch == '/' {
1062 last_slash_byte = Some(out.len());
1063 }
1064 out.push(ch);
1065 }
1066 }
1067 Seg::Var(name) => {
1068 if !is_allowed_var(name, allowlist) {
1069 return Err((
1070 format!("${name}"),
1071 ToolError::denied(format!(
1072 "variable ${name} is not in the confined shell's allowlist"
1073 )),
1074 ));
1075 }
1076 for ch in env.get(name).unwrap_or_default().chars() {
1077 if ch == '/' {
1078 last_slash_byte = Some(out.len());
1079 }
1080 last_var_byte = Some(out.len());
1081 out.push(ch);
1082 }
1083 }
1084 }
1085 }
1086 let basename_start = last_slash_byte.map_or(0, |i| i + 1);
1089 if last_var_byte.is_some_and(|v| v >= basename_start) {
1090 return Err((
1091 "$VAR".to_string(),
1092 ToolError::denied(
1093 "a variable in a glob's basename is not supported (re-injection guard); \
1094 put the variable in the directory prefix, e.g. $DIR/*.rs",
1095 ),
1096 ));
1097 }
1098 Ok(out)
1099}
1100
1101#[derive(Debug, Clone, PartialEq, Eq)]
1106pub(crate) struct GlobEntry {
1107 pub name: String,
1108 pub is_dir: bool,
1109}
1110
1111pub(crate) trait DirLister: Send + Sync {
1114 fn list(&self, dir: &Path) -> Vec<GlobEntry>;
1116}
1117
1118pub(crate) struct RealDirLister;
1120impl DirLister for RealDirLister {
1121 fn list(&self, dir: &Path) -> Vec<GlobEntry> {
1122 std::fs::read_dir(dir)
1123 .map(|rd| {
1124 rd.filter_map(|e| {
1125 let e = e.ok()?;
1126 let name = e.file_name().into_string().ok()?;
1127 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
1128 Some(GlobEntry { name, is_dir })
1129 })
1130 .collect()
1131 })
1132 .unwrap_or_default()
1133 }
1134}
1135
1136fn join_rel(rel: &str, name: &str) -> String {
1139 if rel.is_empty() {
1140 name.to_string()
1141 } else if rel == "/" {
1142 format!("/{name}")
1143 } else {
1144 format!("{rel}/{name}")
1145 }
1146}
1147
1148fn descend_all(
1152 real: &Path,
1153 rel: &str,
1154 list: &dyn DirLister,
1155 leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
1156 depth: usize,
1157 max_matches: usize,
1158 out: &mut Vec<(PathBuf, String)>,
1159) -> ToolResult<()> {
1160 if depth == 0 || out.len() >= max_matches {
1161 return Ok(());
1162 }
1163 leash(real)?;
1164 let mut entries = list.list(real);
1165 entries.sort_by(|a, b| a.name.cmp(&b.name));
1166 for e in entries {
1167 if e.is_dir && !e.name.starts_with('.') {
1168 let child_real = real.join(&e.name);
1169 let child_rel = join_rel(rel, &e.name);
1170 out.push((child_real.clone(), child_rel.clone()));
1171 if out.len() >= max_matches {
1172 break;
1173 }
1174 descend_all(
1175 &child_real,
1176 &child_rel,
1177 list,
1178 leash,
1179 depth - 1,
1180 max_matches,
1181 out,
1182 )?;
1183 }
1184 }
1185 Ok(())
1186}
1187
1188fn expand_glob_walk(
1196 pattern: &str,
1197 cwd: Option<&str>,
1198 list: &dyn DirLister,
1199 leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
1200 max_depth: usize,
1201 max_matches: usize,
1202) -> ToolResult<Vec<String>> {
1203 let absolute = pattern.starts_with('/');
1204 let segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
1205
1206 let base_real = if absolute {
1207 PathBuf::from("/")
1208 } else {
1209 cwd.map_or_else(|| PathBuf::from("."), PathBuf::from)
1210 };
1211 let base_rel = if absolute {
1212 "/".to_string()
1213 } else {
1214 String::new()
1215 };
1216 let mut frontier: Vec<(PathBuf, String)> = vec![(base_real, base_rel)];
1217
1218 for seg in &segments {
1219 let mut next: Vec<(PathBuf, String)> = Vec::new();
1220 if *seg == "**" {
1221 for (real, rel) in &frontier {
1222 next.push((real.clone(), rel.clone())); descend_all(real, rel, list, leash, max_depth, max_matches, &mut next)?;
1224 }
1225 } else {
1226 let seg_hidden = seg.starts_with('.');
1227 for (real, rel) in &frontier {
1228 leash(real)?;
1229 let mut entries = list.list(real);
1230 entries.sort_by(|a, b| a.name.cmp(&b.name));
1231 for e in entries {
1232 if (seg_hidden || !e.name.starts_with('.')) && fnmatch(seg, &e.name) {
1233 next.push((real.join(&e.name), join_rel(rel, &e.name)));
1234 if next.len() >= max_matches {
1235 break;
1236 }
1237 }
1238 }
1239 }
1240 }
1241 frontier = next;
1242 if frontier.is_empty() {
1243 break;
1244 }
1245 }
1246
1247 let mut matches: Vec<String> = frontier.into_iter().map(|(_, rel)| rel).collect();
1248 matches.retain(|m| !m.is_empty()); matches.sort();
1250 matches.dedup();
1251 if matches.is_empty() {
1252 Ok(vec![pattern.to_string()])
1253 } else {
1254 Ok(matches)
1255 }
1256}
1257
1258fn fnmatch(pattern: &str, name: &str) -> bool {
1261 let p: Vec<char> = pattern.chars().collect();
1262 let n: Vec<char> = name.chars().collect();
1263 fnmatch_inner(&p, &n)
1264}
1265
1266fn fnmatch_inner(p: &[char], n: &[char]) -> bool {
1267 match p.first() {
1268 None => n.is_empty(),
1269 Some('*') => fnmatch_inner(&p[1..], n) || (!n.is_empty() && fnmatch_inner(p, &n[1..])),
1270 Some('?') => !n.is_empty() && fnmatch_inner(&p[1..], &n[1..]),
1271 Some('[') => {
1272 if n.is_empty() {
1273 return false;
1274 }
1275 match match_class(&p[1..], n[0]) {
1276 Some((matched, rest)) => matched && fnmatch_inner(rest, &n[1..]),
1277 None => n[0] == '[' && fnmatch_inner(&p[1..], &n[1..]),
1279 }
1280 }
1281 Some(&c) => !n.is_empty() && c == n[0] && fnmatch_inner(&p[1..], &n[1..]),
1282 }
1283}
1284
1285fn match_class(p: &[char], c: char) -> Option<(bool, &[char])> {
1288 let mut i = 0;
1289 let negate = matches!(p.first(), Some('!' | '^'));
1290 if negate {
1291 i = 1;
1292 }
1293 let mut matched = false;
1294 let mut first = true;
1295 while i < p.len() {
1296 if p[i] == ']' && !first {
1297 return Some((matched ^ negate, &p[i + 1..]));
1298 }
1299 first = false;
1300 if i + 2 < p.len() && p[i + 1] == '-' && p[i + 2] != ']' {
1301 if c >= p[i] && c <= p[i + 2] {
1302 matched = true;
1303 }
1304 i += 3;
1305 } else {
1306 if c == p[i] {
1307 matched = true;
1308 }
1309 i += 1;
1310 }
1311 }
1312 None
1313}
1314
1315fn open_for_write(path: &str, append: bool) -> std::io::Result<std::fs::File> {
1319 #[cfg(windows)]
1320 if append {
1321 let mut file = std::fs::OpenOptions::new()
1322 .write(true)
1323 .create(true)
1324 .truncate(false)
1325 .open(path)?;
1326 file.seek(SeekFrom::End(0))?;
1327 return Ok(file);
1328 }
1329
1330 std::fs::OpenOptions::new()
1331 .write(true)
1332 .create(true)
1333 .truncate(!append)
1334 .append(append)
1335 .open(path)
1336}
1337
1338fn kill_all(children: &mut [Child]) {
1341 for child in children.iter_mut() {
1342 let _ = child.kill();
1343 let _ = child.wait();
1344 }
1345}
1346
1347fn expand_stage_argv(stage: &Command, _cwd: Option<&str>) -> Vec<String> {
1352 let mut argv = Vec::with_capacity(stage.argv.len());
1353 for arg in &stage.argv {
1354 match arg {
1355 Arg::Lit(s) => argv.push(s.clone()),
1356 Arg::Var(segs) => {
1360 let mut word = String::new();
1361 for seg in segs {
1362 match seg {
1363 Seg::Lit(s) => word.push_str(s),
1364 Seg::Var(name) => word.push_str(&std::env::var(name).unwrap_or_default()),
1365 }
1366 }
1367 argv.push(word);
1368 }
1369 Arg::Glob(_) => unreachable!("glob expanded at admission"),
1373 Arg::VarGlob(_) => unreachable!("VarGlob lowered/expanded at admission"),
1374 }
1375 }
1376 argv
1377}
1378
1379fn run_pipeline(
1391 stages: &[Command],
1392 cwd: Option<&str>,
1393 wrap: &[String],
1394 env: &BTreeMap<String, String>,
1395 max_output: usize,
1396 output: OutputEmitter,
1397) -> ToolResult<Captured> {
1398 debug_assert!(!stages.is_empty(), "the parser guarantees ≥1 stage");
1399 let n = stages.len();
1400 let last = n - 1;
1401
1402 let mut children: Vec<Child> = Vec::with_capacity(n);
1403 let mut prev_stdin: Option<PipeReader> = None;
1405 let mut stdout_capture: Option<PipeReader> = None;
1407 let mut stderr_threads: Vec<std::thread::JoinHandle<(Vec<u8>, bool)>> = Vec::new();
1410
1411 for (i, stage) in stages.iter().enumerate() {
1412 let is_last = i == last;
1413 let stage_argv = expand_stage_argv(stage, cwd);
1414 let argv: Vec<String> = if wrap.is_empty() {
1419 stage_argv
1420 } else {
1421 wrap.iter().cloned().chain(stage_argv).collect()
1422 };
1423 let mut cmd = std::process::Command::new(&argv[0]);
1424 cmd.args(&argv[1..]);
1425 if let Some(dir) = cwd {
1426 cmd.current_dir(dir);
1427 }
1428 for (k, v) in env {
1437 cmd.env(k, v);
1438 }
1439
1440 if let Some(path) = stage.stdin_path() {
1442 let file = ok_or_kill(std::fs::File::open(path), &mut children)?;
1443 cmd.stdin(Stdio::from(file));
1444 prev_stdin = None;
1445 } else {
1446 cmd.stdin(match prev_stdin.take() {
1447 Some(reader) => Stdio::from(reader),
1448 None => Stdio::null(),
1449 });
1450 }
1451
1452 let dup_source: DupSource;
1456 if let Some((path, append)) = stage.stdout_redirect() {
1457 let file = ok_or_kill(open_for_write(path, append), &mut children)?;
1458 let clone = ok_or_kill(file.try_clone(), &mut children)?;
1459 cmd.stdout(Stdio::from(file));
1460 dup_source = DupSource::File(clone);
1461 } else {
1462 let (reader, writer) = ok_or_kill(std::io::pipe(), &mut children)?;
1463 let clone = ok_or_kill(writer.try_clone(), &mut children)?;
1464 cmd.stdout(Stdio::from(writer));
1465 if is_last {
1466 stdout_capture = Some(reader);
1467 } else {
1468 prev_stdin = Some(reader);
1469 }
1470 dup_source = DupSource::Pipe(clone);
1471 }
1472
1473 match stage.stderr_disposition() {
1475 StderrTo::Stdout => match dup_source {
1478 DupSource::File(f) => {
1479 cmd.stderr(Stdio::from(f));
1480 }
1481 DupSource::Pipe(w) => {
1482 cmd.stderr(Stdio::from(w));
1483 }
1484 },
1485 StderrTo::File { path, append } => {
1487 let file = ok_or_kill(open_for_write(&path, append), &mut children)?;
1488 cmd.stderr(Stdio::from(file));
1489 }
1491 StderrTo::Capture => {
1493 cmd.stderr(Stdio::piped());
1494 }
1495 }
1496
1497 let mut child = ok_or_kill(cmd.spawn(), &mut children)?;
1498
1499 if matches!(stage.stderr_disposition(), StderrTo::Capture) {
1500 let err = child.stderr.take().expect("stderr is piped");
1501 let output = output.clone();
1502 stderr_threads.push(std::thread::spawn(move || {
1503 read_capped_observed(err, max_output, &output, crate::ShellOutputStream::Stderr)
1504 }));
1505 }
1506 children.push(child);
1507 }
1508
1509 let stdout_thread = stdout_capture.map(|reader| {
1513 std::thread::spawn(move || {
1514 read_capped_observed(
1515 reader,
1516 max_output,
1517 &output,
1518 crate::ShellOutputStream::Stdout,
1519 )
1520 })
1521 });
1522
1523 let mut exit_code = -1;
1525 for (i, child) in children.iter_mut().enumerate() {
1526 let status = child.wait().map_err(ToolError::Exec)?;
1527 if i == last {
1528 exit_code = status.code().unwrap_or(-1);
1529 }
1530 }
1531
1532 let (stdout, stdout_truncated) =
1533 stdout_thread.map_or((Vec::new(), false), |h| h.join().unwrap_or_default());
1534 let mut stderr = Vec::new();
1535 let mut stderr_truncated = false;
1536 for h in stderr_threads {
1537 let (buf, trunc) = h.join().unwrap_or_default();
1538 stderr.extend(buf);
1539 stderr_truncated |= trunc;
1540 }
1541 let stderr_truncated = stderr_truncated || stderr.len() > max_output;
1544
1545 Ok(Captured {
1546 exit_code,
1547 stdout: capped_utf8(&stdout, max_output),
1548 stderr: capped_utf8(&stderr, max_output),
1549 stdout_truncated,
1550 stderr_truncated,
1551 net_denials: Vec::new(),
1554 })
1555}
1556
1557enum DupSource {
1559 File(std::fs::File),
1560 Pipe(PipeWriter),
1561}
1562
1563fn ok_or_kill<T>(result: std::io::Result<T>, children: &mut [Child]) -> ToolResult<T> {
1566 result.map_err(|e| {
1567 kill_all(children);
1568 ToolError::Exec(e)
1569 })
1570}
1571
1572fn read_capped_observed(
1583 mut reader: impl Read,
1584 max_output: usize,
1585 output: &OutputEmitter,
1586 stream: crate::ShellOutputStream,
1587) -> (Vec<u8>, bool) {
1588 let mut buf = Vec::with_capacity(max_output.min(8 * 1024));
1589 let mut chunk = [0u8; 8 * 1024];
1590 while buf.len() < max_output {
1591 let remaining = max_output - buf.len();
1592 let read_len = remaining.min(chunk.len());
1593 match reader.read(&mut chunk[..read_len]) {
1594 Ok(0) => return (buf, false),
1595 Ok(n) => {
1596 output.emit(stream, &chunk[..n]);
1597 buf.extend_from_slice(&chunk[..n]);
1598 }
1599 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1600 Err(_) => return (buf, false),
1601 }
1602 }
1603 let mut probe = [0u8; 1];
1604 let truncated = loop {
1605 match reader.read(&mut probe) {
1606 Ok(n) => break n > 0,
1607 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1608 Err(_) => break false,
1609 }
1610 };
1611 (buf, truncated)
1612}
1613
1614#[cfg(test)]
1615fn read_capped(reader: impl Read, max_output: usize) -> (Vec<u8>, bool) {
1616 read_capped_observed(
1617 reader,
1618 max_output,
1619 &OutputEmitter::default(),
1620 crate::ShellOutputStream::Stdout,
1621 )
1622}
1623
1624fn capped_utf8(bytes: &[u8], max_output: usize) -> String {
1629 let slice = &bytes[..bytes.len().min(max_output)];
1630 String::from_utf8_lossy(slice).into_owned()
1631}
1632
1633fn cap_string(mut s: String, max_output: usize) -> String {
1636 if s.len() > max_output {
1637 let mut end = max_output;
1638 while !s.is_char_boundary(end) {
1639 end -= 1;
1640 }
1641 s.truncate(end);
1642 }
1643 s
1644}
1645
1646#[cfg(test)]
1647mod tests {
1648 use super::*;
1649 use agent_bridle_core::{Caveats, Gate, Scope};
1650 use std::collections::HashMap;
1651 use std::sync::{mpsc, Mutex};
1652
1653 #[test]
1657 fn schema_loads_from_data_file_with_expected_shape() {
1658 let s = ShellTool::new().schema();
1659 assert_eq!(s["type"], "object");
1660 assert_eq!(s["additionalProperties"], false);
1661 for key in ["program", "args", "cmd", "cwd", "env", "timeout_secs"] {
1662 assert!(
1663 s["properties"].get(key).is_some(),
1664 "schema is missing the `{key}` property: {s}"
1665 );
1666 }
1667 }
1668
1669 #[test]
1673 fn schema_timeout_maximum_tracks_the_configured_limits() {
1674 let limits = agent_bridle_core::LimitsPolicy {
1675 max_timeout_secs: 7,
1676 ..agent_bridle_core::LimitsPolicy::default()
1677 };
1678 let s = ShellTool::with_config(limits).schema();
1679 assert_eq!(s["properties"]["timeout_secs"]["maximum"], 7);
1680 assert!(SHELL_SCHEMA["properties"]["timeout_secs"]
1682 .get("maximum")
1683 .is_none());
1684 }
1685
1686 #[derive(Default)]
1689 struct MockSpawner {
1690 calls: Mutex<Vec<Vec<Command>>>,
1691 envs: Mutex<Vec<BTreeMap<String, String>>>,
1694 exit_by_program: HashMap<String, i32>,
1695 block_ms: u64,
1696 net_denials: Vec<Denial>,
1700 }
1701
1702 impl MockSpawner {
1703 fn with_exit(program: &str, code: i32) -> Self {
1704 let mut m = Self::default();
1705 m.exit_by_program.insert(program.to_string(), code);
1706 m
1707 }
1708
1709 fn with_net_denials(denials: Vec<Denial>) -> Self {
1712 Self {
1713 net_denials: denials,
1714 ..Self::default()
1715 }
1716 }
1717 }
1718
1719 fn prog(stage: &Command) -> &str {
1722 match stage.argv.first() {
1723 Some(Arg::Lit(s) | Arg::Glob(s)) => s,
1724 Some(Arg::Var(_) | Arg::VarGlob(_)) | None => "",
1725 }
1726 }
1727
1728 impl Spawner for MockSpawner {
1729 fn run(
1730 &self,
1731 stages: &[Command],
1732 _cwd: Option<&str>,
1733 _caveats: &Caveats,
1734 env: &BTreeMap<String, String>,
1735 _cfg: &SpawnCfg,
1736 ) -> ToolResult<Captured> {
1737 self.calls.lock().unwrap().push(stages.to_vec());
1738 self.envs.lock().unwrap().push(env.clone());
1739 if self.block_ms > 0 {
1740 std::thread::sleep(Duration::from_millis(self.block_ms));
1741 }
1742 Ok(Captured {
1743 exit_code: self
1744 .exit_by_program
1745 .get(prog(&stages[0]))
1746 .copied()
1747 .unwrap_or(0),
1748 stdout: String::new(),
1749 stderr: String::new(),
1750 net_denials: self.net_denials.clone(),
1751 ..Default::default()
1752 })
1753 }
1754 }
1755
1756 struct CoordinatedSpawner {
1757 proceed: Mutex<mpsc::Receiver<()>>,
1758 finished: mpsc::Sender<()>,
1759 }
1760
1761 impl Spawner for CoordinatedSpawner {
1762 fn run(
1763 &self,
1764 _stages: &[Command],
1765 _cwd: Option<&str>,
1766 _caveats: &Caveats,
1767 _env: &BTreeMap<String, String>,
1768 cfg: &SpawnCfg,
1769 ) -> ToolResult<Captured> {
1770 cfg.output.emit(crate::ShellOutputStream::Stdout, b"first");
1771 self.proceed
1772 .lock()
1773 .expect("proceed lock")
1774 .recv()
1775 .expect("test releases spawner");
1776 cfg.output.emit(crate::ShellOutputStream::Stdout, b"second");
1777 self.finished.send(()).expect("test observes completion");
1778 Ok(Captured {
1779 exit_code: 0,
1780 stdout: "firstsecond".to_string(),
1781 ..Default::default()
1782 })
1783 }
1784 }
1785
1786 fn coordinated_spawner() -> (
1787 Arc<CoordinatedSpawner>,
1788 mpsc::Sender<()>,
1789 mpsc::Receiver<()>,
1790 ) {
1791 let (proceed_tx, proceed_rx) = mpsc::channel();
1792 let (finished_tx, finished_rx) = mpsc::channel();
1793 (
1794 Arc::new(CoordinatedSpawner {
1795 proceed: Mutex::new(proceed_rx),
1796 finished: finished_tx,
1797 }),
1798 proceed_tx,
1799 finished_rx,
1800 )
1801 }
1802
1803 struct BlockingObserver {
1804 entered: mpsc::Sender<()>,
1805 release: Mutex<mpsc::Receiver<()>>,
1806 finished: mpsc::Sender<()>,
1807 }
1808
1809 impl crate::ShellOutputObserver for BlockingObserver {
1810 fn on_output(
1811 &self,
1812 _invocation: crate::ShellInvocationId,
1813 _stream: crate::ShellOutputStream,
1814 _chunk: &[u8],
1815 ) {
1816 self.entered.send(()).expect("observer entered callback");
1817 self.release
1818 .lock()
1819 .expect("observer release lock")
1820 .recv()
1821 .expect("test releases blocked observer");
1822 }
1823
1824 fn on_finish(&self, _invocation: crate::ShellInvocationId) {
1825 self.finished.send(()).expect("record unexpected finish");
1826 }
1827 }
1828
1829 struct TemporalPipelineSpawner;
1830
1831 impl Spawner for TemporalPipelineSpawner {
1832 fn run(
1833 &self,
1834 stages: &[Command],
1835 _cwd: Option<&str>,
1836 _caveats: &Caveats,
1837 _env: &BTreeMap<String, String>,
1838 cfg: &SpawnCfg,
1839 ) -> ToolResult<Captured> {
1840 assert_eq!(stages.len(), 2, "the test request is one pipeline");
1841 cfg.output
1844 .emit(crate::ShellOutputStream::Stderr, b"second-stage");
1845 cfg.output
1846 .emit(crate::ShellOutputStream::Stderr, b"first-stage");
1847 Ok(Captured {
1848 exit_code: 0,
1849 stderr: "firs".to_string(),
1850 stderr_truncated: true,
1851 ..Default::default()
1852 })
1853 }
1854 }
1855
1856 #[derive(Debug, PartialEq, Eq)]
1857 enum PipelineObserverEvent {
1858 Output(crate::ShellInvocationId, crate::ShellOutputStream, Vec<u8>),
1859 Finish(crate::ShellInvocationId),
1860 }
1861
1862 struct PipelineObserver(mpsc::Sender<PipelineObserverEvent>);
1863
1864 impl crate::ShellOutputObserver for PipelineObserver {
1865 fn on_output(
1866 &self,
1867 invocation: crate::ShellInvocationId,
1868 stream: crate::ShellOutputStream,
1869 chunk: &[u8],
1870 ) {
1871 self.0
1872 .send(PipelineObserverEvent::Output(
1873 invocation,
1874 stream,
1875 chunk.to_vec(),
1876 ))
1877 .expect("record pipeline output");
1878 }
1879
1880 fn on_finish(&self, invocation: crate::ShellInvocationId) {
1881 self.0
1882 .send(PipelineObserverEvent::Finish(invocation))
1883 .expect("record pipeline finish");
1884 }
1885 }
1886
1887 #[tokio::test]
1888 async fn observer_receives_output_before_invoke_completes() {
1889 let (spawner, proceed, finished) = coordinated_spawner();
1890 let (seen_tx, seen_rx) = mpsc::channel();
1891 let seen_rx = Arc::new(Mutex::new(seen_rx));
1892 let observer = Arc::new(move |invocation, stream, chunk: &[u8]| {
1893 seen_tx
1894 .send((invocation, stream, chunk.to_vec()))
1895 .expect("test receives observer callback");
1896 });
1897 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
1898 let context = ctx(exec_only(&["anything"]));
1899
1900 let invoke = tokio::spawn(async move {
1901 tool.invoke(serde_json::json!({"program": "anything"}), &context)
1902 .await
1903 });
1904 let first_rx = Arc::clone(&seen_rx);
1905 let first = tokio::task::spawn_blocking(move || {
1906 first_rx
1907 .lock()
1908 .expect("observer receiver lock")
1909 .recv_timeout(Duration::from_secs(2))
1910 })
1911 .await
1912 .expect("receiver task")
1913 .expect("live callback before completion");
1914 let invocation = first.0;
1915 assert_eq!(
1916 first,
1917 (
1918 invocation,
1919 crate::ShellOutputStream::Stdout,
1920 b"first".to_vec()
1921 )
1922 );
1923 assert!(!invoke.is_finished(), "the tool must still be running");
1924
1925 proceed.send(()).expect("release spawner");
1926 finished
1927 .recv_timeout(Duration::from_secs(2))
1928 .expect("spawner completion");
1929 let out = invoke.await.expect("invoke task").expect("invoke result");
1930 assert_eq!(out["stdout"], "firstsecond");
1931 assert_eq!(
1932 seen_rx
1933 .lock()
1934 .expect("observer receiver lock")
1935 .recv_timeout(Duration::from_secs(2))
1936 .expect("second callback"),
1937 (
1938 invocation,
1939 crate::ShellOutputStream::Stdout,
1940 b"second".to_vec()
1941 )
1942 );
1943 }
1944
1945 #[tokio::test]
1946 async fn pipeline_stderr_live_cap_is_temporal_but_envelope_is_authoritative() {
1947 let (events_tx, events_rx) = mpsc::channel();
1948 let mut tool = ShellTool::with_spawner(Arc::new(TemporalPipelineSpawner));
1949 tool.limits.max_output_bytes = 4;
1950 let tool = tool.with_output_observer(Arc::new(PipelineObserver(events_tx)));
1951
1952 let out = tool
1953 .invoke(
1954 serde_json::json!({"cmd": "first | second"}),
1955 &ctx(exec_only(&["first", "second"])),
1956 )
1957 .await
1958 .expect("invoke pipeline");
1959
1960 assert_eq!(out["stderr"], "firs");
1961 assert_eq!(out["stderr_truncated"], true);
1962 let first = events_rx
1963 .recv_timeout(Duration::from_secs(2))
1964 .expect("live stderr event");
1965 let invocation = match first {
1966 PipelineObserverEvent::Output(id, crate::ShellOutputStream::Stderr, bytes) => {
1967 assert_eq!(bytes, b"seco", "the live cap follows enqueue order");
1968 id
1969 }
1970 other => panic!("unexpected first observer event: {other:?}"),
1971 };
1972 assert_eq!(
1973 events_rx
1974 .recv_timeout(Duration::from_secs(2))
1975 .expect("queue-drained finish"),
1976 PipelineObserverEvent::Finish(invocation)
1977 );
1978 assert!(
1979 events_rx.try_recv().is_err(),
1980 "the later stage-order bytes are outside the live cap"
1981 );
1982 }
1983
1984 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1985 async fn cancellation_does_not_wait_for_a_blocked_observer_or_deliver_late_output() {
1986 let (spawner, proceed, finished) = coordinated_spawner();
1987 let (seen_tx, seen_rx) = mpsc::channel();
1988 let (entered_tx, entered_rx) = mpsc::channel();
1989 let (release_observer_tx, release_observer_rx) = mpsc::channel();
1990 let release_observer_rx = Mutex::new(release_observer_rx);
1991 let observer = Arc::new(move |invocation, stream, chunk: &[u8]| {
1992 seen_tx
1993 .send((invocation, stream, chunk.to_vec()))
1994 .expect("observer receiver remains alive");
1995 entered_tx.send(()).expect("observer entered callback");
1996 release_observer_rx
1997 .lock()
1998 .expect("observer release lock")
1999 .recv()
2000 .expect("test releases blocked observer");
2001 });
2002 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
2003 let context = ctx(exec_only(&["anything"]));
2004
2005 let mut invoke = tokio::spawn(async move {
2006 tool.invoke(serde_json::json!({"program": "anything"}), &context)
2007 .await
2008 });
2009 entered_rx
2010 .recv_timeout(Duration::from_secs(2))
2011 .expect("observer is blocked in its first callback");
2012 let first = seen_rx
2013 .recv_timeout(Duration::from_secs(2))
2014 .expect("first callback");
2015 assert_eq!(first.1, crate::ShellOutputStream::Stdout);
2016 assert_eq!(first.2, b"first");
2017
2018 invoke.abort();
2019 let cancelled = tokio::time::timeout(Duration::from_millis(500), &mut invoke).await;
2020 proceed.send(()).expect("release detached worker");
2021 release_observer_tx
2022 .send(())
2023 .expect("release presentation callback");
2024 finished
2025 .recv_timeout(Duration::from_secs(2))
2026 .expect("detached worker attempted its late write");
2027 let cancelled = cancelled.expect("cancellation must not wait for observer code");
2028 assert!(
2029 cancelled.expect_err("invoke is cancelled").is_cancelled(),
2030 "the invocation future was cancelled"
2031 );
2032 assert!(
2033 seen_rx.recv_timeout(Duration::from_millis(50)).is_err(),
2034 "output emitted by the detached worker after cancellation is ignored"
2035 );
2036 }
2037
2038 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2039 async fn timeout_does_not_wait_for_a_blocked_observer_or_finish_the_session() {
2040 let (spawner, proceed, worker_finished) = coordinated_spawner();
2041 let (entered_tx, entered_rx) = mpsc::channel();
2042 let (release_tx, release_rx) = mpsc::channel();
2043 let (finish_tx, finish_rx) = mpsc::channel();
2044 let observer = Arc::new(BlockingObserver {
2045 entered: entered_tx,
2046 release: Mutex::new(release_rx),
2047 finished: finish_tx,
2048 });
2049 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
2050 let context = ctx(exec_only(&["anything"]));
2051
2052 let mut invoke = tokio::spawn(async move {
2053 tool.invoke(
2054 serde_json::json!({"program": "anything", "timeout_secs": 1}),
2055 &context,
2056 )
2057 .await
2058 });
2059 entered_rx
2060 .recv_timeout(Duration::from_secs(2))
2061 .expect("observer is blocked in its first callback");
2062
2063 let result = tokio::time::timeout(Duration::from_secs(2), &mut invoke).await;
2064 if result.is_err() {
2065 invoke.abort();
2066 }
2067 proceed.send(()).expect("release detached worker");
2068 release_tx.send(()).expect("release presentation callback");
2069 worker_finished
2070 .recv_timeout(Duration::from_secs(2))
2071 .expect("detached worker attempted its late write");
2072
2073 let output = result
2074 .expect("tool timeout must not wait for observer code")
2075 .expect("invoke task")
2076 .expect("timeout envelope");
2077 assert_eq!(output["timed_out"], true);
2078 assert!(
2079 finish_rx.recv_timeout(Duration::from_millis(50)).is_err(),
2080 "a timed-out observer session must not report ordinary completion"
2081 );
2082 }
2083
2084 fn ctx(granted: Caveats) -> ToolContext {
2085 Gate::new(0)
2086 .authorize(&ShellTool::new(), &granted)
2087 .expect("authorize")
2088 }
2089
2090 fn ctx_strong(granted: Caveats) -> ToolContext {
2093 Gate::new(0)
2094 .with_strength_floor(agent_bridle_core::AxisEnforcement::Kernel)
2095 .authorize(&ShellTool::new(), &granted)
2096 .expect("authorize")
2097 }
2098
2099 fn exec_only(names: &[&str]) -> Caveats {
2100 Caveats {
2101 exec: Scope::only(names.iter().map(|s| (*s).to_string())),
2102 ..Caveats::top()
2103 }
2104 }
2105
2106 fn calls(mock: &Arc<MockSpawner>) -> Vec<Vec<Command>> {
2107 mock.calls.lock().unwrap().clone()
2108 }
2109
2110 fn envs(mock: &Arc<MockSpawner>) -> Vec<BTreeMap<String, String>> {
2112 mock.envs.lock().unwrap().clone()
2113 }
2114
2115 #[tokio::test]
2129 async fn strong_principal_fails_closed_on_unenforceable_exec() {
2130 let granted = exec_only(&["echo"]);
2131 let exec_is_kernel_confined = enforcement_report(
2134 &granted,
2135 intended_sandbox_kind(&granted, &Arc::new(SandboxPolicy::default())),
2136 )
2137 .exec
2138 == Some(agent_bridle_core::AxisEnforcement::Kernel);
2139
2140 let mock = Arc::new(MockSpawner::default());
2141 let out = ShellTool::with_spawner(mock.clone())
2142 .invoke(
2143 serde_json::json!({"cmd": "echo hi"}),
2144 &ctx_strong(granted.clone()),
2145 )
2146 .await
2147 .expect("invoke");
2148 if exec_is_kernel_confined {
2149 assert_ne!(
2152 out["denied"],
2153 serde_json::json!(true),
2154 "kernel-confined exec must run for a strong principal: {out}"
2155 );
2156 assert_eq!(
2157 out["enforcement"]["exec"], "kernel",
2158 "exec is reported kernel-confined: {out}"
2159 );
2160 assert_eq!(ran_programs(&mock), ["echo"], "the program spawned: {out}");
2161 } else {
2162 assert_eq!(
2165 out["denied"], true,
2166 "strong principal must fail closed on unenforceable exec: {out}"
2167 );
2168 assert!(ran_programs(&mock).is_empty(), "nothing may spawn: {out}");
2169 }
2170
2171 let mock = Arc::new(MockSpawner::default());
2174 let out = ShellTool::with_spawner(mock.clone())
2175 .invoke(serde_json::json!({"cmd": "echo hi"}), &ctx(granted))
2176 .await
2177 .expect("invoke");
2178 assert_ne!(
2179 out["denied"],
2180 serde_json::json!(true),
2181 "default principal still runs: {out}"
2182 );
2183 }
2184
2185 #[tokio::test]
2192 async fn net_refusal_surfaces_as_a_net_denial_in_the_envelope() {
2193 let mock = Arc::new(MockSpawner::with_net_denials(vec![Denial {
2194 kind: DenialKind::Net,
2195 target: "github.com".to_string(),
2196 reason: "net does not permit 'github.com'".to_string(),
2197 }]));
2198 let out = ShellTool::with_spawner(mock)
2199 .invoke(
2200 serde_json::json!({ "cmd": "echo hi" }),
2201 &ctx(exec_only(&["echo"])),
2202 )
2203 .await
2204 .expect("invoke");
2205 assert_eq!(
2206 out["denied"],
2207 serde_json::json!(true),
2208 "a net denial sets denied: {out}"
2209 );
2210 assert_eq!(out["denials"][0]["kind"], "net");
2211 assert_eq!(out["denials"][0]["target"], "github.com");
2212 assert!(out.get("exit_code").is_some(), "command still ran: {out}");
2215 }
2216
2217 fn ran_programs(mock: &Arc<MockSpawner>) -> Vec<String> {
2218 calls(mock)
2219 .iter()
2220 .map(|pipeline| prog(&pipeline[0]).to_string())
2221 .collect()
2222 }
2223
2224 #[tokio::test]
2230 async fn env_map_is_passed_to_the_spawner() {
2231 let mock = Arc::new(MockSpawner::default());
2232 let out = ShellTool::with_spawner(mock.clone())
2233 .invoke(
2234 serde_json::json!({
2235 "program": "echo",
2236 "args": ["hi"],
2237 "env": { "FOO": "bar", "VIRTUAL_ENV": "/venv" },
2238 }),
2239 &ctx(exec_only(&["echo"])),
2240 )
2241 .await
2242 .expect("invoke");
2243 assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
2244 let envs = envs(&mock);
2245 assert_eq!(envs.len(), 1, "one pipeline ran");
2246 assert_eq!(envs[0].get("FOO").map(String::as_str), Some("bar"));
2247 assert_eq!(
2248 envs[0].get("VIRTUAL_ENV").map(String::as_str),
2249 Some("/venv"),
2250 "every env entry reaches the child: {:?}",
2251 envs[0]
2252 );
2253 }
2254
2255 #[tokio::test]
2262 async fn env_does_not_change_the_program_the_leash_checks() {
2263 let mock = Arc::new(MockSpawner::default());
2264 let out = ShellTool::with_spawner(mock.clone())
2267 .invoke(
2268 serde_json::json!({
2269 "cmd": "hostname; uname -s",
2270 "env": { "FOO": "bar" },
2271 }),
2272 &ctx(exec_only(&["hostname", "uname"])),
2273 )
2274 .await
2275 .expect("invoke");
2276 assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
2277 let programs = ran_programs(&mock);
2279 assert_eq!(
2280 programs,
2281 vec!["hostname".to_string(), "uname".to_string()],
2282 "the leash/spawner see the real programs, never `export`/env keys: {programs:?}"
2283 );
2284 for e in envs(&mock) {
2286 assert_eq!(e.get("FOO").map(String::as_str), Some("bar"));
2287 }
2288 }
2289
2290 #[test]
2293 fn parse_env_field_present_and_absent() {
2294 let parsed = ShellArgs::parse(
2296 &serde_json::json!({
2297 "program": "echo",
2298 "env": { "FOO": "bar", "BAZ": "qux" },
2299 }),
2300 &agent_bridle_core::LimitsPolicy::default(),
2301 )
2302 .expect("parse");
2303 assert_eq!(parsed.env.get("FOO").map(String::as_str), Some("bar"));
2304 assert_eq!(parsed.env.get("BAZ").map(String::as_str), Some("qux"));
2305 assert_eq!(parsed.env.len(), 2);
2306
2307 let parsed = ShellArgs::parse(
2309 &serde_json::json!({ "program": "echo" }),
2310 &agent_bridle_core::LimitsPolicy::default(),
2311 )
2312 .expect("parse");
2313 assert!(parsed.env.is_empty(), "absent env defaults to empty");
2314 }
2315
2316 #[test]
2320 fn parse_timeout_uses_configured_limits() {
2321 let limits = agent_bridle_core::LimitsPolicy {
2322 max_timeout_secs: 5,
2323 default_timeout_secs: 3,
2324 ..agent_bridle_core::LimitsPolicy::default()
2325 };
2326 let over = ShellArgs::parse(
2328 &serde_json::json!({ "program": "echo", "timeout_secs": 9999 }),
2329 &limits,
2330 )
2331 .expect("parse");
2332 assert_eq!(over.timeout, std::time::Duration::from_secs(5));
2333 let dflt =
2335 ShellArgs::parse(&serde_json::json!({ "program": "echo" }), &limits).expect("parse");
2336 assert_eq!(dflt.timeout, std::time::Duration::from_secs(3));
2337 }
2338
2339 struct FakeEnv(HashMap<String, String>);
2342 impl EnvProvider for FakeEnv {
2343 fn get(&self, name: &str) -> Option<String> {
2344 self.0.get(name).cloned()
2345 }
2346 }
2347 fn fake_env(pairs: &[(&str, &str)]) -> Arc<dyn EnvProvider> {
2348 Arc::new(FakeEnv(
2349 pairs
2350 .iter()
2351 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
2352 .collect(),
2353 ))
2354 }
2355
2356 struct MapLister(HashMap<String, Vec<GlobEntry>>);
2359 impl DirLister for MapLister {
2360 fn list(&self, dir: &Path) -> Vec<GlobEntry> {
2361 let key = dir.to_string_lossy().replace('\\', "/");
2364 self.0.get(&key).cloned().unwrap_or_default()
2365 }
2366 }
2367 fn ent(name: &str, is_dir: bool) -> GlobEntry {
2368 GlobEntry {
2369 name: name.to_string(),
2370 is_dir,
2371 }
2372 }
2373 fn map_lister(dirs: &[(&str, Vec<GlobEntry>)]) -> Arc<dyn DirLister> {
2374 Arc::new(MapLister(
2375 dirs.iter()
2376 .map(|(d, es)| ((*d).to_string(), es.clone()))
2377 .collect(),
2378 ))
2379 }
2380
2381 #[tokio::test]
2387 async fn redirect_var_is_expanded_and_reaches_spawner_resolved() {
2388 let tmp = std::env::temp_dir().to_string_lossy().into_owned();
2389 let mock = Arc::new(MockSpawner::default());
2390 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
2391 let out = tool
2393 .invoke(
2394 serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
2395 &ctx(exec_only(&["echo"])),
2396 )
2397 .await
2398 .expect("invoke");
2399 assert_ne!(
2400 out["denied"],
2401 serde_json::json!(true),
2402 "in-scope var: {out}"
2403 );
2404 let redir = &calls(&mock)[0][0].redirects[0];
2405 assert_eq!(
2406 *redir,
2407 Redirect::Stdout {
2408 path: vec![Seg::Lit(format!("{tmp}/out"))],
2409 append: false,
2410 }
2411 );
2412 }
2413
2414 #[tokio::test]
2416 async fn redirect_var_not_in_allowlist_is_denied() {
2417 let mock = Arc::new(MockSpawner::default());
2418 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/x")]));
2419 let out = tool
2420 .invoke(
2421 serde_json::json!({"cmd": "echo hi > $SECRET"}),
2422 &ctx(exec_only(&["echo"])),
2423 )
2424 .await
2425 .expect("invoke");
2426 assert_eq!(out["denied"], true, "non-allowlisted redirect var: {out}");
2427 assert!(
2428 ran_programs(&mock).is_empty(),
2429 "no spawn on a denied redirect"
2430 );
2431 assert!(out["denials"][0]["reason"]
2432 .as_str()
2433 .unwrap_or_default()
2434 .contains("SECRET"));
2435 }
2436
2437 #[test]
2443 fn expand_varglob_keeps_value_metachars_literal_and_refuses_basename_var() {
2444 let env = FakeEnv(HashMap::from([("TMPDIR".to_string(), "/a*b".to_string())]));
2446 let allow = agent_bridle_core::LimitsPolicy::default().var_allowlist;
2447 let pattern = expand_varglob(
2450 &[Seg::Var("TMPDIR".into()), Seg::Lit("/*.rs".into())],
2451 &env,
2452 &allow,
2453 )
2454 .unwrap();
2455 assert_eq!(pattern, "/a*b/*.rs");
2456 let err = expand_varglob(
2458 &[Seg::Var("TMPDIR".into()), Seg::Lit("*.rs".into())],
2459 &env,
2460 &allow,
2461 );
2462 assert!(err.is_err(), "var in glob basename must be refused");
2463 }
2464
2465 #[tokio::test]
2468 async fn glob_var_expands_to_resolved_matches_before_spawn() {
2469 let mock = Arc::new(MockSpawner::default());
2470 let lister = map_lister(&[
2471 (".", vec![ent("proj", true)]),
2472 ("./proj", vec![ent("a.rs", false), ent("b.rs", false)]),
2473 ]);
2474 let tool = ShellTool::with_seams(mock.clone(), fake_env(&[("TMPDIR", "proj")]), lister);
2475 let out = tool
2476 .invoke(
2477 serde_json::json!({"cmd": "ls $TMPDIR/*.rs"}), &ctx(exec_only(&["ls"])),
2479 )
2480 .await
2481 .expect("invoke");
2482 assert_ne!(
2483 out["denied"],
2484 serde_json::json!(true),
2485 "in-scope glob var: {out}"
2486 );
2487 assert_eq!(
2488 calls(&mock)[0][0].argv,
2489 vec![
2490 Arg::Lit("ls".into()),
2491 Arg::Lit("proj/a.rs".into()),
2492 Arg::Lit("proj/b.rs".into())
2493 ]
2494 );
2495 }
2496
2497 #[tokio::test]
2499 async fn glob_var_in_basename_is_denied() {
2500 let mock = Arc::new(MockSpawner::default());
2501 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("PREFIX", "foo")]));
2502 let out = tool
2503 .invoke(
2504 serde_json::json!({"cmd": "ls $PREFIX*.rs"}),
2505 &ctx(exec_only(&["ls"])),
2506 )
2507 .await
2508 .expect("invoke");
2509 assert_eq!(out["denied"], true, "var in glob basename refused: {out}");
2510 assert!(ran_programs(&mock).is_empty());
2511 }
2512
2513 #[tokio::test]
2515 async fn glob_var_not_in_allowlist_is_denied() {
2516 let mock = Arc::new(MockSpawner::default());
2517 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/s")]));
2518 let out = tool
2519 .invoke(
2520 serde_json::json!({"cmd": "ls $SECRET/*.rs"}),
2521 &ctx(exec_only(&["ls"])),
2522 )
2523 .await
2524 .expect("invoke");
2525 assert_eq!(out["denied"], true, "non-allowlisted glob var: {out}");
2526 assert!(ran_programs(&mock).is_empty());
2527 }
2528
2529 #[tokio::test]
2533 async fn redirect_var_resolved_path_out_of_fs_write_scope_denied() {
2534 let tmp = std::env::temp_dir().to_string_lossy().into_owned();
2535 let mock = Arc::new(MockSpawner::default());
2536 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
2537 let granted = Caveats {
2538 exec: Scope::only(["echo".to_string()]),
2539 fs_write: Scope::only(["/nonexistent-grant-root".to_string()]),
2540 ..Caveats::top()
2541 };
2542 let out = tool
2543 .invoke(
2544 serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
2545 &ctx(granted),
2546 )
2547 .await
2548 .expect("invoke");
2549 assert_eq!(out["denied"], true, "resolved path outside fs_write: {out}");
2550 assert!(ran_programs(&mock).is_empty());
2551 }
2552
2553 #[tokio::test]
2556 async fn and_short_circuits_on_failure() {
2557 let mock = Arc::new(MockSpawner::with_exit("false", 1));
2558 ShellTool::with_spawner(mock.clone())
2559 .invoke(
2560 serde_json::json!({"cmd": "false && echo hi"}),
2561 &ctx(exec_only(&["false", "echo"])),
2562 )
2563 .await
2564 .expect("invoke");
2565 assert_eq!(ran_programs(&mock), vec!["false"], "echo must be skipped");
2566 }
2567
2568 #[tokio::test]
2569 async fn out_of_scope_anywhere_denies_the_whole_script() {
2570 let mock = Arc::new(MockSpawner::default());
2571 let out = ShellTool::with_spawner(mock.clone())
2572 .invoke(
2573 serde_json::json!({"cmd": "echo ok ; rm -rf x"}),
2574 &ctx(exec_only(&["echo"])),
2575 )
2576 .await
2577 .expect("invoke");
2578 assert_eq!(out["denied"], true);
2579 assert!(ran_programs(&mock).is_empty());
2580 }
2581
2582 #[tokio::test]
2587 async fn glob_arg_expanded_to_matches_before_spawn() {
2588 let mock = Arc::new(MockSpawner::default());
2589 let lister = map_lister(&[(
2590 ".",
2591 vec![ent("a.rs", false), ent("b.rs", false), ent("c.txt", false)],
2592 )]);
2593 ShellTool::with_seams(mock.clone(), fake_env(&[]), lister)
2594 .invoke(
2595 serde_json::json!({"cmd": "ls *.rs"}), &ctx(exec_only(&["ls"])),
2597 )
2598 .await
2599 .expect("invoke");
2600 assert_eq!(
2601 calls(&mock)[0][0].argv,
2602 vec![
2603 Arg::Lit("ls".into()),
2604 Arg::Lit("a.rs".into()),
2605 Arg::Lit("b.rs".into())
2606 ]
2607 );
2608 }
2609
2610 #[tokio::test]
2612 async fn glob_as_program_name_denied() {
2613 let mock = Arc::new(MockSpawner::default());
2614 let out = ShellTool::with_spawner(mock.clone())
2615 .invoke(serde_json::json!({"cmd": "*.sh foo"}), &ctx(Caveats::top()))
2616 .await
2617 .expect("invoke");
2618 assert_eq!(out["denied"], true);
2619 assert!(ran_programs(&mock).is_empty());
2620 }
2621
2622 #[tokio::test]
2624 async fn glob_dir_out_of_fs_read_scope_denied() {
2625 let mock = Arc::new(MockSpawner::default());
2626 let granted = Caveats {
2627 exec: Scope::only(["echo".to_string()]),
2628 fs_read: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
2630 ..Caveats::top()
2631 };
2632 let out = ShellTool::with_spawner(mock.clone())
2633 .invoke(serde_json::json!({"cmd": "echo *"}), &ctx(granted))
2634 .await
2635 .expect("invoke");
2636 assert_eq!(out["denied"], true);
2637 assert_eq!(out["denials"][0]["kind"], "open");
2638 assert!(ran_programs(&mock).is_empty());
2639 }
2640
2641 #[tokio::test]
2645 async fn allowlisted_var_reaches_spawner() {
2646 let mock = Arc::new(MockSpawner::default());
2647 ShellTool::with_spawner(mock.clone())
2648 .invoke(
2649 serde_json::json!({"cmd": "echo $HOME"}),
2650 &ctx(exec_only(&["echo"])),
2651 )
2652 .await
2653 .expect("invoke");
2654 let c = calls(&mock);
2655 assert_eq!(
2656 c[0][0].argv,
2657 vec![
2658 Arg::Lit("echo".into()),
2659 Arg::Var(vec![Seg::Var("HOME".into())]),
2660 ]
2661 );
2662 }
2663
2664 #[tokio::test]
2667 async fn non_allowlisted_var_denied() {
2668 let mock = Arc::new(MockSpawner::default());
2669 let out = ShellTool::with_spawner(mock.clone())
2670 .invoke(
2671 serde_json::json!({"cmd": "echo $AWS_SECRET_KEY"}),
2672 &ctx(Caveats::top()),
2673 )
2674 .await
2675 .expect("invoke");
2676 assert_eq!(out["denied"], true);
2677 assert_eq!(out["denials"][0]["target"], "$AWS_SECRET_KEY");
2678 assert!(ran_programs(&mock).is_empty());
2679 }
2680
2681 #[tokio::test]
2683 async fn var_as_program_name_denied() {
2684 let mock = Arc::new(MockSpawner::default());
2685 let out = ShellTool::with_spawner(mock.clone())
2686 .invoke(
2687 serde_json::json!({"cmd": "$HOME foo"}),
2688 &ctx(Caveats::top()),
2689 )
2690 .await
2691 .expect("invoke");
2692 assert_eq!(out["denied"], true);
2693 assert!(ran_programs(&mock).is_empty());
2694 }
2695
2696 #[tokio::test]
2700 async fn stderr_to_file_out_of_scope_denied() {
2701 let mock = Arc::new(MockSpawner::default());
2702 let granted = Caveats {
2703 exec: Scope::only(["cmd".to_string()]),
2704 fs_write: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
2705 ..Caveats::top()
2706 };
2707 let out = ShellTool::with_spawner(mock.clone())
2708 .invoke(
2709 serde_json::json!({"cmd": "cmd 2> /etc/passwd"}),
2710 &ctx(granted),
2711 )
2712 .await
2713 .expect("invoke");
2714 assert_eq!(out["denied"], true);
2715 assert_eq!(out["denials"][0]["kind"], "open");
2716 assert_eq!(out["denials"][0]["target"], "/etc/passwd");
2717 assert!(ran_programs(&mock).is_empty());
2718 }
2719
2720 #[tokio::test]
2722 async fn stderr_merge_reaches_spawner() {
2723 let mock = Arc::new(MockSpawner::default());
2724 ShellTool::with_spawner(mock.clone())
2725 .invoke(
2726 serde_json::json!({"cmd": "cmd 2>&1"}),
2727 &ctx(exec_only(&["cmd"])),
2728 )
2729 .await
2730 .expect("invoke");
2731 let c = calls(&mock);
2732 assert_eq!(c[0][0].stderr_disposition(), StderrTo::Stdout);
2733 }
2734
2735 #[tokio::test]
2736 async fn both_program_and_cmd_is_a_hard_error() {
2737 let res = ShellTool::new()
2738 .invoke(
2739 serde_json::json!({"program": "echo", "cmd": "echo hi"}),
2740 &ctx(Caveats::top()),
2741 )
2742 .await;
2743 assert!(res.is_err());
2744 }
2745
2746 #[tokio::test]
2747 async fn timeout_is_reported() {
2748 let mock = Arc::new(MockSpawner {
2749 block_ms: 1500,
2750 ..Default::default()
2751 });
2752 let out = ShellTool::with_spawner(mock)
2753 .invoke(
2754 serde_json::json!({"program": "anything", "timeout_secs": 1}),
2755 &ctx(exec_only(&["anything"])),
2756 )
2757 .await
2758 .expect("invoke");
2759 assert_eq!(out["timed_out"], true);
2760 }
2761
2762 #[test]
2765 fn fnmatch_basics() {
2766 assert!(fnmatch("*.rs", "a.rs"));
2767 assert!(!fnmatch("*.rs", "a.txt"));
2768 assert!(fnmatch("a?c", "abc"));
2769 assert!(!fnmatch("a?c", "ac"));
2770 assert!(fnmatch("*", ""));
2771 assert!(fnmatch("a*", "a"));
2772 assert!(fnmatch("[abc]x", "bx"));
2773 assert!(!fnmatch("[abc]x", "dx"));
2774 assert!(fnmatch("[!abc]x", "dx"));
2775 assert!(fnmatch("[a-c]", "b"));
2776 assert!(!fnmatch("[a-c]", "d"));
2777 assert!(fnmatch("foo*bar", "fooXYbar"));
2778 }
2779
2780 #[test]
2785 fn read_capped_bounds_buffering_and_flags_truncation() {
2786 const CAP: usize = 1 << 20;
2788 struct Endless {
2791 served: usize,
2792 }
2793 impl Read for Endless {
2794 fn read(&mut self, b: &mut [u8]) -> std::io::Result<usize> {
2795 self.served = self.served.saturating_add(b.len());
2796 assert!(
2797 self.served <= CAP + 64 * 1024,
2798 "read_capped over-read {} bytes (cap {CAP})",
2799 self.served
2800 );
2801 b.fill(b'x');
2802 Ok(b.len())
2803 }
2804 }
2805 let (buf, truncated) = read_capped(Endless { served: 0 }, CAP);
2806 assert_eq!(buf.len(), CAP, "peak buffering bounded by the cap");
2807 assert!(
2808 truncated,
2809 "a source longer than the cap is flagged truncated"
2810 );
2811
2812 let (buf2, trunc2) = read_capped(&b"hello"[..], CAP);
2814 assert_eq!(buf2, b"hello");
2815 assert!(!trunc2, "a sub-cap source is not truncated");
2816 }
2817
2818 #[test]
2819 fn read_capped_retries_an_interrupted_read() {
2820 struct InterruptedOnce {
2821 interrupted: bool,
2822 inner: std::io::Cursor<Vec<u8>>,
2823 }
2824
2825 impl Read for InterruptedOnce {
2826 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2827 if !self.interrupted {
2828 self.interrupted = true;
2829 return Err(std::io::Error::from(std::io::ErrorKind::Interrupted));
2830 }
2831 self.inner.read(buf)
2832 }
2833 }
2834
2835 let reader = InterruptedOnce {
2836 interrupted: false,
2837 inner: std::io::Cursor::new(b"abcdef".to_vec()),
2838 };
2839 let (captured, truncated) = read_capped(reader, 4);
2840
2841 assert_eq!(captured, b"abcd");
2842 assert!(truncated);
2843 }
2844
2845 #[test]
2846 fn glob_walk_single_segment_and_subpath() {
2847 let lister = map_lister(&[
2848 (
2849 ".",
2850 vec![
2851 ent("a.rs", false),
2852 ent("b.rs", false),
2853 ent("c.txt", false),
2854 ent(".hidden.rs", false),
2855 ent("src", true),
2856 ],
2857 ),
2858 ("./src", vec![ent("a.rs", false), ent("b.rs", false)]),
2859 ]);
2860 let mut allow = |_d: &Path| Ok(());
2861 assert_eq!(
2863 expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2864 vec!["a.rs", "b.rs"]
2865 );
2866 assert_eq!(
2868 expand_glob_walk("zzz*", None, &*lister, &mut allow, 64, 4096).unwrap(),
2869 vec!["zzz*"]
2870 );
2871 assert_eq!(
2873 expand_glob_walk("src/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2874 vec!["src/a.rs", "src/b.rs"]
2875 );
2876 }
2877
2878 #[test]
2879 fn glob_walk_multi_segment_and_recursive() {
2880 let lister = map_lister(&[
2881 (
2882 ".",
2883 vec![ent("a", true), ent("b", true), ent("x.rs", false)],
2884 ),
2885 ("./a", vec![ent("foo.rs", false), ent("sub", true)]),
2886 ("./b", vec![ent("bar.rs", false)]),
2887 ("./a/sub", vec![ent("deep.rs", false)]),
2888 ]);
2889 let mut allow = |_d: &Path| Ok(());
2890 assert_eq!(
2892 expand_glob_walk("*/foo.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2893 vec!["a/foo.rs"]
2894 );
2895 assert_eq!(
2897 expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2898 vec!["a/foo.rs", "a/sub/deep.rs", "b/bar.rs", "x.rs"]
2899 );
2900 }
2901
2902 #[test]
2903 fn glob_walk_leashes_every_directory_and_denies_out_of_scope() {
2904 let lister = map_lister(&[
2905 (".", vec![ent("a", true), ent("x.rs", false)]),
2906 ("./a", vec![ent("secret.rs", false)]),
2907 ]);
2908 let mut deny_a = |d: &Path| {
2911 if d.to_string_lossy().contains("a") {
2912 Err(ToolError::denied("out of fs_read scope"))
2913 } else {
2914 Ok(())
2915 }
2916 };
2917 assert!(expand_glob_walk("**/*.rs", None, &*lister, &mut deny_a, 64, 4096).is_err());
2918 }
2919
2920 #[test]
2923 fn glob_walk_respects_configured_match_cap() {
2924 let lister = map_lister(&[(
2925 ".",
2926 vec![
2927 ent("a.rs", false),
2928 ent("b.rs", false),
2929 ent("c.rs", false),
2930 ent("d.rs", false),
2931 ],
2932 )]);
2933 let mut allow = |_d: &Path| Ok(());
2934 let got = expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 2).unwrap();
2935 assert_eq!(got.len(), 2, "match cap of 2 must bound the result set");
2936 }
2937
2938 #[test]
2941 fn glob_walk_respects_configured_depth_cap() {
2942 let lister = map_lister(&[
2943 (".", vec![ent("a", true), ent("x.rs", false)]),
2944 ("./a", vec![ent("foo.rs", false), ent("sub", true)]),
2945 ("./a/sub", vec![ent("deep.rs", false)]),
2946 ]);
2947 let mut allow = |_d: &Path| Ok(());
2948 let got = expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 1, 4096).unwrap();
2950 assert!(
2951 !got.iter().any(|m| m.contains("deep.rs")),
2952 "depth cap of 1 must not reach a/sub/deep.rs; got {got:?}"
2953 );
2954 }
2955
2956 #[test]
2960 fn var_allowlist_is_config_driven() {
2961 let allow_custom = vec!["MY_CUSTOM_VAR".to_string()];
2963 let env = FakeEnv(HashMap::from([(
2964 "MY_CUSTOM_VAR".to_string(),
2965 "/data".to_string(),
2966 )]));
2967 let out = expand_redirect_target(&[Seg::Var("MY_CUSTOM_VAR".into())], &env, &allow_custom)
2968 .unwrap();
2969 assert_eq!(out, "/data");
2970 assert!(!is_allowed_var("HOME", &["PWD".to_string()]));
2972 assert!(is_allowed_var("PWD", &["PWD".to_string()]));
2973 }
2974
2975 #[test]
2980 fn net_audit_sink_is_config_driven() {
2981 use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
2982 let ev = NetAuditEvent {
2983 ts_ms: 0,
2984 host: "example.test".to_string(),
2985 port: 443,
2986 kind: NetKind::Connect,
2987 decision: NetDecision::Allowed,
2988 bytes_up: 1,
2989 bytes_down: 2,
2990 dur_ms: 3,
2991 };
2992 net_audit_sink(None).record(&ev);
2994
2995 let path = std::env::temp_dir().join(format!("ab-audit-{}.jsonl", std::process::id()));
2997 let _ = std::fs::remove_file(&path);
2998 let sink = net_audit_sink(path.to_str());
2999 sink.record(&ev);
3000 drop(sink);
3001 let contents = std::fs::read_to_string(&path).expect("configured audit file written");
3002 assert!(
3003 contents.contains("example.test"),
3004 "the configured sink must write the event: {contents}"
3005 );
3006 let _ = std::fs::remove_file(&path);
3007 }
3008
3009 #[test]
3013 fn net_audit_sink_bad_path_degrades_to_null() {
3014 use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
3015 let ev = NetAuditEvent {
3016 ts_ms: 0,
3017 host: "example.test".to_string(),
3018 port: 443,
3019 kind: NetKind::Http,
3020 decision: NetDecision::Allowed,
3021 bytes_up: 1,
3022 bytes_down: 2,
3023 dur_ms: 3,
3024 };
3025 let bad = std::env::temp_dir()
3027 .join(format!("ab-nope-{}", std::process::id()))
3028 .join("does/not/exist/audit.jsonl");
3029 let sink = net_audit_sink(bad.to_str());
3030 sink.record(&ev); assert!(
3032 !bad.exists(),
3033 "a bad audit path must not create a file (degraded to null): {bad:?}"
3034 );
3035 }
3036}