Repository navigation
fix(bash-policy): exempt this run's writable mounts and compare paths by identity - #61
Merged
Merged
Conversation
…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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-corefrom 0.12.2 to 0.14.1 and regenerateuv.lock. The workspace-root alias fix from main is retained.Validation
Every dangerous regression command is assessed only, never executed.
Known remaining gap
Pre-existing: command substitutions nested beyond
_MAX_NESTcan bypass group denials inoffmode (Harness #632). This PR does not change the substitution-depth limit.