fix(apodex): prevent saved Bash allow from bypassing typed confirm via substitution - #42
Conversation
…a substitution Root cause: PermissionStore._matches compared raw segments, ignoring unquoted command substitution, and assess_with_rules dropped the danger label on downgrade. Validation: repro script confirms git push --force and echo pip install payloads stay confirm with danger; 5 permission tests pass; ruff and pyright clean. Closes ApodexAI#39
|
Hey @dq-ai-dev just put this one up, would love your eyes on it when you get a sec! This was a sneaky one: a saved I tightened both layers nested shell now needs its own allow, and anything with a Happy to tweak the approach if you'd rather handle the nested case differently! |
|
Thanks for putting this together and adding regression tests! The confirmation-gate change looks good. While checking the substitution handling, I found a few edge cases that would be helpful to cover before merging.
|
…s and double quotes Follow-up to the review on ApodexAI#42. - Allow and deny are now separate checks. An allow still needs every segment and every nested payload to match. A deny now fires when any segment matches, top-level or nested. With allow Bash(*) and deny Bash(echo), `echo $(touch x)` is denied again. - The allow check collects $(...) and backtick payloads from the whole command before the helper filter runs. With only Bash(python) allowed, `echo $(touch x) && python -V` now goes to confirm. - _extract_nested_shell and the fallback scanner in permissions.py track double quotes and backslash escapes. `echo "'$(touch x)'"` now yields `touch x`, while `echo '$(x)'` and `echo \$(x)` stay literal. A quoted ")" no longer ends a substitution early. The bash policy uses the same extractor, so enforce mode now denies `echo "'$(foobarcmd)'"` too. - A segment that is an empty word (`"" && python -V`) no longer raises IndexError. Tests: one regression test per case, plus a test that the two scanners return the same results. The new tests fail on the previous commit and pass on this one. The full pytest run has the same failing set before and after (Windows path and symlink tests). ruff is clean, and pyright reports 0 errors on the touched files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s and double quotes Follow-up to the review on ApodexAI#42. - Allow and deny are now separate checks. An allow still needs every segment and every nested payload to match. A deny now fires when any segment matches, top-level or nested. With allow Bash(*) and deny Bash(echo), `echo $(touch x)` is denied again. - The allow check collects $(...) and backtick payloads from the whole command before the helper filter runs. With only Bash(python) allowed, `echo $(touch x) && python -V` now goes to confirm. - _extract_nested_shell and the fallback scanner in permissions.py track double quotes and backslash escapes. `echo "'$(touch x)'"` now yields `touch x`, while `echo '$(x)'` and `echo \$(x)` stay literal. A quoted ")" no longer ends a substitution early. The bash policy uses the same extractor, so enforce mode now denies `echo "'$(foobarcmd)'"` too. - A segment that is an empty word (`"" && python -V`) no longer raises IndexError. Tests: one regression test per case, plus a test that the two scanners return the same results. The new tests fail on the previous commit and pass on this one. The full pytest run has the same failing set before and after (Windows path and symlink tests). ruff is clean, and pyright reports 0 errors on the touched files.
e844766 to
743c26a
Compare
|
Thanks for following up and adding tests for all three cases! I verified that the original examples are now handled correctly. It looks like |
Follow-up to the second review. - _substitution_end shared one quote state across nested $(...), so the quotes in `echo $(echo "$(echo ")'")" $(touch x))` paired across levels and the scan stopped before `touch x`. Each nested $( now starts with its own quote state, kept on a list instead of the call stack. The fallback scanner in permissions.py has the same change. - The allow check split a nested snippet on ;/&&/| before reading its inner payloads. The split ignores quotes, so a quoted ";" could cut a string in half and hide $(...) in what then looked like a single-quoted span. Inner payloads now come from the whole snippet. - Allow and deny stop after 16 levels of nesting. Past that, allow returns False and deny returns True. Before, very deep nesting raised RecursionError. - Segments are also split on a single & and on newlines, without touching the & inside 2>&1, >&2 or &>file. `echo hi & touch x` no longer passes Bash(echo). - <(...) and >(...) count as nested commands, like $(...). - bash expands the target of >&word a second time after quote removal, so `echo x >&'$(touch m)'` runs touch. Both scanners now read that word with its quotes removed. The bash policy uses the same extractor, so enforce mode now denies `echo x >&'$(foobarcmd)'`. Tests: regression tests for each case, more cases in the scanner parity test, and a deep-nesting test. The new tests fail on the previous commit and pass on this one. A differential fuzz run against real bash (mutating tricky quoting and substitution commands, running every command the matcher allows under Bash(echo), and checking whether `touch` ran) finds 98 bypasses on the previous commit and none on this one. The full pytest failing set matches the previous commit apart from a timing-flaky TUI clipboard test that also fails there. ruff is clean, and pyright reports 0 errors on the touched files.
|
Good catch @zhanghanduo, and thanks for checking it against a real file. You were right: I pushed a follow-up commit. Each nested While testing that, I wrote a small fuzzer that mutates tricky quoting and substitution commands, runs every one the matcher allows under
With these changes the same fuzz run finds no bypasses. Every new test fails on the previous commit and passes on this one, the rest of the suite is unchanged, CI is green, and ruff and pyright are clean on the touched files. |
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>
Summary
PermissionStore._matchesagainst unquoted$(...)/backtick substitution: nested payloads must independently match a savedBash(...)prefix, otherwise the command is not authorized.assess_with_rulesfrom downgradingCONFIRMtoSAFEwhen the call carries adangerlabel, so dep-installs and force-pushes still require typedyes.echo $(pip install x), backticks,git push --force, and single-quoted literals.Validation
git push --forcestaysconfirmwithdanger='git force-push';echo $(pip install evil-pkg)staysconfirmwithdanger='installs dependencies'; single-quoted stays allowed.uv run pytest apodex/tests/test_features.py -k "saved_allow or single_quoted or permission_store or assess_with_rules": 5 passed.test_features.py: 84 passed, 4 failed — identical 4 fail on unmodifiedmain(Windows path expectations), no new failures.uv run ruff check apodex/permissions.py apodex/agent_tools.py apodex/tests/test_features.py: passes.uv run pyright apodex/permissions.py apodex/agent_tools.py: 0 errors.uv run python tools/import_smoke.py --stage 1: 288/289 (onlyfcntlmissing on Windows, pre-existing).Root cause
_matchescompared raw&&/|/;segments by prefix, never unwrapping substitution, soecho $(pip install x)looked like "just an echo".assess_with_rulesreturnedSAFEwith thedangerlabel dropped. Sinceobservers.pyskipsconfirm()forSAFE, the typed gate never fired. The hard denylist did not help because both repros areconfirm, notdeny.Closes #39