feat(core): add CommandInterceptor exec/open hook to ShellExtensions#1184
feat(core): add CommandInterceptor exec/open hook to ShellExtensions#1184hartsock wants to merge 3 commits into
Conversation
* fix(shell): stub ShellTool pending reubeno/brush#1184 The brush CommandInterceptor hook that backs the shell tool cannot be packaged as a crates.io dependency until the upstream PR merges. This commit removes all brush git deps and replaces the real implementation with a stub that: - Preserves the complete argv/free-form JSON schema so consumers can introspect the tool interface. - Returns ToolError::Other with a message linking to reubeno/brush#1184 and #20 on every invoke() call — no functionality is silently missing. - Updates the MCP handler unit tests and the end-to-end stdio integration test to assert the stub behaviour instead of real execution or caveats-based denial. - Deletes caveat_interceptor.rs (preserved in git history; restoration procedure in lib.rs doc comment and issues #20 / newt-agent#206). Unblocks crates.io publish of the whole agent-bridle workspace. Tracking: #20 Upstream: reubeno/brush#1184 Co-authored-by: Beaver (MacBook agent, Claude Sonnet 4.6) <noreply@anthropic.com> * ci: add release workflow + update publish-crates recipe to cover all 5 crates Now that the brush git dep is removed (feat/stub-shell), all five crates in the workspace can publish to crates.io. This commit: - Adds .github/workflows/release.yml: tag-triggered build of the agent-bridle-mcp binary (Linux x86_64 + macOS arm64), GitHub release creation, and topological crates.io publish for all 5 crates: agent-bridle-core → agent-bridle-tool-shell → agent-bridle-tool-web → agent-bridle → agent-bridle-mcp. - Updates the `publish-crates` justfile recipe to match the same crate list and order (HOOK PARITY). Previously only agent-bridle-core and agent-bridle-tool-web were included. Requires CARGO_REGISTRY_TOKEN secret under Settings → Secrets. Co-authored-by: Beaver (MacBook agent, Claude Sonnet 4.6) <noreply@anthropic.com> * style+lint: cargo fmt + fix clippy doc_lazy_continuation (CI gates) PR #21's 'fmt + clippy + test' job failed at rustfmt; after that, clippy -D warnings also flags doc_lazy_continuation on the stub doc comment. - cargo fmt --all (handlers.rs over-width assert; shell_tool.rs compressed test impls) - blank doc line before the trailing 'See <issue>' so it isn't parsed as an unindented continuation of the numbered restore-list. --------- Co-authored-by: hartsock <hartsock@users.noreply.github.com> Co-authored-by: Beaver (MacBook agent, Claude Sonnet 4.6) <noreply@anthropic.com>
…ell (#238) While the brush fork's CommandInterceptor is unpublishable (pending reubeno/brush#1184), main stays on agent-bridle's `feat/stub-shell` branch so the workspace publishes to crates.io — but that makes run_command/shell_run return "shell temporarily unavailable". This adds a dev-only lever to run the real Caveats-confined shell locally without disturbing the publishable default. - `just shell-real` — repoints the [patch.crates-io] agent-bridle entries at agent-bridle `main` (the real brush shell). DEV ONLY; not committable. - `just shell-stub` — flips back to the publishable stub branch. - `just shell-check` — guard that fails if the real-shell override is present, so the dev patch can't reach main. A cargo `--features` flag can't express this: crates.io forbids git deps in any form (even optional/feature-gated), so toggling the real shell swaps a dependency *source*, not a feature — hence a patch-swap recipe. The guard runs in the pre-push hook and is mirrored inline in CI's lint job (PIPELINE PARITY). Verified: guard passes on the stub tree, fails when flipped to `main`, and shell-stub restores Cargo.toml byte-for-byte. Co-authored-by: hartsock <hartsock@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-reported: on stub-shell builds (the only crates.io-publishable
configuration) agent-bridle's shell tool fails closed on every command,
so run_command cannot do agentic coding at all without the brush
CommandInterceptor patch underneath. This adds the explicit, loud,
interim bypass the issue specifies:
- newt-core: `ocap_disabled()` (NEWT_DISABLE_OCAP=1, exact-value,
fail-closed read; env-only — deliberately NO config key) and a
run_command bypass that executes on the plain host shell (bash -c,
sh fallback; cmd /C on Windows) with the SAME venv/PATH prefix and
an envelope structurally identical to the bridle one
({exit_code, stdout, stderr, sandbox_kind}, denied/denials omitted),
so envelope_denied/shell_envelope_output and the loop need no changes.
- Scope: exec only. web_fetch is NOT bypassed — determined empirically
that the stub-shell branch stubs only the shell tool; the web tool at
agent-bridle rev 2129c91 is the real leash-enforcing implementation.
fs tools keep the newt-native workspace fence: yolo is unconfined
exec, fenced fs — never authority-off.
- Precedence: --disable-ocap > --prompt-for-permissions for exec
(nothing is denied, so the #263 gate is structurally unreachable);
fs prompting unaffected.
- newt-cli: --disable-ocap with visible alias --yolo (global), threads
via NEWT_DISABLE_OCAP=1 exactly like #289's --prompt-for-permissions.
- newt-tui: unmissable session-start banner + ONE ocap-disabled record
({decision: "ocap-disabled", scope: "session", kind: exec, target: *})
in the #263 permission log so the audit trail shows the session ran open.
- Everything is doc-marked INTERIM, referencing #297, agent-bridle#20,
and reubeno/brush#1184 — removed (or demoted to a debug flag) when
brush upstreams CommandInterceptor.
13 new tests: flag-off pinned bit-for-bit (stub error verbatim), yolo
runs the denied command on the host shell with envelope parity, banner,
log record round-trip, fs fence enforced under yolo, precedence over
prompt mode (exec never prompts, fs still does), env-var equivalence,
exact-value flag parsing, --yolo alias. Coverage 90.10% (floor 80%).
Refs #297
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Operator-reported: on stub-shell builds (the only crates.io-publishable
configuration) agent-bridle's shell tool fails closed on every command,
so run_command cannot do agentic coding at all without the brush
CommandInterceptor patch underneath. This adds the explicit, loud,
interim bypass the issue specifies:
- newt-core: `ocap_disabled()` (NEWT_DISABLE_OCAP=1, exact-value,
fail-closed read; env-only — deliberately NO config key) and a
run_command bypass that executes on the plain host shell (bash -c,
sh fallback; cmd /C on Windows) with the SAME venv/PATH prefix and
an envelope structurally identical to the bridle one
({exit_code, stdout, stderr, sandbox_kind}, denied/denials omitted),
so envelope_denied/shell_envelope_output and the loop need no changes.
- Scope: exec only. web_fetch is NOT bypassed — determined empirically
that the stub-shell branch stubs only the shell tool; the web tool at
agent-bridle rev 2129c91 is the real leash-enforcing implementation.
fs tools keep the newt-native workspace fence: yolo is unconfined
exec, fenced fs — never authority-off.
- Precedence: --disable-ocap > --prompt-for-permissions for exec
(nothing is denied, so the #263 gate is structurally unreachable);
fs prompting unaffected.
- newt-cli: --disable-ocap with visible alias --yolo (global), threads
via NEWT_DISABLE_OCAP=1 exactly like #289's --prompt-for-permissions.
- newt-tui: unmissable session-start banner + ONE ocap-disabled record
({decision: "ocap-disabled", scope: "session", kind: exec, target: *})
in the #263 permission log so the audit trail shows the session ran open.
- Everything is doc-marked INTERIM, referencing #297, agent-bridle#20,
and reubeno/brush#1184 — removed (or demoted to a debug flag) when
brush upstreams CommandInterceptor.
13 new tests: flag-off pinned bit-for-bit (stub error verbatim), yolo
runs the denied command on the host shell with envelope parity, banner,
log record round-trip, fs fence enforced under yolo, precedence over
prompt mode (exec never prompts, fs still does), env-var equivalence,
exact-value flag parsing, --yolo alias. Coverage 90.10% (floor 80%).
Refs #297
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Operator-reported: on stub-shell builds (the only crates.io-publishable
configuration) agent-bridle's shell tool fails closed on every command,
so run_command cannot do agentic coding at all without the brush
CommandInterceptor patch underneath. This adds the explicit, loud,
interim bypass the issue specifies:
- newt-core: `ocap_disabled()` (NEWT_DISABLE_OCAP=1, exact-value,
fail-closed read; env-only — deliberately NO config key) and a
run_command bypass that executes on the plain host shell (bash -c,
sh fallback; cmd /C on Windows) with the SAME venv/PATH prefix and
an envelope structurally identical to the bridle one
({exit_code, stdout, stderr, sandbox_kind}, denied/denials omitted),
so envelope_denied/shell_envelope_output and the loop need no changes.
- Scope: exec only. web_fetch is NOT bypassed — determined empirically
that the stub-shell branch stubs only the shell tool; the web tool at
agent-bridle rev 2129c91 is the real leash-enforcing implementation.
fs tools keep the newt-native workspace fence: yolo is unconfined
exec, fenced fs — never authority-off.
- Precedence: --disable-ocap > --prompt-for-permissions for exec
(nothing is denied, so the #263 gate is structurally unreachable);
fs prompting unaffected.
- newt-cli: --disable-ocap with visible alias --yolo (global), threads
via NEWT_DISABLE_OCAP=1 exactly like #289's --prompt-for-permissions.
- newt-tui: unmissable session-start banner + ONE ocap-disabled record
({decision: "ocap-disabled", scope: "session", kind: exec, target: *})
in the #263 permission log so the audit trail shows the session ran open.
- Everything is doc-marked INTERIM, referencing #297, agent-bridle#20,
and reubeno/brush#1184 — removed (or demoted to a debug flag) when
brush upstreams CommandInterceptor.
13 new tests: flag-off pinned bit-for-bit (stub error verbatim), yolo
runs the denied command on the host shell with envelope parity, banner,
log record round-trip, fs fence enforced under yolo, precedence over
prompt mode (exec never prompts, fs still does), env-var equivalence,
exact-value flag parsing, --yolo alias. Coverage 90.10% (floor 80%).
Refs #297
Co-authored-by: hartsock <hartsock@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
15208cf to
af5290c
Compare
|
Committed to landing this one — I'll keep the branch rebased on |
3c6d45f to
6b1621f
Compare
|
Full CI is green on my fork for this branch — all 30 jobs pass across macOS, Windows, Linux (x86_64/aarch64/musl), wasm32, and the OS-target distros: https://github.com/hartsock/brush/actions/runs/27522097280 |
7655eee to
fde15cc
Compare
… not main The CI "agent-bridle stub guard" correctly rejected `branch = "main"` (main carries the real brush shell — dev-only). The right ref is `brush-stub/publish-unblock`: the publishable STUB branch (brush git-deps removed, crates.io-safe) that ALSO carries the `step_up` Gate (#24). Repins there (locked d5af5d4), and makes the convention consistent: - justfile `shell-stub`/`shell-real` toggle + comment now target `brush-stub/publish-unblock` (was feat/stub-shell); the stub guard (forbids only `main`) passes unchanged. - Cargo.toml [patch] comment + CHANGELOG updated; flagged INTERIM (Path B) — revisit before cutting 0.6.9 (canonical fix: fold step_up into feat/stub-shell). The new stub branch updated the unavailable-shell denial message; updated the 7 test assertions that pinned the old `reubeno/brush/pull/1184` string to match the new "shell unavailable in this build" (newt-core 2, newt-mcp-server 3, newt-tui 2). Full workspace green (2052 tests, 75 suites); shell guard OK; clippy + fmt clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…changelog (#498) * chore(deps): advance agent-bridle pin feat/stub-shell -> main (step_up Gate) + 0.6.9 changelog Repoints the agent-bridle [patch.crates-io] from feat/stub-shell to main and re-locks to main#7e5e8f4. main carries the human-presence step_up Gate (agent-bridle#24, Gate::evaluate -> Allow | NeedsDischarge | Deny) that ROADMAP 23.2 builds on, and keeps the brush git-deps removed (stub shell) so the build stays crates.io-safe. feat/stub-shell lacked step_up, which blocked 23.2 (newt#497). Full workspace green (2052 tests, 75 suites) — NO API drift from the bump. step_up/NeedsDischarge are now reachable, unblocking 23.2. Documents the move + the broader crew/team/overseer stack under a new CHANGELOG [Unreleased] section targeting 0.6.9, including the release action: bump the workspace version 0.6.8 -> 0.6.9, and drop the [patch] block once agent-bridle 0.1.0 is on crates.io (do NOT revert to feat/stub-shell — it lacks step_up). Closes #497. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(deps): pin agent-bridle to brush-stub/publish-unblock (Path B), not main The CI "agent-bridle stub guard" correctly rejected `branch = "main"` (main carries the real brush shell — dev-only). The right ref is `brush-stub/publish-unblock`: the publishable STUB branch (brush git-deps removed, crates.io-safe) that ALSO carries the `step_up` Gate (#24). Repins there (locked d5af5d4), and makes the convention consistent: - justfile `shell-stub`/`shell-real` toggle + comment now target `brush-stub/publish-unblock` (was feat/stub-shell); the stub guard (forbids only `main`) passes unchanged. - Cargo.toml [patch] comment + CHANGELOG updated; flagged INTERIM (Path B) — revisit before cutting 0.6.9 (canonical fix: fold step_up into feat/stub-shell). The new stub branch updated the unavailable-shell denial message; updated the 7 test assertions that pinned the old `reubeno/brush/pull/1184` string to match the new "shell unavailable in this build" (newt-core 2, newt-mcp-server 3, newt-tui 2). Full workspace green (2052 tests, 75 suites); shell guard OK; clippy + fmt clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: hartsock <hartsock@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fde15cc to
03a6eb6
Compare
…verclaims Adds docs/decisions/ocap_confinement_model.md — confined-execution vs confined-paths vs delegated-execution, the faithfulness ladder, and a built-vs-target accounting that an adversarial review (+ the code) forced to be honest. An earlier draft of this very doc OVERCLAIMED; corrected: - The OS sandbox (b1-os-isolation: Landlock/Seatbelt/AppContainer/seccomp/netns) is UNBUILT — newt_core::ocap::verify_b1() returns Absent (sandbox_kind=none; "the in-process monitor is the only barrier"). Marked TARGET, not present. - The brush-backed confined shell is a fail-closed STUB on crates.io builds (pending reubeno/brush#1184 + agent-bridle#20); today the only path that runs a command is --yolo (unconfined host shell). - What IS built: the in-process Caveats monitor over newt's NATIVE fs tools (lock_fs_to_workspace @ tui_permits_path — not a kernel fence, not over subprocesses), the web_fetch net leash, and delegated forge-fetch (token read in the harness, never in model context). - --venv is name-set (basename) exec grants, symlinks FOLLOWED-to-grant, no resolved-target/Landlock check — not a tight capability; its safety is whatever fence later applies (in-process monitor today; nothing under --yolo). - --yolo: fail-closed default + a *provided* fail-open allowance (unbridle the agent) — honest that the web_fetch leash + native-fs fence stay on while the spawned SUBPROCESS is unfenced ("unconfined exec, fenced native fs"). Corrects transparent_command_layer.md §4 (--venv) and §5 (the OS fence is the TARGET, not a present kernel wall). Cross-links the ADR into the sibling docs. Re-verified clean against the code by a focused adversarial pass. Refs #548 #552 #560 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QuysMycvvtHeFnY6Astj4u
) * docs(testing): live behavioral tool-loop UAT suite + first results Formalizes the live-coding-prompt test technique into a documented UAT suite that EXTENDS bat-uat.md (the L2/L3 live tier for tool-LOOP behavior, complementing the golden-diff newt-eval cases and the mocked tool_round_cap_tests): - docs/testing/uat-tool-loop.md — the methodology (disposable workspace + golden pre-state, sandbox HOME w/ default-ish config, user-phrased prompt, DUAL assertion: outcome + tool-loop behavior signals), the 7-scenario catalog (S1-S4 capability; S5-S7 adversarial: honest-precondition, dup-guard, honest cap-exit), how-to-run, and the load-bearing gotchas (dgx1 reliable / gnuc flaky; run_command is the dead stub; NEVER pkill -f a name — it self-kills the shell, exit 144; kill by PID / fuser -k by port). - docs/testing/scripts/uat_tool_loop.sh — self-contained parameterized runner. - docs/testing/results/uat-tool-loop-baba2e7.md — first run vs latest main (Phase 27 in) on dgx1: harness solid (S1/S3/S4 pass), 27.1 corrective + 27.4 plan-ledger active, 27.2 branch workflow works (sharpened); the two model-behavior gaps (S2 branch-on-master, S5 fabricate-missing-module) were PROMPTABLE; 27.3/27.5 guards need a weak model to trigger live (capable model adapts) — deterministic guarantee stays the mocked uat_thrash test. Test-only docs + harness; no behavior change. Extends #614 (Step 27.6 BAT/UAT). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QuysMycvvtHeFnY6Astj4u * docs(testing): UAT real-shell prerequisite — just install-real until brush#1184 run_command is the fail-closed stub on the default build (yet advertised), so any shell-dependent scenario (S6/S7) must 'just install-real' first or it tests dead-tool handling, not the shell. Documented as a standing stopgap until reubeno/brush#1184; the runner prints the reminder. Non-shell scenarios (S1-S5) are fine on the stub build. --------- Co-authored-by: Shawn Hartsock <hartsock@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
21c474e to
a6a4be8
Compare
…ST-over-regex ADR) (#561) * docs(design): command-plugin runtime + transparent command layer + AST-over-regex ADR Design artifacts for the harness link-resolver work, captured ahead of implementation (design-first; the code is held). - docs/decisions/structural_parsing_over_regex.md — ADR: parse untrusted / authority-bearing input with a real parser (AST), not regex. In an OCAP system every parse is an authority boundary; a regex differential is an authority bypass (SSRF / open-redirect / traversal). Regex stays for trusted/cosmetic text. - docs/design/transparent_command_layer.md — the parse·route·govern model: one structural front-end (URLs + CLI argv) feeding a handler registry across two hooks (user input, model tool-calls). Answers the OCAP question — the layer is a parser+router, never an authority source; ambient python/bash is out by construction; commands tier read / egress / exec. - docs/design/command_plugin_runtime.md — slash commands as installable plugins under ~/.newt/commands/<name>/ (install/remove/update/replace, no rebuild). manifest.toml binds a command to a compiled "kind"; data, never arbitrary code. Decisions: embedded github/gitlab defaults; per-command resolvers over the built-in base (no shared pool); provenance/signing deferred to #560. Motivated by #548 (/help rollups) and #552 (hide embedded git); the forge resolver becomes the first `forge-fetch` kind + default `issue` plugin. Refs #548 #552 #560 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QuysMycvvtHeFnY6Astj4u * docs(design): exclude interpreters from the transparent layer; fence find to cwd Review feedback on the transparent command layer: - General-purpose interpreters (python/bash/node/…) are EXCLUDED, not "handled carefully." Sharpened the inclusion criterion: a command belongs in the layer iff its authority is determinable from a structural parse of the invocation. `python script.py` has no parseable authority → not a candidate. This makes the layer's guarantee honest: everything it touches, it can bound. - python's governed path already exists: `newt --venv <path>` — an explicit human grant that confines exec to a chosen venv's bin/ via the captured shell. Excluding python costs nothing; it points at the right capability. - Read-tier commands are path-fenced to the workspace — e.g. `find <path>` is refused unless it resolves under cwd (the same fence the fs tools enforce). - Dropped the "arbitrary execution" tier + "exec phase" from the layer; updated the tier table, hazard framing, architecture, and phasing to match. Refs #548 #552 #560 * docs(design): fence ALL file-accessing commands at the execution boundary Answering "we need to fence all file-accessing commands — can we do that?": yes, and the robust mechanism already exists. Generalized the `find`→cwd note into a principle (§5): - The fence is the EXECUTION boundary, not per-command path-flag parsing (which is fragile — a missed flag, config-file path, symlink, or $(…) escapes). - Every routed command runs inside newt's workspace fence: lock_fs_to_workspace (newt-core::caveats), widened only by explicit --read/--write grants, and enforced by OS isolation — b1-os-isolation: Landlock (Linux) / Seatbelt (macOS) / AppContainer (Windows). The OS denies access outside the workspace regardless of how a command names its paths. - So fencing all file-accessing commands is mostly already true — they inherit the fence by running confined. Per-command path parsing stays only as fast-fail / defense-in-depth, never the boundary. Updated the read-tier table accordingly (added cat/ls; the fence is §5). Refs #548 #552 #560 * docs(decisions): honest OCAP confinement model; correct --venv + §5 overclaims Adds docs/decisions/ocap_confinement_model.md — confined-execution vs confined-paths vs delegated-execution, the faithfulness ladder, and a built-vs-target accounting that an adversarial review (+ the code) forced to be honest. An earlier draft of this very doc OVERCLAIMED; corrected: - The OS sandbox (b1-os-isolation: Landlock/Seatbelt/AppContainer/seccomp/netns) is UNBUILT — newt_core::ocap::verify_b1() returns Absent (sandbox_kind=none; "the in-process monitor is the only barrier"). Marked TARGET, not present. - The brush-backed confined shell is a fail-closed STUB on crates.io builds (pending reubeno/brush#1184 + agent-bridle#20); today the only path that runs a command is --yolo (unconfined host shell). - What IS built: the in-process Caveats monitor over newt's NATIVE fs tools (lock_fs_to_workspace @ tui_permits_path — not a kernel fence, not over subprocesses), the web_fetch net leash, and delegated forge-fetch (token read in the harness, never in model context). - --venv is name-set (basename) exec grants, symlinks FOLLOWED-to-grant, no resolved-target/Landlock check — not a tight capability; its safety is whatever fence later applies (in-process monitor today; nothing under --yolo). - --yolo: fail-closed default + a *provided* fail-open allowance (unbridle the agent) — honest that the web_fetch leash + native-fs fence stay on while the spawned SUBPROCESS is unfenced ("unconfined exec, fenced native fs"). Corrects transparent_command_layer.md §4 (--venv) and §5 (the OS fence is the TARGET, not a present kernel wall). Cross-links the ADR into the sibling docs. Re-verified clean against the code by a focused adversarial pass. Refs #548 #552 #560 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QuysMycvvtHeFnY6Astj4u * docs(decisions): host_command_confinement — fence the host suite, don't reimplement GNU Standing decision (anti-relitigation) on how newt gives the agent CLI tools: - Run the host's commands INSIDE the fence (it bounds any binary regardless of args/author) — do NOT reimplement the GNU/coreutils suite. Reimplement only the egress/escape-hatch handful (curl/wget → delegated egress service; interpreters → excluded/confined-exec). Explicit non-goal: rewriting find/grep/cat/... "for safety" is rejected — fence them instead. - Per-command allow/deny lists are POLICY, never the boundary: identity ≠ authority (same binary spans tiers by args; launcher-transitivity collapses any deny-list; deny-lists fail open; name-matching is spoofable; a fenceless allow-list is unbounded). Keep the {allow, attest, deny} vocabulary as policy. - The policy IS the step-up decision surface × presence strength: {allow, attest, deny} × {none, presence-prompt, passkey/biometric (YubiKey/ Touch ID)} — agent-bridle Gate/step_up (#24) + newt-core crew_attest (#479). - The fence is the boundary and is DECOUPLED from brush: pre_exec Landlock/ seccomp/netns (no upstream dep), or bubblewrap/nsjail, or a real shell in the box, or heavier per-worker isolation; per-OS Seatbelt/AppContainer cross-platform. brush CommandInterceptor = optional shell ergonomics, not the only path. Honest built-vs-target throughout: the OS fence (b1) is UNBUILT (verify_b1 → Absent), the confined shell is a stub (brush#1184), passkey ENFORCEMENT awaits BOOT (#472) — this ADR sets the target shape, claims nothing runs today. Verified honest+accurate against the code by a focused adversarial pass. Cross-linked into transparent_command_layer.md + ocap_confinement_model.md. Refs #560 #472 #479 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QuysMycvvtHeFnY6Astj4u --------- Co-authored-by: Shawn Hartsock <hartsock@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ell (#238) While the brush fork's CommandInterceptor is unpublishable (pending reubeno/brush#1184), main stays on agent-bridle's `feat/stub-shell` branch so the workspace publishes to crates.io — but that makes run_command/shell_run return "shell temporarily unavailable". This adds a dev-only lever to run the real Caveats-confined shell locally without disturbing the publishable default. - `just shell-real` — repoints the [patch.crates-io] agent-bridle entries at agent-bridle `main` (the real brush shell). DEV ONLY; not committable. - `just shell-stub` — flips back to the publishable stub branch. - `just shell-check` — guard that fails if the real-shell override is present, so the dev patch can't reach main. A cargo `--features` flag can't express this: crates.io forbids git deps in any form (even optional/feature-gated), so toggling the real shell swaps a dependency *source*, not a feature — hence a patch-swap recipe. The guard runs in the pre-push hook and is mirrored inline in CI's lint job (PIPELINE PARITY). Verified: guard passes on the stub tree, fails when flipped to `main`, and shell-stub restores Cargo.toml byte-for-byte. Co-authored-by: hartsock <hartsock@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-reported: on stub-shell builds (the only crates.io-publishable
configuration) agent-bridle's shell tool fails closed on every command,
so run_command cannot do agentic coding at all without the brush
CommandInterceptor patch underneath. This adds the explicit, loud,
interim bypass the issue specifies:
- newt-core: `ocap_disabled()` (NEWT_DISABLE_OCAP=1, exact-value,
fail-closed read; env-only — deliberately NO config key) and a
run_command bypass that executes on the plain host shell (bash -c,
sh fallback; cmd /C on Windows) with the SAME venv/PATH prefix and
an envelope structurally identical to the bridle one
({exit_code, stdout, stderr, sandbox_kind}, denied/denials omitted),
so envelope_denied/shell_envelope_output and the loop need no changes.
- Scope: exec only. web_fetch is NOT bypassed — determined empirically
that the stub-shell branch stubs only the shell tool; the web tool at
agent-bridle rev 2129c91 is the real leash-enforcing implementation.
fs tools keep the newt-native workspace fence: yolo is unconfined
exec, fenced fs — never authority-off.
- Precedence: --disable-ocap > --prompt-for-permissions for exec
(nothing is denied, so the #263 gate is structurally unreachable);
fs prompting unaffected.
- newt-cli: --disable-ocap with visible alias --yolo (global), threads
via NEWT_DISABLE_OCAP=1 exactly like #289's --prompt-for-permissions.
- newt-tui: unmissable session-start banner + ONE ocap-disabled record
({decision: "ocap-disabled", scope: "session", kind: exec, target: *})
in the #263 permission log so the audit trail shows the session ran open.
- Everything is doc-marked INTERIM, referencing #297, agent-bridle#20,
and reubeno/brush#1184 — removed (or demoted to a debug flag) when
brush upstreams CommandInterceptor.
13 new tests: flag-off pinned bit-for-bit (stub error verbatim), yolo
runs the denied command on the host shell with envelope parity, banner,
log record round-trip, fs fence enforced under yolo, precedence over
prompt mode (exec never prompts, fs still does), env-var equivalence,
exact-value flag parsing, --yolo alias. Coverage 90.10% (floor 80%).
Refs #297
Co-authored-by: hartsock <hartsock@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…changelog (#498) * chore(deps): advance agent-bridle pin feat/stub-shell -> main (step_up Gate) + 0.6.9 changelog Repoints the agent-bridle [patch.crates-io] from feat/stub-shell to main and re-locks to main#7e5e8f4. main carries the human-presence step_up Gate (agent-bridle#24, Gate::evaluate -> Allow | NeedsDischarge | Deny) that ROADMAP 23.2 builds on, and keeps the brush git-deps removed (stub shell) so the build stays crates.io-safe. feat/stub-shell lacked step_up, which blocked 23.2 (newt#497). Full workspace green (2052 tests, 75 suites) — NO API drift from the bump. step_up/NeedsDischarge are now reachable, unblocking 23.2. Documents the move + the broader crew/team/overseer stack under a new CHANGELOG [Unreleased] section targeting 0.6.9, including the release action: bump the workspace version 0.6.8 -> 0.6.9, and drop the [patch] block once agent-bridle 0.1.0 is on crates.io (do NOT revert to feat/stub-shell — it lacks step_up). Closes #497. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(deps): pin agent-bridle to brush-stub/publish-unblock (Path B), not main The CI "agent-bridle stub guard" correctly rejected `branch = "main"` (main carries the real brush shell — dev-only). The right ref is `brush-stub/publish-unblock`: the publishable STUB branch (brush git-deps removed, crates.io-safe) that ALSO carries the `step_up` Gate (#24). Repins there (locked d5af5d4), and makes the convention consistent: - justfile `shell-stub`/`shell-real` toggle + comment now target `brush-stub/publish-unblock` (was feat/stub-shell); the stub guard (forbids only `main`) passes unchanged. - Cargo.toml [patch] comment + CHANGELOG updated; flagged INTERIM (Path B) — revisit before cutting 0.6.9 (canonical fix: fold step_up into feat/stub-shell). The new stub branch updated the unavailable-shell denial message; updated the 7 test assertions that pinned the old `reubeno/brush/pull/1184` string to match the new "shell unavailable in this build" (newt-core 2, newt-mcp-server 3, newt-tui 2). Full workspace green (2052 tests, 75 suites); shell guard OK; clippy + fmt clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: hartsock <hartsock@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ign (#628) * docs(research): weak-model tool-loop findings + refined plan-mode design Live A/B investigation (dgx1) of "would a better plan mode help weak models?". Headline findings, by data: - The #1 weak-model failure is tool-call FORMAT, not plan retention: small coders (qwen2.5-coder:3b/14b, codestral:22b) can't emit a native tool call (text-dump → tool_calls=0 → never execute); even qwen3-coder:30b emitted a <function=> XML form that didn't parse (0 steps, 2/6 runs). → P0: a tool-call recovery handler is the highest-leverage fix, above any plan work. - "Re-seat the plan every round" is NOT validated and mildly HURTS with no drift (devstral 8-step: baseline 8/8/8 in 13-21 rounds; re-seat 7/8 + 24-round cap every run). → re-seat must be conditional + tested under genuine drift. - A capable model adapts; drift only emerges at higher complexity (12-step fair-test induces baseline drift; re-seat verdict to be appended). - Tool calls ran against the dead STUB shell; real-shell tests need `just install-real` until reubeno/brush#1184 lands. Includes the two-tier weak-model failure taxonomy, the A/B method+data, the hardened rig methodology (three confounds defeated: pkill self-kill, tool-call format, line-by-line prompt delivery), and the priority-ordered refined design (P0 tool-call recovery, P1 conditional re-seat, P2 overseer/crew split). Refs #559 #614 * docs(research): append 12-step fair-test verdict — re-seat fixes drift (12/12 vs 8/12) The harder 12-step task induces baseline drift (mean 8.00/12, runs 6/6/12 — drops half the later steps 2/3 of runs); re-seat completes 12.00/12 (12,12,12, zero variance). Combined with the 8-step null/mild-overhead result, re-seat is VALIDATED for the drift regime (the user's actual problem) and only costs mild overhead where there's no drift → P1 upgraded: keep re-seat, gated on a drift signal. Confirms the user's 'loses track over time' diagnosis. * docs(research): append recovery re-validation (c) — qwen2.5-coder:14b 0/8 -> ~7.7/8 Live re-validation of the recovery handler (#629): qwen2.5-coder:14b went from ~0/8 (baseline — text-dumped JSON, never executed) to 8/8/7 with the handler firing 19-24 rounds/run. A previously non-functional weak model now completes the task. P0/P1 marked BUILT (#629/#631). Refs #629 #631 #630 --------- Co-authored-by: Shawn Hartsock <hartsock@users.noreply.github.com>
) * docs(testing): live behavioral tool-loop UAT suite + first results Formalizes the live-coding-prompt test technique into a documented UAT suite that EXTENDS bat-uat.md (the L2/L3 live tier for tool-LOOP behavior, complementing the golden-diff newt-eval cases and the mocked tool_round_cap_tests): - docs/testing/uat-tool-loop.md — the methodology (disposable workspace + golden pre-state, sandbox HOME w/ default-ish config, user-phrased prompt, DUAL assertion: outcome + tool-loop behavior signals), the 7-scenario catalog (S1-S4 capability; S5-S7 adversarial: honest-precondition, dup-guard, honest cap-exit), how-to-run, and the load-bearing gotchas (dgx1 reliable / gnuc flaky; run_command is the dead stub; NEVER pkill -f a name — it self-kills the shell, exit 144; kill by PID / fuser -k by port). - docs/testing/scripts/uat_tool_loop.sh — self-contained parameterized runner. - docs/testing/results/uat-tool-loop-601d87b.md — first run vs latest main (Phase 27 in) on dgx1: harness solid (S1/S3/S4 pass), 27.1 corrective + 27.4 plan-ledger active, 27.2 branch workflow works (sharpened); the two model-behavior gaps (S2 branch-on-master, S5 fabricate-missing-module) were PROMPTABLE; 27.3/27.5 guards need a weak model to trigger live (capable model adapts) — deterministic guarantee stays the mocked uat_thrash test. Test-only docs + harness; no behavior change. Extends #614 (Step 27.6 BAT/UAT). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QuysMycvvtHeFnY6Astj4u * docs(testing): UAT real-shell prerequisite — just install-real until brush#1184 run_command is the fail-closed stub on the default build (yet advertised), so any shell-dependent scenario (S6/S7) must 'just install-real' first or it tests dead-tool handling, not the shell. Documented as a standing stopgap until reubeno/brush#1184; the runner prints the reminder. Non-shell scenarios (S1-S5) are fine on the stub build. --------- Co-authored-by: Shawn Hartsock <hartsock@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ST-over-regex ADR) (#561) * docs(design): command-plugin runtime + transparent command layer + AST-over-regex ADR Design artifacts for the harness link-resolver work, captured ahead of implementation (design-first; the code is held). - docs/decisions/structural_parsing_over_regex.md — ADR: parse untrusted / authority-bearing input with a real parser (AST), not regex. In an OCAP system every parse is an authority boundary; a regex differential is an authority bypass (SSRF / open-redirect / traversal). Regex stays for trusted/cosmetic text. - docs/design/transparent_command_layer.md — the parse·route·govern model: one structural front-end (URLs + CLI argv) feeding a handler registry across two hooks (user input, model tool-calls). Answers the OCAP question — the layer is a parser+router, never an authority source; ambient python/bash is out by construction; commands tier read / egress / exec. - docs/design/command_plugin_runtime.md — slash commands as installable plugins under ~/.newt/commands/<name>/ (install/remove/update/replace, no rebuild). manifest.toml binds a command to a compiled "kind"; data, never arbitrary code. Decisions: embedded github/gitlab defaults; per-command resolvers over the built-in base (no shared pool); provenance/signing deferred to #560. Motivated by #548 (/help rollups) and #552 (hide embedded git); the forge resolver becomes the first `forge-fetch` kind + default `issue` plugin. Refs #548 #552 #560 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QuysMycvvtHeFnY6Astj4u * docs(design): exclude interpreters from the transparent layer; fence find to cwd Review feedback on the transparent command layer: - General-purpose interpreters (python/bash/node/…) are EXCLUDED, not "handled carefully." Sharpened the inclusion criterion: a command belongs in the layer iff its authority is determinable from a structural parse of the invocation. `python script.py` has no parseable authority → not a candidate. This makes the layer's guarantee honest: everything it touches, it can bound. - python's governed path already exists: `newt --venv <path>` — an explicit human grant that confines exec to a chosen venv's bin/ via the captured shell. Excluding python costs nothing; it points at the right capability. - Read-tier commands are path-fenced to the workspace — e.g. `find <path>` is refused unless it resolves under cwd (the same fence the fs tools enforce). - Dropped the "arbitrary execution" tier + "exec phase" from the layer; updated the tier table, hazard framing, architecture, and phasing to match. Refs #548 #552 #560 * docs(design): fence ALL file-accessing commands at the execution boundary Answering "we need to fence all file-accessing commands — can we do that?": yes, and the robust mechanism already exists. Generalized the `find`→cwd note into a principle (§5): - The fence is the EXECUTION boundary, not per-command path-flag parsing (which is fragile — a missed flag, config-file path, symlink, or $(…) escapes). - Every routed command runs inside newt's workspace fence: lock_fs_to_workspace (newt-core::caveats), widened only by explicit --read/--write grants, and enforced by OS isolation — b1-os-isolation: Landlock (Linux) / Seatbelt (macOS) / AppContainer (Windows). The OS denies access outside the workspace regardless of how a command names its paths. - So fencing all file-accessing commands is mostly already true — they inherit the fence by running confined. Per-command path parsing stays only as fast-fail / defense-in-depth, never the boundary. Updated the read-tier table accordingly (added cat/ls; the fence is §5). Refs #548 #552 #560 * docs(decisions): honest OCAP confinement model; correct --venv + §5 overclaims Adds docs/decisions/ocap_confinement_model.md — confined-execution vs confined-paths vs delegated-execution, the faithfulness ladder, and a built-vs-target accounting that an adversarial review (+ the code) forced to be honest. An earlier draft of this very doc OVERCLAIMED; corrected: - The OS sandbox (b1-os-isolation: Landlock/Seatbelt/AppContainer/seccomp/netns) is UNBUILT — newt_core::ocap::verify_b1() returns Absent (sandbox_kind=none; "the in-process monitor is the only barrier"). Marked TARGET, not present. - The brush-backed confined shell is a fail-closed STUB on crates.io builds (pending reubeno/brush#1184 + agent-bridle#20); today the only path that runs a command is --yolo (unconfined host shell). - What IS built: the in-process Caveats monitor over newt's NATIVE fs tools (lock_fs_to_workspace @ tui_permits_path — not a kernel fence, not over subprocesses), the web_fetch net leash, and delegated forge-fetch (token read in the harness, never in model context). - --venv is name-set (basename) exec grants, symlinks FOLLOWED-to-grant, no resolved-target/Landlock check — not a tight capability; its safety is whatever fence later applies (in-process monitor today; nothing under --yolo). - --yolo: fail-closed default + a *provided* fail-open allowance (unbridle the agent) — honest that the web_fetch leash + native-fs fence stay on while the spawned SUBPROCESS is unfenced ("unconfined exec, fenced native fs"). Corrects transparent_command_layer.md §4 (--venv) and §5 (the OS fence is the TARGET, not a present kernel wall). Cross-links the ADR into the sibling docs. Re-verified clean against the code by a focused adversarial pass. Refs #548 #552 #560 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QuysMycvvtHeFnY6Astj4u * docs(decisions): host_command_confinement — fence the host suite, don't reimplement GNU Standing decision (anti-relitigation) on how newt gives the agent CLI tools: - Run the host's commands INSIDE the fence (it bounds any binary regardless of args/author) — do NOT reimplement the GNU/coreutils suite. Reimplement only the egress/escape-hatch handful (curl/wget → delegated egress service; interpreters → excluded/confined-exec). Explicit non-goal: rewriting find/grep/cat/... "for safety" is rejected — fence them instead. - Per-command allow/deny lists are POLICY, never the boundary: identity ≠ authority (same binary spans tiers by args; launcher-transitivity collapses any deny-list; deny-lists fail open; name-matching is spoofable; a fenceless allow-list is unbounded). Keep the {allow, attest, deny} vocabulary as policy. - The policy IS the step-up decision surface × presence strength: {allow, attest, deny} × {none, presence-prompt, passkey/biometric (YubiKey/ Touch ID)} — agent-bridle Gate/step_up (#24) + newt-core crew_attest (#479). - The fence is the boundary and is DECOUPLED from brush: pre_exec Landlock/ seccomp/netns (no upstream dep), or bubblewrap/nsjail, or a real shell in the box, or heavier per-worker isolation; per-OS Seatbelt/AppContainer cross-platform. brush CommandInterceptor = optional shell ergonomics, not the only path. Honest built-vs-target throughout: the OS fence (b1) is UNBUILT (verify_b1 → Absent), the confined shell is a stub (brush#1184), passkey ENFORCEMENT awaits BOOT (#472) — this ADR sets the target shape, claims nothing runs today. Verified honest+accurate against the code by a focused adversarial pass. Cross-linked into transparent_command_layer.md + ocap_confinement_model.md. Refs #560 #472 #479 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QuysMycvvtHeFnY6Astj4u --------- Co-authored-by: Shawn Hartsock <hartsock@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2cde127 to
e59257b
Compare
Embedders of brush-core could not confine command execution in-process.
The path-separator dispatch branch in commands.rs (e.g. `/bin/rm`, `./x`)
bypasses both the PATH search and the builtin table, so any name- or
PATH-based gate an embedder might apply is trivially defeated. ShellExtensions
carried only ErrorFormatter (presentation), with no authority seam.
Add an optional CommandInterceptor component to ShellExtensions, mirroring the
existing ErrorFormatter pattern, with two default-allow hooks:
- before_exec(program, args) -> ExecDecision, called at the single external-
spawn funnel (execute_external_command), so BOTH dispatch branches —
including the path-separator branch — are covered.
- before_open(path, write) -> OpenDecision, called in Shell::open_file, the
single choke point for all filesystem-path opens (redirections + source/.).
On Deny, execs fail with a new ErrorKind::ExecDenied (exit code 126) and opens
fail with a PermissionDenied io::Error surfaced by the existing redirection /
source error wrapping. Neither panics nor silently skips.
The change is purely additive: ShellExtensionsImpl gains a second type param
that defaults to DefaultCommandInterceptor (allow-all), so DefaultShellExtensions
and all existing call sites are source-compatible and byte-for-byte identical in
behavior. The full existing suite (including the YAML bash-compat and redirection
cases) passes unchanged.
Motivation: agent-bridle, an object-capability confinement layer for LLM coding
agents. The agent harness is a confused deputy (full ambient authority + untrusted
instructions); the ocap remedy enforces attenuated capabilities at the point of
use. An embedded brush is the script-execution surface, so enforcement must live
inside the shell before the spawn/open — and must cover the path-separator bypass.
Includes brush-core/tests/command_interceptor_tests.rs, which proves a custom
interceptor denies `rm`, denies the absolute-path `/bin/rm` (the load-bearing
path-separator case), allows `/bin/true`, denies writes outside an allowed dir,
and allows read-only source opens. Upstream contribution drafted at
docs/upstream/reubeno-brush-exec-open-hook.md (not yet filed).
Assisted-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
e59257b to
fc1de3a
Compare
…rary (#206) Make brush's bundled uu_* coreutils (ls/cat/…) usable from the EMBEDDED brush engine with no host tools — the "just a filesystem" / distroless / Windows story (Track 2 Gate 2). Previously the dispatcher lived only in the brush *binary*, so an embedder's carried coreutils never ran. - `coreutils_dispatch.rs` lifts the dispatcher + shim registration into a library (cribbed from the fork's `brush-shell/src/bundled.rs`): `maybe_dispatch()`, `install_default_providers()`, `register_shims()`, `shim_execute()`. An embedder makes its binary dispatch-capable with one line in `main`: `if let Some(c) = maybe_dispatch() { std::process::exit(c); }`. - We keep the RE-EXEC (fork+exec) model deliberately: "fork + uumain in-process" is unsafe in a multithreaded process (fork-without-exec deadlocks on allocator locks; uumain isn't async-signal-safe). execve resets the address space, so re-exec is safe AND portable (Windows too). - `BrushShellTool` registers the coreutils shims when `carried-coreutils` is on; the shim's re-exec still funnels through the `before_exec` interceptor (leash holds). Feature `carried-coreutils` (off by default) carries the fork's coreutils crate (git dep) + a minimal ls/cat/echo uutils set. Runnable proof: `dispatch_host` bin (dispatch-capable) + `tests/carried_coreutils.rs` run carried `ls`/`cat` with the environment SCRUBBED (`env_clear` — no PATH, no host tools) and assert the output. Full suite green with carried-coreutils,host-shell,linux-landlock (114 tests); clippy -D warnings clean; default build + core publish-check unaffected. To upstream: the dispatch-as-a-library move is a natural follow-up to the CommandInterceptor hook (reubeno/brush#1184). Refs #206, #204, agent-bridle#20. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HRv5vteHCw4jUayHGL5esx
…sh-ocap-* published] (#204) * feat(shell): restore the carried brush engine (BrushShellTool) behind `brush` feature Bring back the in-process, CommandInterceptor-confined bash engine as a third engine behind the ADR 0005 D2 seam (peer of ShellTool / HostShellTool), using the temporary `brush-ocap-*` fork (upstream brush + reubeno/brush#1184). Unlike HostShellTool — which refuses a restricted exec/net grant because it can't bound a real /bin/sh's children — the brush engine's interceptor fires on every resolved program name (before_exec) and opened path (before_open) *inside* brush, so it CONFINES a restricted exec/fs grant in-process, on any platform, and records each denial as structured `denials` on the envelope. `exec` is stripped from the curated builtin set (the one builtin that spawns outside the funnel). - `caveat_interceptor.rs` recovered from history (5adfd91) — its core deps (ToolContext::check_exec/check_path_*, Denial/DenialKind) are unchanged. - `brush_shell.rs` is a fresh, minimal engine (modeled on HostShellTool): OS pipes + `Shell::builder_with_extensions` + the interceptor + curated builtins, run on a per-invocation current-thread runtime, output captured. - PARITY: seeds the FULL ambient PATH (`default_exec_path()`) when exec is unrestricted (agent's own tools resolve like a host shell); a minimal standard PATH when exec is restricted (externals still resolve to reach the before_exec gate that denies the out-of-scope ones). - Feature-gated `brush` (off by default) on tool-shell + the facade; the facade re-exports `BrushShellTool`. Default build + core stay publish-clean. Tests (tests/brush_real.rs, real spawn): `$(...)` runs in-process (safe-subset refuses it); a restricted-exec out-of-scope command is DENIED and never runs; in-scope runs. Full tool-shell suite green with brush+host-shell+landlock; clippy -D warnings clean. NOT MERGEABLE YET: while `brush` is on, this carries a GIT dep on the fork, so tool-shell/facade can't publish to crates.io. To land: publish `brush-ocap-*`, swap the git deps to crates.io version deps, then merge (agent-bridle#20). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HRv5vteHCw4jUayHGL5esx * feat(shell): carried coreutils that work embedded — dispatch-as-a-library (#206) Make brush's bundled uu_* coreutils (ls/cat/…) usable from the EMBEDDED brush engine with no host tools — the "just a filesystem" / distroless / Windows story (Track 2 Gate 2). Previously the dispatcher lived only in the brush *binary*, so an embedder's carried coreutils never ran. - `coreutils_dispatch.rs` lifts the dispatcher + shim registration into a library (cribbed from the fork's `brush-shell/src/bundled.rs`): `maybe_dispatch()`, `install_default_providers()`, `register_shims()`, `shim_execute()`. An embedder makes its binary dispatch-capable with one line in `main`: `if let Some(c) = maybe_dispatch() { std::process::exit(c); }`. - We keep the RE-EXEC (fork+exec) model deliberately: "fork + uumain in-process" is unsafe in a multithreaded process (fork-without-exec deadlocks on allocator locks; uumain isn't async-signal-safe). execve resets the address space, so re-exec is safe AND portable (Windows too). - `BrushShellTool` registers the coreutils shims when `carried-coreutils` is on; the shim's re-exec still funnels through the `before_exec` interceptor (leash holds). Feature `carried-coreutils` (off by default) carries the fork's coreutils crate (git dep) + a minimal ls/cat/echo uutils set. Runnable proof: `dispatch_host` bin (dispatch-capable) + `tests/carried_coreutils.rs` run carried `ls`/`cat` with the environment SCRUBBED (`env_clear` — no PATH, no host tools) and assert the output. Full suite green with carried-coreutils,host-shell,linux-landlock (114 tests); clippy -D warnings clean; default build + core publish-check unaffected. To upstream: the dispatch-as-a-library move is a natural follow-up to the CommandInterceptor hook (reubeno/brush#1184). Refs #206, #204, agent-bridle#20. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HRv5vteHCw4jUayHGL5esx * build(shell): switch brush-ocap-* deps from the git fork to crates.io versions Now that brush-ocap-core 0.5.0, brush-ocap-builtins 0.2.0, and brush-ocap-coreutils-builtins 0.1.0 are published to crates.io, the `brush` / `carried-coreutils` features use version deps instead of the `hartsock/brush` git rev. The crate is git-dep-free and publishable with the features on. Facade gains a `carried-coreutils` feature forwarding to tool-shell. Behavior is identical (crates.io == fork rev ee58e24): brush_real + carried_coreutils green, builds resolve `registry+…crates.io` for all three brush-ocap-* crates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HRv5vteHCw4jUayHGL5esx --------- Co-authored-by: Shawn Hartsock <hartsock@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@reubeno just a note from me to say I am highly invested in this patch. I am working on an Object Capability security harness and I think working inside a shell like Brush is a major breakthrough in future security work that will ONLY be possible with a project like this. Thanks for taking on such a BEAST and doing a good job managing the community! |
Thanks for checking in on this @hartsock, and also for your patience! I and we appreciate your enthusiasm and contributions 😄. I'm also very supportive of getting these kinds of hooks added -- I think it's just a matter of making sure we can arrive at the right API surface. June was rough for me, but I should hopefully have more time in the next few weeks to engage with you here on more detailed review. |
Addresses #1183.
What
Adds an optional
CommandInterceptorcomponent toShellExtensionswith twodefault-allow hooks,
before_exec(program, args)andbefore_open(path, write),letting an embedding host deny external command execution and file opens
in-process. Default behavior is unchanged.
Why
See #1183. In short: embedders cannot currently confine command
execution in-process, and the path-separator dispatch branch (
/bin/rm,./x) bypasses PATH and the builtin table, defeating any name/PATH-based gate.This is the minimal additive seam to close that.
How
extensions::CommandInterceptor(mirrorsErrorFormatter),collected as
ShellExtensions::CommandInterceptor, withDefaultCommandInterceptor(allow-all).ShellExtensionsImplgains a secondtype parameter that defaults to
DefaultCommandInterceptor, soDefaultShellExtensionsand all existing call sites are source-compatible.Shell<SE>stores acommand_interceptor: SE::CommandInterceptorinstance(parallel to
error_formatter), wired throughCreateOptions/the builder,new,Default, andClone. New accessorShell::command_interceptor().before_execis invoked once, at the top ofcommands::execute_external_command— the single funnel for both dispatchbranches. Denials return a new
ErrorKind::ExecDenied(program, reason)(mapped to exit code 126, "cannot execute").
before_openis invoked inShell::open_file(covers redirections andsource/.). Denials return anio::Error(PermissionDenied), which theexisting
RedirectionFailure/FailedSourcingFilewrapping reports cleanly.Write-intent is derived from the
OpenOptions(best-effort, fail-safe toward"write").
Test plan
brush-core/tests/command_interceptor_tests.rs:rm, PATH-resolved);/bin/rm), proving thepath-separator branch is now hooked (the load-bearing case);
/bin/true);allowing one inside it;
sourceopen (proving thewriteflag is threaded).cargo test --workspace, including the YAML bash-compatcases and the redirection cases) passes unchanged — the default-allow impl is
behavior-preserving.
cargo fmt --checkandcargo clippy --workspace --all-targets -D warningsare clean.
Compatibility / risk
Additive only.
ShellExtensionsImpl's new type parameter is defaulted, sodownstream
type X = ShellExtensionsImpl<MyFormatter>keeps compiling. Nobehavior change for any shell that does not opt in to a custom interceptor.
Open questions for the maintainer
CommandInterceptorvs.SecurityPolicyvs.Sandbox? Hook namesbefore_exec/before_openvs.check_exec/check_open?before_openget its ownErrorKind::OpenDeniedrather than reusing aPermissionDeniedio::Error? (We chose the latter to fit the existingopen-file error flow.)
argv0/ process-grouppolicy, or is
(program, args)sufficient?composed synchronously?