-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathhook-spawn.ts
More file actions
312 lines (295 loc) · 12.9 KB
/
Copy pathhook-spawn.ts
File metadata and controls
312 lines (295 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
// Shared command-hook spawn machinery (A2 of the hooks platform).
//
// Every dialect adapter (Cursor / Claude / Copse) spawns its hook the same way:
// a shell child process fed the dialect wire payload on stdin, with stdout the
// response channel and stderr captured for the spine (decision 6 — stderr was
// previously discarded). Only the *marshalling* of stdin and the *interpretation*
// of the exit code + stdout differ per dialect; the process plumbing is shared
// here so the adapters stay focused on their wire contract.
//
// F3 (decision 7) reversed the spawn default: hook processes now run **inside
// the project sandbox by default** (reversing the earlier outside-sandbox
// spawn), with the Copse `sandbox: false` per-hook escape as the only opt-out.
// Enforcement is macOS-only (seatbelt via ASRT); on other platforms
// `isProjectSandboxEnabled()` is hard-false, so "sandboxed" is a *default*, not
// a guarantee. A sandbox-blocked hook never fail-opens silently — the runner
// keys off the recorded violation count here (never the hook's own stdout).
//
// rule 4: spawning is Electron-adjacent host code, never `packages/agent`.
import { spawn, type ChildProcess } from 'node:child_process'
import { hooksDialectsEnvironment } from './environment.ts'
import { safeJsonStringify } from '@copse/std/safe-json.ts'
import { childHookEnv, currentHookDepth, HOOK_DEPTH_ENV } from './hook-depth.ts'
/** Default per-hook timeout. Vendor-specific overrides live in each adapter (decision 13, H4). */
export const DEFAULT_HOOK_TIMEOUT_MS = 5_000
/** Cap captured stream sizes so a runaway hook can't exhaust memory. */
const OUTPUT_CAP_BYTES = 1_000_000
/** Everything observed about one spawned hook process, before dialect interpretation. */
export interface HookSpawnResult {
/**
* The exact bytes written to the hook's stdin, verbatim for the spine blob
* (decision 6). Reported even when the spawn failed — "what would it have
* seen?" is the first question a broken hook raises. Empty when the payload
* could not be JSON-serialized.
*/
stdin: string
/** Raw captured stdout (the response channel), verbatim for the spine blob. */
stdout: string
/** Raw captured stderr, verbatim for the spine blob (decision 6). */
stderr: string
/** Process exit code; null when killed (timeout / output cap) or spawn failed. */
exitCode: number | null
/** True when the process was killed for exceeding its timeout. */
timedOut: boolean
/** True when the process failed to start (spawn error / stdin write error). */
spawnError: boolean
/**
* Whether this run actually went through the project sandbox (F3, decision 7).
* True only when the hook was sandboxed-by-default *and* an OS sandbox is
* active (macOS seatbelt) — a *default*, not a guarantee, so on Linux / Windows
* or for a `sandbox: false` escape this is false. The runner keys its
* blocked-by-sandbox detection off this + {@link sandboxViolationCount}.
*/
sandboxed: boolean
/**
* Sandbox policy violations the runner (ASRT/seatbelt) recorded for this
* command (runner-side signal, never derived from the hook's own stdout — issue
* #104). Always 0 for an unsandboxed run. A non-zero count on a non-zero exit is
* the trustworthy "the sandbox blocked this hook" signal.
*/
sandboxViolationCount: number
startedAt: number
durationMs: number
}
export interface HookSpawnOptions {
/** Working directory; relative commands resolve against it. */
cwd: string
/** Kill the process after this many ms (treated by adapters as a failure). */
timeoutMs?: number
/** Abort signal for the current run; kills the process when it fires. */
signal?: AbortSignal
/**
* Session-scoped environment overlay (H4). Merged on top of the scrubbed
* {@link childHookEnv} so a `sessionStart` hook's `env` output reaches every
* later hook process spawned in the same session (decision-doc "`sessionStart`
* env propagation"). The overlay never removes the depth guard / scrubbing —
* it only adds session vars, applied last so it cannot clobber
* `COPSE_HOOK_DEPTH`.
*/
sessionEnv?: Record<string, string>
/**
* Whether the hook runs **inside the project sandbox** (F3, decision 7). Hooks
* are sandboxed by default; `false` is the Copse `sandbox: false` escape. The
* OS sandbox is macOS-only, so even a sandboxed hook only runs contained when
* {@link isProjectSandboxEnabled} — a default, not a guarantee. Absent = the
* default (sandboxed).
*/
sandbox?: boolean
}
/**
* Injectable seam over the (macOS-only, native) project sandbox so hook-spawn
* stays testable on Linux CI without real seatbelt (F3 acceptance: "mock/fake
* sandbox if needed"). The real implementation delegates to the project-sandbox
* module; tests swap in a fake that can simulate a sandboxed spawn and report
* synthetic violation counts.
*/
export interface HookSandboxRuntime {
/** Whether an OS sandbox boundary is active (macOS seatbelt initialized). */
enabled(): boolean
/** Spawn a shell command line inside the project sandbox (stdio piped). */
spawnShell(
command: string,
opts: { cwd: string; env: NodeJS.ProcessEnv; signal?: AbortSignal },
): Promise<ChildProcess>
/** Runner-recorded sandbox policy violations for this command (never stdout-derived). */
violationCount(command: string): number
/** Per-command sandbox cleanup, mirroring the shell tool's `afterSandboxedCommand`. */
afterCommand(): void
}
let sandboxRuntimeOverride: HookSandboxRuntime | null = null
/** The host-configured sandbox runtime (see `environment.ts`), unless a test swapped one in. */
function sandboxRuntime(): HookSandboxRuntime {
return sandboxRuntimeOverride ?? hooksDialectsEnvironment().sandbox
}
/** Swap the sandbox runtime for a test fake; pass null to restore the configured one. */
export function setHookSandboxRuntimeForTest(runtime: HookSandboxRuntime | null): void {
sandboxRuntimeOverride = runtime
}
/**
* JSON-serialize a hook's stdin payload. A payload that cannot be serialized
* (a cycle, a BigInt) resolves to the empty string rather than throwing:
* `spawnHookProcess` never rejects, so an unserializable payload has to reach
* the caller as "the hook read nothing", handled by its dialect exit-code table.
*/
function serializeStdin(payload: unknown): string {
try {
return safeJsonStringify(payload) ?? ''
} catch {
return ''
}
}
/** Reject when `promise` does not settle within `ms` (wedged sandbox wrapper). */
function promiseWithTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`sandbox wrapper did not start within ${String(ms)}ms`))
}, ms)
promise.then(
(v) => {
clearTimeout(timer)
resolve(v)
},
(err: unknown) => {
clearTimeout(timer)
reject(err instanceof Error ? err : new Error(String(err)))
},
)
})
}
/**
* Spawn one hook command through a shell, write `stdinPayload` (JSON-serialized)
* to its stdin, and resolve once it closes / is killed. Never rejects: a spawn
* failure is reported as `spawnError`, a timeout as `timedOut`, so the caller's
* dialect exit-code table (decision 9) is the single place failure semantics
* are decided.
*
* The process inherits `childHookEnv()` — the same secret-scrubbed env as
* `run_shell` (so LLM provider keys never reach hook scripts; non-LLM tool
* tokens remain, the documented trust boundary in docs/cursor-hooks.md) plus a
* bumped `COPSE_HOOK_DEPTH` so a Copse re-entered from the hook suppresses its
* own hooks (decision 5 recursion guard).
*/
export async function spawnHookProcess(
command: string,
stdinPayload: unknown,
opts: HookSpawnOptions,
): Promise<HookSpawnResult> {
// F3 (decision 7): hooks are sandboxed by default — the `sandbox: false`
// escape (Copse-only) is the sole opt-out. The OS sandbox is macOS-only, so
// this only *contains* the hook when an OS boundary is actually active; on
// Linux / Windows it is a no-op default, never a guarantee.
const sandboxed = opts.sandbox !== false && sandboxRuntime().enabled()
const startedAt = Date.now()
const depthEnv = { [HOOK_DEPTH_ENV]: String(currentHookDepth() + 1) }
// Serialize once, up front: the same string is written to the child and
// reported back for the spine's payload blob, so the recorded stdin is
// byte-for-byte what the hook read — not a re-serialization that could drift.
const stdin = serializeStdin(stdinPayload)
let child: ChildProcess
try {
if (sandboxed) {
// The sandbox spawner supplies the scrubbed base env + the workspace-owned
// $TMPDIR (which the seatbelt allows); overlay only the hook-specific vars
// — session env (H4) then the depth guard last, so a session var can never
// clobber `COPSE_HOOK_DEPTH` (the recursion guard, decision 5).
const overlay: NodeJS.ProcessEnv = { ...(opts.sessionEnv ?? {}), ...depthEnv }
// Race the wrapper against the hook's own timeout: the kill timer below
// only arms once a ChildProcess exists, so a wedged sandbox-wrapper
// promise would otherwise hang a *blocking* hook indefinitely (with the
// run deadline paused, H4). A wrapper that loses the race is reported as
// a spawn error — same failure surface as a wrapper throw.
const timeoutMs = opts.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
child = await promiseWithTimeout(
sandboxRuntime().spawnShell(command, {
cwd: opts.cwd,
env: overlay,
...(opts.signal ? { signal: opts.signal } : {}),
}),
timeoutMs,
)
} else {
// Unsandboxed path: arbitrary user/project shell with non-LLM tool tokens
// present in `env`, gated by workspace trust + `cursorHooksEnabled` (see
// docs/cursor-hooks.md#security). Session env (H4) is layered on the
// scrubbed child env, depth guard re-applied last.
const baseEnv = childHookEnv()
const env = opts.sessionEnv ? { ...baseEnv, ...opts.sessionEnv, ...depthEnv } : baseEnv
child = spawn(command, {
cwd: opts.cwd,
shell: true,
env,
stdio: ['pipe', 'pipe', 'pipe'],
...(opts.signal ? { signal: opts.signal } : {}),
})
}
} catch {
// The sandbox wrapper itself failed to start (runner-side, not command
// output). Report it as a spawn error — the runner's blocked-by-sandbox
// detection treats a sandboxed spawn failure as a block (never fail-open).
return {
stdin,
stdout: '',
stderr: '',
exitCode: null,
timedOut: false,
spawnError: true,
sandboxed,
sandboxViolationCount: 0,
startedAt,
durationMs: Date.now() - startedAt,
}
}
return new Promise((resolve) => {
let stdout = ''
let stderr = ''
let exitCode: number | null = null
let timedOut = false
let settled = false
const finish = (spawnError: boolean): void => {
if (settled) return
settled = true
clearTimeout(timer)
// Query the runner-recorded violation count BEFORE per-command cleanup,
// matching the shell tool's ordering. Only meaningful for a sandboxed run.
const sandboxViolationCount = sandboxed ? sandboxRuntime().violationCount(command) : 0
if (sandboxed) sandboxRuntime().afterCommand()
resolve({
stdin,
stdout,
stderr,
exitCode,
timedOut,
spawnError,
sandboxed,
sandboxViolationCount,
startedAt,
durationMs: Date.now() - startedAt,
})
}
const timer = setTimeout(() => {
timedOut = true
child.kill('SIGKILL')
finish(false)
}, opts.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS)
child.stdout?.on('data', (chunk: Buffer) => {
// stdout is the response channel: a runaway response is fatal to the hook.
if (stdout.length <= OUTPUT_CAP_BYTES) stdout += chunk.toString('utf-8')
else child.kill('SIGKILL')
})
// Overflow only truncates the stderr capture; it never kills the hook,
// because stderr chatter carries no decision.
child.stderr?.on('data', (chunk: Buffer) => {
if (stderr.length <= OUTPUT_CAP_BYTES) stderr += chunk.toString('utf-8')
})
child.on('error', () => {
finish(true)
})
child.on('close', (code) => {
exitCode = code
finish(false)
})
// A process that exits before draining stdin (e.g. a command that does not
// exist, or one that never reads its input) makes the write race the close:
// the pipe can be gone by the time we write, surfacing as an async EPIPE on
// the stdin stream. Swallow it — the close/error handlers own the outcome —
// so it never becomes an unhandled exception.
child.stdin?.on('error', () => {
/* the process closed its input early; the decision comes from close/error */
})
try {
child.stdin?.end(stdin)
} catch {
finish(true)
}
})
}