Skip to content

fix(notify): leave headless agent runs alone - #229

Open
adefreitas wants to merge 6 commits into
StackOneHQ:mainfrom
adefreitas:fix/headless-prompts
Open

adefreitas wants to merge 6 commits into
StackOneHQ:mainfrom
adefreitas:fix/headless-prompts

Conversation

@adefreitas

@adefreitas adefreitas commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

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.sh now skips those runs unless STACKNUDGE_HEADLESS_NUDGES=on.

Changes

  • notify.sh: agent_is_headless inspects the agent process's args (-p / --print, or exec for Codex) and exits without a decision, so the agent's own permission setting decides. STACKNUDGE_HEADLESS_NUDGES=on restores the old behaviour.
  • scripts/test-notify-headless.py: five cases (interactive claude, claude -p prompt, claude --print stop, codex exec prompt, claude -p with the opt-in), reusing the harness in test-notify-subagents.py. Wired into make test-notify.
  • notify.conf.example: the new key, commented out.
  • README: a paragraph on headless runs next to the subagent one.

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.py crashes with ValueError: flush of closed file in run_reply_case on this machine, both with and without this change (checked against origin/main's notify.sh), so I could not use it as a check.

Manual, after make reload:

  1. Run 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.
  2. Run claude interactively and trigger a permission prompt. The banner and panel prompt appear as before.
  3. Add STACKNUDGE_HEADLESS_NUDGES=on to ~/.stack-nudge/config and 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.sh now skips those runs — prompts and turn-end notifications alike — and lets the agent's own permission setting decide. STACKNUDGE_HEADLESS_NUDGES=on restores the old behavior.

  • Adds scripts/test-notify-headless.py with five cases, wired into make test-notify.
  • Documents the new setting in the README and notify.conf.example.

Written for commit 2ac24e8. Summary will update on new commits.

View guided diff

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>
Comment thread notify.sh Resolved
Comment thread notify.sh Resolved
Comment thread scripts/test-notify-headless.py Resolved
Comment thread Makefile Resolved
- 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>
Comment thread scripts/test-notify-headless.py Resolved

@hiskudin hiskudin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

adefreitas and others added 2 commits October 30, 2026 10:42
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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

Comment thread notify.sh Resolved
Comment thread notify.sh Resolved
adefreitas and others added 2 commits October 30, 2026 11:47
- 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:

@aikido-pr-checks aikido-pr-checks Bot Oct 30, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Suggested change
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Based on your feedback, we ignored this issue because of the following reason:

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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants