Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ jobs:
- name: notify.sh permission-blocking tests
run: python3 scripts/test-notify-permission-blocking.py

- name: notify.sh headless tests
run: python3 scripts/test-notify-headless.py

# install.sh's hook helpers: the code that removes entries from agent configs.
- name: install.sh hook wiring tests
run: python3 scripts/test-hook-wiring.py
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ test:
test-notify:
@python3 ./scripts/test-notify-subagents.py
@python3 ./scripts/test-notify-permission-blocking.py
@python3 ./scripts/test-notify-headless.py
Comment thread
adefreitas marked this conversation as resolved.
Resolved
@python3 ./scripts/test-hook-wiring.py

# Compile-check the XCTest sources without Xcode. Catches the breakage `make
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ Reminders only fire for prompts stack-nudge can *prove* are unanswered. Claude C

**Prompts raised by a subagent say so.** Claude Code's `Task` agents and Codex's `spawn_agent` children run their tool calls through the same permission hook as the main thread, so a prompt from an `Explore` agent used to be indistinguishable from one you'd asked for; that's the case where attribution matters most, since the reaction to an unexpected approval prompt is *"I didn't ask for that"*. Those banners now read **Claude Code · Explore** (plugin-scoped agent names are shown unscoped). If you'd rather not hear from them at all, set `STACKNUDGE_SUBAGENT_NUDGES=off` and they're dropped: the agent still prompts in its own terminal, so nothing is auto-approved, but nothing tells you it's waiting either, which is why tagging is the default. Turn-end nudges never needed this: both agents convert a subagent's turn end to their own `SubagentStop` event, which stack-nudge doesn't wire.

**Headless runs stay quiet.** A script driving an agent (`claude -p` / `--print`, `codex exec` / `e` / `review`) fires the same hooks as a person at a terminal, but nobody can answer its prompts, and a held hook would stall the script for up to 550 seconds. stack-nudge posts nothing for those runs and leaves the decision to the agent's own permission setting; their turns ending aren't nudged either. Set `STACKNUDGE_HEADLESS_NUDGES=on` to treat them like any other session.

**Slack, for when you're not at the Mac.** Banners only work if you're looking at the screen. Point stack-nudge at a Slack bot token and a permission prompt also arrives as a DM **from StackNudge** — which, unlike a message you send yourself, actually notifies you.

| Setting | Default | |
Expand Down
8 changes: 8 additions & 0 deletions notify.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@
# Default: tag
#STACKNUDGE_SUBAGENT_NUDGES=off

# Nudges from a headless agent run: `claude -p` / `--print` or `codex exec` /
# `e` / `review`, a script driving the agent with nobody at a terminal. Off by
# default: such a run's prompts can't be answered by a person, and holding its
# hook stalls the script, so the agent's own permission setting decides
# instead. "on" treats them like any other session.
# Default: off
#STACKNUDGE_HEADLESS_NUDGES=on

# Fair-usage budget for pi, in tokens per day, per lane. pi enforces no quota
# of its own — API models bill per token against your keys, local models are
# free — so these are your own allowances and nothing cuts you off at them. The
Expand Down
56 changes: 56 additions & 0 deletions notify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,62 @@ play_windows() {
|| powershell -c "[console]::beep(${freq},${dur})" 2>/dev/null
}

