Skip to content

init vendors the hook relay into the agent-writable workspace, turning a file-write primitive into unattended persistent code execution #461

Description

@LuckierTrout

Summary

bmad-loop init writes bmad_loop_hook.py into <project>/.bmad-loop/ and registers that in-workspace path in each CLI's hook config. Because the driven sessions have write access to that same tree, a session with only a file-write primitive — no exec — can gain unattended code execution that persists across all future sessions in the project, including ordinary interactive sessions running under normal permission modes.

The relay's own code is clean. This is about where init places it and what it registers.

A second, smaller issue in the relay's event write is included at the bottom.

Environment

  • bmad-loop 0.9.0, installed via uv tool install "bmad-loop[tui] @ git+…" at commit f512288f4e7ca4f028da324b280762fd413b9f90
  • Adapters registered: claude, codex (codex-cli 0.146.0)
  • macOS (arm64), Python 3.11
  • bmad-loop init --project <root> --cli claude --cli codex

Issue 1 (Medium) — in-workspace hook chain: write ⇒ exec, one session ⇒ all sessions

What init produces

.claude/settings.json   hooks → python3 <project>/.bmad-loop/bmad_loop_hook.py <Event>
.codex/hooks.json       hooks → python3 <project>/.bmad-loop/bmad_loop_hook.py <Event>

Every element of that chain is writable by the very sessions the orchestrator drives:

  1. The relay script lives at <project>/.bmad-loop/bmad_loop_hook.py — inside the worktree.
  2. The interpreter is python3, resolved via PATH. On this machine ~8 user-writable directories precede /usr/bin, so the interpreter is hijackable without touching any repo file.
  3. The hook config files themselves (.claude/settings.json, .codex/hooks.json) are in-workspace and writable.
  4. Same class, adjacent blast radius: .bmad-loop/policy.toml [verify] commands (run by the orchestrator on the host after a clean review) and .bmad-loop/profiles/*.toml, which overlay the packaged profile including binary — the command the orchestrator launches.

Why it matters

Hooks execute outside the CLI permission system. A prompt-injected dev/review session, a malicious dependency, or any code path with a write primitive can append a payload to the relay. Nothing runs at that moment. On the next SessionStart of any session in the project — including the operator's ordinary interactive claude, in a mode where every Bash call would be prompted — the payload executes with the user's full credentials and no approval prompt.

Claude Code's hook-change safeguard snapshots the hook entries in settings.json, not the contents of the script they point at. An unchanged command line aimed at a mutated script is therefore not flagged.

Two boundaries are crossed that the operator never consented to:

  • write ⇒ exec
  • one session ⇒ every future session (persistence)

Honest severity weighting

Inside bmad-loop's own driven sessions this is not initial access: those run with --dangerously-bypass-approvals-and-sandbox (codex) / bypassPermissions (claude), so the attacker already has exec. The gain there is persistence and evasion.

It is a genuine escalation for:

  • any restricted or sandboxed session in the same project,
  • non-agent write primitives (dependency, build step, untrusted patch),
  • a collaborator who later pulls a branch where .bmad-loop/ was committed.

Hence Medium, not High.

Suggested fix

  • Don't vendor the relay into the driven tree. Ship it as a package entry point (python -m bmad_loop.hookrelay, or a console script) and register that, or install it to a user-config dir outside the workspace.
  • Register an absolute interpreter path rather than bare python3, so PATH order can't substitute the interpreter.
  • Treat policy.toml, profiles/*.toml and [verify] commands as trusted config that a driven session must not be able to rewrite — relocate outside the driven tree, or integrity-pin them at run start.

Local mitigation (for anyone hitting this before a fix)

Point both hook configs at a copy of the relay outside the workspace and use an absolute interpreter path.


Issue 2 (Low) — relay's event write follows symlinks

Location: bmad_loop_hook.py, the event-write block (os.makedirs(events_dir, exist_ok=True)open(tmp, "w")os.replace(tmp, final)).

Verified by execution: pre-planting <run_dir>/events as a symlink to another directory is accepted by os.makedirs(..., exist_ok=True) (it isdir()-checks through the link), and the subsequent write + os.replace land the event JSON in the symlink target.

Impact: an actor able to write in the run dir — which is <project>/.bmad-loop/runs/<id>/, i.e. inside the workspace and agent-writable by design — can redirect the orchestrator's entire control-plane event stream. SignalWatcher then never observes Stop, so the run stalls to timeout, and the attacker receives a private copy of every event record.

Not an arbitrary-overwrite primitive: the filename is {time.time_ns()}-{task_id}-{event}.json, so the target name is unpredictable and always .json.

Suggested fix: refuse when os.path.islink(events_dir), and write via
os.open(tmp, O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW, 0o600) with a dir_fd opened O_NOFOLLOW|O_DIRECTORY on events/.


Explicitly checked and not vulnerable

Recording these so they don't get re-litigated:

  • No shell or interpreter is reachable from the relay. It imports only json/os/sys/time; there is no subprocess, os.system, eval, or format-into-command. Nothing from stdin, env, or argv reaches an executor.
  • Transcript content is never read or leaked. The relay records transcript_path as a string only. Downstream, tally_usage opens it purely to extract token counts, so a forged path (e.g. ~/.ssh/id_rsa) yields integers or nothing — never content.
  • Path traversal via BMAD_LOOP_TASK_ID / argv[1] is not reachable, despite both being interpolated unsanitized into the filename. The time.time_ns() prefix guarantees the first path component of any ../ payload is a nonexistent directory. Tested with BMAD_LOOP_TASK_ID='../../outside/pwn' and argv[1]='../../outside/evt': both raise FileNotFoundError and write nothing outside the run dir. The producer side is sanitized too (safe_segment).
  • The env guard is genuinely fail-closed. With BMAD_LOOP_RUN_DIR/BMAD_LOOP_TASK_ID unset or empty, the relay exits 0 and writes nothing.
  • Forging events buys nothing. A driven session can invoke the relay with a crafted payload, but it is already sanctioned to write $BMAD_LOOP_RUN_DIR/tasks/$BMAD_LOOP_TASK_ID/result.json — the run dir is agent-writable by design, so no boundary is crossed. session_id is never interpolated into a resume command or any shell.

Unrelated papercut noticed while reviewing

bmad-loop validate reports bmad-loop hooks registered for <cli> based on the hook config referencing bmad-loop — it does not check that the registered script exists on disk. We hit a case where the relay was absent (tracked on one git branch only, so a branch switch deleted it) while validate still reported hooks as registered. A stat of the registered path would have caught it immediately.

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