@brainervirus/workit-core 2.1.5 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,298 @@
1
+ // Codex CLI and Desktop: command hooks (hooks/hooks.json) mapped onto the protocol.
2
+ import type { HostDescriptor } from "../descriptor";
3
+ import type { HookDecision, HookEvent, HookEventKind, HostAdapter } from "../protocol";
4
+ import { commandText, existingDirectory, isRecord, nonEmpty, optionalText } from "./fields";
5
+
6
+ export type CodexHost = "codex_cli" | "codex_desktop";
7
+ export type CodexHookEvent = "SessionStart" | "PreToolUse" | "SubagentStart" | "SubagentStop";
8
+ type SessionSource = "startup" | "resume" | "clear" | "compact";
9
+
10
+ export type CodexHookInput = {
11
+ hook_event_name: CodexHookEvent;
12
+ session_id: string;
13
+ cwd: string;
14
+ model: string | null;
15
+ permission_mode: string | null;
16
+ transcript_path: string | null;
17
+ source?: SessionSource;
18
+ turn_id?: string;
19
+ tool_name?: string;
20
+ tool_input?: unknown;
21
+ /** The shell command as one string; argv arrays are joined. */
22
+ command?: string;
23
+ tool_use_id?: string;
24
+ agent_id?: string;
25
+ agent_type?: string;
26
+ agent_transcript_path?: string | null;
27
+ last_assistant_message?: string | null;
28
+ stop_hook_active?: boolean;
29
+ };
30
+
31
+ export type CodexParseResult = { ok: true; data: CodexHookInput } | { ok: false; error: string };
32
+
33
+ export function detectCodexSurface(env: NodeJS.ProcessEnv): CodexHost {
34
+ return env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE === "Codex Desktop" ||
35
+ Boolean(env.CODEX_ELECTRON_RESOURCES_PATH)
36
+ ? "codex_desktop"
37
+ : "codex_cli";
38
+ }
39
+
40
+ const undocumented = { support: "undocumented", native: null } as const;
41
+
42
+ export const CODEX_DESCRIPTOR: HostDescriptor = {
43
+ host: "codex_cli",
44
+ label: "Codex",
45
+ verifiedAgainst: "codex-cli 0.153.4; Codex Desktop 26.901.20858",
46
+ docs: ["https://developers.openai.com/codex/hooks"],
47
+ transport: "hook-process",
48
+ events: {
49
+ "session.start": { support: "native", native: "SessionStart" },
50
+ "context.turn": undocumented,
51
+ "shell.pre": { support: "native", native: "PreToolUse" },
52
+ "tool.pre": { support: "native", native: "PreToolUse" },
53
+ "shell.post": undocumented,
54
+ "subagent.start": { support: "native", native: "SubagentStart" },
55
+ "subagent.stop": { support: "native", native: "SubagentStop" },
56
+ "prompt.submit": undocumented,
57
+ "compact.pre": { support: "none", native: null },
58
+ stop: undocumented,
59
+ },
60
+ shellPolicy: { deny: "native", channel: "permissionDecision", failClosed: false },
61
+ context: {
62
+ sessionStart: "native",
63
+ perTurn: "undocumented",
64
+ afterCompact: "native",
65
+ task: "single-active",
66
+ },
67
+ subagents: {
68
+ identity: "native",
69
+ parentBinding: "none",
70
+ blockStart: "none",
71
+ worktreeIsolation: "undocumented",
72
+ maxConcurrency: "undocumented",
73
+ },
74
+ provenance: { sessionId: "native", agentIdOnTool: "partial", postToolObserve: "undocumented" },
75
+ interaction: { questions: "none", writeBoundary: "partial" },
76
+ stopControl: "undocumented",
77
+ shellAvailable: "native",
78
+ perEventCost: "low",
79
+ capabilities: [
80
+ {
81
+ name: "interactive_decision",
82
+ surface: "Question",
83
+ refs: ["Question"],
84
+ requires: [],
85
+ assurance: "agent_guided",
86
+ reason: "Codex hooks expose no native arbitrary-question answer receipt",
87
+ },
88
+ {
89
+ name: "known_product_writes",
90
+ surface: "PreToolUse",
91
+ refs: ["PreToolUse"],
92
+ requires: [],
93
+ assurance: "unavailable",
94
+ reason: "file writes are host-policy; PreToolUse allows write tools",
95
+ },
96
+ {
97
+ name: "fresh-context-review",
98
+ surface: "SubagentStart",
99
+ refs: ["SubagentStart"],
100
+ requires: ["event:subagent.start"],
101
+ observed: ["subagent.start"],
102
+ assurance: "agent_guided",
103
+ reason:
104
+ "independent review runs as a bounded subagent; evidence evaluation enforces reviewer exclusivity, and stops remain untrusted",
105
+ unavailableReason: "Codex subagent lifecycle hooks are unavailable for independent review",
106
+ },
107
+ {
108
+ name: "native_subagents",
109
+ surface: "SubagentStart/SubagentStop",
110
+ refs: ["SubagentStart", "SubagentStop"],
111
+ requires: ["event:subagent.start", "event:subagent.stop"],
112
+ observed: ["subagent.start", "subagent.stop"],
113
+ assurance: "agent_guided",
114
+ reason: "Codex reports stable child identities, but cannot block creation or bind a writer",
115
+ unavailableReason: "Codex subagent lifecycle hooks are untrusted or incomplete",
116
+ },
117
+ {
118
+ name: "native_subagent_start",
119
+ surface: "SubagentStart",
120
+ refs: ["SubagentStart"],
121
+ requires: ["event:subagent.start"],
122
+ observed: ["subagent.start"],
123
+ assurance: "agent_guided",
124
+ reason:
125
+ "SubagentStart supplies identity and bounded read-only guidance; continue:false cannot stop creation",
126
+ unavailableReason: "SubagentStart hook is untrusted or unavailable",
127
+ },
128
+ {
129
+ name: "arbitrary_shell_write",
130
+ surface: "unobservable_shell",
131
+ refs: ["PreToolUse"],
132
+ requires: [],
133
+ assurance: "unavailable",
134
+ reason:
135
+ "Only covered known tool inputs are interceptable; specialized and write_stdin paths are not complete",
136
+ },
137
+ {
138
+ name: "compact_context",
139
+ surface: "SessionStart",
140
+ refs: ["SessionStart"],
141
+ requires: ["context.afterCompact"],
142
+ observed: ["session.start"],
143
+ assurance: "agent_guided",
144
+ reason: "SessionStart source=compact is the single restore path",
145
+ },
146
+ ],
147
+ };
148
+
149
+ export const codexDescriptor = (host: CodexHost): HostDescriptor => ({ ...CODEX_DESCRIPTOR, host });
150
+
151
+ const EVENTS: Record<CodexHookEvent, HookEventKind> = {
152
+ SessionStart: "session.start",
153
+ PreToolUse: "shell.pre",
154
+ SubagentStart: "subagent.start",
155
+ SubagentStop: "subagent.stop",
156
+ };
157
+ const SHELL_TOOLS = new Set(["bash", "unified-exec"]);
158
+ const isShellTool = (name: unknown) => SHELL_TOOLS.has(String(name).toLowerCase());
159
+ const SOURCES = new Set<string>(["startup", "resume", "clear", "compact"]);
160
+
161
+ /**
162
+ * Read a Codex payload, checking only what each mapping needs (D17): the
163
+ * event and cwd always, the shell command for shell tools, the tool name for
164
+ * PreToolUse, and the agent id for SubagentStart. Every other field is
165
+ * optional, and unknown keys or values (a new permission mode, say) are
166
+ * ignored, so a Codex release can never turn branch policy off.
167
+ */
168
+ export const parseCodexHookInput = (value: unknown): CodexParseResult => {
169
+ if (!isRecord(value) || !Object.hasOwn(EVENTS, String(value.hook_event_name)))
170
+ return { ok: false, error: "hook_event_name is required" };
171
+ const event = value.hook_event_name as CodexHookEvent;
172
+ const cwd = existingDirectory(value.cwd);
173
+ if (!cwd) return { ok: false, error: "cwd must be an existing absolute directory" };
174
+ if (event === "PreToolUse" && !nonEmpty(value.tool_name))
175
+ return { ok: false, error: "tool_name is required" };
176
+ const command = isRecord(value.tool_input) ? commandText(value.tool_input.command) : null;
177
+ if (event === "PreToolUse" && isShellTool(value.tool_name) && !command)
178
+ return { ok: false, error: "tool_input.command is required for shell tools" };
179
+ if (event === "SubagentStart" && !nonEmpty(value.agent_id))
180
+ return { ok: false, error: "agent_id is required" };
181
+ return {
182
+ ok: true,
183
+ data: {
184
+ hook_event_name: event,
185
+ session_id: nonEmpty(value.session_id) ? value.session_id : "",
186
+ model: optionalText(value.model),
187
+ permission_mode: optionalText(value.permission_mode),
188
+ transcript_path: optionalText(value.transcript_path),
189
+ cwd,
190
+ // An unknown start source restores context but never offers history.
191
+ ...(event === "SessionStart"
192
+ ? { source: SOURCES.has(String(value.source)) ? (value.source as SessionSource) : "resume" }
193
+ : {}),
194
+ ...(nonEmpty(value.turn_id) ? { turn_id: value.turn_id } : {}),
195
+ ...(nonEmpty(value.tool_name) ? { tool_name: value.tool_name } : {}),
196
+ ...(event === "PreToolUse" ? { tool_input: value.tool_input } : {}),
197
+ ...(command ? { command } : {}),
198
+ ...(nonEmpty(value.tool_use_id) ? { tool_use_id: value.tool_use_id } : {}),
199
+ ...(nonEmpty(value.agent_id) ? { agent_id: value.agent_id } : {}),
200
+ ...(nonEmpty(value.agent_type) ? { agent_type: value.agent_type } : {}),
201
+ ...(event === "SubagentStop"
202
+ ? {
203
+ agent_transcript_path: optionalText(value.agent_transcript_path),
204
+ last_assistant_message: optionalText(value.last_assistant_message),
205
+ stop_hook_active: value.stop_hook_active === true,
206
+ }
207
+ : {}),
208
+ },
209
+ };
210
+ };
211
+
212
+ const protocolEvent = (input: CodexHookInput): HookEvent => {
213
+ switch (input.hook_event_name) {
214
+ case "SessionStart":
215
+ return { kind: "session.start", source: input.source! };
216
+ case "PreToolUse": {
217
+ const toolUseId = input.tool_use_id ?? null;
218
+ return isShellTool(input.tool_name)
219
+ ? {
220
+ kind: "shell.pre",
221
+ command: input.command ?? "",
222
+ toolUseId,
223
+ }
224
+ : { kind: "tool.pre", tool: input.tool_name!, toolUseId };
225
+ }
226
+ case "SubagentStart":
227
+ return {
228
+ kind: "subagent.start",
229
+ agentId: input.agent_id!,
230
+ agentType: input.agent_type ?? "unknown",
231
+ task: null,
232
+ };
233
+ case "SubagentStop":
234
+ return {
235
+ kind: "subagent.stop",
236
+ agentId: input.agent_id ?? null,
237
+ agentType: input.agent_type ?? null,
238
+ lastMessage: input.last_assistant_message ?? null,
239
+ stopHookActive: input.stop_hook_active ?? false,
240
+ };
241
+ }
242
+ };
243
+
244
+ const output = (event: string, extra: Record<string, unknown> = {}) => ({
245
+ hookSpecificOutput: { hookEventName: event, ...extra },
246
+ });
247
+
248
+ const render = (decision: HookDecision, native: string | null) => {
249
+ if (native === "SubagentStop" || native === null) return { json: {}, exitCode: 0 };
250
+ if (decision.kind === "deny")
251
+ return {
252
+ json: output(native, {
253
+ permissionDecision: "deny",
254
+ permissionDecisionReason: decision.reason,
255
+ }),
256
+ exitCode: 0,
257
+ };
258
+ if (decision.kind === "context")
259
+ return { json: output(native, { additionalContext: decision.text }), exitCode: 0 };
260
+ return { json: output(native), exitCode: 0 };
261
+ };
262
+
263
+ export const codexAdapter: HostAdapter = {
264
+ descriptor: CODEX_DESCRIPTOR,
265
+ parse(raw, env) {
266
+ const host = detectCodexSurface(env);
267
+ const parsed = parseCodexHookInput(raw);
268
+ if (!parsed.ok) {
269
+ const native =
270
+ isRecord(raw) && Object.hasOwn(EVENTS, String(raw.hook_event_name))
271
+ ? (raw.hook_event_name as CodexHookEvent)
272
+ : null;
273
+ return { ok: false, error: parsed.error, native, event: native ? EVENTS[native] : null };
274
+ }
275
+ const input = parsed.data;
276
+ return {
277
+ ok: true,
278
+ native: input.hook_event_name,
279
+ input: {
280
+ host,
281
+ cwd: input.cwd,
282
+ session: {
283
+ id: input.session_id,
284
+ agentId: input.agent_id ?? null,
285
+ agentType: input.agent_type ?? null,
286
+ parentId: null,
287
+ },
288
+ permissionMode: input.permission_mode,
289
+ transcriptPath: input.transcript_path,
290
+ event: protocolEvent(input),
291
+ },
292
+ };
293
+ },
294
+ render,
295
+ // Without a session id there is no actor to bind a writer to.
296
+ addendum: (input) =>
297
+ `<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run the workit CLI for task mutations: node_modules/.bin/workit <family> <action> --json --confirm${input.session.id ? `; bind the writer to this session with node_modules/.bin/workit writer acquire --task <id> --revision <rev> --actor ${input.session.id} --confirm` : ""}. Binding decisions and external actions need a human.</workit-codex-mutations>`,
298
+ };
@@ -0,0 +1,321 @@
1
+ // Cursor: command hooks (hooks/hooks-cursor.json) mapped onto the protocol.
2
+ import path from "node:path";
3
+ import type { HostDescriptor } from "../descriptor";
4
+ import type { HookDecision, HookEvent, HookEventKind, HostAdapter } from "../protocol";
5
+ import { existingDirectory, isRecord, nonEmpty } from "./fields";
6
+
7
+ export type CursorHookEvent =
8
+ | "sessionStart"
9
+ | "preToolUse"
10
+ | "beforeShellExecution"
11
+ | "subagentStart"
12
+ | "subagentStop"
13
+ | "preCompact";
14
+
15
+ export type CursorHookInput = {
16
+ hook_event_name: CursorHookEvent;
17
+ conversation_id?: string;
18
+ session_id?: string;
19
+ workspace_roots: string[];
20
+ tool_name?: string;
21
+ tool_input?: unknown;
22
+ command?: string;
23
+ cwd?: string;
24
+ subagent_id?: string;
25
+ subagent_type?: string;
26
+ parent_conversation_id?: string;
27
+ task?: string;
28
+ status?: "completed" | "error" | "aborted";
29
+ };
30
+
31
+ export type CursorParseResult = { ok: true; data: CursorHookInput } | { ok: false; error: string };
32
+
33
+ const undocumented = { support: "undocumented", native: null } as const;
34
+ const none = { support: "none", native: null } as const;
35
+
36
+ export const CURSOR_DESCRIPTOR: HostDescriptor = {
37
+ host: "cursor",
38
+ label: "Cursor",
39
+ verifiedAgainst: "Cursor hooks docs; payloads in test/fixtures/hooks/cursor",
40
+ docs: ["https://cursor.com/docs/agent/hooks"],
41
+ transport: "hook-process",
42
+ events: {
43
+ "session.start": { support: "native", native: "sessionStart" },
44
+ "context.turn": none,
45
+ "shell.pre": { support: "native", native: "beforeShellExecution" },
46
+ "tool.pre": { support: "native", native: "preToolUse" },
47
+ "shell.post": undocumented,
48
+ "subagent.start": { support: "native", native: "subagentStart" },
49
+ // subagentStop carries no stable child identity.
50
+ "subagent.stop": { support: "partial", native: "subagentStop" },
51
+ "prompt.submit": undocumented,
52
+ // preCompact can only show a user message.
53
+ "compact.pre": { support: "partial", native: "preCompact" },
54
+ stop: undocumented,
55
+ },
56
+ shellPolicy: { deny: "native", channel: "exit2+json", failClosed: true },
57
+ context: {
58
+ sessionStart: "native",
59
+ perTurn: "none",
60
+ afterCompact: "partial",
61
+ task: "single-active",
62
+ },
63
+ subagents: {
64
+ identity: "native",
65
+ parentBinding: "native",
66
+ blockStart: "native",
67
+ worktreeIsolation: "undocumented",
68
+ maxConcurrency: "undocumented",
69
+ },
70
+ provenance: {
71
+ sessionId: "native",
72
+ agentIdOnTool: "undocumented",
73
+ postToolObserve: "undocumented",
74
+ },
75
+ interaction: { questions: "none", writeBoundary: "partial" },
76
+ stopControl: "undocumented",
77
+ shellAvailable: "native",
78
+ // Every hook spawns `npx -y --prefer-online …@latest`: a registry round-trip per event.
79
+ perEventCost: "npx-network",
80
+ capabilities: [
81
+ {
82
+ name: "interactive_decision",
83
+ surface: "AskQuestion",
84
+ refs: ["AskQuestion"],
85
+ requires: [],
86
+ assurance: "agent_guided",
87
+ reason: "Cursor does not expose AskQuestion answers to Workit hooks or MCP",
88
+ },
89
+ {
90
+ name: "known_product_writes",
91
+ surface: "preToolUse",
92
+ refs: ["preToolUse"],
93
+ requires: [],
94
+ assurance: "unavailable",
95
+ reason:
96
+ "file writes are host-policy; the Cursor hook no longer gates write tools or shell commands",
97
+ },
98
+ {
99
+ name: "native_subagents",
100
+ surface: "subagentStart/subagentStop",
101
+ refs: ["subagentStart", "subagentStop"],
102
+ requires: ["event:subagent.start", "event:subagent.stop"],
103
+ observed: ["subagent.start", "subagent.stop"],
104
+ assurance: "agent_guided",
105
+ reason:
106
+ "reviewer/investigator starts are bounded; Cursor implementer delegation is unavailable and subagentStop lacks a stable child identity",
107
+ unavailableReason: "Cursor native subagent lifecycle hooks are incomplete",
108
+ },
109
+ {
110
+ name: "native_subagent_start",
111
+ surface: "subagentStart",
112
+ refs: ["subagentStart"],
113
+ requires: ["event:subagent.start", "subagents.blockStart"],
114
+ observed: ["subagent.start"],
115
+ assurance: "enforced",
116
+ reason:
117
+ "Cursor subagentStart enforces explicit reviewer/investigator markers; implementer delegation is unavailable",
118
+ unavailableReason: "Cursor subagentStart is absent",
119
+ },
120
+ {
121
+ name: "fresh-context-review",
122
+ surface: "subagentStart",
123
+ refs: ["subagentStart"],
124
+ requires: ["event:subagent.start"],
125
+ observed: ["subagent.start"],
126
+ assurance: "agent_guided",
127
+ reason:
128
+ "independent review runs as a bounded reviewer subagent; stops lack stable identity, so reviewer exclusivity is evaluated from recorded evidence",
129
+ unavailableReason: "Cursor subagentStart is unavailable for independent review",
130
+ },
131
+ {
132
+ name: "arbitrary_shell_write",
133
+ surface: "unobservable_shell",
134
+ refs: ["beforeShellExecution"],
135
+ requires: [],
136
+ assurance: "unavailable",
137
+ reason:
138
+ "Only explicitly parsed shell targets are interceptable; arbitrary shell writes are not provable",
139
+ },
140
+ {
141
+ name: "compact_context",
142
+ surface: "sessionStart/preCompact",
143
+ refs: ["sessionStart", "preCompact"],
144
+ requires: ["context.sessionStart"],
145
+ observed: ["session.start"],
146
+ assurance: "agent_guided",
147
+ reason: "sessionStart injects context; preCompact can only show a bounded user reminder",
148
+ },
149
+ ],
150
+ };
151
+
152
+ const EVENTS: Record<CursorHookEvent, HookEventKind> = {
153
+ sessionStart: "session.start",
154
+ preToolUse: "tool.pre",
155
+ beforeShellExecution: "shell.pre",
156
+ subagentStart: "subagent.start",
157
+ subagentStop: "subagent.stop",
158
+ preCompact: "compact.pre",
159
+ };
160
+
161
+ /** Validate the keys each Cursor event needs; unknown keys are ignored. */
162
+ export const parseCursorHookInput = (value: unknown): CursorParseResult => {
163
+ if (!isRecord(value) || !Object.hasOwn(EVENTS, String(value.hook_event_name)))
164
+ return { ok: false, error: "hook_event_name is required" };
165
+ const roots = value.workspace_roots;
166
+ if (!Array.isArray(roots) || roots.length === 0 || !roots.every(nonEmpty))
167
+ return { ok: false, error: "a workspace root is required" };
168
+ const canonical = roots.map(existingDirectory);
169
+ if (!canonical.every((root): root is string => root !== null))
170
+ return { ok: false, error: "workspace roots must be existing absolute paths" };
171
+ const event = value.hook_event_name as CursorHookEvent;
172
+ const conversationId = nonEmpty(value.conversation_id) ? value.conversation_id : undefined;
173
+ const sessionId = nonEmpty(value.session_id) ? value.session_id : undefined;
174
+ if (conversationId && sessionId && conversationId !== sessionId)
175
+ return { ok: false, error: "conversation_id and session_id must match" };
176
+ if (event !== "preCompact" && !nonEmpty(conversationId ?? sessionId))
177
+ return { ok: false, error: "conversation/session identity is required" };
178
+ if (
179
+ (event === "preToolUse" || event === "beforeShellExecution") &&
180
+ !nonEmpty(value.tool_name ?? value.command)
181
+ )
182
+ return { ok: false, error: "tool or command is required" };
183
+ if (
184
+ event === "subagentStart" &&
185
+ (!nonEmpty(value.subagent_id) || !nonEmpty(value.parent_conversation_id))
186
+ )
187
+ return { ok: false, error: "subagent identity and parent session are required" };
188
+ return {
189
+ ok: true,
190
+ data: {
191
+ hook_event_name: event,
192
+ workspace_roots: canonical,
193
+ ...(nonEmpty(value.conversation_id) ? { conversation_id: value.conversation_id } : {}),
194
+ ...(nonEmpty(value.session_id) ? { session_id: value.session_id } : {}),
195
+ ...(nonEmpty(value.tool_name) ? { tool_name: value.tool_name } : {}),
196
+ ...(value.tool_input !== undefined ? { tool_input: value.tool_input } : {}),
197
+ ...(nonEmpty(value.command) ? { command: value.command } : {}),
198
+ ...(nonEmpty(value.cwd) ? { cwd: value.cwd } : {}),
199
+ ...(event === "subagentStart" && nonEmpty(value.subagent_id)
200
+ ? { subagent_id: value.subagent_id }
201
+ : {}),
202
+ ...(event === "subagentStart" && nonEmpty(value.subagent_type)
203
+ ? { subagent_type: value.subagent_type }
204
+ : {}),
205
+ ...(event === "subagentStart" && nonEmpty(value.parent_conversation_id)
206
+ ? { parent_conversation_id: value.parent_conversation_id }
207
+ : {}),
208
+ ...(event === "subagentStart" && nonEmpty(value.task) ? { task: value.task } : {}),
209
+ },
210
+ };
211
+ };
212
+
213
+ const protocolEvent = (input: CursorHookInput): HookEvent => {
214
+ switch (input.hook_event_name) {
215
+ case "sessionStart":
216
+ // Cursor reports no start source; every sessionStart is a fresh conversation.
217
+ return { kind: "session.start", source: "startup" };
218
+ case "beforeShellExecution":
219
+ // Branch policy reads the command alone; the shell's cwd does not change
220
+ // which branch name it targets.
221
+ return { kind: "shell.pre", command: input.command ?? "", toolUseId: null };
222
+ case "preToolUse":
223
+ return { kind: "tool.pre", tool: input.tool_name ?? "", toolUseId: null };
224
+ case "subagentStart":
225
+ return {
226
+ kind: "subagent.start",
227
+ agentId: input.subagent_id!,
228
+ agentType: input.subagent_type ?? "",
229
+ task: input.task ?? null,
230
+ };
231
+ case "subagentStop":
232
+ return {
233
+ kind: "subagent.stop",
234
+ agentId: null,
235
+ agentType: null,
236
+ lastMessage: null,
237
+ stopHookActive: false,
238
+ };
239
+ case "preCompact":
240
+ return { kind: "compact.pre", trigger: "auto" };
241
+ }
242
+ };
243
+
244
+ const within = (root: string, dir: string) =>
245
+ dir === root || dir.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`);
246
+
247
+ /**
248
+ * Where a hook acts. Shell policy follows the command's own `cwd` (in a
249
+ * multi-root workspace each root is its own repository); everything else
250
+ * uses the workspace root that contains `cwd`, else the first root.
251
+ */
252
+ const hookCwd = (input: CursorHookInput): string => {
253
+ const cwd = existingDirectory(input.cwd);
254
+ if (cwd && input.hook_event_name === "beforeShellExecution") return cwd;
255
+ return cursorWorkspaceRoot(input);
256
+ };
257
+
258
+ /** The workspace root that contains the payload `cwd`, else the first root. */
259
+ export const cursorWorkspaceRoot = (input: CursorHookInput): string => {
260
+ const cwd = existingDirectory(input.cwd);
261
+ return (
262
+ (cwd && input.workspace_roots.find((root) => within(root, cwd))) || input.workspace_roots[0]
263
+ );
264
+ };
265
+
266
+ /** Cursor's documented deny payload; blocking events also exit 2. */
267
+ export const cursorDeny = (reason: string) => ({
268
+ permission: "deny" as const,
269
+ user_message: "Workit blocked this action",
270
+ agent_message: reason,
271
+ });
272
+ const BLOCKING = new Set(["preToolUse", "beforeShellExecution", "subagentStart"]);
273
+
274
+ const render = (decision: HookDecision, native: string | null) => {
275
+ if (decision.kind === "deny")
276
+ return {
277
+ json: cursorDeny(decision.reason),
278
+ exitCode: native !== null && BLOCKING.has(native) ? 2 : 0,
279
+ };
280
+ if (decision.kind === "context")
281
+ return { json: { additional_context: decision.text }, exitCode: 0 };
282
+ if (decision.kind === "notice")
283
+ return { json: { user_message: decision.userMessage }, exitCode: 0 };
284
+ return {
285
+ json: native !== null && BLOCKING.has(native) ? { permission: "allow" } : {},
286
+ exitCode: 0,
287
+ };
288
+ };
289
+
290
+ export const cursorAdapter: HostAdapter = {
291
+ descriptor: CURSOR_DESCRIPTOR,
292
+ parse(raw) {
293
+ const parsed = parseCursorHookInput(raw);
294
+ if (!parsed.ok) {
295
+ const native =
296
+ isRecord(raw) && Object.hasOwn(EVENTS, String(raw.hook_event_name))
297
+ ? (raw.hook_event_name as CursorHookEvent)
298
+ : null;
299
+ return { ok: false, error: parsed.error, native, event: native ? EVENTS[native] : null };
300
+ }
301
+ const input = parsed.data;
302
+ return {
303
+ ok: true,
304
+ native: input.hook_event_name,
305
+ input: {
306
+ host: "cursor",
307
+ cwd: hookCwd(input),
308
+ session: {
309
+ id: input.session_id ?? input.conversation_id ?? "",
310
+ agentId: input.subagent_id ?? null,
311
+ agentType: input.subagent_type ?? null,
312
+ parentId: input.parent_conversation_id ?? null,
313
+ },
314
+ permissionMode: null,
315
+ transcriptPath: null,
316
+ event: protocolEvent(input),
317
+ },
318
+ };
319
+ },
320
+ render,
321
+ };
@@ -0,0 +1,55 @@
1
+ // Lenient field readers for native hook payloads (D17): unknown keys are
2
+ // ignored, and only the keys a mapping needs are checked.
3
+ import { existsSync, realpathSync, statSync } from "node:fs";
4
+ import path from "node:path";
5
+
6
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
7
+ value !== null && typeof value === "object" && !Array.isArray(value);
8
+
9
+ export const nonEmpty = (value: unknown): value is string =>
10
+ typeof value === "string" && value.trim() !== "";
11
+
12
+ export const optionalText = (value: unknown): string | null =>
13
+ typeof value === "string" ? value : null;
14
+
15
+ /** The canonical path of an existing absolute directory, or null. */
16
+ export const existingDirectory = (value: unknown): string | null => {
17
+ if (!nonEmpty(value) || !path.isAbsolute(value) || !existsSync(value)) return null;
18
+ try {
19
+ return statSync(value).isDirectory() ? realpathSync(value) : null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+
25
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash", "pwsh", "powershell", "cmd"]);
26
+ /** `-c`, `-lc`, `-ec`…, PowerShell `-Command`, and cmd `/c`. */
27
+ const SCRIPT_FLAG = /^(?:-\w*c|-command|\/c)$/i;
28
+ const quoteArg = (arg: string) => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : JSON.stringify(arg));
29
+
30
+ /**
31
+ * A shell command as one string. Hosts send either a string or an argv array.
32
+ * For a shell wrapper (`[bash, -e, -c, script]`, `[powershell.exe, -Command,
33
+ * script]`, `[cmd, /c, script]`) the script after the first script flag is
34
+ * the command; other arrays are joined with quoting so one argument never splits.
35
+ */
36
+ export const commandText = (value: unknown): string | null => {
37
+ if (nonEmpty(value)) return value;
38
+ if (!Array.isArray(value) || value.length === 0) return null;
39
+ const argv: unknown[] = value;
40
+ if (!argv.every((arg): arg is string => typeof arg === "string")) return null;
41
+ const shell =
42
+ argv[0]
43
+ .split(/[\\/]/)
44
+ .at(-1)
45
+ ?.replace(/\.exe$/i, "")
46
+ .toLowerCase() ?? "";
47
+ if (SHELLS.has(shell))
48
+ for (let index = 1; index < argv.length - 1; index++) {
49
+ if (SCRIPT_FLAG.test(argv[index])) return nonEmpty(argv[index + 1]) ? argv[index + 1] : null;
50
+ // Other options may precede the script flag; a positional ends the scan.
51
+ if (!/^[-/]/.test(argv[index])) break;
52
+ }
53
+ const joined = argv.map(quoteArg).join(" ");
54
+ return nonEmpty(joined) ? joined : null;
55
+ };