1use std::collections::BTreeMap;
29use std::io::{PipeReader, PipeWriter, Read};
30#[cfg(windows)]
31use std::io::{Seek, SeekFrom};
32use std::path::{Path, PathBuf};
33use std::process::{Child, Stdio};
34use std::sync::{Arc, LazyLock};
35use std::time::Duration;
36
37use agent_bridle_core::{
38 best_available_sandbox, confinement_unenforceable, effective_sandbox_kind, enforcement_report,
39 human_gate, is_unbridled, Caveats, Denial, DenialKind, Disclosure, EnforcementReport,
40 LimitsPolicy, SandboxKind, SandboxPolicy, Tool, ToolContext, ToolEnvelope, ToolError,
41 ToolResult,
42};
43use async_trait::async_trait;
44
45use crate::net_proxy;
46use crate::output_observer::{output_session, OutputEmitter};
47use crate::parse::{
48 classify, seg_literal, Arg, Command, Redirect, Refusal, Script, ScriptItem, Seg, Sep, StderrTo,
49};
50
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub(crate) struct Captured {
60 pub exit_code: i32,
61 pub stdout: String,
62 pub stderr: String,
63 pub stdout_truncated: bool,
65 pub stderr_truncated: bool,
67 pub net_denials: Vec<Denial>,
73}
74
75pub(crate) struct SpawnCfg {
88 pub max_output: usize,
90 pub audit_sink: Option<String>,
92 pub sandbox: Arc<SandboxPolicy>,
94 pub unbridled: bool,
98 pub output: OutputEmitter,
100}
101
102pub(crate) trait Spawner: Send + Sync {
103 fn run(
112 &self,
113 stages: &[Command],
114 cwd: Option<&str>,
115 caveats: &Caveats,
116 env: &BTreeMap<String, String>,
117 cfg: &SpawnCfg,
118 ) -> ToolResult<Captured>;
119}
120
121struct OsSpawner;
124
125impl Spawner for OsSpawner {
126 fn run(
127 &self,
128 stages: &[Command],
129 cwd: Option<&str>,
130 caveats: &Caveats,
131 env: &BTreeMap<String, String>,
132 cfg: &SpawnCfg,
133 ) -> ToolResult<Captured> {
134 if cfg.unbridled {
138 return run_pipeline(stages, cwd, &[], env, cfg.max_output, cfg.output.clone());
139 }
140 if let Some((allow_hosts, fenced)) = egress_proxy_plan(caveats, &cfg.sandbox) {
145 return run_with_egress_proxy(stages, cwd, &fenced, env, allow_hosts, cfg);
146 }
147 if intended_sandbox_kind(caveats, &cfg.sandbox) == SandboxKind::None {
151 run_pipeline(stages, cwd, &[], env, cfg.max_output, cfg.output.clone())
152 } else {
153 run_confined(stages, cwd, caveats, env, cfg)
154 }
155 }
156}
157
158fn egress_proxy_plan(
165 caveats: &Caveats,
166 sandbox: &Arc<SandboxPolicy>,
167) -> Option<(Vec<String>, Caveats)> {
168 agent_bridle_core::egress_proxy_plan(caveats, sandbox)
169}
170
171fn run_with_egress_proxy(
180 stages: &[Command],
181 cwd: Option<&str>,
182 fenced: &Caveats,
183 env: &BTreeMap<String, String>,
184 allow_hosts: Vec<String>,
185 cfg: &SpawnCfg,
186) -> ToolResult<Captured> {
187 let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(fenced)?;
189 let proxy = net_proxy::start(
193 allow_hosts,
194 Arc::new(net_proxy::StdResolver),
195 net_audit_sink(cfg.audit_sink.as_deref()),
196 )
197 .map_err(ToolError::Exec)?;
198 let mut env = env.clone();
201 for (k, v) in proxy.proxy_env() {
202 env.insert(k, v);
203 }
204
205 let stages = stages.to_vec();
206 let cwd = cwd.map(str::to_string);
207 let fenced = fenced.clone();
208 let max_output = cfg.max_output;
209 let output = cfg.output.clone();
210 let sandbox = cfg.sandbox.clone();
211 let captured = std::thread::Builder::new()
212 .name("agent-bridle-confined".to_string())
213 .spawn(move || {
214 best_available_sandbox(&sandbox).apply(&fenced)?;
215 run_pipeline(&stages, cwd.as_deref(), &prefix, &env, max_output, output)
216 })
217 .map_err(ToolError::Exec)?
218 .join()
219 .map_err(|_| {
220 ToolError::Exec(std::io::Error::other("confined execution thread panicked"))
221 })?;
222 let refused = proxy.refused_hosts();
226 drop(proxy); let mut captured = captured?;
228 captured.net_denials = refused
229 .into_iter()
230 .map(|host| Denial {
231 kind: DenialKind::Net,
232 reason: format!("net does not permit '{host}'"),
233 target: host,
234 })
235 .collect();
236 Ok(captured)
237}
238
239fn net_audit_sink(configured: Option<&str>) -> Arc<dyn net_proxy::AuditSink> {
248 match configured {
249 Some(path) if !path.is_empty() => std::fs::OpenOptions::new()
250 .create(true)
251 .append(true)
252 .open(path)
253 .map(|f| Arc::new(net_proxy::JsonlSink::new(f)) as Arc<dyn net_proxy::AuditSink>)
254 .unwrap_or_else(|_| Arc::new(net_proxy::NullSink)),
255 _ => Arc::new(net_proxy::NullSink),
256 }
257}
258
259fn intended_sandbox_kind(caveats: &Caveats, sandbox: &Arc<SandboxPolicy>) -> SandboxKind {
265 effective_sandbox_kind(best_available_sandbox(sandbox).kind(), caveats)
266}
267
268fn run_confined(
279 stages: &[Command],
280 cwd: Option<&str>,
281 caveats: &Caveats,
282 env: &BTreeMap<String, String>,
283 cfg: &SpawnCfg,
284) -> ToolResult<Captured> {
285 let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(caveats)?;
287 let stages = stages.to_vec();
288 let cwd = cwd.map(str::to_string);
289 let caveats = caveats.clone();
290 let env = env.clone();
291 let max_output = cfg.max_output;
292 let output = cfg.output.clone();
293 let sandbox = cfg.sandbox.clone();
294 std::thread::Builder::new()
295 .name("agent-bridle-confined".to_string())
296 .spawn(move || {
297 best_available_sandbox(&sandbox).apply(&caveats)?;
298 run_pipeline(&stages, cwd.as_deref(), &prefix, &env, max_output, output)
299 })
300 .map_err(ToolError::Exec)?
301 .join()
302 .map_err(|_| ToolError::Exec(std::io::Error::other("confined execution thread panicked")))?
303}
304
305static SHELL_SCHEMA: LazyLock<serde_json::Value> = LazyLock::new(|| {
312 serde_json::from_str(include_str!("shell_tool.schema.json"))
313 .expect("embedded shell_tool.schema.json must be valid JSON")
314});
315
316#[derive(Clone)]
323pub struct ShellTool {
324 spawner: Arc<dyn Spawner>,
325 env: Arc<dyn EnvProvider>,
326 lister: Arc<dyn DirLister>,
327 limits: LimitsPolicy,
328 sandbox: Arc<SandboxPolicy>,
331 output_observer: Option<Arc<dyn crate::ShellOutputObserver>>,
332}
333
334impl std::fmt::Debug for ShellTool {
335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 f.write_str("ShellTool")
337 }
338}
339
340impl ShellTool {
341 #[must_use]
344 pub fn new() -> Self {
345 Self::with_config(LimitsPolicy::default())
346 }
347
348 #[must_use]
351 pub fn with_config(limits: LimitsPolicy) -> Self {
352 Self {
353 spawner: Arc::new(OsSpawner),
354 env: Arc::new(RealEnv),
355 lister: Arc::new(RealDirLister),
356 limits,
357 sandbox: Arc::new(SandboxPolicy::default()),
358 output_observer: None,
359 }
360 }
361
362 #[must_use]
369 pub fn with_output_observer(mut self, observer: Arc<dyn crate::ShellOutputObserver>) -> Self {
370 self.output_observer = Some(observer);
371 self
372 }
373
374 #[must_use]
377 pub fn with_sandbox_policy(mut self, sandbox: SandboxPolicy) -> Self {
378 self.sandbox = Arc::new(sandbox);
379 self
380 }
381
382 #[cfg(test)]
384 fn with_spawner(spawner: Arc<dyn Spawner>) -> Self {
385 Self {
386 spawner,
387 env: Arc::new(RealEnv),
388 lister: Arc::new(RealDirLister),
389 limits: LimitsPolicy::default(),
390 sandbox: Arc::new(SandboxPolicy::default()),
391 output_observer: None,
392 }
393 }
394
395 #[cfg(test)]
399 fn with_spawner_and_env(spawner: Arc<dyn Spawner>, env: Arc<dyn EnvProvider>) -> Self {
400 Self {
401 spawner,
402 env,
403 lister: Arc::new(RealDirLister),
404 limits: LimitsPolicy::default(),
405 sandbox: Arc::new(SandboxPolicy::default()),
406 output_observer: None,
407 }
408 }
409
410 #[cfg(test)]
414 fn with_seams(
415 spawner: Arc<dyn Spawner>,
416 env: Arc<dyn EnvProvider>,
417 lister: Arc<dyn DirLister>,
418 ) -> Self {
419 Self {
420 spawner,
421 env,
422 lister,
423 limits: LimitsPolicy::default(),
424 sandbox: Arc::new(SandboxPolicy::default()),
425 output_observer: None,
426 }
427 }
428}
429
430impl Default for ShellTool {
431 fn default() -> Self {
432 Self::new()
433 }
434}
435
436#[async_trait]
437impl Tool for ShellTool {
438 fn name(&self) -> &str {
439 "shell"
440 }
441
442 fn schema(&self) -> serde_json::Value {
443 let mut schema = SHELL_SCHEMA.clone();
449 schema["properties"]["timeout_secs"]["maximum"] =
450 serde_json::Value::from(self.limits.max_timeout_secs);
451 schema
452 }
453
454 async fn invoke(
455 &self,
456 args: serde_json::Value,
457 cx: &ToolContext,
458 ) -> ToolResult<serde_json::Value> {
459 let parsed = ShellArgs::parse(&args, &self.limits)?;
460 let unbridled = is_unbridled();
466 let sandbox_kind = if unbridled {
478 SandboxKind::None
479 } else {
480 match egress_proxy_plan(cx.caveats(), &self.sandbox) {
481 Some((_, fenced)) => intended_sandbox_kind(&fenced, &self.sandbox),
482 None => intended_sandbox_kind(cx.caveats(), &self.sandbox),
483 }
484 };
485 let enforcement = enforcement_report(cx.caveats(), sandbox_kind);
488
489 let mut script = match parsed.script() {
491 Ok(s) => s,
492 Err(refusal) => {
493 return Ok(refused_envelope(
494 sandbox_kind,
495 enforcement,
496 &refusal,
497 parsed.cmd.as_deref(),
498 ))
499 }
500 };
501
502 for item in &mut script {
508 for stage in &mut item.pipeline {
509 let mut new_argv: Vec<Arg> = Vec::with_capacity(stage.argv.len());
515 for (i, arg) in stage.argv.drain(..).enumerate() {
516 let pattern: Option<String> = if i == 0 {
517 None
518 } else {
519 match &arg {
520 Arg::Glob(p) => Some(p.clone()),
521 Arg::VarGlob(segs) => {
522 match expand_varglob(segs, &*self.env, &self.limits.var_allowlist) {
523 Ok(p) => Some(p),
524 Err((target, e)) => {
525 return Ok(deny(
526 sandbox_kind,
527 enforcement,
528 DenialKind::Exec,
529 &target,
530 &e,
531 ))
532 }
533 }
534 }
535 _ => None,
536 }
537 };
538 match pattern {
539 Some(p) => {
540 let mut leash = |dir: &Path| cx.check_path_read(dir);
541 match expand_glob_walk(
542 &p,
543 parsed.cwd.as_deref(),
544 &*self.lister,
545 &mut leash,
546 self.limits.max_glob_depth,
547 self.limits.max_glob_matches,
548 ) {
549 Ok(ms) => new_argv.extend(ms.into_iter().map(Arg::Lit)),
550 Err(e) => {
551 return Ok(deny(
552 sandbox_kind,
553 enforcement,
554 DenialKind::Open,
555 &p,
556 &e,
557 ))
558 }
559 }
560 }
561 None => new_argv.push(arg),
562 }
563 }
564 stage.argv = new_argv;
565 for redirect in &mut stage.redirects {
566 let segs = match redirect {
567 Redirect::Stdout { path, .. }
568 | Redirect::Stderr { path, .. }
569 | Redirect::Stdin { path } => path,
570 Redirect::StderrToStdout => continue,
571 };
572 match expand_redirect_target(segs, &*self.env, &self.limits.var_allowlist) {
573 Ok(resolved) => *segs = vec![Seg::Lit(resolved)],
574 Err((target, e)) => {
575 return Ok(deny(
576 sandbox_kind,
577 enforcement,
578 DenialKind::Open,
579 &target,
580 &e,
581 ))
582 }
583 }
584 }
585 }
586 }
587
588 for item in &script {
594 for stage in &item.pipeline {
595 match stage.argv.first() {
596 Some(Arg::Lit(program)) => {
597 if let Err(e) = cx.check_exec(program) {
598 return Ok(deny(
599 sandbox_kind,
600 enforcement,
601 DenialKind::Exec,
602 program,
603 &e,
604 ));
605 }
606 }
607 Some(Arg::Glob(pattern)) => {
608 return Ok(deny(
609 sandbox_kind,
610 enforcement,
611 DenialKind::Exec,
612 pattern,
613 &ToolError::denied("a glob pattern is not allowed as a program name"),
614 ));
615 }
616 Some(Arg::Var(_segs)) => {
617 return Ok(deny(
618 sandbox_kind,
619 enforcement,
620 DenialKind::Exec,
621 "$VAR",
622 &ToolError::denied("a variable is not allowed as a program name"),
623 ));
624 }
625 Some(Arg::VarGlob(_)) => {
628 return Ok(deny(
629 sandbox_kind,
630 enforcement,
631 DenialKind::Exec,
632 "$VAR/glob",
633 &ToolError::denied("a glob pattern is not allowed as a program name"),
634 ));
635 }
636 None => {} }
638 for arg in &stage.argv {
639 match arg {
640 Arg::Var(segs) => {
643 for seg in segs {
644 if let Seg::Var(name) = seg {
645 if !is_allowed_var(name, &self.limits.var_allowlist) {
646 return Ok(deny(
647 sandbox_kind,
648 enforcement,
649 DenialKind::Exec,
650 &format!("${name}"),
651 &ToolError::denied(format!(
652 "variable ${name} is not in the confined shell's allowlist"
653 )),
654 ));
655 }
656 }
657 }
658 }
659 Arg::Glob(_) => unreachable!("glob expanded at admission"),
662 Arg::VarGlob(_) => unreachable!("VarGlob expanded at admission"),
663 Arg::Lit(_) => {}
664 }
665 }
666 for redirect in &stage.redirects {
667 let (path, checked) = match redirect {
670 Redirect::Stdout { path, .. } | Redirect::Stderr { path, .. } => {
671 let p = seg_literal(path).expect("redirect target lowered");
672 (p, cx.check_path_write(Path::new(p)))
673 }
674 Redirect::Stdin { path } => {
675 let p = seg_literal(path).expect("redirect target lowered");
676 (p, cx.check_path_read(Path::new(p)))
677 }
678 Redirect::StderrToStdout => continue,
680 };
681 if let Err(e) = checked {
682 return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, path, &e));
683 }
684 }
685 }
686 }
687 if let Some(cwd) = &parsed.cwd {
689 if let Err(e) = cx.check_path_read(Path::new(cwd)) {
690 return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, cwd, &e));
691 }
692 }
693
694 if !unbridled && confinement_unenforceable(sandbox_kind, cx.caveats(), cx.strength_floor())
712 {
713 return Ok(deny(
714 sandbox_kind,
715 enforcement,
716 DenialKind::Exec,
717 "confinement",
718 &ToolError::denied(format!(
719 "a restricted filesystem/exec/net axis cannot be enforced on this host \
720 at the required strength floor ({:?}); refusing to run unconfined",
721 cx.strength_floor()
722 )),
723 ));
724 }
725
726 let spawner = Arc::clone(&self.spawner);
729 let cwd = parsed.cwd.clone();
730 let timeout = parsed.timeout;
731 let (output_guard, output) =
732 output_session(self.output_observer.clone(), self.limits.max_output_bytes);
733 let cfg = SpawnCfg {
734 max_output: self.limits.max_output_bytes,
735 audit_sink: self.limits.audit_sink.clone(),
736 sandbox: Arc::clone(&self.sandbox),
737 unbridled,
738 output,
739 };
740 let disclosure = Disclosure {
742 unbridled,
743 human_gate: human_gate(),
744 ..Disclosure::default()
745 };
746 let env = parsed.env;
749 let caveats = cx.caveats().clone();
750 let run = tokio::task::spawn_blocking(move || {
751 run_script(&*spawner, &script, cwd.as_deref(), &caveats, &env, &cfg)
752 });
753 match tokio::time::timeout(timeout, run).await {
754 Ok(joined) => {
755 let captured = joined
756 .map_err(|e| ToolError::Other(anyhow::anyhow!("shell task panicked: {e}")))??;
757 let envelope = ToolEnvelope::new(sandbox_kind)
761 .with_enforcement(enforcement)
762 .with_disclosure(disclosure)
763 .with_exit_code(captured.exit_code)
764 .with_truncation(captured.stdout_truncated, captured.stderr_truncated)
765 .with_stdout(captured.stdout)
766 .with_stderr(captured.stderr)
767 .with_denials(captured.net_denials)
768 .with_timed_out(false)
769 .into_json();
770 output_guard.finish();
771 Ok(envelope)
772 }
773 Err(_elapsed) => {
774 drop(output_guard);
777 Ok(ToolEnvelope::new(sandbox_kind)
778 .with_enforcement(enforcement)
779 .with_disclosure(disclosure)
780 .with_stderr(format!("command timed out after {}s", timeout.as_secs()))
781 .with_timed_out(true)
782 .into_json())
783 }
784 }
785 }
786}
787
788fn run_script(
792 spawner: &dyn Spawner,
793 script: &[ScriptItem],
794 cwd: Option<&str>,
795 caveats: &Caveats,
796 env: &BTreeMap<String, String>,
797 cfg: &SpawnCfg,
798) -> ToolResult<Captured> {
799 let mut stdout = String::new();
800 let mut stderr = String::new();
801 let mut status: i32 = 0;
802 let mut stdout_truncated = false;
803 let mut stderr_truncated = false;
804 let mut net_denials: Vec<Denial> = Vec::new();
806
807 for item in script {
808 let run_it = match item.sep {
809 Sep::Seq => true,
810 Sep::And => status == 0,
811 Sep::Or => status != 0,
812 };
813 if run_it {
814 let captured = spawner.run(&item.pipeline, cwd, caveats, env, cfg)?;
815 stdout.push_str(&captured.stdout);
816 stderr.push_str(&captured.stderr);
817 stdout_truncated |= captured.stdout_truncated;
818 stderr_truncated |= captured.stderr_truncated;
819 net_denials.extend(captured.net_denials);
820 status = captured.exit_code;
821 }
822 }
823
824 let stdout_truncated = stdout_truncated || stdout.len() > cfg.max_output;
826 let stderr_truncated = stderr_truncated || stderr.len() > cfg.max_output;
827
828 Ok(Captured {
829 exit_code: status,
830 stdout: cap_string(stdout, cfg.max_output),
831 stderr: cap_string(stderr, cfg.max_output),
832 net_denials,
833 stdout_truncated,
834 stderr_truncated,
835 })
836}
837
838fn deny(
840 sandbox_kind: SandboxKind,
841 enforcement: EnforcementReport,
842 kind: DenialKind,
843 target: &str,
844 err: &ToolError,
845) -> serde_json::Value {
846 ToolEnvelope::new(sandbox_kind)
847 .with_enforcement(enforcement)
848 .with_disclosure(unbridle_disclosure())
849 .with_denials(vec![Denial {
850 kind,
851 target: target.to_string(),
852 reason: err.to_string(),
853 }])
854 .into_json()
855}
856
857fn unbridle_disclosure() -> Disclosure {
861 Disclosure {
862 unbridled: is_unbridled(),
863 human_gate: human_gate(),
864 ..Disclosure::default()
865 }
866}
867
868fn refused_envelope(
870 sandbox_kind: SandboxKind,
871 enforcement: EnforcementReport,
872 refusal: &Refusal,
873 cmd: Option<&str>,
874) -> serde_json::Value {
875 let envelope = ToolEnvelope::new(sandbox_kind)
876 .with_enforcement(enforcement)
877 .with_disclosure(unbridle_disclosure())
878 .with_denials(vec![Denial {
879 kind: DenialKind::Exec,
880 target: refusal.construct(),
881 reason: refusal.to_string(),
882 }])
883 .into_json();
884
885 #[cfg(feature = "brush")]
892 {
893 let mut envelope = envelope;
894 if matches!(refusal, Refusal::Dynamic(_)) {
895 if let Some(cmd) = cmd {
896 if let Ok(inspection) = crate::inspect_shell(cmd) {
897 if let Ok(value) = serde_json::to_value(inspection) {
898 envelope["shell_inspection"] = value;
899 }
900 }
901 }
902 }
903 envelope
904 }
905 #[cfg(not(feature = "brush"))]
906 {
907 let _ = cmd;
908 envelope
909 }
910}
911
912struct ShellArgs {
914 program: Option<String>,
915 args: Vec<String>,
916 cmd: Option<String>,
917 cwd: Option<String>,
918 env: BTreeMap<String, String>,
922 timeout: Duration,
923}
924
925impl ShellArgs {
926 fn parse(v: &serde_json::Value, limits: &LimitsPolicy) -> ToolResult<Self> {
927 let obj = v
928 .as_object()
929 .ok_or_else(|| ToolError::denied("shell args must be a JSON object"))?;
930
931 let program = obj
932 .get("program")
933 .and_then(|x| x.as_str())
934 .map(String::from);
935 let cmd = obj.get("cmd").and_then(|x| x.as_str()).map(String::from);
936 let args = obj
937 .get("args")
938 .and_then(|x| x.as_array())
939 .map(|a| {
940 a.iter()
941 .filter_map(|x| x.as_str().map(String::from))
942 .collect::<Vec<_>>()
943 })
944 .unwrap_or_default();
945 let cwd = obj.get("cwd").and_then(|x| x.as_str()).map(String::from);
946 let env = obj
950 .get("env")
951 .and_then(|x| x.as_object())
952 .map(|m| {
953 m.iter()
954 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
955 .collect::<BTreeMap<String, String>>()
956 })
957 .unwrap_or_default();
958 let timeout_secs = obj
959 .get("timeout_secs")
960 .and_then(serde_json::Value::as_u64)
961 .unwrap_or(limits.default_timeout_secs)
962 .clamp(1, limits.max_timeout_secs);
963
964 match (&program, &cmd) {
965 (Some(_), Some(_)) => {
966 return Err(ToolError::denied(
967 "provide exactly one of `program` or `cmd`, not both",
968 ))
969 }
970 (None, None) => return Err(ToolError::denied("provide one of `program` or `cmd`")),
971 _ => {}
972 }
973 if program.is_none() && !args.is_empty() {
974 return Err(ToolError::denied(
975 "`args` may only be used together with `program`",
976 ));
977 }
978
979 Ok(Self {
980 program,
981 args,
982 cmd,
983 cwd,
984 env,
985 timeout: Duration::from_secs(timeout_secs),
986 })
987 }
988
989 fn script(&self) -> Result<Script, Refusal> {
993 if let Some(program) = &self.program {
994 let mut argv = Vec::with_capacity(1 + self.args.len());
995 argv.push(Arg::Lit(program.clone()));
996 argv.extend(self.args.iter().cloned().map(Arg::Lit));
997 Ok(vec![ScriptItem {
998 sep: Sep::Seq,
999 pipeline: vec![Command {
1000 argv,
1001 redirects: Vec::new(),
1002 }],
1003 }])
1004 } else {
1005 classify(self.cmd.as_deref().unwrap_or(""))
1006 }
1007 }
1008}
1009
1010fn is_allowed_var(name: &str, allowlist: &[String]) -> bool {
1019 allowlist.iter().any(|v| v == name)
1020}
1021
1022pub(crate) trait EnvProvider: Send + Sync {
1027 fn get(&self, name: &str) -> Option<String>;
1029}
1030
1031pub(crate) struct RealEnv;
1033impl EnvProvider for RealEnv {
1034 fn get(&self, name: &str) -> Option<String> {
1035 std::env::var(name).ok()
1036 }
1037}
1038
1039fn expand_redirect_target(
1044 segs: &[Seg],
1045 env: &dyn EnvProvider,
1046 allowlist: &[String],
1047) -> Result<String, (String, ToolError)> {
1048 let mut out = String::new();
1049 for seg in segs {
1050 match seg {
1051 Seg::Lit(s) => out.push_str(s),
1052 Seg::Var(name) => {
1053 if !is_allowed_var(name, allowlist) {
1054 return Err((
1055 format!("${name}"),
1056 ToolError::denied(format!(
1057 "variable ${name} is not in the confined shell's allowlist"
1058 )),
1059 ));
1060 }
1061 out.push_str(&env.get(name).unwrap_or_default());
1062 }
1063 }
1064 }
1065 Ok(out)
1066}
1067
1068fn expand_varglob(
1078 segs: &[Seg],
1079 env: &dyn EnvProvider,
1080 allowlist: &[String],
1081) -> Result<String, (String, ToolError)> {
1082 let mut out = String::new();
1083 let mut last_var_byte: Option<usize> = None; let mut last_slash_byte: Option<usize> = None; for seg in segs {
1086 match seg {
1087 Seg::Lit(s) => {
1088 for ch in s.chars() {
1089 if ch == '/' {
1090 last_slash_byte = Some(out.len());
1091 }
1092 out.push(ch);
1093 }
1094 }
1095 Seg::Var(name) => {
1096 if !is_allowed_var(name, allowlist) {
1097 return Err((
1098 format!("${name}"),
1099 ToolError::denied(format!(
1100 "variable ${name} is not in the confined shell's allowlist"
1101 )),
1102 ));
1103 }
1104 for ch in env.get(name).unwrap_or_default().chars() {
1105 if ch == '/' {
1106 last_slash_byte = Some(out.len());
1107 }
1108 last_var_byte = Some(out.len());
1109 out.push(ch);
1110 }
1111 }
1112 }
1113 }
1114 let basename_start = last_slash_byte.map_or(0, |i| i + 1);
1117 if last_var_byte.is_some_and(|v| v >= basename_start) {
1118 return Err((
1119 "$VAR".to_string(),
1120 ToolError::denied(
1121 "a variable in a glob's basename is not supported (re-injection guard); \
1122 put the variable in the directory prefix, e.g. $DIR/*.rs",
1123 ),
1124 ));
1125 }
1126 Ok(out)
1127}
1128
1129#[derive(Debug, Clone, PartialEq, Eq)]
1134pub(crate) struct GlobEntry {
1135 pub name: String,
1136 pub is_dir: bool,
1137}
1138
1139pub(crate) trait DirLister: Send + Sync {
1142 fn list(&self, dir: &Path) -> Vec<GlobEntry>;
1144}
1145
1146pub(crate) struct RealDirLister;
1148impl DirLister for RealDirLister {
1149 fn list(&self, dir: &Path) -> Vec<GlobEntry> {
1150 std::fs::read_dir(dir)
1151 .map(|rd| {
1152 rd.filter_map(|e| {
1153 let e = e.ok()?;
1154 let name = e.file_name().into_string().ok()?;
1155 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
1156 Some(GlobEntry { name, is_dir })
1157 })
1158 .collect()
1159 })
1160 .unwrap_or_default()
1161 }
1162}
1163
1164fn join_rel(rel: &str, name: &str) -> String {
1167 if rel.is_empty() {
1168 name.to_string()
1169 } else if rel == "/" {
1170 format!("/{name}")
1171 } else {
1172 format!("{rel}/{name}")
1173 }
1174}
1175
1176fn descend_all(
1180 real: &Path,
1181 rel: &str,
1182 list: &dyn DirLister,
1183 leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
1184 depth: usize,
1185 max_matches: usize,
1186 out: &mut Vec<(PathBuf, String)>,
1187) -> ToolResult<()> {
1188 if depth == 0 || out.len() >= max_matches {
1189 return Ok(());
1190 }
1191 leash(real)?;
1192 let mut entries = list.list(real);
1193 entries.sort_by(|a, b| a.name.cmp(&b.name));
1194 for e in entries {
1195 if e.is_dir && !e.name.starts_with('.') {
1196 let child_real = real.join(&e.name);
1197 let child_rel = join_rel(rel, &e.name);
1198 out.push((child_real.clone(), child_rel.clone()));
1199 if out.len() >= max_matches {
1200 break;
1201 }
1202 descend_all(
1203 &child_real,
1204 &child_rel,
1205 list,
1206 leash,
1207 depth - 1,
1208 max_matches,
1209 out,
1210 )?;
1211 }
1212 }
1213 Ok(())
1214}
1215
1216fn expand_glob_walk(
1224 pattern: &str,
1225 cwd: Option<&str>,
1226 list: &dyn DirLister,
1227 leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
1228 max_depth: usize,
1229 max_matches: usize,
1230) -> ToolResult<Vec<String>> {
1231 let absolute = pattern.starts_with('/');
1232 let segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
1233
1234 let base_real = if absolute {
1235 PathBuf::from("/")
1236 } else {
1237 cwd.map_or_else(|| PathBuf::from("."), PathBuf::from)
1238 };
1239 let base_rel = if absolute {
1240 "/".to_string()
1241 } else {
1242 String::new()
1243 };
1244 let mut frontier: Vec<(PathBuf, String)> = vec![(base_real, base_rel)];
1245
1246 for seg in &segments {
1247 let mut next: Vec<(PathBuf, String)> = Vec::new();
1248 if *seg == "**" {
1249 for (real, rel) in &frontier {
1250 next.push((real.clone(), rel.clone())); descend_all(real, rel, list, leash, max_depth, max_matches, &mut next)?;
1252 }
1253 } else {
1254 let seg_hidden = seg.starts_with('.');
1255 for (real, rel) in &frontier {
1256 leash(real)?;
1257 let mut entries = list.list(real);
1258 entries.sort_by(|a, b| a.name.cmp(&b.name));
1259 for e in entries {
1260 if (seg_hidden || !e.name.starts_with('.')) && fnmatch(seg, &e.name) {
1261 next.push((real.join(&e.name), join_rel(rel, &e.name)));
1262 if next.len() >= max_matches {
1263 break;
1264 }
1265 }
1266 }
1267 }
1268 }
1269 frontier = next;
1270 if frontier.is_empty() {
1271 break;
1272 }
1273 }
1274
1275 let mut matches: Vec<String> = frontier.into_iter().map(|(_, rel)| rel).collect();
1276 matches.retain(|m| !m.is_empty()); matches.sort();
1278 matches.dedup();
1279 if matches.is_empty() {
1280 Ok(vec![pattern.to_string()])
1281 } else {
1282 Ok(matches)
1283 }
1284}
1285
1286fn fnmatch(pattern: &str, name: &str) -> bool {
1289 let p: Vec<char> = pattern.chars().collect();
1290 let n: Vec<char> = name.chars().collect();
1291 fnmatch_inner(&p, &n)
1292}
1293
1294fn fnmatch_inner(p: &[char], n: &[char]) -> bool {
1295 match p.first() {
1296 None => n.is_empty(),
1297 Some('*') => fnmatch_inner(&p[1..], n) || (!n.is_empty() && fnmatch_inner(p, &n[1..])),
1298 Some('?') => !n.is_empty() && fnmatch_inner(&p[1..], &n[1..]),
1299 Some('[') => {
1300 if n.is_empty() {
1301 return false;
1302 }
1303 match match_class(&p[1..], n[0]) {
1304 Some((matched, rest)) => matched && fnmatch_inner(rest, &n[1..]),
1305 None => n[0] == '[' && fnmatch_inner(&p[1..], &n[1..]),
1307 }
1308 }
1309 Some(&c) => !n.is_empty() && c == n[0] && fnmatch_inner(&p[1..], &n[1..]),
1310 }
1311}
1312
1313fn match_class(p: &[char], c: char) -> Option<(bool, &[char])> {
1316 let mut i = 0;
1317 let negate = matches!(p.first(), Some('!' | '^'));
1318 if negate {
1319 i = 1;
1320 }
1321 let mut matched = false;
1322 let mut first = true;
1323 while i < p.len() {
1324 if p[i] == ']' && !first {
1325 return Some((matched ^ negate, &p[i + 1..]));
1326 }
1327 first = false;
1328 if i + 2 < p.len() && p[i + 1] == '-' && p[i + 2] != ']' {
1329 if c >= p[i] && c <= p[i + 2] {
1330 matched = true;
1331 }
1332 i += 3;
1333 } else {
1334 if c == p[i] {
1335 matched = true;
1336 }
1337 i += 1;
1338 }
1339 }
1340 None
1341}
1342
1343fn open_for_write(path: &str, append: bool) -> std::io::Result<std::fs::File> {
1347 #[cfg(windows)]
1348 if append {
1349 let mut file = std::fs::OpenOptions::new()
1350 .write(true)
1351 .create(true)
1352 .truncate(false)
1353 .open(path)?;
1354 file.seek(SeekFrom::End(0))?;
1355 return Ok(file);
1356 }
1357
1358 std::fs::OpenOptions::new()
1359 .write(true)
1360 .create(true)
1361 .truncate(!append)
1362 .append(append)
1363 .open(path)
1364}
1365
1366fn kill_all(children: &mut [Child]) {
1369 for child in children.iter_mut() {
1370 let _ = child.kill();
1371 let _ = child.wait();
1372 }
1373}
1374
1375fn expand_stage_argv(stage: &Command, _cwd: Option<&str>) -> Vec<String> {
1380 let mut argv = Vec::with_capacity(stage.argv.len());
1381 for arg in &stage.argv {
1382 match arg {
1383 Arg::Lit(s) => argv.push(s.clone()),
1384 Arg::Var(segs) => {
1388 let mut word = String::new();
1389 for seg in segs {
1390 match seg {
1391 Seg::Lit(s) => word.push_str(s),
1392 Seg::Var(name) => word.push_str(&std::env::var(name).unwrap_or_default()),
1393 }
1394 }
1395 argv.push(word);
1396 }
1397 Arg::Glob(_) => unreachable!("glob expanded at admission"),
1401 Arg::VarGlob(_) => unreachable!("VarGlob lowered/expanded at admission"),
1402 }
1403 }
1404 argv
1405}
1406
1407fn run_pipeline(
1419 stages: &[Command],
1420 cwd: Option<&str>,
1421 wrap: &[String],
1422 env: &BTreeMap<String, String>,
1423 max_output: usize,
1424 output: OutputEmitter,
1425) -> ToolResult<Captured> {
1426 debug_assert!(!stages.is_empty(), "the parser guarantees ≥1 stage");
1427 let n = stages.len();
1428 let last = n - 1;
1429
1430 let mut children: Vec<Child> = Vec::with_capacity(n);
1431 let mut prev_stdin: Option<PipeReader> = None;
1433 let mut stdout_capture: Option<PipeReader> = None;
1435 let mut stderr_threads: Vec<std::thread::JoinHandle<(Vec<u8>, bool)>> = Vec::new();
1438
1439 for (i, stage) in stages.iter().enumerate() {
1440 let is_last = i == last;
1441 let stage_argv = expand_stage_argv(stage, cwd);
1442 let argv: Vec<String> = if wrap.is_empty() {
1447 stage_argv
1448 } else {
1449 wrap.iter().cloned().chain(stage_argv).collect()
1450 };
1451 let mut cmd = std::process::Command::new(&argv[0]);
1452 cmd.args(&argv[1..]);
1453 if let Some(dir) = cwd {
1454 cmd.current_dir(dir);
1455 }
1456 for (k, v) in env {
1465 cmd.env(k, v);
1466 }
1467
1468 if let Some(path) = stage.stdin_path() {
1470 let file = ok_or_kill(std::fs::File::open(path), &mut children)?;
1471 cmd.stdin(Stdio::from(file));
1472 prev_stdin = None;
1473 } else {
1474 cmd.stdin(match prev_stdin.take() {
1475 Some(reader) => Stdio::from(reader),
1476 None => Stdio::null(),
1477 });
1478 }
1479
1480 let dup_source: DupSource;
1484 if let Some((path, append)) = stage.stdout_redirect() {
1485 let file = ok_or_kill(open_for_write(path, append), &mut children)?;
1486 let clone = ok_or_kill(file.try_clone(), &mut children)?;
1487 cmd.stdout(Stdio::from(file));
1488 dup_source = DupSource::File(clone);
1489 } else {
1490 let (reader, writer) = ok_or_kill(std::io::pipe(), &mut children)?;
1491 let clone = ok_or_kill(writer.try_clone(), &mut children)?;
1492 cmd.stdout(Stdio::from(writer));
1493 if is_last {
1494 stdout_capture = Some(reader);
1495 } else {
1496 prev_stdin = Some(reader);
1497 }
1498 dup_source = DupSource::Pipe(clone);
1499 }
1500
1501 match stage.stderr_disposition() {
1503 StderrTo::Stdout => match dup_source {
1506 DupSource::File(f) => {
1507 cmd.stderr(Stdio::from(f));
1508 }
1509 DupSource::Pipe(w) => {
1510 cmd.stderr(Stdio::from(w));
1511 }
1512 },
1513 StderrTo::File { path, append } => {
1515 let file = ok_or_kill(open_for_write(&path, append), &mut children)?;
1516 cmd.stderr(Stdio::from(file));
1517 }
1519 StderrTo::Capture => {
1521 cmd.stderr(Stdio::piped());
1522 }
1523 }
1524
1525 let mut child = ok_or_kill(cmd.spawn(), &mut children)?;
1526
1527 if matches!(stage.stderr_disposition(), StderrTo::Capture) {
1528 let err = child.stderr.take().expect("stderr is piped");
1529 let output = output.clone();
1530 stderr_threads.push(std::thread::spawn(move || {
1531 read_capped_observed(err, max_output, &output, crate::ShellOutputStream::Stderr)
1532 }));
1533 }
1534 children.push(child);
1535 }
1536
1537 let stdout_thread = stdout_capture.map(|reader| {
1541 std::thread::spawn(move || {
1542 read_capped_observed(
1543 reader,
1544 max_output,
1545 &output,
1546 crate::ShellOutputStream::Stdout,
1547 )
1548 })
1549 });
1550
1551 let mut exit_code = -1;
1553 for (i, child) in children.iter_mut().enumerate() {
1554 let status = child.wait().map_err(ToolError::Exec)?;
1555 if i == last {
1556 exit_code = status.code().unwrap_or(-1);
1557 }
1558 }
1559
1560 let (stdout, stdout_truncated) =
1561 stdout_thread.map_or((Vec::new(), false), |h| h.join().unwrap_or_default());
1562 let mut stderr = Vec::new();
1563 let mut stderr_truncated = false;
1564 for h in stderr_threads {
1565 let (buf, trunc) = h.join().unwrap_or_default();
1566 stderr.extend(buf);
1567 stderr_truncated |= trunc;
1568 }
1569 let stderr_truncated = stderr_truncated || stderr.len() > max_output;
1572
1573 Ok(Captured {
1574 exit_code,
1575 stdout: capped_utf8(&stdout, max_output),
1576 stderr: capped_utf8(&stderr, max_output),
1577 stdout_truncated,
1578 stderr_truncated,
1579 net_denials: Vec::new(),
1582 })
1583}
1584
1585enum DupSource {
1587 File(std::fs::File),
1588 Pipe(PipeWriter),
1589}
1590
1591fn ok_or_kill<T>(result: std::io::Result<T>, children: &mut [Child]) -> ToolResult<T> {
1594 result.map_err(|e| {
1595 kill_all(children);
1596 ToolError::Exec(e)
1597 })
1598}
1599
1600fn read_capped_observed(
1611 mut reader: impl Read,
1612 max_output: usize,
1613 output: &OutputEmitter,
1614 stream: crate::ShellOutputStream,
1615) -> (Vec<u8>, bool) {
1616 let mut buf = Vec::with_capacity(max_output.min(8 * 1024));
1617 let mut chunk = [0u8; 8 * 1024];
1618 while buf.len() < max_output {
1619 let remaining = max_output - buf.len();
1620 let read_len = remaining.min(chunk.len());
1621 match reader.read(&mut chunk[..read_len]) {
1622 Ok(0) => return (buf, false),
1623 Ok(n) => {
1624 output.emit(stream, &chunk[..n]);
1625 buf.extend_from_slice(&chunk[..n]);
1626 }
1627 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1628 Err(_) => return (buf, false),
1629 }
1630 }
1631 let mut probe = [0u8; 1];
1632 let truncated = loop {
1633 match reader.read(&mut probe) {
1634 Ok(n) => break n > 0,
1635 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1636 Err(_) => break false,
1637 }
1638 };
1639 (buf, truncated)
1640}
1641
1642#[cfg(test)]
1643fn read_capped(reader: impl Read, max_output: usize) -> (Vec<u8>, bool) {
1644 read_capped_observed(
1645 reader,
1646 max_output,
1647 &OutputEmitter::default(),
1648 crate::ShellOutputStream::Stdout,
1649 )
1650}
1651
1652fn capped_utf8(bytes: &[u8], max_output: usize) -> String {
1657 let slice = &bytes[..bytes.len().min(max_output)];
1658 String::from_utf8_lossy(slice).into_owned()
1659}
1660
1661fn cap_string(mut s: String, max_output: usize) -> String {
1664 if s.len() > max_output {
1665 let mut end = max_output;
1666 while !s.is_char_boundary(end) {
1667 end -= 1;
1668 }
1669 s.truncate(end);
1670 }
1671 s
1672}
1673
1674#[cfg(test)]
1675mod tests {
1676 use super::*;
1677 use agent_bridle_core::{Caveats, Gate, Scope};
1678 use std::collections::HashMap;
1679 use std::sync::{mpsc, Mutex};
1680
1681 #[test]
1685 fn schema_loads_from_data_file_with_expected_shape() {
1686 let s = ShellTool::new().schema();
1687 assert_eq!(s["type"], "object");
1688 assert_eq!(s["additionalProperties"], false);
1689 for key in ["program", "args", "cmd", "cwd", "env", "timeout_secs"] {
1690 assert!(
1691 s["properties"].get(key).is_some(),
1692 "schema is missing the `{key}` property: {s}"
1693 );
1694 }
1695 }
1696
1697 #[test]
1701 fn schema_timeout_maximum_tracks_the_configured_limits() {
1702 let limits = agent_bridle_core::LimitsPolicy {
1703 max_timeout_secs: 7,
1704 ..agent_bridle_core::LimitsPolicy::default()
1705 };
1706 let s = ShellTool::with_config(limits).schema();
1707 assert_eq!(s["properties"]["timeout_secs"]["maximum"], 7);
1708 assert!(SHELL_SCHEMA["properties"]["timeout_secs"]
1710 .get("maximum")
1711 .is_none());
1712 }
1713
1714 #[cfg(feature = "brush")]
1718 #[tokio::test]
1719 async fn dynamic_refusal_attaches_non_executing_shell_inspection() {
1720 let cmd = r#"ls -1 $(find . -name "*.rs" -type f -exec wc -l {} + 2>/dev/null | sort -nr | head -10)"#;
1721 let mock = Arc::new(MockSpawner::default());
1722 let out = ShellTool::with_spawner(mock.clone())
1723 .invoke(serde_json::json!({"cmd": cmd}), &ctx(Caveats::top()))
1724 .await
1725 .expect("structured refusal");
1726
1727 assert_eq!(out["denied"], true);
1728 assert_eq!(out["denials"][0]["target"], "command substitution `$(`");
1729 assert_eq!(out["shell_inspection"]["source"], cmd);
1730 assert_eq!(
1731 out["shell_inspection"]["constructs"][0]["kind"],
1732 "command_substitution"
1733 );
1734 assert_eq!(
1735 out["shell_inspection"]["constructs"][0]["inspection"]["commands"][0]
1736 ["descendant_execs"][0]["program"],
1737 "wc"
1738 );
1739 assert!(
1740 calls(&mock).is_empty(),
1741 "inspection must not execute any stage: {out}"
1742 );
1743
1744 let arithmetic_cmd = r#"echo "$((1 + 2))""#;
1745 let arithmetic = ShellTool::with_spawner(mock.clone())
1746 .invoke(
1747 serde_json::json!({"cmd": arithmetic_cmd}),
1748 &ctx(Caveats::top()),
1749 )
1750 .await
1751 .expect("structured arithmetic refusal");
1752
1753 assert_eq!(
1754 arithmetic["denials"][0]["target"],
1755 "arithmetic expansion `$((`"
1756 );
1757 assert_eq!(
1758 arithmetic["shell_inspection"]["constructs"][0]["kind"],
1759 "arithmetic_expansion"
1760 );
1761 assert!(
1762 calls(&mock).is_empty(),
1763 "arithmetic inspection must not execute any stage: {arithmetic}"
1764 );
1765
1766 let runtime_arithmetic = ShellTool::with_spawner(mock.clone())
1767 .invoke(
1768 serde_json::json!({"cmd": "echo $((runtime_value))"}),
1769 &ctx(Caveats::top()),
1770 )
1771 .await
1772 .expect("structured runtime arithmetic refusal");
1773 assert_eq!(
1774 runtime_arithmetic["denials"][0]["target"],
1775 "arithmetic expansion `$((`"
1776 );
1777 assert!(
1778 runtime_arithmetic.get("shell_inspection").is_none(),
1779 "an incomplete runtime-state projection must not be attached: {runtime_arithmetic}"
1780 );
1781 assert!(
1782 calls(&mock).is_empty(),
1783 "runtime arithmetic inspection must not execute any stage: {runtime_arithmetic}"
1784 );
1785 }
1786
1787 #[derive(Default)]
1790 struct MockSpawner {
1791 calls: Mutex<Vec<Vec<Command>>>,
1792 envs: Mutex<Vec<BTreeMap<String, String>>>,
1795 exit_by_program: HashMap<String, i32>,
1796 block_ms: u64,
1797 net_denials: Vec<Denial>,
1801 }
1802
1803 impl MockSpawner {
1804 fn with_exit(program: &str, code: i32) -> Self {
1805 let mut m = Self::default();
1806 m.exit_by_program.insert(program.to_string(), code);
1807 m
1808 }
1809
1810 fn with_net_denials(denials: Vec<Denial>) -> Self {
1813 Self {
1814 net_denials: denials,
1815 ..Self::default()
1816 }
1817 }
1818 }
1819
1820 fn prog(stage: &Command) -> &str {
1823 match stage.argv.first() {
1824 Some(Arg::Lit(s) | Arg::Glob(s)) => s,
1825 Some(Arg::Var(_) | Arg::VarGlob(_)) | None => "",
1826 }
1827 }
1828
1829 impl Spawner for MockSpawner {
1830 fn run(
1831 &self,
1832 stages: &[Command],
1833 _cwd: Option<&str>,
1834 _caveats: &Caveats,
1835 env: &BTreeMap<String, String>,
1836 _cfg: &SpawnCfg,
1837 ) -> ToolResult<Captured> {
1838 self.calls.lock().unwrap().push(stages.to_vec());
1839 self.envs.lock().unwrap().push(env.clone());
1840 if self.block_ms > 0 {
1841 std::thread::sleep(Duration::from_millis(self.block_ms));
1842 }
1843 Ok(Captured {
1844 exit_code: self
1845 .exit_by_program
1846 .get(prog(&stages[0]))
1847 .copied()
1848 .unwrap_or(0),
1849 stdout: String::new(),
1850 stderr: String::new(),
1851 net_denials: self.net_denials.clone(),
1852 ..Default::default()
1853 })
1854 }
1855 }
1856
1857 struct CoordinatedSpawner {
1858 proceed: Mutex<mpsc::Receiver<()>>,
1859 finished: mpsc::Sender<()>,
1860 }
1861
1862 impl Spawner for CoordinatedSpawner {
1863 fn run(
1864 &self,
1865 _stages: &[Command],
1866 _cwd: Option<&str>,
1867 _caveats: &Caveats,
1868 _env: &BTreeMap<String, String>,
1869 cfg: &SpawnCfg,
1870 ) -> ToolResult<Captured> {
1871 cfg.output.emit(crate::ShellOutputStream::Stdout, b"first");
1872 self.proceed
1873 .lock()
1874 .expect("proceed lock")
1875 .recv()
1876 .expect("test releases spawner");
1877 cfg.output.emit(crate::ShellOutputStream::Stdout, b"second");
1878 self.finished.send(()).expect("test observes completion");
1879 Ok(Captured {
1880 exit_code: 0,
1881 stdout: "firstsecond".to_string(),
1882 ..Default::default()
1883 })
1884 }
1885 }
1886
1887 fn coordinated_spawner() -> (
1888 Arc<CoordinatedSpawner>,
1889 mpsc::Sender<()>,
1890 mpsc::Receiver<()>,
1891 ) {
1892 let (proceed_tx, proceed_rx) = mpsc::channel();
1893 let (finished_tx, finished_rx) = mpsc::channel();
1894 (
1895 Arc::new(CoordinatedSpawner {
1896 proceed: Mutex::new(proceed_rx),
1897 finished: finished_tx,
1898 }),
1899 proceed_tx,
1900 finished_rx,
1901 )
1902 }
1903
1904 struct BlockingObserver {
1905 entered: mpsc::Sender<()>,
1906 release: Mutex<mpsc::Receiver<()>>,
1907 finished: mpsc::Sender<()>,
1908 }
1909
1910 impl crate::ShellOutputObserver for BlockingObserver {
1911 fn on_output(
1912 &self,
1913 _invocation: crate::ShellInvocationId,
1914 _stream: crate::ShellOutputStream,
1915 _chunk: &[u8],
1916 ) {
1917 self.entered.send(()).expect("observer entered callback");
1918 self.release
1919 .lock()
1920 .expect("observer release lock")
1921 .recv()
1922 .expect("test releases blocked observer");
1923 }
1924
1925 fn on_finish(&self, _invocation: crate::ShellInvocationId) {
1926 self.finished.send(()).expect("record unexpected finish");
1927 }
1928 }
1929
1930 struct TemporalPipelineSpawner;
1931
1932 impl Spawner for TemporalPipelineSpawner {
1933 fn run(
1934 &self,
1935 stages: &[Command],
1936 _cwd: Option<&str>,
1937 _caveats: &Caveats,
1938 _env: &BTreeMap<String, String>,
1939 cfg: &SpawnCfg,
1940 ) -> ToolResult<Captured> {
1941 assert_eq!(stages.len(), 2, "the test request is one pipeline");
1942 cfg.output
1945 .emit(crate::ShellOutputStream::Stderr, b"second-stage");
1946 cfg.output
1947 .emit(crate::ShellOutputStream::Stderr, b"first-stage");
1948 Ok(Captured {
1949 exit_code: 0,
1950 stderr: "firs".to_string(),
1951 stderr_truncated: true,
1952 ..Default::default()
1953 })
1954 }
1955 }
1956
1957 #[derive(Debug, PartialEq, Eq)]
1958 enum PipelineObserverEvent {
1959 Output(crate::ShellInvocationId, crate::ShellOutputStream, Vec<u8>),
1960 Finish(crate::ShellInvocationId),
1961 }
1962
1963 struct PipelineObserver(mpsc::Sender<PipelineObserverEvent>);
1964
1965 impl crate::ShellOutputObserver for PipelineObserver {
1966 fn on_output(
1967 &self,
1968 invocation: crate::ShellInvocationId,
1969 stream: crate::ShellOutputStream,
1970 chunk: &[u8],
1971 ) {
1972 self.0
1973 .send(PipelineObserverEvent::Output(
1974 invocation,
1975 stream,
1976 chunk.to_vec(),
1977 ))
1978 .expect("record pipeline output");
1979 }
1980
1981 fn on_finish(&self, invocation: crate::ShellInvocationId) {
1982 self.0
1983 .send(PipelineObserverEvent::Finish(invocation))
1984 .expect("record pipeline finish");
1985 }
1986 }
1987
1988 #[tokio::test]
1989 async fn observer_receives_output_before_invoke_completes() {
1990 let (spawner, proceed, finished) = coordinated_spawner();
1991 let (seen_tx, seen_rx) = mpsc::channel();
1992 let seen_rx = Arc::new(Mutex::new(seen_rx));
1993 let observer = Arc::new(move |invocation, stream, chunk: &[u8]| {
1994 seen_tx
1995 .send((invocation, stream, chunk.to_vec()))
1996 .expect("test receives observer callback");
1997 });
1998 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
1999 let context = ctx(exec_only(&["anything"]));
2000
2001 let invoke = tokio::spawn(async move {
2002 tool.invoke(serde_json::json!({"program": "anything"}), &context)
2003 .await
2004 });
2005 let first_rx = Arc::clone(&seen_rx);
2006 let first = tokio::task::spawn_blocking(move || {
2007 first_rx
2008 .lock()
2009 .expect("observer receiver lock")
2010 .recv_timeout(Duration::from_secs(2))
2011 })
2012 .await
2013 .expect("receiver task")
2014 .expect("live callback before completion");
2015 let invocation = first.0;
2016 assert_eq!(
2017 first,
2018 (
2019 invocation,
2020 crate::ShellOutputStream::Stdout,
2021 b"first".to_vec()
2022 )
2023 );
2024 assert!(!invoke.is_finished(), "the tool must still be running");
2025
2026 proceed.send(()).expect("release spawner");
2027 finished
2028 .recv_timeout(Duration::from_secs(2))
2029 .expect("spawner completion");
2030 let out = invoke.await.expect("invoke task").expect("invoke result");
2031 assert_eq!(out["stdout"], "firstsecond");
2032 assert_eq!(
2033 seen_rx
2034 .lock()
2035 .expect("observer receiver lock")
2036 .recv_timeout(Duration::from_secs(2))
2037 .expect("second callback"),
2038 (
2039 invocation,
2040 crate::ShellOutputStream::Stdout,
2041 b"second".to_vec()
2042 )
2043 );
2044 }
2045
2046 #[tokio::test]
2047 async fn pipeline_stderr_live_cap_is_temporal_but_envelope_is_authoritative() {
2048 let (events_tx, events_rx) = mpsc::channel();
2049 let mut tool = ShellTool::with_spawner(Arc::new(TemporalPipelineSpawner));
2050 tool.limits.max_output_bytes = 4;
2051 let tool = tool.with_output_observer(Arc::new(PipelineObserver(events_tx)));
2052
2053 let out = tool
2054 .invoke(
2055 serde_json::json!({"cmd": "first | second"}),
2056 &ctx(exec_only(&["first", "second"])),
2057 )
2058 .await
2059 .expect("invoke pipeline");
2060
2061 assert_eq!(out["stderr"], "firs");
2062 assert_eq!(out["stderr_truncated"], true);
2063 let first = events_rx
2064 .recv_timeout(Duration::from_secs(2))
2065 .expect("live stderr event");
2066 let invocation = match first {
2067 PipelineObserverEvent::Output(id, crate::ShellOutputStream::Stderr, bytes) => {
2068 assert_eq!(bytes, b"seco", "the live cap follows enqueue order");
2069 id
2070 }
2071 other => panic!("unexpected first observer event: {other:?}"),
2072 };
2073 assert_eq!(
2074 events_rx
2075 .recv_timeout(Duration::from_secs(2))
2076 .expect("queue-drained finish"),
2077 PipelineObserverEvent::Finish(invocation)
2078 );
2079 assert!(
2080 events_rx.try_recv().is_err(),
2081 "the later stage-order bytes are outside the live cap"
2082 );
2083 }
2084
2085 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2086 async fn cancellation_does_not_wait_for_a_blocked_observer_or_deliver_late_output() {
2087 let (spawner, proceed, finished) = coordinated_spawner();
2088 let (seen_tx, seen_rx) = mpsc::channel();
2089 let (entered_tx, entered_rx) = mpsc::channel();
2090 let (release_observer_tx, release_observer_rx) = mpsc::channel();
2091 let release_observer_rx = Mutex::new(release_observer_rx);
2092 let observer = Arc::new(move |invocation, stream, chunk: &[u8]| {
2093 seen_tx
2094 .send((invocation, stream, chunk.to_vec()))
2095 .expect("observer receiver remains alive");
2096 entered_tx.send(()).expect("observer entered callback");
2097 release_observer_rx
2098 .lock()
2099 .expect("observer release lock")
2100 .recv()
2101 .expect("test releases blocked observer");
2102 });
2103 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
2104 let context = ctx(exec_only(&["anything"]));
2105
2106 let mut invoke = tokio::spawn(async move {
2107 tool.invoke(serde_json::json!({"program": "anything"}), &context)
2108 .await
2109 });
2110 entered_rx
2111 .recv_timeout(Duration::from_secs(2))
2112 .expect("observer is blocked in its first callback");
2113 let first = seen_rx
2114 .recv_timeout(Duration::from_secs(2))
2115 .expect("first callback");
2116 assert_eq!(first.1, crate::ShellOutputStream::Stdout);
2117 assert_eq!(first.2, b"first");
2118
2119 invoke.abort();
2120 let cancelled = tokio::time::timeout(Duration::from_millis(500), &mut invoke).await;
2121 proceed.send(()).expect("release detached worker");
2122 release_observer_tx
2123 .send(())
2124 .expect("release presentation callback");
2125 finished
2126 .recv_timeout(Duration::from_secs(2))
2127 .expect("detached worker attempted its late write");
2128 let cancelled = cancelled.expect("cancellation must not wait for observer code");
2129 assert!(
2130 cancelled.expect_err("invoke is cancelled").is_cancelled(),
2131 "the invocation future was cancelled"
2132 );
2133 assert!(
2134 seen_rx.recv_timeout(Duration::from_millis(50)).is_err(),
2135 "output emitted by the detached worker after cancellation is ignored"
2136 );
2137 }
2138
2139 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2140 async fn timeout_does_not_wait_for_a_blocked_observer_or_finish_the_session() {
2141 let (spawner, proceed, worker_finished) = coordinated_spawner();
2142 let (entered_tx, entered_rx) = mpsc::channel();
2143 let (release_tx, release_rx) = mpsc::channel();
2144 let (finish_tx, finish_rx) = mpsc::channel();
2145 let observer = Arc::new(BlockingObserver {
2146 entered: entered_tx,
2147 release: Mutex::new(release_rx),
2148 finished: finish_tx,
2149 });
2150 let tool = ShellTool::with_spawner(spawner).with_output_observer(observer);
2151 let context = ctx(exec_only(&["anything"]));
2152
2153 let mut invoke = tokio::spawn(async move {
2154 tool.invoke(
2155 serde_json::json!({"program": "anything", "timeout_secs": 1}),
2156 &context,
2157 )
2158 .await
2159 });
2160 entered_rx
2161 .recv_timeout(Duration::from_secs(2))
2162 .expect("observer is blocked in its first callback");
2163
2164 let result = tokio::time::timeout(Duration::from_secs(2), &mut invoke).await;
2165 if result.is_err() {
2166 invoke.abort();
2167 }
2168 proceed.send(()).expect("release detached worker");
2169 release_tx.send(()).expect("release presentation callback");
2170 worker_finished
2171 .recv_timeout(Duration::from_secs(2))
2172 .expect("detached worker attempted its late write");
2173
2174 let output = result
2175 .expect("tool timeout must not wait for observer code")
2176 .expect("invoke task")
2177 .expect("timeout envelope");
2178 assert_eq!(output["timed_out"], true);
2179 assert!(
2180 finish_rx.recv_timeout(Duration::from_millis(50)).is_err(),
2181 "a timed-out observer session must not report ordinary completion"
2182 );
2183 }
2184
2185 fn ctx(granted: Caveats) -> ToolContext {
2186 Gate::new(0)
2187 .authorize(&ShellTool::new(), &granted)
2188 .expect("authorize")
2189 }
2190
2191 fn ctx_strong(granted: Caveats) -> ToolContext {
2194 Gate::new(0)
2195 .with_strength_floor(agent_bridle_core::AxisEnforcement::Kernel)
2196 .authorize(&ShellTool::new(), &granted)
2197 .expect("authorize")
2198 }
2199
2200 fn exec_only(names: &[&str]) -> Caveats {
2201 Caveats {
2202 exec: Scope::only(names.iter().map(|s| (*s).to_string())),
2203 ..Caveats::top()
2204 }
2205 }
2206
2207 fn calls(mock: &Arc<MockSpawner>) -> Vec<Vec<Command>> {
2208 mock.calls.lock().unwrap().clone()
2209 }
2210
2211 fn envs(mock: &Arc<MockSpawner>) -> Vec<BTreeMap<String, String>> {
2213 mock.envs.lock().unwrap().clone()
2214 }
2215
2216 #[tokio::test]
2230 async fn strong_principal_fails_closed_on_unenforceable_exec() {
2231 let granted = exec_only(&["echo"]);
2232 let exec_is_kernel_confined = enforcement_report(
2235 &granted,
2236 intended_sandbox_kind(&granted, &Arc::new(SandboxPolicy::default())),
2237 )
2238 .exec
2239 == Some(agent_bridle_core::AxisEnforcement::Kernel);
2240
2241 let mock = Arc::new(MockSpawner::default());
2242 let out = ShellTool::with_spawner(mock.clone())
2243 .invoke(
2244 serde_json::json!({"cmd": "echo hi"}),
2245 &ctx_strong(granted.clone()),
2246 )
2247 .await
2248 .expect("invoke");
2249 if exec_is_kernel_confined {
2250 assert_ne!(
2253 out["denied"],
2254 serde_json::json!(true),
2255 "kernel-confined exec must run for a strong principal: {out}"
2256 );
2257 assert_eq!(
2258 out["enforcement"]["exec"], "kernel",
2259 "exec is reported kernel-confined: {out}"
2260 );
2261 assert_eq!(ran_programs(&mock), ["echo"], "the program spawned: {out}");
2262 } else {
2263 assert_eq!(
2266 out["denied"], true,
2267 "strong principal must fail closed on unenforceable exec: {out}"
2268 );
2269 assert!(ran_programs(&mock).is_empty(), "nothing may spawn: {out}");
2270 }
2271
2272 let mock = Arc::new(MockSpawner::default());
2275 let out = ShellTool::with_spawner(mock.clone())
2276 .invoke(serde_json::json!({"cmd": "echo hi"}), &ctx(granted))
2277 .await
2278 .expect("invoke");
2279 assert_ne!(
2280 out["denied"],
2281 serde_json::json!(true),
2282 "default principal still runs: {out}"
2283 );
2284 }
2285
2286 #[tokio::test]
2293 async fn net_refusal_surfaces_as_a_net_denial_in_the_envelope() {
2294 let mock = Arc::new(MockSpawner::with_net_denials(vec![Denial {
2295 kind: DenialKind::Net,
2296 target: "github.com".to_string(),
2297 reason: "net does not permit 'github.com'".to_string(),
2298 }]));
2299 let out = ShellTool::with_spawner(mock)
2300 .invoke(
2301 serde_json::json!({ "cmd": "echo hi" }),
2302 &ctx(exec_only(&["echo"])),
2303 )
2304 .await
2305 .expect("invoke");
2306 assert_eq!(
2307 out["denied"],
2308 serde_json::json!(true),
2309 "a net denial sets denied: {out}"
2310 );
2311 assert_eq!(out["denials"][0]["kind"], "net");
2312 assert_eq!(out["denials"][0]["target"], "github.com");
2313 assert!(out.get("exit_code").is_some(), "command still ran: {out}");
2316 }
2317
2318 fn ran_programs(mock: &Arc<MockSpawner>) -> Vec<String> {
2319 calls(mock)
2320 .iter()
2321 .map(|pipeline| prog(&pipeline[0]).to_string())
2322 .collect()
2323 }
2324
2325 #[tokio::test]
2331 async fn env_map_is_passed_to_the_spawner() {
2332 let mock = Arc::new(MockSpawner::default());
2333 let out = ShellTool::with_spawner(mock.clone())
2334 .invoke(
2335 serde_json::json!({
2336 "program": "echo",
2337 "args": ["hi"],
2338 "env": { "FOO": "bar", "VIRTUAL_ENV": "/venv" },
2339 }),
2340 &ctx(exec_only(&["echo"])),
2341 )
2342 .await
2343 .expect("invoke");
2344 assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
2345 let envs = envs(&mock);
2346 assert_eq!(envs.len(), 1, "one pipeline ran");
2347 assert_eq!(envs[0].get("FOO").map(String::as_str), Some("bar"));
2348 assert_eq!(
2349 envs[0].get("VIRTUAL_ENV").map(String::as_str),
2350 Some("/venv"),
2351 "every env entry reaches the child: {:?}",
2352 envs[0]
2353 );
2354 }
2355
2356 #[tokio::test]
2363 async fn env_does_not_change_the_program_the_leash_checks() {
2364 let mock = Arc::new(MockSpawner::default());
2365 let out = ShellTool::with_spawner(mock.clone())
2368 .invoke(
2369 serde_json::json!({
2370 "cmd": "hostname; uname -s",
2371 "env": { "FOO": "bar" },
2372 }),
2373 &ctx(exec_only(&["hostname", "uname"])),
2374 )
2375 .await
2376 .expect("invoke");
2377 assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
2378 let programs = ran_programs(&mock);
2380 assert_eq!(
2381 programs,
2382 vec!["hostname".to_string(), "uname".to_string()],
2383 "the leash/spawner see the real programs, never `export`/env keys: {programs:?}"
2384 );
2385 for e in envs(&mock) {
2387 assert_eq!(e.get("FOO").map(String::as_str), Some("bar"));
2388 }
2389 }
2390
2391 #[test]
2394 fn parse_env_field_present_and_absent() {
2395 let parsed = ShellArgs::parse(
2397 &serde_json::json!({
2398 "program": "echo",
2399 "env": { "FOO": "bar", "BAZ": "qux" },
2400 }),
2401 &agent_bridle_core::LimitsPolicy::default(),
2402 )
2403 .expect("parse");
2404 assert_eq!(parsed.env.get("FOO").map(String::as_str), Some("bar"));
2405 assert_eq!(parsed.env.get("BAZ").map(String::as_str), Some("qux"));
2406 assert_eq!(parsed.env.len(), 2);
2407
2408 let parsed = ShellArgs::parse(
2410 &serde_json::json!({ "program": "echo" }),
2411 &agent_bridle_core::LimitsPolicy::default(),
2412 )
2413 .expect("parse");
2414 assert!(parsed.env.is_empty(), "absent env defaults to empty");
2415 }
2416
2417 #[test]
2421 fn parse_timeout_uses_configured_limits() {
2422 let limits = agent_bridle_core::LimitsPolicy {
2423 max_timeout_secs: 5,
2424 default_timeout_secs: 3,
2425 ..agent_bridle_core::LimitsPolicy::default()
2426 };
2427 let over = ShellArgs::parse(
2429 &serde_json::json!({ "program": "echo", "timeout_secs": 9999 }),
2430 &limits,
2431 )
2432 .expect("parse");
2433 assert_eq!(over.timeout, std::time::Duration::from_secs(5));
2434 let dflt =
2436 ShellArgs::parse(&serde_json::json!({ "program": "echo" }), &limits).expect("parse");
2437 assert_eq!(dflt.timeout, std::time::Duration::from_secs(3));
2438 }
2439
2440 struct FakeEnv(HashMap<String, String>);
2443 impl EnvProvider for FakeEnv {
2444 fn get(&self, name: &str) -> Option<String> {
2445 self.0.get(name).cloned()
2446 }
2447 }
2448 fn fake_env(pairs: &[(&str, &str)]) -> Arc<dyn EnvProvider> {
2449 Arc::new(FakeEnv(
2450 pairs
2451 .iter()
2452 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
2453 .collect(),
2454 ))
2455 }
2456
2457 struct MapLister(HashMap<String, Vec<GlobEntry>>);
2460 impl DirLister for MapLister {
2461 fn list(&self, dir: &Path) -> Vec<GlobEntry> {
2462 let key = dir.to_string_lossy().replace('\\', "/");
2465 self.0.get(&key).cloned().unwrap_or_default()
2466 }
2467 }
2468 fn ent(name: &str, is_dir: bool) -> GlobEntry {
2469 GlobEntry {
2470 name: name.to_string(),
2471 is_dir,
2472 }
2473 }
2474 fn map_lister(dirs: &[(&str, Vec<GlobEntry>)]) -> Arc<dyn DirLister> {
2475 Arc::new(MapLister(
2476 dirs.iter()
2477 .map(|(d, es)| ((*d).to_string(), es.clone()))
2478 .collect(),
2479 ))
2480 }
2481
2482 #[tokio::test]
2488 async fn redirect_var_is_expanded_and_reaches_spawner_resolved() {
2489 let tmp = std::env::temp_dir().to_string_lossy().into_owned();
2490 let mock = Arc::new(MockSpawner::default());
2491 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
2492 let out = tool
2494 .invoke(
2495 serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
2496 &ctx(exec_only(&["echo"])),
2497 )
2498 .await
2499 .expect("invoke");
2500 assert_ne!(
2501 out["denied"],
2502 serde_json::json!(true),
2503 "in-scope var: {out}"
2504 );
2505 let redir = &calls(&mock)[0][0].redirects[0];
2506 assert_eq!(
2507 *redir,
2508 Redirect::Stdout {
2509 path: vec![Seg::Lit(format!("{tmp}/out"))],
2510 append: false,
2511 }
2512 );
2513 }
2514
2515 #[tokio::test]
2517 async fn redirect_var_not_in_allowlist_is_denied() {
2518 let mock = Arc::new(MockSpawner::default());
2519 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/x")]));
2520 let out = tool
2521 .invoke(
2522 serde_json::json!({"cmd": "echo hi > $SECRET"}),
2523 &ctx(exec_only(&["echo"])),
2524 )
2525 .await
2526 .expect("invoke");
2527 assert_eq!(out["denied"], true, "non-allowlisted redirect var: {out}");
2528 assert!(
2529 ran_programs(&mock).is_empty(),
2530 "no spawn on a denied redirect"
2531 );
2532 assert!(out["denials"][0]["reason"]
2533 .as_str()
2534 .unwrap_or_default()
2535 .contains("SECRET"));
2536 }
2537
2538 #[test]
2544 fn expand_varglob_keeps_value_metachars_literal_and_refuses_basename_var() {
2545 let env = FakeEnv(HashMap::from([("TMPDIR".to_string(), "/a*b".to_string())]));
2547 let allow = agent_bridle_core::LimitsPolicy::default().var_allowlist;
2548 let pattern = expand_varglob(
2551 &[Seg::Var("TMPDIR".into()), Seg::Lit("/*.rs".into())],
2552 &env,
2553 &allow,
2554 )
2555 .unwrap();
2556 assert_eq!(pattern, "/a*b/*.rs");
2557 let err = expand_varglob(
2559 &[Seg::Var("TMPDIR".into()), Seg::Lit("*.rs".into())],
2560 &env,
2561 &allow,
2562 );
2563 assert!(err.is_err(), "var in glob basename must be refused");
2564 }
2565
2566 #[tokio::test]
2569 async fn glob_var_expands_to_resolved_matches_before_spawn() {
2570 let mock = Arc::new(MockSpawner::default());
2571 let lister = map_lister(&[
2572 (".", vec![ent("proj", true)]),
2573 ("./proj", vec![ent("a.rs", false), ent("b.rs", false)]),
2574 ]);
2575 let tool = ShellTool::with_seams(mock.clone(), fake_env(&[("TMPDIR", "proj")]), lister);
2576 let out = tool
2577 .invoke(
2578 serde_json::json!({"cmd": "ls $TMPDIR/*.rs"}), &ctx(exec_only(&["ls"])),
2580 )
2581 .await
2582 .expect("invoke");
2583 assert_ne!(
2584 out["denied"],
2585 serde_json::json!(true),
2586 "in-scope glob var: {out}"
2587 );
2588 assert_eq!(
2589 calls(&mock)[0][0].argv,
2590 vec![
2591 Arg::Lit("ls".into()),
2592 Arg::Lit("proj/a.rs".into()),
2593 Arg::Lit("proj/b.rs".into())
2594 ]
2595 );
2596 }
2597
2598 #[tokio::test]
2600 async fn glob_var_in_basename_is_denied() {
2601 let mock = Arc::new(MockSpawner::default());
2602 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("PREFIX", "foo")]));
2603 let out = tool
2604 .invoke(
2605 serde_json::json!({"cmd": "ls $PREFIX*.rs"}),
2606 &ctx(exec_only(&["ls"])),
2607 )
2608 .await
2609 .expect("invoke");
2610 assert_eq!(out["denied"], true, "var in glob basename refused: {out}");
2611 assert!(ran_programs(&mock).is_empty());
2612 }
2613
2614 #[tokio::test]
2616 async fn glob_var_not_in_allowlist_is_denied() {
2617 let mock = Arc::new(MockSpawner::default());
2618 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/s")]));
2619 let out = tool
2620 .invoke(
2621 serde_json::json!({"cmd": "ls $SECRET/*.rs"}),
2622 &ctx(exec_only(&["ls"])),
2623 )
2624 .await
2625 .expect("invoke");
2626 assert_eq!(out["denied"], true, "non-allowlisted glob var: {out}");
2627 assert!(ran_programs(&mock).is_empty());
2628 }
2629
2630 #[tokio::test]
2634 async fn redirect_var_resolved_path_out_of_fs_write_scope_denied() {
2635 let tmp = std::env::temp_dir().to_string_lossy().into_owned();
2636 let mock = Arc::new(MockSpawner::default());
2637 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
2638 let granted = Caveats {
2639 exec: Scope::only(["echo".to_string()]),
2640 fs_write: Scope::only(["/nonexistent-grant-root".to_string()]),
2641 ..Caveats::top()
2642 };
2643 let out = tool
2644 .invoke(
2645 serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
2646 &ctx(granted),
2647 )
2648 .await
2649 .expect("invoke");
2650 assert_eq!(out["denied"], true, "resolved path outside fs_write: {out}");
2651 assert!(ran_programs(&mock).is_empty());
2652 }
2653
2654 #[tokio::test]
2657 async fn and_short_circuits_on_failure() {
2658 let mock = Arc::new(MockSpawner::with_exit("false", 1));
2659 ShellTool::with_spawner(mock.clone())
2660 .invoke(
2661 serde_json::json!({"cmd": "false && echo hi"}),
2662 &ctx(exec_only(&["false", "echo"])),
2663 )
2664 .await
2665 .expect("invoke");
2666 assert_eq!(ran_programs(&mock), vec!["false"], "echo must be skipped");
2667 }
2668
2669 #[tokio::test]
2670 async fn out_of_scope_anywhere_denies_the_whole_script() {
2671 let mock = Arc::new(MockSpawner::default());
2672 let out = ShellTool::with_spawner(mock.clone())
2673 .invoke(
2674 serde_json::json!({"cmd": "echo ok ; rm -rf x"}),
2675 &ctx(exec_only(&["echo"])),
2676 )
2677 .await
2678 .expect("invoke");
2679 assert_eq!(out["denied"], true);
2680 assert!(ran_programs(&mock).is_empty());
2681 }
2682
2683 #[tokio::test]
2688 async fn glob_arg_expanded_to_matches_before_spawn() {
2689 let mock = Arc::new(MockSpawner::default());
2690 let lister = map_lister(&[(
2691 ".",
2692 vec![ent("a.rs", false), ent("b.rs", false), ent("c.txt", false)],
2693 )]);
2694 ShellTool::with_seams(mock.clone(), fake_env(&[]), lister)
2695 .invoke(
2696 serde_json::json!({"cmd": "ls *.rs"}), &ctx(exec_only(&["ls"])),
2698 )
2699 .await
2700 .expect("invoke");
2701 assert_eq!(
2702 calls(&mock)[0][0].argv,
2703 vec![
2704 Arg::Lit("ls".into()),
2705 Arg::Lit("a.rs".into()),
2706 Arg::Lit("b.rs".into())
2707 ]
2708 );
2709 }
2710
2711 #[tokio::test]
2713 async fn glob_as_program_name_denied() {
2714 let mock = Arc::new(MockSpawner::default());
2715 let out = ShellTool::with_spawner(mock.clone())
2716 .invoke(serde_json::json!({"cmd": "*.sh foo"}), &ctx(Caveats::top()))
2717 .await
2718 .expect("invoke");
2719 assert_eq!(out["denied"], true);
2720 assert!(ran_programs(&mock).is_empty());
2721 }
2722
2723 #[tokio::test]
2725 async fn glob_dir_out_of_fs_read_scope_denied() {
2726 let mock = Arc::new(MockSpawner::default());
2727 let granted = Caveats {
2728 exec: Scope::only(["echo".to_string()]),
2729 fs_read: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
2731 ..Caveats::top()
2732 };
2733 let out = ShellTool::with_spawner(mock.clone())
2734 .invoke(serde_json::json!({"cmd": "echo *"}), &ctx(granted))
2735 .await
2736 .expect("invoke");
2737 assert_eq!(out["denied"], true);
2738 assert_eq!(out["denials"][0]["kind"], "open");
2739 assert!(ran_programs(&mock).is_empty());
2740 }
2741
2742 #[tokio::test]
2746 async fn allowlisted_var_reaches_spawner() {
2747 let mock = Arc::new(MockSpawner::default());
2748 ShellTool::with_spawner(mock.clone())
2749 .invoke(
2750 serde_json::json!({"cmd": "echo $HOME"}),
2751 &ctx(exec_only(&["echo"])),
2752 )
2753 .await
2754 .expect("invoke");
2755 let c = calls(&mock);
2756 assert_eq!(
2757 c[0][0].argv,
2758 vec![
2759 Arg::Lit("echo".into()),
2760 Arg::Var(vec![Seg::Var("HOME".into())]),
2761 ]
2762 );
2763 }
2764
2765 #[tokio::test]
2768 async fn non_allowlisted_var_denied() {
2769 let mock = Arc::new(MockSpawner::default());
2770 let out = ShellTool::with_spawner(mock.clone())
2771 .invoke(
2772 serde_json::json!({"cmd": "echo $AWS_SECRET_KEY"}),
2773 &ctx(Caveats::top()),
2774 )
2775 .await
2776 .expect("invoke");
2777 assert_eq!(out["denied"], true);
2778 assert_eq!(out["denials"][0]["target"], "$AWS_SECRET_KEY");
2779 assert!(ran_programs(&mock).is_empty());
2780 }
2781
2782 #[tokio::test]
2784 async fn var_as_program_name_denied() {
2785 let mock = Arc::new(MockSpawner::default());
2786 let out = ShellTool::with_spawner(mock.clone())
2787 .invoke(
2788 serde_json::json!({"cmd": "$HOME foo"}),
2789 &ctx(Caveats::top()),
2790 )
2791 .await
2792 .expect("invoke");
2793 assert_eq!(out["denied"], true);
2794 assert!(ran_programs(&mock).is_empty());
2795 }
2796
2797 #[tokio::test]
2801 async fn stderr_to_file_out_of_scope_denied() {
2802 let mock = Arc::new(MockSpawner::default());
2803 let granted = Caveats {
2804 exec: Scope::only(["cmd".to_string()]),
2805 fs_write: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
2806 ..Caveats::top()
2807 };
2808 let out = ShellTool::with_spawner(mock.clone())
2809 .invoke(
2810 serde_json::json!({"cmd": "cmd 2> /etc/passwd"}),
2811 &ctx(granted),
2812 )
2813 .await
2814 .expect("invoke");
2815 assert_eq!(out["denied"], true);
2816 assert_eq!(out["denials"][0]["kind"], "open");
2817 assert_eq!(out["denials"][0]["target"], "/etc/passwd");
2818 assert!(ran_programs(&mock).is_empty());
2819 }
2820
2821 #[tokio::test]
2823 async fn stderr_merge_reaches_spawner() {
2824 let mock = Arc::new(MockSpawner::default());
2825 ShellTool::with_spawner(mock.clone())
2826 .invoke(
2827 serde_json::json!({"cmd": "cmd 2>&1"}),
2828 &ctx(exec_only(&["cmd"])),
2829 )
2830 .await
2831 .expect("invoke");
2832 let c = calls(&mock);
2833 assert_eq!(c[0][0].stderr_disposition(), StderrTo::Stdout);
2834 }
2835
2836 #[tokio::test]
2837 async fn both_program_and_cmd_is_a_hard_error() {
2838 let res = ShellTool::new()
2839 .invoke(
2840 serde_json::json!({"program": "echo", "cmd": "echo hi"}),
2841 &ctx(Caveats::top()),
2842 )
2843 .await;
2844 assert!(res.is_err());
2845 }
2846
2847 #[tokio::test]
2848 async fn timeout_is_reported() {
2849 let mock = Arc::new(MockSpawner {
2850 block_ms: 1500,
2851 ..Default::default()
2852 });
2853 let out = ShellTool::with_spawner(mock)
2854 .invoke(
2855 serde_json::json!({"program": "anything", "timeout_secs": 1}),
2856 &ctx(exec_only(&["anything"])),
2857 )
2858 .await
2859 .expect("invoke");
2860 assert_eq!(out["timed_out"], true);
2861 }
2862
2863 #[test]
2866 fn fnmatch_basics() {
2867 assert!(fnmatch("*.rs", "a.rs"));
2868 assert!(!fnmatch("*.rs", "a.txt"));
2869 assert!(fnmatch("a?c", "abc"));
2870 assert!(!fnmatch("a?c", "ac"));
2871 assert!(fnmatch("*", ""));
2872 assert!(fnmatch("a*", "a"));
2873 assert!(fnmatch("[abc]x", "bx"));
2874 assert!(!fnmatch("[abc]x", "dx"));
2875 assert!(fnmatch("[!abc]x", "dx"));
2876 assert!(fnmatch("[a-c]", "b"));
2877 assert!(!fnmatch("[a-c]", "d"));
2878 assert!(fnmatch("foo*bar", "fooXYbar"));
2879 }
2880
2881 #[test]
2886 fn read_capped_bounds_buffering_and_flags_truncation() {
2887 const CAP: usize = 1 << 20;
2889 struct Endless {
2892 served: usize,
2893 }
2894 impl Read for Endless {
2895 fn read(&mut self, b: &mut [u8]) -> std::io::Result<usize> {
2896 self.served = self.served.saturating_add(b.len());
2897 assert!(
2898 self.served <= CAP + 64 * 1024,
2899 "read_capped over-read {} bytes (cap {CAP})",
2900 self.served
2901 );
2902 b.fill(b'x');
2903 Ok(b.len())
2904 }
2905 }
2906 let (buf, truncated) = read_capped(Endless { served: 0 }, CAP);
2907 assert_eq!(buf.len(), CAP, "peak buffering bounded by the cap");
2908 assert!(
2909 truncated,
2910 "a source longer than the cap is flagged truncated"
2911 );
2912
2913 let (buf2, trunc2) = read_capped(&b"hello"[..], CAP);
2915 assert_eq!(buf2, b"hello");
2916 assert!(!trunc2, "a sub-cap source is not truncated");
2917 }
2918
2919 #[test]
2920 fn read_capped_retries_an_interrupted_read() {
2921 struct InterruptedOnce {
2922 interrupted: bool,
2923 inner: std::io::Cursor<Vec<u8>>,
2924 }
2925
2926 impl Read for InterruptedOnce {
2927 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2928 if !self.interrupted {
2929 self.interrupted = true;
2930 return Err(std::io::Error::from(std::io::ErrorKind::Interrupted));
2931 }
2932 self.inner.read(buf)
2933 }
2934 }
2935
2936 let reader = InterruptedOnce {
2937 interrupted: false,
2938 inner: std::io::Cursor::new(b"abcdef".to_vec()),
2939 };
2940 let (captured, truncated) = read_capped(reader, 4);
2941
2942 assert_eq!(captured, b"abcd");
2943 assert!(truncated);
2944 }
2945
2946 #[test]
2947 fn glob_walk_single_segment_and_subpath() {
2948 let lister = map_lister(&[
2949 (
2950 ".",
2951 vec![
2952 ent("a.rs", false),
2953 ent("b.rs", false),
2954 ent("c.txt", false),
2955 ent(".hidden.rs", false),
2956 ent("src", true),
2957 ],
2958 ),
2959 ("./src", vec![ent("a.rs", false), ent("b.rs", false)]),
2960 ]);
2961 let mut allow = |_d: &Path| Ok(());
2962 assert_eq!(
2964 expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2965 vec!["a.rs", "b.rs"]
2966 );
2967 assert_eq!(
2969 expand_glob_walk("zzz*", None, &*lister, &mut allow, 64, 4096).unwrap(),
2970 vec!["zzz*"]
2971 );
2972 assert_eq!(
2974 expand_glob_walk("src/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2975 vec!["src/a.rs", "src/b.rs"]
2976 );
2977 }
2978
2979 #[test]
2980 fn glob_walk_multi_segment_and_recursive() {
2981 let lister = map_lister(&[
2982 (
2983 ".",
2984 vec![ent("a", true), ent("b", true), ent("x.rs", false)],
2985 ),
2986 ("./a", vec![ent("foo.rs", false), ent("sub", true)]),
2987 ("./b", vec![ent("bar.rs", false)]),
2988 ("./a/sub", vec![ent("deep.rs", false)]),
2989 ]);
2990 let mut allow = |_d: &Path| Ok(());
2991 assert_eq!(
2993 expand_glob_walk("*/foo.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2994 vec!["a/foo.rs"]
2995 );
2996 assert_eq!(
2998 expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2999 vec!["a/foo.rs", "a/sub/deep.rs", "b/bar.rs", "x.rs"]
3000 );
3001 }
3002
3003 #[test]
3004 fn glob_walk_leashes_every_directory_and_denies_out_of_scope() {
3005 let lister = map_lister(&[
3006 (".", vec![ent("a", true), ent("x.rs", false)]),
3007 ("./a", vec![ent("secret.rs", false)]),
3008 ]);
3009 let mut deny_a = |d: &Path| {
3012 if d.to_string_lossy().contains("a") {
3013 Err(ToolError::denied("out of fs_read scope"))
3014 } else {
3015 Ok(())
3016 }
3017 };
3018 assert!(expand_glob_walk("**/*.rs", None, &*lister, &mut deny_a, 64, 4096).is_err());
3019 }
3020
3021 #[test]
3024 fn glob_walk_respects_configured_match_cap() {
3025 let lister = map_lister(&[(
3026 ".",
3027 vec![
3028 ent("a.rs", false),
3029 ent("b.rs", false),
3030 ent("c.rs", false),
3031 ent("d.rs", false),
3032 ],
3033 )]);
3034 let mut allow = |_d: &Path| Ok(());
3035 let got = expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 2).unwrap();
3036 assert_eq!(got.len(), 2, "match cap of 2 must bound the result set");
3037 }
3038
3039 #[test]
3042 fn glob_walk_respects_configured_depth_cap() {
3043 let lister = map_lister(&[
3044 (".", vec![ent("a", true), ent("x.rs", false)]),
3045 ("./a", vec![ent("foo.rs", false), ent("sub", true)]),
3046 ("./a/sub", vec![ent("deep.rs", false)]),
3047 ]);
3048 let mut allow = |_d: &Path| Ok(());
3049 let got = expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 1, 4096).unwrap();
3051 assert!(
3052 !got.iter().any(|m| m.contains("deep.rs")),
3053 "depth cap of 1 must not reach a/sub/deep.rs; got {got:?}"
3054 );
3055 }
3056
3057 #[test]
3061 fn var_allowlist_is_config_driven() {
3062 let allow_custom = vec!["MY_CUSTOM_VAR".to_string()];
3064 let env = FakeEnv(HashMap::from([(
3065 "MY_CUSTOM_VAR".to_string(),
3066 "/data".to_string(),
3067 )]));
3068 let out = expand_redirect_target(&[Seg::Var("MY_CUSTOM_VAR".into())], &env, &allow_custom)
3069 .unwrap();
3070 assert_eq!(out, "/data");
3071 assert!(!is_allowed_var("HOME", &["PWD".to_string()]));
3073 assert!(is_allowed_var("PWD", &["PWD".to_string()]));
3074 }
3075
3076 #[test]
3081 fn net_audit_sink_is_config_driven() {
3082 use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
3083 let ev = NetAuditEvent {
3084 ts_ms: 0,
3085 host: "example.test".to_string(),
3086 port: 443,
3087 kind: NetKind::Connect,
3088 decision: NetDecision::Allowed,
3089 bytes_up: 1,
3090 bytes_down: 2,
3091 dur_ms: 3,
3092 };
3093 net_audit_sink(None).record(&ev);
3095
3096 let path = std::env::temp_dir().join(format!("ab-audit-{}.jsonl", std::process::id()));
3098 let _ = std::fs::remove_file(&path);
3099 let sink = net_audit_sink(path.to_str());
3100 sink.record(&ev);
3101 drop(sink);
3102 let contents = std::fs::read_to_string(&path).expect("configured audit file written");
3103 assert!(
3104 contents.contains("example.test"),
3105 "the configured sink must write the event: {contents}"
3106 );
3107 let _ = std::fs::remove_file(&path);
3108 }
3109
3110 #[test]
3114 fn net_audit_sink_bad_path_degrades_to_null() {
3115 use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
3116 let ev = NetAuditEvent {
3117 ts_ms: 0,
3118 host: "example.test".to_string(),
3119 port: 443,
3120 kind: NetKind::Http,
3121 decision: NetDecision::Allowed,
3122 bytes_up: 1,
3123 bytes_down: 2,
3124 dur_ms: 3,
3125 };
3126 let bad = std::env::temp_dir()
3128 .join(format!("ab-nope-{}", std::process::id()))
3129 .join("does/not/exist/audit.jsonl");
3130 let sink = net_audit_sink(bad.to_str());
3131 sink.record(&ev); assert!(
3133 !bad.exists(),
3134 "a bad audit path must not create a file (degraded to null): {bad:?}"
3135 );
3136 }
3137}