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. |
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