@agent-guardrails/opencode-shell-warden-hook 0.1.0 → 0.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.
package/src/protocol.ts DELETED
@@ -1,87 +0,0 @@
1
- // NDJSON JSON-RPC framing — docs/policyd-plugin-design/02-protocol.md §2.1–2.2
2
- // One UTF-8 JSON value per \n-terminated line; lines over the byte cap are a
3
- // protocol violation; unparseable lines are a protocol violation.
4
-
5
- import type { WireMessage } from "./types.ts"
6
-
7
- export class FrameError extends Error {
8
- readonly kind: "frame-limit" | "parse"
9
- constructor(kind: "frame-limit" | "parse") {
10
- super(`protocol violation: ${kind}`)
11
- this.kind = kind
12
- }
13
- }
14
-
15
- export function encodeMessage(msg: WireMessage, maxFrameBytes: number): string {
16
- const line = JSON.stringify(msg)
17
- if (Buffer.byteLength(line, "utf8") + 1 > maxFrameBytes) {
18
- throw new FrameError("frame-limit")
19
- }
20
- return line + "\n"
21
- }
22
-
23
- /**
24
- * Incremental line parser for a byte stream. After `fatal` is set the parser
25
- * stops consuming input; the caller must treat the connection as violated.
26
- */
27
- export class FrameParser {
28
- private buf = ""
29
- private queue: WireMessage[] = []
30
- fatal: string | null = null
31
-
32
- private readonly maxFrameBytes: number
33
- constructor(maxFrameBytes: number) {
34
- this.maxFrameBytes = maxFrameBytes
35
- }
36
-
37
- push(chunk: Buffer | string): void {
38
- if (this.fatal !== null) return
39
- this.buf += typeof chunk === "string" ? chunk : chunk.toString("utf8")
40
- for (;;) {
41
- const nl = this.buf.indexOf("\n")
42
- if (nl === -1) {
43
- if (Buffer.byteLength(this.buf, "utf8") > this.maxFrameBytes) this.fatal = "frame-limit"
44
- return
45
- }
46
- const line = this.buf.slice(0, nl)
47
- this.buf = this.buf.slice(nl + 1)
48
- if (Buffer.byteLength(line, "utf8") > this.maxFrameBytes) {
49
- this.fatal = "frame-limit"
50
- return
51
- }
52
- if (line.trim().length === 0) {
53
- this.fatal = "parse" // blank lines are not valid NDJSON
54
- return
55
- }
56
- let msg: unknown
57
- try {
58
- msg = JSON.parse(line)
59
- } catch {
60
- this.fatal = "parse"
61
- return
62
- }
63
- if (msg === null || typeof msg !== "object") {
64
- this.fatal = "parse"
65
- return
66
- }
67
- this.queue.push(msg as WireMessage)
68
- }
69
- }
70
-
71
- drain(): WireMessage[] {
72
- const q = this.queue
73
- this.queue = []
74
- return q
75
- }
76
- }
77
-
78
- /** Monotonic JSON-RPC id source; reset per engine connection. */
79
- export class IdPool {
80
- private next = 1
81
- take(): number {
82
- return this.next++
83
- }
84
- reset(): void {
85
- this.next = 1
86
- }
87
- }
package/src/supervisor.ts DELETED
@@ -1,412 +0,0 @@
1
- // Engine supervisor — docs/policyd-plugin-design/03-shim.md §3.3, 02-protocol.md §2.7
2
- // Lazy spawn (empty env, absolute argv), exact-version handshake, per-check
3
- // deadlines, violation/restart ladder, unhealthy cooldown, fail-closed dispose.
4
-
5
- import { spawn } from "node:child_process"
6
- import type { ChildProcess } from "node:child_process"
7
- import { createHash } from "node:crypto"
8
- import { createReadStream } from "node:fs"
9
- import { FrameParser, IdPool, encodeMessage } from "./protocol.ts"
10
- import type { CheckParams, CheckResult, WireMessage } from "./types.ts"
11
- import type { CheckOutcome } from "./apply.ts"
12
-
13
- export interface SupervisorOptions {
14
- command: string[]
15
- protocolVersion: number
16
- sha256: string | null
17
- cwd: string | null
18
- clientVersion: string
19
- budgets: {
20
- handshakeTimeoutMs: number
21
- checkTimeoutMs: number
22
- killGraceMs: number
23
- maxFrameBytes: number
24
- maxInFlight: number
25
- maxViolations: number
26
- maxViolationsWindowMs: number
27
- maxBackoffMs: number
28
- windowMs: number
29
- resetAfterMs: number
30
- }
31
- log?: (level: "debug" | "info" | "warn" | "error", msg: string) => void
32
- }
33
-
34
- type Waiter = {
35
- resolve: (o: CheckOutcome) => void
36
- timer: ReturnType<typeof setTimeout>
37
- id: number | null
38
- params: CheckParams
39
- }
40
-
41
- const MAX_QUEUED = 64
42
- const FATAL_COOLDOWN_MS = 15 * 60_000
43
- const KILL_KINDS = new Set(["frame-limit", "parse", "version-mismatch", "handshake-timeout", "timeout", "exit", "spawn"])
44
-
45
- export class EngineSupervisor {
46
- private state: "cold" | "spawning" | "handshaking" | "ready" | "restarting" | "unhealthy" | "disposed" = "cold"
47
- private child: ChildProcess | null = null
48
- private parser: FrameParser
49
- private ids = new IdPool()
50
- private pending = new Map<number, Waiter>()
51
- private queue: Waiter[] = []
52
- private handshakeId: number | null = null
53
- private handshakeTimer: ReturnType<typeof setTimeout> | null = null
54
- private violations = { count: 0, windowStart: 0 }
55
- private restartsAt: number[] = []
56
- private backoffLevel = 0
57
- private unhealthyUntil = 0
58
- private lastReadyAt = 0
59
- private spawnTimer: ReturnType<typeof setTimeout> | null = null
60
- private graceTimer: ReturnType<typeof setTimeout> | null = null
61
- private stderrRing: string[] = []
62
- private stderrBytes = 0
63
- private disposed = false
64
-
65
- private readonly opts: SupervisorOptions
66
- private readonly now: () => number
67
-
68
- constructor(opts: SupervisorOptions, now: () => number = Date.now) {
69
- this.opts = opts
70
- this.now = now
71
- this.parser = new FrameParser(opts.budgets.maxFrameBytes)
72
- }
73
-
74
- get currentState(): string {
75
- return this.state
76
- }
77
-
78
- lastStderr(): string {
79
- return this.stderrRing.join("")
80
- }
81
-
82
- async check(params: CheckParams): Promise<CheckOutcome> {
83
- if (this.disposed || this.state === "disposed") return { ok: false, reason: "disposed" }
84
- if (this.now() < this.unhealthyUntil) return { ok: false, reason: "unhealthy" }
85
- void this.ensureEngine()
86
- return new Promise<CheckOutcome>((resolve) => {
87
- if (this.state === "ready" && this.child !== null && this.pending.size < this.opts.budgets.maxInFlight) {
88
- this.sendCheck(resolve, params)
89
- return
90
- }
91
- if (this.queue.length >= MAX_QUEUED) {
92
- resolve({ ok: false, reason: "backpressure" })
93
- return
94
- }
95
- const waiter: Waiter = {
96
- resolve,
97
- id: null,
98
- params,
99
- timer: setTimeout(() => this.failWaiter(waiter, "timeout"), this.opts.budgets.checkTimeoutMs),
100
- }
101
- this.queue.push(waiter)
102
- })
103
- }
104
-
105
- /** Fire-and-forget audit notification (policy/observe); dropped unless ready. */
106
- observe(notification: Record<string, unknown>): void {
107
- if (this.state !== "ready" || this.child === null) return
108
- try {
109
- this.child.stdin?.write(encodeMessage({ jsonrpc: "2.0", method: "policy/observe", params: notification }, this.opts.budgets.maxFrameBytes))
110
- } catch {
111
- /* audit must never throw */
112
- }
113
- }
114
-
115
- dispose(): void {
116
- if (this.disposed) return
117
- this.disposed = true
118
- this.state = "disposed"
119
- this.clearTimers()
120
- this.rejectPending("disposed")
121
- this.rejectQueued("disposed")
122
- if (this.child !== null) {
123
- try {
124
- this.child.kill("SIGKILL")
125
- } catch {
126
- /* already gone */
127
- }
128
- this.child = null
129
- }
130
- }
131
-
132
- // ---------- internal ----------
133
-
134
- private ensureEngine(): void {
135
- if (this.disposed || this.state !== "cold") return
136
- if (this.now() < this.unhealthyUntil) {
137
- this.rejectQueued("unhealthy")
138
- return
139
- }
140
- if (this.lastReadyAt !== 0 && this.now() - this.lastReadyAt >= this.opts.budgets.resetAfterMs) {
141
- this.restartsAt = []
142
- this.backoffLevel = 0
143
- }
144
- this.state = "spawning"
145
- void this.spawnNow()
146
- }
147
-
148
- private async spawnNow(): Promise<void> {
149
- if (this.disposed || this.state !== "spawning") return
150
- if (this.opts.sha256 !== null) {
151
- const ok = await this.verifySha256(this.opts.sha256)
152
- if (!ok) {
153
- this.markFatal("engine binary sha256 mismatch")
154
- return
155
- }
156
- }
157
- const [cmd, ...args] = this.opts.command
158
- const child = spawn(cmd as string, args, {
159
- env: {},
160
- cwd: this.opts.cwd ?? undefined,
161
- stdio: ["pipe", "pipe", "pipe"],
162
- })
163
- this.child = child
164
- this.parser = new FrameParser(this.opts.budgets.maxFrameBytes)
165
- this.ids.reset()
166
- this.handshakeId = null
167
- this.state = "handshaking"
168
- child.on("error", (err: Error & { code?: string }) => {
169
- this.protocolViolation(`spawn:${err.code ?? "error"}`)
170
- })
171
- child.stdout?.setEncoding("utf8")
172
- child.stdout?.on("data", (chunk: string) => {
173
- this.parser.push(chunk)
174
- if (this.parser.fatal !== null) {
175
- this.protocolViolation(`frame:${this.parser.fatal}`)
176
- return
177
- }
178
- for (const msg of this.parser.drain()) this.route(msg)
179
- })
180
- child.stderr?.setEncoding("utf8")
181
- child.stderr?.on("data", (chunk: string) => {
182
- this.stderrRing.push(chunk)
183
- this.stderrBytes += chunk.length
184
- while (this.stderrBytes > 8 * 1024 && this.stderrRing.length > 0) {
185
- const head = this.stderrRing.shift() as string
186
- this.stderrBytes -= head.length
187
- }
188
- })
189
- child.on("exit", (code, signal) => this.onExit(code, signal))
190
- this.sendHandshake()
191
- }
192
-
193
- private sendHandshake(): void {
194
- const id = this.ids.take()
195
- this.handshakeId = id
196
- this.handshakeTimer = setTimeout(() => {
197
- this.handshakeId = null
198
- this.protocolViolation("handshake-timeout")
199
- }, this.opts.budgets.handshakeTimeoutMs)
200
- this.write({
201
- jsonrpc: "2.0",
202
- id,
203
- method: "initialize",
204
- params: { protocolVersion: this.opts.protocolVersion, client: { name: "opencode-shell-warden-hook", version: this.opts.clientVersion } },
205
- })
206
- }
207
-
208
- private completeHandshake(result: unknown): void {
209
- if (this.handshakeTimer !== null) clearTimeout(this.handshakeTimer)
210
- this.handshakeTimer = null
211
- this.handshakeId = null
212
- if (this.state !== "handshaking") return
213
- const r = result as { protocolVersion?: number } | null
214
- if (r === null || typeof r !== "object" || r.protocolVersion !== this.opts.protocolVersion) {
215
- this.protocolViolation("version-mismatch")
216
- return
217
- }
218
- this.state = "ready"
219
- this.log("info", `engine ready: ${JSON.stringify((result as InitializeLike).engine ?? {})}`)
220
- this.flushQueue()
221
- }
222
-
223
- private flushQueue(): void {
224
- while (this.state === "ready" && this.queue.length > 0 && this.pending.size < this.opts.budgets.maxInFlight) {
225
- const waiter = this.queue.shift()
226
- if (waiter === undefined) break
227
- this.sendCheck(waiter.resolve, waiter.params, waiter)
228
- }
229
- }
230
-
231
- private sendCheck(resolve: (o: CheckOutcome) => void, params: CheckParams, existing?: Waiter): void {
232
- let waiter: Waiter
233
- if (existing !== undefined) {
234
- waiter = existing
235
- } else {
236
- const w: Waiter = { resolve, params, id: null, timer: null! }
237
- w.timer = setTimeout(() => this.failWaiter(w, "timeout"), this.opts.budgets.checkTimeoutMs)
238
- waiter = w
239
- }
240
- const id = this.ids.take()
241
- waiter.id = id
242
- this.pending.set(id, waiter)
243
- this.write({ jsonrpc: "2.0", id, method: "policy/check", params })
244
- }
245
-
246
- private route(msg: WireMessage): void {
247
- if ("method" in msg && !("id" in msg)) {
248
- if (msg.method === "audit/event") this.log("info", `audit: ${JSON.stringify(msg.params ?? {})}`)
249
- return // unknown notifications ignored (forward compatibility)
250
- }
251
- if ("error" in msg) {
252
- if (msg.id === null) return
253
- const waiter = this.pending.get(msg.id)
254
- if (waiter !== undefined) {
255
- this.pending.delete(msg.id)
256
- clearTimeout(waiter.timer)
257
- waiter.resolve({ ok: false, reason: "engine-error" })
258
- }
259
- if (msg.error.data?.fatal === true) this.markFatal(`engine fatal: ${msg.error.message}`)
260
- return
261
- }
262
- if (!("result" in msg)) return
263
- const id = msg.id
264
- if (id === this.handshakeId) {
265
- this.completeHandshake(msg.result)
266
- return
267
- }
268
- const waiter = this.pending.get(id)
269
- if (waiter === undefined) return // unknown/late id — ignore
270
- this.pending.delete(id)
271
- clearTimeout(waiter.timer)
272
- const result = msg.result as CheckResult | null
273
- if (result === null || typeof result !== "object" || typeof (result as CheckResult).verdict !== "string") {
274
- this.protocolViolation("bad-result-shape")
275
- waiter.resolve({ ok: false, reason: "malformed-response" })
276
- return
277
- }
278
- waiter.resolve({ ok: true, result })
279
- }
280
-
281
- private failWaiter(waiter: Waiter, reason: string): void {
282
- if (waiter.id !== null) this.pending.delete(waiter.id)
283
- else this.queue = this.queue.filter((w) => w !== waiter)
284
- waiter.resolve({ ok: false, reason })
285
- if (reason === "timeout") this.protocolViolation("timeout")
286
- }
287
-
288
- private onExit(_code: number | null, _signal: string | null): void {
289
- if (this.graceTimer !== null) clearTimeout(this.graceTimer)
290
- this.graceTimer = null
291
- if (this.disposed || this.state === "restarting" || this.state === "unhealthy" || this.state === "disposed") return
292
- this.rejectPending("engine-exited")
293
- this.protocolViolation("exit")
294
- }
295
-
296
- private protocolViolation(kind: string): void {
297
- if (this.disposed) return
298
- this.log("warn", `violation: ${kind}`)
299
- const now = this.now()
300
- if (now - this.violations.windowStart > this.opts.budgets.maxViolationsWindowMs) {
301
- this.violations = { count: 1, windowStart: now }
302
- } else {
303
- this.violations.count += 1
304
- }
305
- if (this.violations.count >= this.opts.budgets.maxViolations) {
306
- this.violations = { count: 0, windowStart: 0 }
307
- this.killAndRestart(`violations:${kind}`)
308
- return
309
- }
310
- if (KILL_KINDS.has(kind) || kind.startsWith("spawn:")) this.killAndRestart(kind)
311
- }
312
-
313
- private killAndRestart(reason: string): void {
314
- if (this.disposed) return
315
- const now = this.now()
316
- this.restartsAt.push(now)
317
- this.restartsAt = this.restartsAt.filter((t) => now - t <= this.opts.budgets.windowMs)
318
- this.state = "restarting"
319
- this.rejectPending("engine-restarting")
320
- this.killChild()
321
- if (this.restartsAt.length > this.opts.budgets.maxViolations) {
322
- this.unhealthyUntil = now + this.opts.budgets.windowMs
323
- this.state = "unhealthy"
324
- this.rejectQueued("unhealthy")
325
- this.log("error", `engine unhealthy until ${new Date(this.unhealthyUntil).toISOString()} (${reason})`)
326
- return
327
- }
328
- const delay = Math.min(250 * 2 ** this.backoffLevel, this.opts.budgets.maxBackoffMs)
329
- this.backoffLevel += 1
330
- this.log("warn", `restart #${this.restartsAt.length} in ${delay}ms (${reason})`)
331
- this.spawnTimer = setTimeout(() => {
332
- this.spawnTimer = null
333
- this.state = "cold"
334
- void this.ensureEngine()
335
- }, delay)
336
- }
337
-
338
- private killChild(): void {
339
- const child = this.child
340
- this.child = null
341
- if (child === null) return
342
- try {
343
- child.kill("SIGTERM")
344
- } catch {
345
- /* already gone */
346
- }
347
- this.graceTimer = setTimeout(() => {
348
- try {
349
- child.kill("SIGKILL")
350
- } catch {
351
- /* already gone */
352
- }
353
- }, this.opts.budgets.killGraceMs)
354
- }
355
-
356
- private markFatal(reason: string): void {
357
- this.unhealthyUntil = this.now() + FATAL_COOLDOWN_MS
358
- this.state = "unhealthy"
359
- this.rejectPending("unhealthy")
360
- this.rejectQueued("unhealthy")
361
- this.killChild()
362
- this.log("error", `fatal: ${reason}`)
363
- }
364
-
365
- private rejectPending(reason: string): void {
366
- for (const [, waiter] of this.pending) {
367
- clearTimeout(waiter.timer)
368
- waiter.resolve({ ok: false, reason })
369
- }
370
- this.pending.clear()
371
- }
372
-
373
- private rejectQueued(reason: string): void {
374
- for (const waiter of this.queue) {
375
- clearTimeout(waiter.timer)
376
- waiter.resolve({ ok: false, reason })
377
- }
378
- this.queue = []
379
- }
380
-
381
- private clearTimers(): void {
382
- if (this.handshakeTimer !== null) clearTimeout(this.handshakeTimer)
383
- if (this.spawnTimer !== null) clearTimeout(this.spawnTimer)
384
- if (this.graceTimer !== null) clearTimeout(this.graceTimer)
385
- }
386
-
387
- private write(msg: WireMessage): void {
388
- if (this.child === null) return
389
- try {
390
- this.child.stdin?.write(encodeMessage(msg, this.opts.budgets.maxFrameBytes))
391
- } catch {
392
- this.protocolViolation("frame-limit")
393
- }
394
- }
395
-
396
- private log(level: "debug" | "info" | "warn" | "error", msg: string): void {
397
- this.opts.log?.(level, `[opencode-shell-warden-hook] ${msg}`)
398
- }
399
-
400
- private async verifySha256(expected: string): Promise<boolean> {
401
- const path = this.opts.command[0] as string
402
- return new Promise((resolve) => {
403
- const hash = createHash("sha256")
404
- const stream = createReadStream(path)
405
- stream.on("data", (chunk) => hash.update(chunk))
406
- stream.on("end", () => resolve(hash.digest("hex") === expected))
407
- stream.on("error", () => resolve(false))
408
- })
409
- }
410
- }
411
-
412
- type InitializeLike = { engine?: { name?: string; version?: string } }