Skip to content

Commit b4792a4

Browse files
peyton-altclaude
andcommitted
Validate VS Code hookEventName against CLI subcommand
- Add VS Code hookEventName constants from official docs - Validate hookEventName matches the invoked CLI hook for VS Code payloads; silently skip mismatches instead of processing them - Fix test fixtures: VS Code sends "Stop" (not "SessionEnd") for both agent-stop and session-end hooks - Strengthen assertNoParseFailure to catch warning text in stderr - Refactor ParseHookEvent to read envelope once and dispatch to builders Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: b42218d939a1
1 parent 60de3d1 commit b4792a4

File tree

4 files changed

+126
-44
lines changed

4 files changed

+126
-44
lines changed

cmd/entire/cli/agent/copilotcli/compat.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,34 @@ const (
1616
HostVSCode HookHost = "vscode"
1717
)
1818

19+
// VS Code hookEventName values (from official VS Code docs).
20+
// See: https://code.visualstudio.com/docs/copilot/customization/hooks
21+
const (
22+
VSCodeEventSessionStart = "SessionStart"
23+
VSCodeEventUserPromptSubmit = "UserPromptSubmit"
24+
VSCodeEventStop = "Stop"
25+
VSCodeEventPreToolUse = "PreToolUse"
26+
VSCodeEventPostToolUse = "PostToolUse"
27+
VSCodeEventPreCompact = "PreCompact"
28+
VSCodeEventSubagentStart = "SubagentStart"
29+
VSCodeEventSubagentStop = "SubagentStop"
30+
)
31+
32+
// vsCodeEventToHookNames maps each VS Code hookEventName to the CLI hook name(s)
33+
// that are allowed to carry that event. "Stop" maps to both agent-stop and
34+
// session-end because VS Code uses a single Stop event where Copilot CLI
35+
// distinguishes the two.
36+
var vsCodeEventToHookNames = map[string][]string{
37+
VSCodeEventUserPromptSubmit: {HookNameUserPromptSubmitted},
38+
VSCodeEventSessionStart: {HookNameSessionStart},
39+
VSCodeEventStop: {HookNameAgentStop, HookNameSessionEnd},
40+
VSCodeEventSubagentStop: {HookNameSubagentStop},
41+
VSCodeEventPreToolUse: {HookNamePreToolUse},
42+
VSCodeEventPostToolUse: {HookNamePostToolUse},
43+
VSCodeEventPreCompact: {},
44+
VSCodeEventSubagentStart: {},
45+
}
46+
1947
type hookEnvelope struct {
2048
Host HookHost
2149
SessionID string
@@ -134,3 +162,19 @@ func isJSONNumber(raw json.RawMessage) bool {
134162
var n int64
135163
return json.Unmarshal(raw, &n) == nil
136164
}
165+
166+
// validateVSCodeEvent checks whether the hookEventName is consistent with the
167+
// CLI hook subcommand that was invoked. Returns true if the event should be
168+
// processed, false if it should be silently skipped (mismatch or unknown event).
169+
func validateVSCodeEvent(hookEventName, hookName string) bool {
170+
allowedHooks, known := vsCodeEventToHookNames[hookEventName]
171+
if !known {
172+
return false
173+
}
174+
for _, allowed := range allowedHooks {
175+
if allowed == hookName {
176+
return true
177+
}
178+
}
179+
return false
180+
}

cmd/entire/cli/agent/copilotcli/lifecycle.go

Lines changed: 42 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -41,34 +41,53 @@ func (c *CopilotCLIAgent) HookNames() []string {
4141

4242
// ParseHookEvent translates a Copilot CLI hook into a normalized lifecycle Event.
4343
// Returns nil if the hook has no lifecycle significance (pass-through hooks).
44+
//
45+
// For VS Code payloads (detected via hookEventName), the event name is validated
46+
// against the CLI subcommand. Mismatches are silently skipped to avoid processing
47+
// a payload that doesn't match the hook being invoked.
4448
func (c *CopilotCLIAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io.Reader) (*agent.Event, error) {
49+
// Pass-through hooks: skip immediately without reading stdin.
50+
switch hookName {
51+
case HookNamePreToolUse, HookNamePostToolUse, HookNameErrorOccurred:
52+
return nil, nil //nolint:nilnil // Pass-through hooks have no lifecycle action
53+
}
54+
55+
// For lifecycle hooks, read and parse the envelope first so we can
56+
// validate VS Code hookEventName before constructing an event.
57+
env, err := c.readHookEnvelope(stdin)
58+
if err != nil {
59+
return nil, err
60+
}
61+
62+
// VS Code payloads: validate hookEventName matches the CLI subcommand.
63+
if env.Host == HostVSCode && env.HookEventName != "" {
64+
if !validateVSCodeEvent(env.HookEventName, hookName) {
65+
logging.Debug(ctx, "copilot-cli: skipping VS Code event with mismatched hookEventName",
66+
"hookEventName", env.HookEventName, "hookName", hookName)
67+
return nil, nil //nolint:nilnil // Mismatched VS Code event — skip silently.
68+
}
69+
}
70+
4571
switch hookName {
4672
case HookNameUserPromptSubmitted:
47-
return c.parseUserPromptSubmitted(ctx, stdin)
73+
return c.buildUserPromptSubmitted(ctx, env), nil
4874
case HookNameSessionStart:
49-
return c.parseSessionStart(stdin)
75+
return c.buildSessionStart(env), nil
5076
case HookNameAgentStop:
51-
return c.parseAgentStop(ctx, stdin)
77+
return c.buildAgentStop(ctx, env), nil
5278
case HookNameSessionEnd:
53-
return c.parseSessionEnd(stdin)
79+
return c.buildSessionEnd(env), nil
5480
case HookNameSubagentStop:
55-
return c.parseSubagentStop(stdin)
56-
case HookNamePreToolUse, HookNamePostToolUse, HookNameErrorOccurred:
57-
return nil, nil //nolint:nilnil // Pass-through hooks have no lifecycle action
81+
return c.buildSubagentStop(env), nil
5882
default:
5983
logging.Debug(ctx, "copilot-cli: ignoring unknown hook", "hook", hookName)
6084
return nil, nil //nolint:nilnil // Unknown hooks have no lifecycle action
6185
}
6286
}
6387

64-
// --- Internal hook parsing functions ---
65-
66-
func (c *CopilotCLIAgent) parseUserPromptSubmitted(ctx context.Context, stdin io.Reader) (*agent.Event, error) {
67-
env, err := c.readHookEnvelope(stdin)
68-
if err != nil {
69-
return nil, err
70-
}
88+
// --- Internal event builders (envelope already parsed) ---
7189

90+
func (c *CopilotCLIAgent) buildUserPromptSubmitted(ctx context.Context, env *hookEnvelope) *agent.Event {
7291
transcriptRef := env.TranscriptPath
7392
if transcriptRef == "" {
7493
transcriptRef = c.resolveTranscriptRef(ctx, env.SessionID)
@@ -80,28 +99,18 @@ func (c *CopilotCLIAgent) parseUserPromptSubmitted(ctx context.Context, stdin io
8099
SessionRef: transcriptRef,
81100
Prompt: env.Prompt,
82101
Timestamp: env.Timestamp,
83-
}, nil
102+
}
84103
}
85104

86-
func (c *CopilotCLIAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) {
87-
env, err := c.readHookEnvelope(stdin)
88-
if err != nil {
89-
return nil, err
90-
}
105+
func (c *CopilotCLIAgent) buildSessionStart(env *hookEnvelope) *agent.Event {
91106
return &agent.Event{
92107
Type: agent.SessionStart,
93108
SessionID: env.SessionID,
94109
Timestamp: env.Timestamp,
95-
}, nil
96-
}
97-
98-
func (c *CopilotCLIAgent) parseAgentStop(ctx context.Context, stdin io.Reader) (*agent.Event, error) {
99-
env, err := c.readHookEnvelope(stdin)
100-
if err != nil {
101-
return nil, err
102110
}
111+
}
103112

104-
// Extract model from transcript (Copilot CLI hooks don't include model)
113+
func (c *CopilotCLIAgent) buildAgentStop(ctx context.Context, env *hookEnvelope) *agent.Event {
105114
var model string
106115
if env.TranscriptPath != "" {
107116
model = ExtractModelFromTranscript(ctx, env.TranscriptPath)
@@ -113,31 +122,23 @@ func (c *CopilotCLIAgent) parseAgentStop(ctx context.Context, stdin io.Reader) (
113122
SessionRef: env.TranscriptPath,
114123
Model: model,
115124
Timestamp: env.Timestamp,
116-
}, nil
125+
}
117126
}
118127

119-
func (c *CopilotCLIAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error) {
120-
env, err := c.readHookEnvelope(stdin)
121-
if err != nil {
122-
return nil, err
123-
}
128+
func (c *CopilotCLIAgent) buildSessionEnd(env *hookEnvelope) *agent.Event {
124129
return &agent.Event{
125130
Type: agent.SessionEnd,
126131
SessionID: env.SessionID,
127132
Timestamp: env.Timestamp,
128-
}, nil
133+
}
129134
}
130135

131-
func (c *CopilotCLIAgent) parseSubagentStop(stdin io.Reader) (*agent.Event, error) {
132-
env, err := c.readHookEnvelope(stdin)
133-
if err != nil {
134-
return nil, err
135-
}
136+
func (c *CopilotCLIAgent) buildSubagentStop(env *hookEnvelope) *agent.Event {
136137
return &agent.Event{
137138
Type: agent.SubagentEnd,
138139
SessionID: env.SessionID,
139140
Timestamp: env.Timestamp,
140-
}, nil
141+
}
141142
}
142143

143144
func (c *CopilotCLIAgent) readHookEnvelope(stdin io.Reader) (*hookEnvelope, error) {

cmd/entire/cli/agent/copilotcli/lifecycle_test.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,8 @@ func TestParseHookEvent_SessionEnd_VSCodePayload(t *testing.T) {
247247
t.Parallel()
248248

249249
ag := &CopilotCLIAgent{}
250-
input := `{"timestamp":"2026-02-09T10:30:00.000Z","cwd":"/path/to/repo","sessionId":"` + testSessionID + `","hookEventName":"SessionEnd","reason":"complete"}`
250+
// VS Code uses "Stop" for both agent-stop and session-end hooks.
251+
input := `{"timestamp":"2026-02-09T10:30:00.000Z","cwd":"/path/to/repo","sessionId":"` + testSessionID + `","hookEventName":"Stop","reason":"complete"}`
251252

252253
event, err := ag.ParseHookEvent(context.Background(), HookNameSessionEnd, strings.NewReader(input))
253254

@@ -263,6 +264,39 @@ func TestParseHookEvent_SessionEnd_VSCodePayload(t *testing.T) {
263264
}
264265
}
265266

267+
func TestParseHookEvent_VSCodeMismatchedHookEventName_ReturnsNil(t *testing.T) {
268+
t.Parallel()
269+
270+
ag := &CopilotCLIAgent{}
271+
// VS Code sends "SessionStart" but the CLI subcommand is "agent-stop" — mismatch.
272+
input := `{"timestamp":"2026-02-09T10:30:00.000Z","cwd":"/path/to/repo","sessionId":"` + testSessionID + `","hookEventName":"SessionStart","transcript_path":"/tmp/t.json"}`
273+
274+
event, err := ag.ParseHookEvent(context.Background(), HookNameAgentStop, strings.NewReader(input))
275+
276+
if err != nil {
277+
t.Fatalf("unexpected error: %v", err)
278+
}
279+
if event != nil {
280+
t.Errorf("expected nil event for mismatched hookEventName, got %+v", event)
281+
}
282+
}
283+
284+
func TestParseHookEvent_VSCodeUnknownHookEventName_ReturnsNil(t *testing.T) {
285+
t.Parallel()
286+
287+
ag := &CopilotCLIAgent{}
288+
input := `{"timestamp":"2026-02-09T10:30:00.000Z","cwd":"/path/to/repo","sessionId":"` + testSessionID + `","hookEventName":"FutureEvent"}`
289+
290+
event, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, strings.NewReader(input))
291+
292+
if err != nil {
293+
t.Fatalf("unexpected error: %v", err)
294+
}
295+
if event != nil {
296+
t.Errorf("expected nil event for unknown hookEventName, got %+v", event)
297+
}
298+
}
299+
266300
func TestParseHookEvent_PassthroughHooks_VSCodePayload_ReturnNil(t *testing.T) {
267301
t.Parallel()
268302

cmd/entire/cli/integration_test/copilot_vscode_hooks_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func TestCopilotVSCodeHooks_AgentStopCreatesCheckpoint(t *testing.T) {
9595
"cwd": env.RepoDir,
9696
"sessionId": sessionID,
9797
"stopReason": "end_turn",
98-
"hookEventName": "SessionEnd",
98+
"hookEventName": "Stop",
9999
"transcript_path": transcriptPath,
100100
}),
101101
)
@@ -169,7 +169,7 @@ func TestCopilotVSCodeHooks_GeneratedHookCommands(t *testing.T) {
169169
"cwd": env.RepoDir,
170170
"sessionId": sessionID,
171171
"stopReason": "end_turn",
172-
"hookEventName": "SessionEnd",
172+
"hookEventName": "Stop",
173173
"transcript_path": transcriptPath,
174174
}),
175175
)
@@ -214,6 +214,9 @@ func assertNoParseFailure(t *testing.T, output HookOutput) {
214214
if strings.Contains(stderr, "cannot unmarshal string into Go struct field") {
215215
t.Fatalf("unexpected schema mismatch in stderr: %s", stderr)
216216
}
217+
if strings.Contains(stderr, "Warning from") {
218+
t.Fatalf("unexpected warning in stderr: %s", stderr)
219+
}
217220
}
218221

219222
func writeCopilotTranscript(t *testing.T, transcriptPath, modifiedFile, prompt string) {

0 commit comments

Comments
 (0)