Skip to content

security/trust handoff hardening - #555

Merged
paulirotta merged 3 commits into
mainfrom
security/trust-handoff-hardening
Aug 8, 2026
Merged

security/trust handoff hardening#555
paulirotta merged 3 commits into
mainfrom
security/trust-handoff-hardening

Conversation

@paulirotta

Copy link
Copy Markdown
Owner
  • feat(ahma_mcp): contain trust-handoff sandbox escapes
  • docs: correct the macOS read claim and specify the trust-handoff threat model
  • feat(ahma_mcp): finish the trust-handoff hardening deferred items

Confining writes is necessary but not sufficient. A write that lands
legitimately *inside* the scope can still be executed later by a trusted
component that was never sandboxed at all. Pillar Security published seven
instances of this against Cursor, Codex, Gemini CLI and Antigravity in 2026;
one carries CVE-2026-48124 at CVSS 8.5. In none of them was a sandbox rule
violated at any step.

ahma is squarely in scope, because its central premise is that the workspace
is freely writable so the agent never has to ask.

New `sandbox/exec_config.rs` classifies paths into two tiers:

  * deny-write for paths no legitimate task touches: hook directories under
    every resolved git dir, the project `.ahma/` tool-config directory,
    container daemon sockets, and venv-shaped fake interpreters;
  * allow-plus-loud-disclosure for paths users genuinely ask an agent to edit
    (editor and harness configuration, per-project git config), where the
    result now names the file *and* the trigger that will execute it.

Blocking the second tier would break "set up my editor for this project";
prompting on each would be the permission fatigue R5.3 exists to prevent.

Git directories are resolved through `gitdir:` pointer files and worktree
`commondir` indirection rather than matched as a path spelling, because a
defence written as `^.*/\.git/config$` is defeated by `git init
--separate-git-dir=.git-alt` without bypassing anything -- the rule was
describing a spelling of the boundary rather than the boundary.

Two deny-tier paths have legitimate authors -- a repo's own git hook, and a
project's tool definitions -- so each gets a narrow opt-in
(`--allow-git-hooks`, `--allow-project-tool-config`), off by default,
disclosed at startup when on, and named in the denial message itself. A
default with no documented way out is not refusable; it is an instruction to
disable the sandbox wholesale.

Also closed, same class:

  * Container daemon sockets are denied for read and write. A `--privileged`
    container with a host bind mount converts socket access into unrestricted
    host writes, performed by a daemon entirely outside the sandbox. The
    comment justifying open unix-socket egress -- "local IPC cannot exfiltrate
    off the host on its own" -- is exactly the reasoning this refutes, and is
    replaced.
  * `~/.ssh/id_*` is denied with `id_*.pub` re-allowed, so key material stops
    being world-readable while `known_hosts`/`config` and git-over-ssh keep
    working. `SSH_AUTH_SOCK` is deliberately kept for the same reason.
  * Code-injection variables (`BASH_ENV`, `LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`
    and family) and client-redirect variables (`DOCKER_HOST`) are stripped from
    every sandboxed child. The kernel sandbox restricts the filesystem, not
    environment inheritance, and `bash -c` honours `BASH_ENV` even
    non-interactively. Without the `DOCKER_HOST` strip the socket deny is
    trivially sidestepped via a remote daemon.

Enforcement is asymmetric and the asymmetry is disclosed rather than implied:
kernel-enforced on macOS via last-match-wins SBPL denies emitted after the
workspace allow; application-layer only on Linux, because Landlock's ABI V1 is
additive-allow with no way to subtract a path beneath an allowed directory, and
therefore bypassable from `run_terminal_command`; nothing on Windows until
R6.3.3 lands.

Reload of tool definitions is now explicit via `restart`, never watched. The
watcher collapsed "the agent writes a file" and "something trusted executes it"
into a single step with no user action in between -- the trust-handoff shape
with the human removed from the loop. Removing it also drops ~300 lines of
spawn_blocking + setup timeout + debounce + polling fallback carrying a
documented Drop-ordering hazard, the `--hot-reload` flag it existed to serve,
and the `notify` dependency subtree. A workspace tool definition can no longer
shadow a built-in or operator-configured tool name.
…at model

SPEC R6.2.2 stated that reads are "strictly limited to the sandbox scope".
That is false on macOS: `get_macos_system_rules` emits a bare, unqualified
`(allow file-read*)`, because APFS firmlink/cryptex structure on Apple Silicon
defeats subpath matching for reads. macOS is a *write* boundary, with read
isolation resting entirely on a denylist. The behaviour was already disclosed
at runtime; the SPEC was the thing out of step, and a SPEC that overstates the
boundary is worse than one that admits the gap, because it is relied upon.