# A headless run (`claude -p` / `--print`, `codex exec` / `e` / `review`) is a
# script driving the agent, with nobody at a terminal: a prompt from it can't be
# answered by a person, and holding its hook for the 550s FIFO wait stalls the
# script. Leave it alone and let the agent's own permission setting decide. Its
# turns ending aren't news either. STACKNUDGE_HEADLESS_NUDGES=on brings both
# back.
#
# Claude Code says how it was started in CLAUDE_CODE_ENTRYPOINT, which its
# hooks inherit: `sdk-cli` for `-p` and the other `sdk-*` values for the SDKs,
# `cli` for a terminal session, and whatever an IDE or other host set. Only an
# older Claude Code without the variable falls back to reading argv, and ps
# flattens argv into one string, so there a prompt that itself contains " -p "
# still reads as headless.
#
# codex has no such variable, so its subcommand decides. Its `-p` is
# `--profile`, and global flags can come before the subcommand, so skip them
# and their values and read the first positional word.
agent_is_headless() {
if [[ "$AGENT" == claude* && -n "${CLAUDE_CODE_ENTRYPOINT:-}" ]]; then
[[ "$CLAUDE_CODE_ENTRYPOINT" == sdk-* ]]
return
fi
local pid="${NUDGE_AGENT_PID:-}"
if [[ -z "$pid" ]]; then
walk_session_chain
pid="$AGENT_PID"
fi
[[ -z "$pid" ]] && return 1
local args
args="$(ps -p "$pid" -o args= 2>/dev/null)"
case "$AGENT" in
claude*)
[[ " $args " =~ \ (-p|--print)(\ |=) ]] && return 0
Comment thread
adefreitas marked this conversation as resolved.
Resolved
;;
codex*)
local -a words
read -ra words <<< "$args"
local i=1
while (( i < ${#words[@]} )); do
case "${words[i]}" in
-c|--config|-m|--model|-p|--profile|-a|--ask-for-approval|-s|--sandbox|-C|--cd|-i|--image)
(( i += 2 )) ;;
-*) (( i += 1 )) ;;
exec|e|review) return 0 ;;
*) return 1 ;;
esac
done
;;
esac
return 1
}
if [[ "${STACKNUDGE_HEADLESS_NUDGES:-off}" != "on" ]] && agent_is_headless; then
nudge_debug "nudge skipped: the agent is a headless run"
exit 0
fi

TITLE="$(agent_label "$AGENT")"

# Say which subagent a nudge came from, so "I didn't ask for that" has an
Expand Down
165 changes: 165 additions & 0 deletions scripts/test-notify-headless.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Behaviour tests for notify.sh's headless-run check.

A script driving an agent (`claude -p`, `codex exec`) fires the same hooks as a
person at a terminal, but nobody can answer its prompts, and a held hook stalls
the script for up to 550s. notify.sh therefore posts nothing for a headless run
unless STACKNUDGE_HEADLESS_NUDGES=on.

Most cases seed the agent through NUDGE_AGENT_PID: a stand-in process whose
command line carries the flags under test. Claude cases that set
CLAUDE_CODE_ENTRYPOINT check that the variable wins over the command line. One case leaves NUDGE_AGENT_PID unset
and runs the hook as the child of a process named `claude`, so notify.sh finds
the agent by walking up from $PPID, as it does under a real agent. The run
itself reuses the harness in test-notify-subagents.py (throwaway $HOME, fake
panel socket).

Usage: scripts/test-notify-headless.py [path/to/notify.sh]
"""

import importlib.util
import os
import shutil
import subprocess
import sys
import tempfile

HERE = os.path.dirname(os.path.abspath(__file__))
spec = importlib.util.spec_from_file_location("harness", os.path.join(HERE, "test-notify-subagents.py"))
harness = importlib.util.module_from_spec(spec)
spec.loader.exec_module(harness)

# name, agent, stand-in argv after the program, event, env, expected title (None = nothing posted)
CASES = [
("interactive claude prompt", "claude-code", ["--resume", "abc"], "permission", {}, "Claude Code"),
("claude -p prompt", "claude-code", ["-p", "--output-format", "json"], "permission", {}, None),
("claude --print stop", "claude-code", ["--print", "hi"], "stop", {}, None),
("codex exec prompt", "codex", ["exec", "fix it"], "permission", {}, None),
("codex e prompt", "codex", ["e"], "permission", {}, None),
("codex review prompt", "codex", ["review"], "permission", {}, None),
# codex's -p is --profile, not a headless flag.
("codex -p profile prompt", "codex", ["-p", "work"], "permission", {}, "Codex"),
("claude -p, opted back in", "claude-code", ["-p"], "permission",
{"STACKNUDGE_HEADLESS_NUDGES": "on"}, "Claude Code"),
# The entrypoint decides when it's set: a prompt that mentions -p is still
# an interactive session, and an SDK run is headless whatever its argv.
("claude cli, prompt says -p", "claude-code", ["fix", "-p", "parsing"], "permission",
{"CLAUDE_CODE_ENTRYPOINT": "cli"}, "Claude Code"),
("claude sdk-cli prompt", "claude-code", ["--resume", "abc"], "permission",
{"CLAUDE_CODE_ENTRYPOINT": "sdk-cli"}, None),
("claude sdk-ts prompt", "claude-code", [], "permission",
{"CLAUDE_CODE_ENTRYPOINT": "sdk-ts"}, None),
# Global flags and their values come before codex's subcommand.
("codex -c value exec prompt", "codex", ["-c", "model=o3", "exec"], "permission", {}, None),
("codex --model value e prompt", "codex", ["--model", "m", "e"], "permission", {}, None),
# A model named exec is the flag's value, not the subcommand.
("codex -m exec prompt", "codex", ["-m", "exec"], "permission", {}, "Codex"),
]

# Hook run as the child of `claude <flag>`, NUDGE_AGENT_PID unset. The
# interactive run is the control: it shows the stand-in parent is found at all,
# so the headless run passing means the flag was read, not that the walk missed.
PARENT_CASES = [
("hook under interactive claude", "--resume", "Claude Code"),
("hook under claude -p", "-p", None),
]

# Written out so deleting a case can't quietly lower the bar.
EXPECTED_CASE_COUNT = 16


# The codex stand-in. It reads none of its arguments, so any codex argv runs
# as given. A system tool won't do: an interpreter parses `-c` and `--model`
# as its own options, and macOS 15's yes writes a newline over the end of
# argv[1], which ps then shows merged with argv[2].
CODEX_STAND_IN_C = "#include <unistd.h>\nint main(void) { for (;;) pause(); }\n"


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.

source_file.write(CODEX_STAND_IN_C)
subprocess.run(["cc", "-o", binary, source], check=True)
return binary


def start_stand_in(agent, argv, codex_binary):
"""Start a sleeping process whose `ps -o args=` reads like the agent's."""
if agent == "codex":
# The codex check reads the words after the program, so the argv has
# to start right there, under argv[0] "codex".
return subprocess.Popen(["codex", *argv], executable=codex_binary)
# The claude check matches the flags anywhere, so a python stand-in will do.
return subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)", *argv])


def run_under_claude_parent(workdir, flag, expect_event):
"""Run a permission hook as the child of `claude <flag>`, NUDGE_AGENT_PID unset.

ps reports a process by the name it was launched under, so the parent is
bash run through a symlink called `claude`. A copy of bash would need
re-signing to launch at all, and on the CI runner the re-signed copy never
ran the hook.
"""
claude = os.path.join(workdir, "claude")
if not os.path.lexists(claude):
os.symlink("/bin/bash", claude)
base_real = os.path.realpath(workdir)
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.

# The trailing `exit` keeps bash from exec'ing notify.sh in its own
# place, which would drop `claude` out of the process tree.
wrapper_file.write('#!/bin/sh\nexec "$CLAUDE_STAND_IN" -c \'"$@"; exit $?\' "$CLAUDE_FLAG" "$REAL_NOTIFY" "$@"\n')
os.chmod(wrapper_real, 0o755)
real_notify = harness.NOTIFY
harness.NOTIFY = wrapper_real
try:
env = {"CLAUDE_STAND_IN": claude, "CLAUDE_FLAG": flag, "REAL_NOTIFY": real_notify}
return harness.posted_title("claude-code", {}, env, expect_event, False, "permission")
finally:
harness.NOTIFY = real_notify


def main():
if sys.platform != "darwin":
print("skipped: notify.sh only posts to the panel on macOS")
return 0
os.environ.pop("NUDGE_AGENT_PID", None)
# Set when the tests themselves run under Claude Code; it would decide
# every claude case.
os.environ.pop("CLAUDE_CODE_ENTRYPOINT", None)
workdir = tempfile.mkdtemp(prefix="stack-nudge-test-headless-")
passed = 0
try:
codex_binary = build_codex_stand_in(workdir)
for name, agent, argv, event, env, expected in CASES:
stand_in = start_stand_in(agent, argv, codex_binary)
try:
actual = harness.posted_title(agent, {}, dict(env, NUDGE_AGENT_PID=str(stand_in.pid)),
expected is not None, False, event)
finally:
stand_in.kill()
stand_in.wait()
ok = actual == expected
passed += ok
print("%-4s %-30s expected=%-16r actual=%r" % ("PASS" if ok else "FAIL", name, expected, actual))

for name, flag, expected in PARENT_CASES:
actual = run_under_claude_parent(workdir, flag, expected is not None)
ok = actual == expected
passed += ok
print("%-4s %-30s expected=%-16r actual=%r" % ("PASS" if ok else "FAIL", name, expected, actual))
finally:
shutil.rmtree(workdir, ignore_errors=True)
ran = len(CASES) + len(PARENT_CASES)
if ran != EXPECTED_CASE_COUNT:
print("FAIL ran %d cases, expected %d" % (ran, EXPECTED_CASE_COUNT))
return 1
return 0 if passed == ran else 1


if __name__ == "__main__":
sys.exit(main())
Loading