Skip to content

fix(bash-policy): bind group denials in every mode and screen only executed text - #53

Open
zhanghanduo wants to merge 5 commits into
mainfrom
fix/bash-policy-sync
Open

zhanghanduo wants to merge 5 commits into
mainfrom
fix/bash-policy-sync

Conversation

@zhanghanduo

@zhanghanduo zhanghanduo commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Handoff item 2 (Harness → FrontierAgent sync): ports the behavior of internal ApodexHarness fixes into plugins/tools/_bash_policy.py. Independent of #52.

Frontier's parser had diverged from Harness (it has its own redirect-sentinel tokenizer, no product tiers), so this ports the behavior, and the Harness regression suites along with it. It is not a cherry-pick.

Bugs reproduced from the handoff (checked only, never executed)

command before after
sudo id (mode off) allow deny
printf '%s\n' 'halt' deny allow

Ported behavior

#497 (partial, adapted)

  • New Layer 1.5 _argv_group_deny: priv_esc, exfil, process_kill are denied in every mode. Before, they were only checked by Layer 2, which the default off mode never reaches. The _DENIED_BINARIES keys and messages are unchanged; the table is now built from named groups.
  • interactive=True (passed only by calling code, never by config or a request) turns a group hit into confirm with group set. apodex's gate maps this to must_ask: it needs a typed yes, and auto_for_me and saved allow rules cannot downgrade it. Under auto-approve (-y / bypass) it is blocked with an explanation instead of running.
  • set_policy_mode with an unknown value now logs a warning; before, it silently fell back to off. ExecutionScope bash_allowlist_mode can only tighten the mode.
  • Wrapper option values (env -u X, timeout --signal TERM 5) are skipped when resolving the real command.

#503

  • A single scanner (_find_expansion_end / _substitution_spans) now feeds masking, extraction and _split_top_level. This fixes the quoted-), apostrophe-in-"…" and unterminated-$( bypasses.
  • $((…)) is arithmetic, not a command. A substitution nested inside it is still assessed.
  • An expansion in executable position gets a clear deny reason in enforce; the internal sentinels never leak into reasons.

#631

  • Layer-1 word rules (shutdown, mkfs, dd of=/dev, fork bomb) run on _raw_screen_views. Quoted args and non-shell heredoc bodies are blanked only for known data consumers. Shell -c/-lc, shell heredocs, stdin into a shell, at/batch, evaluators (watch/tmux/screen/script/ssh/su…), find -exec, write-then-run scripts and systemctl shutdown-unit activation are still screened. Any other command falls back to screening the whole text.
  • Heredoc parsing: multiple bodies per line, quoted/escaped/numeric/empty delimiters, <<-, << as a shift in $[..]/${..}/subscripts, and unterminated heredocs are fail-closed.
  • bash -lc '…' payloads are now parsed. This also closes the bash -lc 'rm -rf /' gap that was noted in #631.
  • A quote-removed redirect target is screened too, so echo x > "/dev/sda" is caught.

Not ported (product differences)

  • Harness's product tiers (sandbox_full / sandbox_guarded / native), their derivation from sandbox state, and the ratchet_tier wiring. Frontier's sandboxed workflows already pin enforce, which is roughly guarded. The apodex CLI keeps its own human gate, so there is no container "full" tier to unlock.
  • Harness native denies the groups even with a human present. Frontier keeps its existing typed-confirmation UX for these, but auto-approve can no longer wave them through.
  • Harness's allowlist expansions, script-file allowance and relocated-root logic are out of scope; they belong to item 4 of the handoff.
  • The DROP TABLE rule keeps screening the whole text, as in #631, so SQL in documentation can still be refused.

Verification (this repo)

  • New tests/test_bash_policy.py (520 cases) plus the Harness composition / data_consumers / consumer_audit suites ported with the tier dimension removed: 1616 cases. Against the old policy, 372 of these fail; against this branch, all pass.
  • apodex gate tests added to apodex/tests/test_features.py (must-ask, typed confirmation, auto-approve block, hard deny still wins).
  • Full pytest: 3937 passed, 1 skipped. pyright: 0 errors. ruff check: 13 errors, the same as main (all pre-existing).
  • This is still a bounded static policy, not a full shell parser. The sandbox remains the execution boundary.

…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>
Comment thread plugins/tools/_bash_policy.py Fixed
… $'...'

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

zhanghanduo commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

All three findings are valid and are fixed in 5428172 (fix(bash-policy): let the group check see runner payloads, env -S and $'...').

Origin. I checked the same 8 commands in off mode against three versions of the policy: this PR, Frontier main, and ApodexHarness HEAD 1e3b7550 (tier sandbox_full). All three returned allow for every command, so these are gaps inherited from the Harness design, not porting errors. This PR is still the right place to fix them, because it claims the groups are denied "in every mode".

finding cause fix
evaluator payloads (watch / script -c / tmux new) #631 sent evaluator payloads only to the word screens, never to the group check _parse_commands now recurses into _evaluator_payloads. If an evaluator's argument form isn't recognised (su, parallel, unknown tmux subcommands), the group check looks at every word instead of guessing
env -S / --split-string the split string was treated as one argument -S now counts as a value-taking env option, and its payload (plus the trailing words) is parsed as the command env runs. Combined forms -iS and --split-string= are covered
$'...' shlex doesn't understand ANSI-C quoting and read $'sudo id' as the executable $sudo before any scanner or recursion runs, $'...' is decoded into plain single quotes: \', \xHH, octal, \u, \cX; unknown escapes keep their backslash, as in bash. An unterminated $' stays unbalanced, so it becomes a parse error instead of being silently accepted

Regression tests (tests/test_bash_policy.py):

  • 17 hidden-runner forms × 3 modes. Each must be deny with the right group, and confirm when the caller is interactive.
  • The word screens must see env -S 'halt', bash -c $'halt' and watch $'reboot'.
  • Benign counterparts stay allow: watch -n 5 ls, tmux ls, env -S 'python3 -V', echo $'a\nb'.

Verification: full pytest 4003 passed, 1 skipped; pyright 0 errors; ruff is clean on the changed files. All example commands were only passed to assess_bash_command, never executed.

These gaps also exist in Harness. I'll report them to the Harness side separately.

zhanghanduo and others added 3 commits September 29, 2026 12:03
`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>

This branch has not been deployed

No deployments
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