R6.2.2/R6.2.3 now state the real per-platform position, with R6.1.6 (Linux
reads genuinely are confined), R6.1.7 (Landlock cannot carve a deny hole inside
an allowed subtree) and R6.3.9 (Windows Job Objects restrict neither direction
by path) recording the facts the rest of the security requirements lean on.

New R-HANDOFF family specifies the class of attack where the agent never
breaks the sandbox -- it writes something the sandbox legitimately permits, and
a trusted component outside the sandbox executes it later. It fixes the shape
(write / discovered by convention / executed on a later trigger), requires
boundaries be computed from the abstraction a consumer resolves rather than a
path spelling, mandates the two-tier response, and states the platform
enforcement asymmetry as a requirement-level fact per R7's rule that ahma never
silently disables enforcement.

R-HANDOFF.8 documents a channel that was written down nowhere: the shipped Rust
profile grants rw on the cargo registry and git caches, enabled by default,
because cargo needs it to extract crates. That cache is shared by every project
on the machine, so an agent working in project X can edit an extracted crate's
source and have it compiled and run -- as a build script or proc macro -- when
the user later builds an unrelated project Y. Workspace narrowing does not help,
because the cache was never inside the workspace. The mitigation already ships
as `--no-package-cache-write`; it was presented as generic hardening rather than
as the answer to a named risk.

R1.4 is reversed: it previously *mandated* the tools-directory watcher.

The same false containment claim is corrected in README and across docs/, along
with a sweep for absolute claims ("zero access", "cannot"), stale hot-reload
references, and `AHMA_*` variables documented as live configuration after
retirement.
Completes the four items left open by the previous commit. They are one commit
because they were built and verified as one unit and share four files
(`lib.rs`, `config.rs`, `server.rs`, `main.rs`); splitting them would produce
commits that do not compile on their own.

Network hosts declared by profiles (R-PERM.5.3, R-PERM.5.4)
-----------------------------------------------------------
A sandbox profile could grant `~/.cargo` but nothing to reach
`index.crates.io` with, so turning on `--restrict-network` broke `cargo build`
on the first command — which is why almost nobody turned it on. Profiles now
declare the hosts their toolchain needs, each with a mandatory `reason`, and the
reachable set is the union of those and the operator's `[network] allow`.

Every host was verified against what the tool actually contacts rather than
copied from a blog post, and the verification is recorded next to the entry.
`storage.googleapis.com` is deliberately *not* shipped for Go: the advice that
the module mirror redirects there is stale — `proxy.golang.org` answers 200
directly — and since host matching cannot express "one bucket", shipping it
would have granted every sandboxed command reach into all of Google Cloud
Storage to solve a problem that no longer exists.

Host grants are refusable independently of path grants (`[network]
profile_hosts`, `deny_profile_hosts`): trusting a toolchain with a directory is
not trusting it with the internet. Every host names its grantor wherever it is
displayed, because a grant whose origin is invisible cannot be refused.

Matching is label-boundary-anchored, never a suffix test — a plain suffix
compare makes `evilcrates.io` match `crates.io`, turning an allowlist into an
attacker-registrable namespace. Non-ASCII is rejected rather than folded to
punycode: `сrates.io` with a Cyrillic `с` renders identically, and silently
normalising it would decide on the reader's behalf that two visually-equal
strings are the same host. Malformed entries are dropped with a warning, never
coerced into something that matches.

The default is unchanged: `[network] restrict` stays `false`. Profile hosts
exist to make the restriction usable when chosen, not to make it default.

Execution audit log (R-HANDOFF.10)
----------------------------------
The trust-handoff shape is discovered after the fact — the write that mattered
looked ordinary at the time. Operation output could not answer "what ran, when,
where, and what did it write that something else will later execute?", because
output says what a command printed, not that it happened.

Every execution path — sync, async, PTY, session — now appends to
`<project log dir>/audit.jsonl`: a `tool_call` written *before* the process is
spawned, and exactly one `tool_complete` on every terminal path. Recording
before spawn is the point: no panic, kill, or power loss can leave the log
without a record of what was asked for. Sandbox denials land in the same file
whether refused up front or by the kernel at runtime, so the two halves of one
story do not end up in two places with only one surviving.

The wire format is the vault's, so one reader parses both, and a test asserts
that rather than leaving it as a convention to be remembered. Fields are
redacted through the same function operation output uses and are individually
bounded — including `working_dir`, which is the field most easily assumed short
and is attacker-influenceable via `mkdir`. Bounding is not cosmetic: it keeps
one event one `write` syscall, which is what makes concurrent `O_APPEND`
appends non-interleaving without a contended lock on every execution.

An audit write failure never fails the operation, but always warns with the
path. A trail that stops silently is worse than one that never existed, because
it is still believed.

