Repository navigation
fix(notify): leave headless agent runs alone - #229
adefreitas wants to merge 6 commits into
Conversation
A script driving an agent (`claude -p` / `--print`, `codex exec`) fires the same hooks as a person at a terminal. Its PermissionRequest became a prompt nobody could answer, with the hook holding the run for up to 550s on the FIFO; its Stop became a "finished" nudge every time the script ran. notify.sh now exits without a decision when the agent process behind the hook is a headless run, so the agent's own permission setting decides. STACKNUDGE_HEADLESS_NUDGES=on keeps the old behaviour. Covered by scripts/test-notify-headless.py, now part of `make test-notify`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Match the headless flags per agent: `-p` / `--print` for claude only, since codex's `-p` is `--profile`; for codex, only the first argument after the binary counts. - Treat `codex e` (the exec alias) and `codex review` as headless too. - Test the $PPID walk: run the hook under a process named `claude`, with and without `-p`, NUDGE_AGENT_PID unset. Add `codex -p work` (still posts), `codex e` and `codex review` cases. - Run the headless tests in CI next to the other notify.sh scripts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hiskudin
left a comment
There was a problem hiding this comment.
Review by Claude, from Hisku's session.
Thanks, skipping headless runs makes sense. One problem with how they're detected, though:
An interactive session can be silenced by mistake. agent_is_headless matches -p / --print / exec anywhere in ps -o args=, and that includes a prompt passed on the command line. claude "why does grep -p fail", or codex "exec the tests", is an interactive session that would lose every nudge, silently.
Suggestion: use CLAUDE_CODE_ENTRYPOINT. Claude Code sets it, and hooks inherit it. I checked locally: it's cli in an interactive session and sdk-cli under claude -p. Treat only sdk-cli as headless, because the other SDK front ends are interactive. For example, Zed's Claude agent runs claude --input-format stream-json … with no -p, and a person answers its prompts. For Codex, keep the ps check as a fallback, but stop scanning at the first argument that isn't a flag, so prompt text can't match. This also avoids running ps on every hook.
Worth noting in the README or PR: the early exit 0 comes before everything else, so headless runs also drop out of Tickets and Outcomes, not just out of nudges. Probably intended, but best said explicitly.
The test-notify-permission-blocking.py crash you hit (ValueError: flush of closed file) happens on main too. It deserves its own issue.
On the CI runner the hook never ran under the re-signed copy of /bin/bash, so the interactive control case posted nothing. A symlink named `claude` gives ps the same name with no copy and no codesign. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applies the path guard Aikido suggested on the wrapper write, with the stray escaped quote in its sh line dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| wrapper_real = os.path.realpath(os.path.join(workdir, "under-claude.sh")) | ||
| if os.path.commonpath([base_real, wrapper_real]) != base_real: | ||
| raise Exception("Invalid file path") | ||
| with open(wrapper_real, "w", encoding="utf-8") as wrapper_file: |
There was a problem hiding this comment.
Potential file inclusion attack via reading file - medium severity
If an attacker can control the input leading into the open function, they might be able to read sensitive files and launch further attacks with that information.
Show fix
Remediation: Ignore this issue only after you've verified or sanitized the input going into this function.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
@AikidoSec ignore: the path is a fixed file name inside the temp dir the test creates for itself, and the realpath/commonpath guard from 9bfda39 sits directly above the open(). No input reaches it.
There was a problem hiding this comment.
✅ Based on your feedback, we ignored this issue because of the following reason:
the path is a fixed file name inside the temp dir the test creates for itself, and the realpath/commonpath guard from 9bfda39 sits directly above the
open(). No input reaches it.
- Detect a headless claude run from CLAUDE_CODE_ENTRYPOINT (sdk-* is headless, any other value is not) and read argv only when the variable is unset, so a prompt containing " -p " no longer reads as headless. - Skip codex's global flags and their values before reading the subcommand, so `codex -c model=o3 exec` and `codex --model m e` count as headless while `codex -m exec` still posts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
macOS 15's yes writes a newline over the end of argv[1], so ps showed `codex exec fix it` as `codex exec?fix it` and the bare-subcommand cases failed on CI. A program that reads none of its arguments keeps argv as given. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| def build_codex_stand_in(workdir): | ||
| source = os.path.join(workdir, "codex.c") | ||
| binary = os.path.join(workdir, "codex") | ||
| with open(source, "w", encoding="utf-8") as source_file: |
There was a problem hiding this comment.
Potential file inclusion attack via reading file - medium severity
If an attacker can control the input leading into the open function, they might be able to read sensitive files and launch further attacks with that information.
Show fix
| with open(source, "w", encoding="utf-8") as source_file: | |
| base_real = os.path.realpath(workdir) | |
| source_real = os.path.realpath(source) | |
| if os.path.commonpath([base_real, source_real]) != base_real: | |
| raise Exception("Invalid file path") | |
| with open(source_real, "w", encoding="utf-8") as source_file: |
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
@AikidoSec ignore: source is the fixed name codex.c joined onto the temp dir the test creates for itself with tempfile.mkdtemp. No input reaches the path.
There was a problem hiding this comment.
✅ Based on your feedback, we ignored this issue because of the following reason:
sourceis the fixed namecodex.cjoined onto the temp dir the test creates for itself withtempfile.mkdtemp. No input reaches the path.
Summary
A script driving an agent (
claude -p/--print,codex exec) fires the same hooks as a person at a terminal. Its permission prompts can't be answered by anyone, and the held hook stalls the script for up to 550 seconds on the FIFO; every run's Stop also became a "finished" nudge.notify.shnow skips those runs unlessSTACKNUDGE_HEADLESS_NUDGES=on.Changes
notify.sh:agent_is_headlessinspects the agent process's args (-p/--print, orexecfor Codex) and exits without a decision, so the agent's own permission setting decides.STACKNUDGE_HEADLESS_NUDGES=onrestores the old behaviour.scripts/test-notify-headless.py: five cases (interactive claude,claude -pprompt,claude --printstop,codex execprompt,claude -pwith the opt-in), reusing the harness intest-notify-subagents.py. Wired intomake test-notify.notify.conf.example: the new key, commented out.Testing
Automated, on macOS:
python3 scripts/test-notify-headless.py(5/5),python3 scripts/test-notify-subagents.py(25/25),python3 scripts/test-hook-wiring.py(42/42),bash -n notify.sh,./build.sh,make test-without-xcode(1447 tests, 0 failures),make typecheck-tests.scripts/test-notify-permission-blocking.pycrashes withValueError: flush of closed fileinrun_reply_caseon this machine, both with and without this change (checked against origin/main'snotify.sh), so I could not use it as a check.Manual, after
make reload:claude -p "run ls"(or any prompt that needs a permission). No banner or panel prompt appears and the run isn't held waiting for a reply.claudeinteractively and trigger a permission prompt. The banner and panel prompt appear as before.STACKNUDGE_HEADLESS_NUDGES=onto~/.stack-nudge/configand repeat step 1. The prompt now appears.Related issues
None.
🤖 Generated with Claude Code
Summary by cubic
Stops nudging headless agent runs (
claude -p,--print,codex exec): a script driving an agent fires the same hooks as a person at a terminal, but nobody can answer its permission prompts, and a held hook stalls the script for up to 550 seconds.notify.shnow skips those runs — prompts and turn-end notifications alike — and lets the agent's own permission setting decide.STACKNUDGE_HEADLESS_NUDGES=onrestores the old behavior.scripts/test-notify-headless.pywith five cases, wired intomake test-notify.notify.conf.example.Written for commit 2ac24e8. Summary will update on new commits.