Skip to content

fix(bash-policy): exempt this run's writable mounts and compare paths by identity - #61

Merged
zhanghanduo merged 10 commits into
mainfrom
fix/bash-policy-writable-roots
Oct 6, 2026
Merged

zhanghanduo merged 10 commits into
mainfrom
fix/bash-policy-writable-roots

Conversation

@zhanghanduo

@zhanghanduo zhanghanduo commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Fix bash path protection for relocated and symlinked runtime mounts (Harness #589/#590). #53 and #60 are now merged; this branch includes current main and resolves the bash-policy and changelog conflicts.

A run whose workspace, outputs or scratch directory lives under a system prefix can write and clean its own files. Read-only inputs take precedence over writable exemptions, including recursive deletion of their ancestors or selection through wildcard operands. Local system roots include resolved aliases such as macOS /private/etc; remote paths are never resolved against this host.

Redirection targets are checked both in the raw command and after parsing, catching adjacent redirects, traversal out of writable roots, and relocated input mounts. The writable exemption requires every path spelling to remain inside a writable root.

Upgrade apodex-agent-core from 0.12.2 to 0.14.1 and regenerate uv.lock. The workspace-root alias fix from main is retained.

Validation

  • Locked 0.14.1 environment: 4417 passed, 8 skipped. The seven macOS install-fixture errors were resolved by rerunning those tests with a short temporary path (7 passed).
  • FrontierChallenge test suite passes.
  • Regression coverage for input ancestors, wildcard deletion, system aliases, and writable scratch paths in all three policy modes.
  • Ruff, Pyright, eval import smoke, symbol closure, and lazy exports pass.
  • Package wheel builds; seven install tests pass using a short temporary path to avoid the macOS shebang-length limit in the fixture.

Every dangerous regression command is assessed only, never executed.

Known remaining gap

Pre-existing: command substitutions nested beyond _MAX_NEST can bypass group denials in off mode (Harness #632). This PR does not change the substitution-depth limit.

zhanghanduo and others added 10 commits September 29, 2026 11:26
…ecuted text

Syncs the behavior of ApodexHarness #497, #503 and #631 into Frontier's own
policy (not a cherry-pick; the parsers had diverged):

- Layer 1.5: privilege escalation, remote/exfil clients and signal senders are
  denied in every mode, including the default `off`. An interactive caller
  (the apodex CLI) gets them as a group-tagged `confirm` that only a human
  answering that prompt can approve.
- Mode resolution: an unknown mode warns instead of silently falling to `off`;
  ExecutionScope metadata can only tighten the mode.
- One substitution scanner feeds masking, extraction and top-level splitting,
  with arithmetic expansions told apart and unterminated expansions fail-closed.
- Layer-1 word screens run on executed-text views: quoted data and non-shell
  heredoc bodies are blanked for known data consumers; shell -c / heredocs /
  stdin, evaluators, find -exec and systemctl shutdown units stay screened.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… $'...'

Review follow-up on #53. Three forms ran a command the always-denied group
check (Layer 1.5) never saw, so `off` mode allowed them:

- evaluator payloads (`watch 'sudo id'`, `script -c`, `tmux new`, `screen`,
  `ssh host '...'`) are now parsed as nested commands; an evaluator whose
  argument form is not recognised has every word checked instead of guessed.
- `env -S` / `--split-string` payloads are parsed as the command env runs.
- `$'...'` ANSI-C quoting is decoded into plain quoting before any scanner,
  instead of shlex reading `$'sudo id'` as the executable `$sudo`.

All three also reproduced on Harness HEAD (sandbox_full / off); they were
inherited gaps, not porting errors.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`env -S '-i sudo id'` (the shebang idiom `env -S -i python3`) put env's own
options at the start of the split string, so the nested parse read `-i` as
the executable and Layer 1.5 never saw `sudo`. The payload is now re-parsed
as an `env` command line, which skips those options the same way the outer
one does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
main gained overlapping work on the same scanner (#42 and follow-ups: nested
substitution quote state, backtick escape removal, `>&` double expansion).
Resolution keeps this branch's architecture — one `_substitution_spans`
scanner feeding BOTH the outer mask and extraction, with arithmetic told apart
and unterminated expansions fail-closed — and folds in main's three
capabilities, each of which this branch missed:

- `_find_expansion_end` now keeps a per-level quote state (from main's
  `_substitution_end`), so `$(echo "$(echo ")'")" $(sudo id))` no longer ends
  its outer span early and hides the last command. Also recognises process
  substitution `<(...)` / `>(...)`.
- `_backtick_body` applies bash's first-pass escape removal, so
  `` echo `echo \`sudo id\`` `` is seen.
- `_dup_redirect_bodies` / `_dup_redirect_word` assess a `>&` target's second
  expansion, so `echo x >&'$(sudo id)'` is seen.

main's stricter-looking verdicts on quoted data (`printf '%s\n' 'halt'`, prose
heredocs, `$((1+1))`, `env -S 'python3 -V'`) are the false positives this
branch fixes; those stay allowed. apodex/agent_tools.py takes main's
danger-label rule; apodex/tests/test_features.py keeps both sides' tests.

Verified by differential assessment over 36 commands across both PRs'
concerns: every case either side denied is denied here, and every benign case
stays allowed. 4144 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The remote branch had already merged main, but resolved the scanner conflict
by taking this branch's side wholesale — which dropped the three protections
main's #42 had added. Differential assessment on the remote head: `echo x
>&'$(sudo id)'`, `` echo `echo \`sudo id\`` `` and
`$(echo "$(echo ")'")" $(sudo id))` were all allow in `off` mode.

This keeps the remote's own work (the policy-mode logging fix for the CodeQL
clear-text alert, plus main's sandbox/task_runner/docs changes) and re-applies
the three capabilities on top of this branch's single-scanner architecture.

36-command differential check: no regression against either side, and no
false positive on the quoted-data cases this branch exists to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… roots

4c's writable-root exemption and symlink-identity checks read the trusted
state, so it needs 4b. Only the CHANGELOG overlapped; both sections kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… by identity

Handoff item 4c — Harness #589 and #590, which depend on 4b's trusted runtime
filesystem state (merged into this branch) and on #53's scanner.

#589 — the run's own directories are not system paths:
  - _writable_roots() reads the trusted state's scratch roots, and a redirect
    strictly inside one is exempt from the static /var,/opt,... prefixes, so a
    run whose outputs live under macOS $TMPDIR or a container volume can write
    its deliverables. `..` is collapsed first, so this cannot climb out.
  - the target capture stops at shell punctuation instead of taking every
    non-space character, so `>/outputs/a>/etc/passwd` can no longer hide the
    second redirect inside the first match.
  - the deny reason now names the directories this run actually has, and a
    test asserts every directory it recommends would itself be accepted.

#590 — identity, not spelling:
  - _path_spellings() reports the written and resolved name, but only for a
    local state and an absolute path with no unexpanded variable: a remote path
    must not be interpreted by this host, and the shell's cwd is not ours.
  - the canonical _SYSTEM_ROOTS stay matched as written (resolving /var gives
    /private/var, which contains $TMPDIR), while every spelling of the TARGET
    is checked, so /private/etc is recognised.
  - this run's read-only mounts are protected wherever they were mounted.

Two orderings this adds beyond the Harness PRs, both found by differential
assessment rather than by reading:
  - a read-only mount nested inside a writable root (the inputs mount under
    $TMPDIR, or anywhere under /tmp) wins over the writable exemption;
  - the writable exemption requires EVERY spelling to be contained, so a
    symlink planted in /tmp pointing at /etc is not exempt — otherwise the
    static protection would be retired altogether, /tmp being a writable root.

Redirect targets are also checked after parsing, which catches a target that
climbs out of a writable root and a write into a relocated read-only mount;
neither starts with a literal system prefix, so the raw regex never saw them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zhanghanduo
zhanghanduo merged commit 8012a2f into main Oct 6, 2026
5 checks passed
@zhanghanduo
zhanghanduo deleted the fix/bash-policy-writable-roots branch October 6, 2026 07:48
yermakoffivan pushed a commit to yermakoffivan/frontieragent that referenced this pull request Oct 7, 2026
…ad of dropping it

Closes the gap recorded in ApodexAI#61 and tracked upstream as ApodexHarness#632.

`_parse_commands` stopped recursing at `_MAX_NEST` (4) and stopped silently,
so every level below was assessed by nobody. Measured in `off` mode — the
default, and what most deployments run:

    $($($($($($(sudo id))))))        -> allow      (6 levels)
    $($($($($($(ssh h id))))))       -> allow
    $($($($($($(pkill -f x))))))     -> allow

against Layer 1.5 rules that are supposed to bind in every mode. `enforce`
denied them, but only because the masked sentinel left in executable position
is off the allowlist — incidental, not the rule that should have applied.

At the limit the remaining text is now screened by its WORDS in one linear
pass (`_expansion_words`): expansion punctuation is blanked and each word
becomes a candidate executable, so `sudo` falls out of any depth.

Two wrong turns on the way, both worth recording since each looked right:

- Removing the cap and recursing on gave `RecursionError` at depth 2,000 — a
  crash instead of a verdict, which is worse than the bypass.
- Peeling the chain one layer at a time cost a scan per layer: 143ms at depth
  500 against main's 19ms, 11.6s at 5,000 — and at any step bound it lost the
  payload again, restoring the very bypass. The word screen is 19.7ms at 500
  and 770ms at 20,000.

The screen is deliberately conservative past the limit: it cannot tell a
command name from a word that looks like one, so `$(...$(echo ssh)...)` nested
8 deep is refused on the strength of `ssh` appearing in it. That applies only
past a depth no real command reaches, and it can only add refusals.

Differential check over the nesting corpus: 9 verdicts change, all of them
`off`-mode allow -> deny with the correct group; no benign form changes at any
depth, and no `enforce` verdict moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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