Skip to content

feat(core): add CommandInterceptor exec/open hook to ShellExtensions#1184

Open
hartsock wants to merge 3 commits into
reubeno:mainfrom
hartsock:cap-hook-upstream
Open

feat(core): add CommandInterceptor exec/open hook to ShellExtensions#1184
hartsock wants to merge 3 commits into
reubeno:mainfrom
hartsock:cap-hook-upstream

Conversation

@hartsock

@hartsock hartsock commented Jun 3, 2026

Copy link
Copy Markdown

Addresses #1183.

What

Adds an optional CommandInterceptor component to ShellExtensions with two
default-allow hooks, before_exec(program, args) and before_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

  • New sub-trait extensions::CommandInterceptor (mirrors ErrorFormatter),
    collected as ShellExtensions::CommandInterceptor, with
    DefaultCommandInterceptor (allow-all). ShellExtensionsImpl gains a second
    type parameter that defaults to DefaultCommandInterceptor, so
    DefaultShellExtensions and all existing call sites are source-compatible.
  • Shell<SE> stores a command_interceptor: SE::CommandInterceptor instance
    (parallel to error_formatter), wired through CreateOptions/the builder,
    new, Default, and Clone. New accessor Shell::command_interceptor().
  • before_exec is invoked once, at the top of
    commands::execute_external_command — the single funnel for both dispatch
    branches. Denials return a new ErrorKind::ExecDenied(program, reason)
    (mapped to exit code 126, "cannot execute").
  • before_open is invoked in Shell::open_file (covers redirections and
    source/.). Denials return an io::Error(PermissionDenied), which the
    existing RedirectionFailure / FailedSourcingFile wrapping reports cleanly.
    Write-intent is derived from the OpenOptions (best-effort, fail-safe toward
    "write").

Test plan

  • New integration test brush-core/tests/command_interceptor_tests.rs:
    • denies a bare-name command (rm, PATH-resolved);
    • denies an absolute-path command (/bin/rm), proving the
      path-separator branch is now hooked (the load-bearing case);
    • allows a permitted command (/bin/true);
    • denies an output redirection writing outside an allowed directory while
      allowing one inside it;
    • allows a read-only source open (proving the write flag is threaded).
  • Full existing suite (cargo test --workspace, including the YAML bash-compat
    cases and the redirection cases) passes unchanged — the default-allow impl is
    behavior-preserving.
  • cargo fmt --check and cargo clippy --workspace --all-targets -D warnings
    are clean.

Compatibility / risk

Additive only. ShellExtensionsImpl's new type parameter is defaulted, so
downstream type X = ShellExtensionsImpl<MyFormatter> keeps compiling. No
behavior change for any shell that does not opt in to a custom interceptor.

Open questions for the maintainer

  • Naming: CommandInterceptor vs. SecurityPolicy vs. Sandbox? Hook names
    before_exec/before_open vs. check_exec/check_open?
  • Should before_open get its own ErrorKind::OpenDenied rather than reusing a
    PermissionDenied io::Error? (We chose the latter to fit the existing
    open-file error flow.)
  • Would you want the hook to also see the resolved argv0 / process-group
    policy, or is (program, args) sufficient?
  • Should there be a parallel async hook, or is sync acceptable given spawns are
    composed synchronously?

Per the project's AI Policy: this change was authored with AI assistance and
reviewed/tested by a human contributor; the commit carries an Assisted-by:
trailer accordingly.

hartsock added a commit to Gilamonster-Foundation/agent-bridle that referenced this pull request Jun 9, 2026
* 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>
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 9, 2026
…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>
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 12, 2026
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>
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 12, 2026
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>
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 12, 2026
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>
@hartsock hartsock force-pushed the cap-hook-upstream branch 2 times, most recently from 15208cf to af5290c Compare June 13, 2026 13:51
@hartsock

Copy link
Copy Markdown
Author

Committed to landing this one — I'll keep the branch rebased on main so it stays mergeable whenever you have a chance to review. Thanks!

@hartsock

Copy link
Copy Markdown
Author

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

@hartsock hartsock force-pushed the cap-hook-upstream branch 4 times, most recently from 7655eee to fde15cc Compare June 19, 2026 03:24
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 19, 2026
… 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>
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 19, 2026
…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>
@hartsock hartsock force-pushed the cap-hook-upstream branch from fde15cc to 03a6eb6 Compare June 20, 2026 03:24
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 22, 2026
…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
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 26, 2026
)

* 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>
@hartsock hartsock force-pushed the cap-hook-upstream branch from 21c474e to a6a4be8 Compare June 27, 2026 03:24
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 27, 2026
…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>
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 27, 2026
…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>
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 27, 2026
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>
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 27, 2026
…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>
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 27, 2026
…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>
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 27, 2026
)

* 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>
hartsock added a commit to Gilamonster-Foundation/newt-agent that referenced this pull request Jun 27, 2026
…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>
@hartsock hartsock force-pushed the cap-hook-upstream branch 2 times, most recently from 2cde127 to e59257b Compare June 29, 2026 03:24
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>
@hartsock hartsock force-pushed the cap-hook-upstream branch from e59257b to fc1de3a Compare July 6, 2026 03:24
hartsock added a commit to Gilamonster-Foundation/agent-bridle that referenced this pull request Jul 6, 2026
…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
hartsock added a commit to Gilamonster-Foundation/agent-bridle that referenced this pull request Jul 6, 2026
…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>
@hartsock

Copy link
Copy Markdown
Author

@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!

@reubeno

reubeno commented Jul 14, 2026

Copy link
Copy Markdown
Owner

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants