- Notifications
You must be signed in to change notification settings - Fork 0
feat(hooks): stop a session following its own PR after handoff#1649
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Uh oh!
There was an error while loading. Please reload this page.
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
2ad32de
feat(hooks): stop a session following its own PR after handoff
BigSimmo 62d5c0c
merge: sync origin/main into pr-handoff-stop-hook
cursoragent 5d96617
fix(hooks): harden pr-handoff-stop against false locks (#1649)
cursoragent 977262f
fix(hooks): tighten pr-handoff-stop against review false-locks
cursoragent 249cb25
fix(hooks): deny follow tokens when jq-less extraction truncates
cursoragent 36c1bcc
fix(hooks): harden jq-less handoff matching against false locks
cursoragent 42ae4f3
chore: correct Run PR ledger HEAD for #1649 tip
cursoragent 057a557
fix(hooks): stop cross-session prune and tighten handoff deny
cursoragent 935671c
chore: ledger PR #1649 review-and-fix at product tip
cursoragent ed21f6f
ci: retrigger after Actions queue timeout
cursoragent 022de31
merge(main): sync after #1664
cursoragent 43250d4
Merge branch 'main' into claude/pr-handoff-stop-hook
BigSimmo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,259 @@ | ||
| #!/usr/bin/env bash | ||
| # Stop the session once its pull request exists. | ||
| # | ||
| # Why: an agent session (especially Claude Code on the web, which keeps running | ||
| # after the PR is opened) that stays attached to its own PR burns usage in a | ||
| # long tail of `gh pr checks` / `gh run watch` / branch-sync polling, or of | ||
| # Monitor/wake-up loops parked on the same PR. Opening the PR is the handoff; | ||
| # CI, review bots, and merge are the user's call from there. | ||
| # | ||
| # Two modes, both registered in .claude/settings.json: | ||
| # | ||
| # post PostToolUse — after a call that created a PR (the `gh pr create` CLI or | ||
| # a GitHub MCP create_pull_request tool) AND whose output contains a real | ||
| # PR URL, drop a session-scoped marker and tell the model the handoff is | ||
| # complete and the turn should end. | ||
| # | ||
| # pre PreToolUse — while that marker exists, deny the PR/CI-following tools: | ||
| # shell polling commands, GitHub MCP PR/CI read tools, and the loop | ||
| # machinery (Monitor / ScheduleWakeup / CronCreate). This is the part | ||
| # with teeth: prose rules in AGENTS.md have not held, a denied tool call | ||
| # does. | ||
| # | ||
| # Escape hatch: prefix a shell command with CLAUDE_ALLOW_PR_FOLLOW=1 (the user | ||
| # asking for CI to be watched is the authorisation). For non-shell tools the | ||
| # unlock is deleting the marker, which the deny reason spells out — do that only | ||
| # on an explicit user ask. | ||
| # | ||
| # `Run PR` sweeps, pr-ci-fix work, and reviews of someone else's PR are | ||
| # untouched: they never create a PR, so no marker is ever written. | ||
| # | ||
| # Contract: never fails a tool call by accident. Any parse problem exits 0 with | ||
| # no decision, which leaves the tool call exactly as it was. | ||
| set -uo pipefail | ||
| mode="${1:-}" | ||
| payload="$(cat 2>/dev/null || true)" | ||
| [ -z "$payload" ] && exit 0 | ||
| # Extract a JSON string value for key $1 from the raw payload (first match). | ||
| # Handles only simple double-quoted values (no escapes). Empty on miss. | ||
| # Callers that need shell-token matching must also scan $payload when | ||
| # jq_available=0 — this helper truncates at the first escaped quote inside | ||
| # the JSON string (e.g. `git commit -m \"msg\" && gh pr checks` → `git commit -m \`). | ||
| json_string_field() { | ||
| local key="$1" | ||
| printf '%s' "$payload" \ | ||
| | grep -o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" \ | ||
| | head -n1 \ | ||
| | sed -E 's/.*"([^"]*)"$/\1/' | ||
| } | ||
| # --- payload fields ----------------------------------------------------------- | ||
| # jq when available (exact). Without jq: | ||
| # - tool_name / session_id from crude key extraction | ||
| # - command_text from tool_input command/script/code/input fields when present, | ||
| # else the whole payload (pre-mode shell deny still needs a searchable string) | ||
| # - tool_output ONLY from the tool_response region — never the whole payload, | ||
| # and never a suffix that still contains a later tool_input (so an input URL | ||
| # cannot satisfy the post-mode URL gate when keys are ordered response-first) | ||
| # - shell token matching also scans a bounded raw-payload slice when jq is | ||
| # missing, because quote-naive extraction truncates mid-command | ||
| jq_available=0 | ||
| if command -v jq >/dev/null 2>&1; then | ||
| jq_available=1 | ||
| tool_name="$(printf '%s' "$payload" | jq -r '.tool_name // empty' 2>/dev/null || true)" | ||
| # Bash uses .command; PowerShell payloads may use script/code/input instead. | ||
| command_text="$(printf '%s' "$payload" | jq -r ' | ||
| .tool_input.command // .tool_input.script // .tool_input.code // .tool_input.input // empty | ||
| ' 2>/dev/null || true)" | ||
| tool_output="$(printf '%s' "$payload" | jq -r '[.tool_response] | tostring' 2>/dev/null || true)" | ||
| session_id="$(printf '%s' "$payload" | jq -r '.session_id // empty' 2>/dev/null || true)" | ||
| else | ||
| tool_name="$(json_string_field tool_name)" | ||
| session_id="$(json_string_field session_id)" | ||
| command_text="$(json_string_field command)" | ||
| [ -z "$command_text" ] && command_text="$(json_string_field script)" | ||
| [ -z "$command_text" ] && command_text="$(json_string_field code)" | ||
| [ -z "$command_text" ] && command_text="$(json_string_field input)" | ||
| # If no command-like field was found, scan the input half of the payload only — | ||
| # never tool_response — so a printed `gh pr create` cannot look like a create. | ||
| if [ -z "$command_text" ]; then | ||
| if printf '%s' "$payload" | grep -Fq '"tool_response"'; then | ||
| command_text="${payload%%\"tool_response\"*}" | ||
| else | ||
| command_text="$payload" | ||
| fi | ||
| fi | ||
| if printf '%s' "$payload" | grep -Fq '"tool_response"'; then | ||
| # Prefer a string-valued tool_response when the crude extractor can see it. | ||
| tool_output="$(json_string_field tool_response)" | ||
| if [ -z "$tool_output" ]; then | ||
| # Complex/non-string response: take the slice after tool_response, but | ||
| # stop before a later tool_input so an input URL cannot pass the gate. | ||
| tool_output="${payload#*\"tool_response\"}" | ||
| case "$tool_output" in | ||
| *\"tool_input\"*) tool_output="${tool_output%%\"tool_input\"*}" ;; | ||
| esac | ||
| fi | ||
| else | ||
| tool_output="" | ||
| fi | ||
| fi | ||
BigSimmo marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| # Pre-mode follow matching: decoded command, plus (jq-less) the full payload so | ||
| # a quote-truncated command_text cannot hide a later follow token. Over-block is | ||
| # the safe direction here. | ||
| shell_command_matches() { | ||
| local re="$1" | ||
| printf '%s' "$command_text" | grep -Eq "$re" && return 0 | ||
| if [ "$jq_available" -eq 0 ]; then | ||
| printf '%s' "$payload" | grep -Eq "$re" && return 0 | ||
| fi | ||
| return 1 | ||
| } | ||
| # Post-mode create-token matching: decoded command, plus (jq-less) only the | ||
| # payload half *before* tool_response. Scanning tool_response would let a | ||
| # command that merely prints `gh pr create` + a PR URL lock the session. | ||
| shell_input_matches() { | ||
| local re="$1" | ||
| printf '%s' "$command_text" | grep -Eq "$re" && return 0 | ||
| if [ "$jq_available" -eq 0 ]; then | ||
| local input_half="${payload%%\"tool_response\"*}" | ||
| printf '%s' "$input_half" | grep -Eq "$re" && return 0 | ||
| fi | ||
| return 1 | ||
| } | ||
| # Missing or unsafe session ids fail open: do not share an unknown-session | ||
| # marker across unrelated malformed payloads (path injection included). | ||
| if [ -z "$session_id" ] || ! printf '%s' "$session_id" | grep -Eq '^[A-Za-z0-9_-]+$'; then | ||
| exit 0 | ||
| fi | ||
| # --- marker location ---------------------------------------------------------- | ||
| # Absolute git dir so the marker path is valid from any cwd (and for linked | ||
| # worktrees). Falls back to TMPDIR outside a repo. | ||
| git_dir="$(git rev-parse --absolute-git-dir 2>/dev/null || true)" | ||
| [ -z "$git_dir" ] && git_dir="${TMPDIR:-/tmp}" | ||
| marker="$git_dir/claude-pr-handoff-$session_id" | ||
| # Intentionally do not prune sibling sessions' markers. Post-mode runs on every | ||
| # Bash/PowerShell call (settings matcher), so age-based deletion of *other* | ||
| # sessions' files would disarm a long-lived handoff session that only uses | ||
| # Read/Edit after opening its PR (those tools never refresh mtime via pre-mode | ||
| # touch). Markers are one-line files under the git dir and disappear with the | ||
| # worktree; leftover orphans are cheap hygiene, not worth silent enforcement loss. | ||
| json_escape() { | ||
| # Escape backslash/quote, fold newlines to spaces, and strip other C0 control | ||
| # characters so a future multi-line deny reason cannot emit unparseable JSON. | ||
| printf '%s' "$1" \ | ||
| | tr '\n\r\t' ' ' \ | ||
| | tr -d '\000-\010\013\014\016-\037' \ | ||
| | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | ||
| } | ||
| is_shell_tool() { | ||
| case "$tool_name" in | ||
| "" | Bash | PowerShell) return 0 ;; | ||
| *) return 1 ;; | ||
| esac | ||
| } | ||
| # A PR-creating or PR-merging tool is never blocked — creation is how the PR gets | ||
| # opened, and merge already carries its own explicit-confirmation rule. | ||
| matches_pr_write_tool() { | ||
| printf '%s' "$tool_name" \ | ||
| | grep -Eqi '(create|merge)_?pull_?request$' | ||
| } | ||
| case "$mode" in | ||
| post) | ||
| # A PR-creating call that actually returned a PR URL. Both halves matter: | ||
| # without the URL check a failed create would end the session with no PR to | ||
| # hand off. | ||
| created=1 | ||
| if is_shell_tool && shell_input_matches 'gh[[:space:]]+pr[[:space:]]+create'; then | ||
| created=0 | ||
| fi | ||
| # End-anchor required: create_pull_request_review / _review_comment must NOT | ||
| # count as opening a PR (pre-mode already uses the same anchored shape). | ||
| if printf '%s' "$tool_name" | grep -Eqi 'create_?pull_?request$'; then | ||
| created=0 | ||
| fi | ||
| [ "$created" -eq 0 ] || exit 0 | ||
| # Require a non-empty tool_output that itself carries a PR URL. An empty | ||
| # tool_output (jq-less payload with no tool_response key) must not match. | ||
| [ -n "$tool_output" ] || exit 0 | ||
| printf '%s' "$tool_output" | grep -Eq 'github\.com/[^ "]+/pull/[0-9]+' || exit 0 | ||
| # Only tell the model tools are denied when the marker actually landed. A | ||
| # failed write (permissions / full disk) must fail open with no context — | ||
| # otherwise the model stops while pre-mode never enforces. | ||
| if ! printf 'pr-opened %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)" >"$marker" 2>/dev/null; then | ||
| exit 0 | ||
| fi | ||
| [ -f "$marker" ] || exit 0 | ||
| context='The pull request is open. That is the end of this session'\''s handoff. Record the ledger row if it is still owed, give the user the PR URL and a short summary, then stop. Do NOT watch CI, poll checks, read workflow runs or job logs, re-run workflows, sync the branch from main, answer review bots, or park a Monitor / ScheduleWakeup / cron loop on this PR — following the PR from here is the wasted-usage loop this repo has explicitly ruled out (AGENTS.md "Stop when the pull request is open"). Those tools are now denied for the rest of this session. If the user asks for CI babysitting afterwards, that is their call and it is allowed then.' | ||
| printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$(json_escape "$context")" | ||
| exit 0 | ||
| ;; | ||
| pre) | ||
| [ -f "$marker" ] || exit 0 | ||
| # Refresh mtime so prune retention tracks last activity, not creation time. | ||
| touch "$marker" 2>/dev/null || true | ||
| matches_pr_write_tool && exit 0 | ||
| reason="" | ||
| # 1. Loop machinery — parking a wake-up or watcher on the PR is the same loop | ||
| # by another route, and hooks are the only thing that can see these. | ||
| case "$tool_name" in | ||
| Monitor | ScheduleWakeup | CronCreate) | ||
| reason='Blocked: this session already opened its pull request, so parking a watcher/wake-up/cron on it is the wasted-usage loop AGENTS.md "Stop when the pull request is open" rules out. Hand the PR URL to the user and stop.' | ||
| ;; | ||
| esac | ||
| # 2. GitHub MCP tools that read or mutate PR / CI state. | ||
| # Keep this token list in sync with the PreToolUse matcher in .claude/settings.json. | ||
| if [ -z "$reason" ] && printf '%s' "$tool_name" \ | ||
| | grep -Eqi 'pull_?request|workflow_run|workflow_job|check_run|check_suite|job_log|pr_status|update_branch'; then | ||
| reason='Blocked: this session already opened its pull request, so reading or nudging its CI and review state is the wasted-usage loop AGENTS.md "Stop when the pull request is open" rules out. Hand the PR URL to the user and stop.' | ||
| fi | ||
| # 3. Shell polling. Narrow on purpose: committing, pushing, ledger appends, and | ||
| # `gh pr merge` (already gated on explicit user confirmation) stay allowed. | ||
| # Matches are substring-based (cheap, no shell parse); false positives that | ||
| # only *mention* a blocked token can use CLAUDE_ALLOW_PR_FOLLOW=1. | ||
| if [ -z "$reason" ] && is_shell_tool; then | ||
| # Escape hatch: documented prefix only (not an incidental echo/mention). | ||
| # A quote-truncated command_text still sees a leading CLAUDE_ALLOW_PR_FOLLOW=1. | ||
| printf '%s' "$command_text" \ | ||
| | grep -Eq '^[[:space:]]*CLAUDE_ALLOW_PR_FOLLOW=1([[:space:]]|$)' \ | ||
| && exit 0 | ||
| # comment/review cover the "answer review bots" loop named in AGENTS.md; | ||
| # merge stays allowed (explicit-confirmation rule elsewhere). | ||
| follow_re='gh[[:space:]]+pr[[:space:]]+(checks|status|view|diff|list|comment|review)' | ||
| follow_re="$follow_re"'|gh[[:space:]]+run[[:space:]]+(watch|view|list|rerun|download)' | ||
| follow_re="$follow_re"'|gh[[:space:]]+api[^|;]*(actions/runs|check-runs|check-suites|/pulls/)' | ||
| follow_re="$follow_re"'|sync:pr-branches' | ||
| if shell_command_matches "$follow_re"; then | ||
| reason='Blocked: this session already opened its pull request, so following it (CI polling, run logs, branch sync) is the wasted-usage loop AGENTS.md "Stop when the pull request is open" rules out. Hand the PR URL to the user and stop. If the user has asked for this check, re-run it prefixed with CLAUDE_ALLOW_PR_FOLLOW=1.' | ||
| fi | ||
| fi | ||
| [ -z "$reason" ] && exit 0 | ||
| reason="$reason"' Unlock only on an explicit user ask, by deleting the marker: CLAUDE_ALLOW_PR_FOLLOW=1 rm "'"$marker"'".' | ||
| printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$(json_escape "$reason")" | ||
| exit 0 | ||
| ;; | ||
| *) | ||
| exit 0 | ||
| ;; | ||
| esac | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Oops, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.