Windows AppContainer spawn isolation (R6.3.3 — implemented, still open)
-----------------------------------------------------------------------
Tool subprocesses are launched into a per-session AppContainer whose SID is
granted exactly the locked scopes, via `STARTUPINFOEX` and an `ahma.exe`
launcher re-entry. The Windows arm also stops building its own `Command`, so it
finally picks up the secret and code-injection env scrub the other platforms
already had — a tool subprocess on Windows had been inheriting ahma's
credentials.

This was written and type-checked for `x86_64-pc-windows-msvc` but has **never
been executed**. R6.3.3 therefore stays open, R6.3.9's disclosure stands, and
`red_team_command_write_escape_blocked` keeps its `#[cfg_attr(windows, ignore)]`
— per AGENTS.md the ignore comes off when the code lands *and Windows CI proves
it*, and only the second half is outstanding. Written is not works.

Two consequences are disclosed rather than papered over: `%TEMP%` is redirected
into the container's own folder, and `--restrict-network` is now refused loudly
on Windows. AppContainer blocks loopback, so the egress proxy would be
unreachable by the very subprocesses it gates while a tool opening its own
socket reached the network unrestricted — broken *and* unenforced, with no way
for the approval prompt to fire and explain it.

Retired AHMA_* variables, in every binary (R-CFG1.2.1)
------------------------------------------------------
`AHMA_PREFER_MUSL` was documented as retired while `ahma update` still honored
it. `AHMA_UNIX_SOCKET` steered the `restart` handler and the TUI's connection
after the server had dropped it. `AHMA_INSTALL_DIR` decided which binary
`uninstall` deleted. `AHMA_MINIMIZE_TOKENS` changed the TUI and not the server.
`AHMA_LOG_TARGET`, `AHMA_TLS_DIR` and the `AHMA_INSECURE_SKIP_*` pair each
spelled the warn-and-ignore verdict in their own words.

One variable with two meanings inside one product is worse than either answer
alone, so `warn_retired_env` moves to `ahma_common` — low enough in the graph
that every crate can reach it — and returns only *whether* a name was set, never
its value, so no caller can accidentally honor one.

The docs and the code had drifted for months and nothing noticed, because the
only thing tying them together was someone remembering.
`retired_env_drift_test` is that tie: it parses the RETIRED tables out of
`docs/environment-variables.md` and fails on any direct read in production
source. Adding a row extends the guard automatically. It found three surfaces
this change had missed.

`ahma uninstall` gains a related fix: its tests set `AHMA_INSTALL_DIR` only to
avoid deleting the developer's real binary, which made a retired variable look
load-bearing when the requirement was an injectable parameter. Uninstalling from
a non-default location is now served by reporting the path ahma can *prove* —
its own running executable — rather than trusting one the environment supplied.

Verified: 4808 tests pass (`--workspace --run-ignored all`, 0 skipped), fmt and
clippy clean, and `sandbox/windows.rs` type-checks against
`x86_64-pc-windows-msvc` including its tests. No Cargo.toml/lock change, so
`cargo deny` is not implicated.
@paulirotta
paulirotta enabled auto-merge (squash) August 8, 2026 11:42
@paulirotta
paulirotta merged commit 3aa8b84 into main Aug 8, 2026
4 checks passed
@paulirotta
paulirotta deleted the security/trust-handoff-hardening branch August 8, 2026 11:49
paulirotta added a commit that referenced this pull request Aug 8, 2026
…ainer path (#558)

A windows-latest CI run of the newly-landed R6.3.3 work (#555) proved
the AppContainer grant DACL does not take effect: in-scope writes are
denied along with out-of-scope ones. Because create_platform_sandboxed_
command() routed every non-Test-mode Windows spawn through the launcher
unconditionally, this broke essentially every Windows integration test
that runs a real subprocess - handshake/timeout/roots tests across
ahma_http_bridge, tool availability probes, and the two AppContainer
gate tests themselves - not just the AppContainer-specific coverage.

create_platform_sandboxed_command() now falls back to the plain
(Job-Object-contained) base_command() on Windows, matching the
platform's pre-R6.3.3 behavior and AGENTS.md's standing status
("AppContainer spawn isolation is still pending"). The AppContainer
code, its launcher, and plan_windows_sandboxed_spawn stay in place and
directly tested; only the two behavioral gate tests that depend on the
grant actually working are marked #[ignore] with the CI failure cited,
matching the existing precedent for red_team_command_write_escape_
blocked. SPEC R6.3.3 status updated from "unproven" to "disproven".

Also fixes five egress-proxy unit tests in shell::modes::server that
asserted maybe_start_egress_proxy always starts a proxy under
restrict_network=true - it deliberately returns None on Windows
(R6.3.3.1a: AppContainer blocks loopback), so those five are now
gated #[cfg(not(windows))] and a new Windows-specific test pins the
None-return behavior instead of leaving it silently untested.
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.

1 participant