[luv-299] fix: enforce require-*-before-stop on GitHub Copilot CLI#299
[luv-299] fix: enforce require-*-before-stop on GitHub Copilot CLI#299NiveditJain merged 4 commits intomainfrom
Conversation
Empirically verified against Copilot CLI 1.0.41 (~/.copilot/logs/process-*.log
+ session-state events.jsonl): exit-2 + stderr from a Stop hook on Copilot's
agentStop is logged as `[WARNING] Hook warning: ...` and surfaced to the user,
but the agent stops cleanly without retrying — same observation-only failure
mode as Cursor/OpenCode/Pi, except Copilot at least shows the message.
The documented retry shape (cli-hooks-reference) is `{decision: "block",
reason}` JSON on stdout (exit 0); reason becomes the next-turn prompt. New
`cli === "copilot"` branch in policy-evaluator.ts:267 emits that shape on Stop
deny, mirroring the existing Gemini AfterAgent branch at :188. Without this
branch, all 5 require-*-before-stop builtins (commit / push / PR / no-conflicts
/ CI-green) were observation-only on Copilot.
Also adds SubagentStop to COPILOT_HOOK_EVENT_TYPES so the same policies fire
when Copilot subagents finish (Copilot's `subagentStop` aliases to PascalCase
via the same VS Code-compat alias that maps `agentStop` ↔ `Stop`).
Tests:
- New unit test (policy-evaluator.test.ts) pinning the Copilot Stop JSON shape
- New e2e regression (copilot-integration.e2e.test.ts) round-tripping Stop deny
- Existing install/uninstall test extended to assert SubagentStop is written
- New assertCopilotStopBlock helper in hook-runner.ts
Docs: CLAUDE.md per-CLI capability matrix + types.ts comment block updated
with the verified Stop block semantics.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe PR adds Copilot-aware Stop handling: Stop and SubagentStop now emit a Copilot-specific JSON "block" payload on stdout with exitCode 0 when the session CLI is "copilot". It extends Copilot event types to include "SubagentStop", updates docs, and adds tests and a test helper to validate the new behavior. ChangesCopilot Stop Event Handling & SubagentStop Support
Sequence DiagramsequenceDiagram
participant Copilot as Copilot CLI
participant HookRunner as Hook Runner
participant Policy as Policy Evaluator
participant Std as Stdout/Stderr
Copilot->>HookRunner: invoke Stop/SubagentStop hook (session.cli="copilot")
HookRunner->>Policy: evaluatePolicies(session, eventType)
Policy-->>HookRunner: { decision: "instruct/deny", reasonText }
alt session.cli == "copilot" and eventType is Stop/SubagentStop
HookRunner->>Std: write stdout JSON { decision: "block", reason: reasonText }
HookRunner-->>Copilot: exitCode 0
else non-Copilot Stop/SubagentStop
HookRunner->>Std: write stderr reasonText
HookRunner-->>Copilot: exitCode 2
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/hooks/policy-evaluator.ts`:
- Around line 267-287: Builtin policies registered with match: { events:
["Stop"] } and the runtime check if (eventType === "Stop") both miss
SubagentStop, so Copilot subagent denials fall through to exitCode 2 instead of
returning the Copilot block payload; update the policy registrations that
currently use match: { events: ["Stop"] } to include "SubagentStop" as well, and
change the runtime condition in policy-evaluator.ts from if (eventType ===
"Stop") to check both Stop and SubagentStop (e.g., eventType === "Stop" ||
eventType === "SubagentStop") so the Copilot branch that returns
JSON.stringify({ decision: "block", reason: reasonText }) (and the surrounding
session?.cli === "copilot" handling, policy.name, reason, decision: "deny"
fields) is executed for both event types.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 2e1207c2-5f3e-4f64-a2cd-97b6540ffdbb
📒 Files selected for processing (8)
CHANGELOG.mdCLAUDE.md__tests__/e2e/helpers/hook-runner.ts__tests__/e2e/hooks/copilot-integration.e2e.test.ts__tests__/hooks/integrations.test.ts__tests__/hooks/policy-evaluator.test.tssrc/hooks/policy-evaluator.tssrc/hooks/types.ts
Address CodeRabbit on PR #299: the cli==="copilot" branch initially only matched eventType==="Stop", so SubagentStop denies fell through to exit-2 — which Copilot ignores for stop-style events, leaving any custom policy matching SubagentStop as observation-only on Copilot subagent boundaries. Widen the condition to `eventType === "Stop" || eventType === "SubagentStop"` so both stop-style events emit the {decision:"block", reason} JSON shape on Copilot. Per the cli-hooks-reference, both `agentStop` and `subagentStop` honor the same retry shape. Skip the second half of CodeRabbit's suggestion (extending the 5 require-*-before-stop builtin policies to also match SubagentStop): those policies are session-completion gates (commit/push/PR/conflicts/CI) and firing them on every subagent return is conceptually mismatched and noisy. SubagentStop subscription remains for custom policies that genuinely care about subagent boundaries. Side effect: Claude SubagentStop denies now use the MANDATORY ACTION REQUIRED wrapper too (was previously falling through to bare-reason in the "other events" fallback). Consistent with Stop and an improvement. Tests: +2 cases — Copilot SubagentStop emits JSON-block; Claude SubagentStop preserves exit-2+wrapped-stderr. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…p widening Existing entry only mentioned the SubagentStop *subscription* in COPILOT_HOOK_EVENT_TYPES. Add the runtime-side change too: the cli==="copilot" Stop branch in policy-evaluator.ts now matches both Stop and SubagentStop so custom policies subscribing to subagent boundaries get the correct retry shape on Copilot. Also calls out the explicit design decision to NOT extend the 5 require-*-before-stop builtin policies to SubagentStop (they're session-completion gates). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CHANGELOG.md`:
- Line 9: Update the CHANGELOG language to avoid implying the five builtins are
affected: clarify that adding SubagentStop to COPILOT_HOOK_EVENT_TYPES and
widening the cli === "copilot" branch in policy-evaluator.ts to handle both Stop
and SubagentStop enables custom policies to fire on Copilot subagent boundaries,
while the existing require-*-before-stop builtins still intentionally match only
Stop; reword the earlier sentence to explicitly state this distinction
(reference symbols: SubagentStop, COPILOT_HOOK_EVENT_TYPES,
require-*-before-stop, policy-evaluator.ts cli === "copilot" branch).
In `@src/hooks/policy-evaluator.ts`:
- Around line 267-288: The instruct-path for policy enforcement misses the
Copilot-specific `{decision: "block", reason}` response for both "Stop" and
"SubagentStop" events; update the instruct branch that currently only checks
`eventType === "Stop"` to also handle `eventType === "SubagentStop"` and add a
`session?.cli === "copilot"` branch that returns the same shape as the deny-path
(exitCode: 0, stdout: JSON.stringify({decision: "block", reason: reasonText}),
stderr: "", policyName: policy.name, reason, decision: "deny") before falling
through to the generic Exit-2 + stderr handler so Copilot retries on both Stop
and SubagentStop for instruct policies.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f7e44b9e-f852-4e73-bd26-b80ebec943f4
📒 Files selected for processing (3)
CHANGELOG.md__tests__/hooks/policy-evaluator.test.tssrc/hooks/policy-evaluator.ts
CodeRabbit catch on PR #299: the instruct branch in policy-evaluator.ts only matched eventType === "Stop" and lacked any cli === "copilot" arm. The deny branch above was already widened, but the instruct path was missed — instruct verdicts on Copilot (Stop or SubagentStop) fell through to exit-2, which Copilot logs as a hook warning but does NOT honor for retry. Mirror the deny-path fix: - Widen the condition to (eventType === "Stop" || eventType === "SubagentStop") - Add a session?.cli === "copilot" branch returning {decision: "block", reason} JSON on stdout (exit 0), same shape as the deny path Builtin require-*-before-stop policies still match Stop only by design; this fix is what custom subagent-instruct policies need to actually enforce on Copilot. Also reword the CHANGELOG sentence so "the same policies fire when Copilot subagents finish" no longer implies the 5 builtins are subagent- scoped (they are session-completion gates and intentionally still match Stop only). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
CHANGELOG.md (1)
9-9: ⚡ Quick winCondense this changelog item to the repo’s single-line format.
This reads like release notes rather than an
Unreleasedchangelog bullet. Please trim it to a short one-line summary with the PR reference.As per coding guidelines,
CHANGELOG.md: “Add every PR update under the## Unreleasedsection with subsections for Features, Fixes, Docs, and Dependencies; format as single-line entries like- Add foo support (#123).”🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@CHANGELOG.md` at line 9, Replace the long multi-paragraph entry in CHANGELOG.md with a single-line Unreleased bullet in the repo's required format: condense the content to one short sentence like "- Enforce require-*-before-stop policies and add Copilot SubagentStop parity (`#PR`)" (use the actual PR number in place of `#PR`), place it under the appropriate Unreleased subsection (Features or Fixes), and remove the verbose release-note style paragraph entirely.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@CHANGELOG.md`:
- Line 9: Replace the long multi-paragraph entry in CHANGELOG.md with a
single-line Unreleased bullet in the repo's required format: condense the
content to one short sentence like "- Enforce require-*-before-stop policies and
add Copilot SubagentStop parity (`#PR`)" (use the actual PR number in place of
`#PR`), place it under the appropriate Unreleased subsection (Features or Fixes),
and remove the verbose release-note style paragraph entirely.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 4fa93ef5-6ae9-4cc1-8175-ac083735f38c
📒 Files selected for processing (3)
CHANGELOG.md__tests__/hooks/policy-evaluator.test.tssrc/hooks/policy-evaluator.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- tests/hooks/policy-evaluator.test.ts
…a.6 (#318) * [luv-319] fix: enforce Stop hook on Cursor Agent CLI (followup_message + SubagentStop parity) Cursor's `stop` hook ignores the flat `{permission: "deny"}` shape — that's honored on tool events only. The only force-retry channel for Stop is `{followup_message}` on stdout (exit 0), per https://cursor.com/docs/hooks. The instruct branch already used this shape correctly since #245; the deny path needed the same treatment, mirroring Copilot's #299 fix. Without this, the 5 require-*-before-stop builtins were observation-only on Cursor — the deny was logged but the agent stopped cleanly. User repro: session 1b510ad4-906c-4f30-9467-ff2e6c581cce at /home/nivedit/dev-purge. Also subscribes to `subagentStop` (CURSOR_HOOK_EVENT_TYPES + CURSOR_EVENT_MAP) and widens both deny and instruct branches to match it, for parity with the Copilot SubagentStop widening from #299. Cloud Agents caveat: Cursor Cloud Agent VMs do NOT run stop/subagentStop hooks at all, so this fix only covers local Cursor sessions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: cut 0.0.10-beta.6 release in CHANGELOG Promote the six entries accumulated under `## Unreleased` to a versioned heading `## 0.0.10-beta.6 — 2026-05-08`. Add a fresh `## Unreleased` heading at the top for the next development cycle. package.json was already at 0.0.10-beta.6 (pre-bumped); no version edit needed here. The CHANGELOG cut completes the release-prep handshake. Entries promoted: - Cursor Stop hook enforcement fix (this PR) - 5 scripts/translate-docs fixes from #305, #306, #307, #312, #313 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* [luv-324] fix: enforce Stop hook on OpenCode Stop hooks fired on OpenCode (visible in dashboard activity feed) but the agent stopped without retry — same failure mode Cursor had pre-#318 and Copilot had pre-#299. Root cause: no `cli === "opencode"` branch in policy-evaluator's Stop / SubagentStop handling, so OpenCode fell into the generic exit-2 path. The plugin shim's applyDecision turns exit-2 into `throw new Error(reason)`, but throwing from the `session.idle` event callback is a no-op — OpenCode is already idle by the time the event fires. Fix: emit `{hookSpecificOutput: {additionalContext: <MANDATORY ACTION reasonText>}}` for opencode Stop / SubagentStop in both deny and instruct paths. The shim already routes `additionalContext` through `client.session.prompt(...)` which submits a new user message that re-triggers the agent loop — same model as Cursor's `followup_message` and Copilot's `{decision: "block", reason}`. Promote applyDecision to async and `await client.session.prompt` for Stop/SubagentStop events so the SDK round-trip completes before the plugin context tears down; keep fire-and-forget for tool events to avoid hot-path latency. Sister CLIs verified while in here: - Gemini AfterAgent (canonical Stop) was already correctly emitting `{decision: "block", reason}`; new unit tests pin both deny and instruct shapes to prevent regression. - Pi `agent_end` is observation-only by upstream design — Pi's agent loop has already exited and `AgentEndEventResult` exposes no `block` field. CLAUDE.md already documents this; no code change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * [luv-324] docs: clarify OpenCode plugin shim Stop semantics Update configuration.mdx to reflect the new Stop / SubagentStop force- retry channel: deny on Stop now routes through `client.session.prompt` just like instruct, since `session.idle` is notification-only and throwing from it is silently dropped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * [luv-324] fix: address CodeRabbit feedback + cut 0.0.10-beta.8 Address PR #323 review: - CHANGELOG.md: append (#323) to the Unreleased entry per repo convention (every entry ends with the PR number). - docs/configuration.mdx:199: "Unlike the other four CLIs" → "Unlike the other six CLIs" — the page now lists six other integrations (Claude Code, Codex, Copilot, Cursor, Pi, Gemini) so the count was stale. Release prep: promote the Unreleased entry to a versioned heading `## 0.0.10-beta.8 — 2026-05-08`. Add a fresh `## Unreleased` heading at the top for the next development cycle. package.json is already at 0.0.10-beta.8 (pre-bumped by chore commit a146ae6 after beta.7 release). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
~/.copilot/logs/process-*.log+ session-stateevents.jsonl): exit-2 + stderr from a Stop hook on Copilot'sagentStopis logged as[WARNING] Hook warning: …and surfaced to the user, but the agent stops cleanly without retrying — same observation-only failure mode as Cursor/OpenCode/Pi, except Copilot at least shows the message.{decision: "block", reason}JSON on stdout (exit 0); reason becomes the next-turn prompt. Newcli === "copilot"branches insrc/hooks/policy-evaluator.tsemit that shape on both the deny path (line ~267) and the instruct path (line ~457), mirroring the existing Gemini AfterAgent branches. Without these branches, all fiverequire-*-before-stopbuiltins (commit / push / PR / no-conflicts / CI-green) were silently observation-only on Copilot.SubagentStoptoCOPILOT_HOOK_EVENT_TYPESso custom policies subscribing to that event are reachable from Copilot subagent boundaries (Copilot'ssubagentStopaliases to PascalCase via the same VS Code-compat alias that mapsagentStop↔Stop).cli === "copilot"branches (deny + instruct) matchStopandSubagentStopso those custom subagent policies get the correct retry shape too. The 5require-*-before-stopbuiltins still matchStoponly by design — they are session-completion gates (commit/push/PR/conflicts/CI), not subagent-return gates — so the SubagentStop widening does not change builtin behavior.CLAUDE.mdper-CLI capability matrix and the Copilot section insrc/hooks/types.tswith the verified Stop block semantics.Test plan
bun run test:run— 1524 passing (was 1517 before; +7 from new Copilot Stop deny, Copilot SubagentStop deny, Claude SubagentStop deny, SubagentStop config assertion, plus the three new instruct-path tests added by the latest commit: Claude SubagentStop instruct, Copilot Stop instruct, Copilot SubagentStop instruct)bun run test:e2e— 292 passing (was 291; +1 from new Copilot Stop deny e2e regression)bun run lint— clean (only pre-existing unrelated@next/next/no-img-elementwarning)bunx tsc --noEmit— clean[WARNING] Hook warning: …with no retry; confirmedagentStopandStopPascalCase aliasing works for user-scope hookseventType === "Stop"arm was missing both the SubagentStop widening AND the Copilot arm, so instruct verdicts on Copilot still fell through to exit-2Open follow-ups (not blocking this PR)
.github/hooks/failproofai.jsonproject-scope appears NOT to be loaded by Copilot CLI 1.0.41 in my sandbox tests — only user-scope~/.copilot/hooks/was dispatched. I tried multiple schema variants (PascalCase + matcher-wrap, flat camelCase) and filenames (failproofai.json, canonicalhooks.json), with files committed to git. None fired. Worth filing a separate investigation issue.followup_messagelift (policy-evaluator.ts:147Stop+deny →{followup_message}instead of{permission:"deny"}) is still pending. Lower stakes; covered in the PR [luv-299] fix: enforce require-*-before-stop on GitHub Copilot CLI #299 review thread.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests