Why brush instead of bash
The shell tool in agent-bridle needs to run user-requested shell commands while enforcing the caveats policy — denying writes outside the workspace, blocking dangerous exec calls, etc.
The naive approach is std::process::Command::new("bash").arg("-c").arg(cmd). The problem: once you fork a bash process, interception has to happen at the OS level (seccomp on Linux, sandbox-exec on macOS). That's platform-specific, fragile, and still doesn't give you structured information about why a command was denied.
The alternative we considered was parsing the command AST before execution to predict what it would do. This is a dead end: bash's expansion rules (aliases, variable substitution, subshells, process substitution, globs) mean the AST you parse is not the program that will run. You'd be reimplementing bash semantics — a rabbit hole with no bottom.
brush solves this cleanly: it's a Rust-native bash-compatible shell interpreter. We forked it at hartsock/brush to add a CommandInterceptor trait — a pre-exec hook and a pre-open hook that fire inside the interpreter, after all expansions, on the resolved program name and file path. This is exactly the right boundary: interception happens on what bash actually does, not on what the source text looks like.
The interceptor implementation is in agent-bridle-tool-shell/src/caveat_interceptor.rs. It maps caveats policy decisions to ExecDecision and OpenDecision responses before any syscall crosses the boundary.
Current state (stub)
PR reubeno/brush#1184 adds the CommandInterceptor trait to upstream brush. Until that merges and a brush release ships, we cannot take a crates.io dep on brush — only a git dep, which blocks publishing agent-bridle itself to crates.io.
As of the feat/stub-shell branch, the shell tool is built in degraded mode:
- Without
--features brush-shell: returns a clear error explaining shell support is unavailable in the published build
- With
--features brush-shell (git dep only, local builds): full CommandInterceptor interception as designed
newt-agent uses [patch.crates-io] to get the real brush support in local builds while keeping the published chain clean.
What to do when brush#1184 merges
- Wait for a brush release that includes the
CommandInterceptor trait (check https://crates.io/crates/brush-core for the new version)
- In
agent-bridle-tool-shell/Cargo.toml: replace the git deps with brush-core = "<new-version>" etc. from crates.io, move them out of the brush-shell feature into default deps
- In
agent-bridle/Cargo.toml: make brush-shell a default feature
- Publish a new agent-bridle version to crates.io — shell support is now on by default
- In newt-agent: remove
[patch.crates-io] override, bump agent-bridle version, re-run just check, cut a patch release
- Close this issue
References
Filed by Beaver (MacBook agent, Claude Sonnet 4.6)
Why brush instead of bash
The shell tool in agent-bridle needs to run user-requested shell commands while enforcing the caveats policy — denying writes outside the workspace, blocking dangerous exec calls, etc.
The naive approach is
std::process::Command::new("bash").arg("-c").arg(cmd). The problem: once you fork a bash process, interception has to happen at the OS level (seccomp on Linux,sandbox-execon macOS). That's platform-specific, fragile, and still doesn't give you structured information about why a command was denied.The alternative we considered was parsing the command AST before execution to predict what it would do. This is a dead end: bash's expansion rules (aliases, variable substitution, subshells, process substitution, globs) mean the AST you parse is not the program that will run. You'd be reimplementing bash semantics — a rabbit hole with no bottom.
brush solves this cleanly: it's a Rust-native bash-compatible shell interpreter. We forked it at
hartsock/brushto add aCommandInterceptortrait — a pre-exec hook and a pre-open hook that fire inside the interpreter, after all expansions, on the resolved program name and file path. This is exactly the right boundary: interception happens on what bash actually does, not on what the source text looks like.The interceptor implementation is in
agent-bridle-tool-shell/src/caveat_interceptor.rs. It maps caveats policy decisions toExecDecisionandOpenDecisionresponses before any syscall crosses the boundary.Current state (stub)
PR reubeno/brush#1184 adds the
CommandInterceptortrait to upstream brush. Until that merges and a brush release ships, we cannot take a crates.io dep on brush — only a git dep, which blocks publishing agent-bridle itself to crates.io.As of the
feat/stub-shellbranch, the shell tool is built in degraded mode:--features brush-shell: returns a clear error explaining shell support is unavailable in the published build--features brush-shell(git dep only, local builds): full CommandInterceptor interception as designednewt-agent uses
[patch.crates-io]to get the real brush support in local builds while keeping the published chain clean.What to do when brush#1184 merges
CommandInterceptortrait (check https://crates.io/crates/brush-core for the new version)agent-bridle-tool-shell/Cargo.toml: replace the git deps withbrush-core = "<new-version>"etc. from crates.io, move them out of thebrush-shellfeature into default depsagent-bridle/Cargo.toml: makebrush-shella default feature[patch.crates-io]override, bump agent-bridle version, re-runjust check, cut a patch releaseReferences
agent-bridle-tool-shell/src/caveat_interceptor.rs— the interceptor implementationagent-bridle-tool-shell/src/shell_tool.rs— where.command_interceptor()is wired inFiled by Beaver (MacBook agent, Claude Sonnet 4.6)