@agent-guardrails/opencode-shell-warden-hook 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,76 @@
1
- # Temporary Holding Version
1
+ # opencode-shell-warden-hook
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Thin **OpenCode V2** plugin (the "hook" part of the shell-warden system): it receives permission
4
+ evaluation events from OpenCode and asks the **shell-warden engine** for a verdict over
5
+ NDJSON JSON-RPC on stdio.
6
+
7
+ The shim contains **no policy logic** — only transport, supervision, budgets, verdict application
8
+ and fail-closed degradation. Spec: `agent-guardrails/opencode-adrs` → `docs/policyd-plugin-design/`
9
+ (`03-shim.md`, `04-shim-config.md`, `02-protocol.md`).
10
+
11
+ ## How it works
12
+
13
+ ```
14
+ OpenCode server ── permission.hook("evaluate") ── shim ── policy/check ──▶ shell-warden engine
15
+ ◀── tighten-only verdict ───────────── verdict: allow | ask | deny
16
+ ```
17
+
18
+ - `allow` / `deny` are applied to the hook event; `ask` falls through to OpenCode's native prompt.
19
+ - The shim never loosens a decision and never synthesizes `allow`: any engine anomaly
20
+ (crash, timeout, malformed frame, bad protocol version) degrades to `onEngineError` (`ask` or `deny`).
21
+ - Engine runs as a persistent child process spawned **without inherited environment**, from an
22
+ **absolute** argv, with an exact `protocolVersion` handshake.
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ npm install opencode-shell-warden-hook # or reference a local checkout
28
+ ```
29
+
30
+ `opencode.jsonc`:
31
+
32
+ ```jsonc
33
+ {
34
+ "plugins": [
35
+ {
36
+ "package": "opencode-shell-warden-hook",
37
+ "options": {
38
+ "engine": { "command": ["/usr/local/bin/shell-warden", "serve"] },
39
+ "onEngineError": "ask",
40
+ "actions": ["shell"]
41
+ }
42
+ }
43
+ ]
44
+ }
45
+ ```
46
+
47
+ All options, defaults and clamps: `04-shim-config.md` in the spec repo. Unknown keys are ignored
48
+ with a warning; invalid `engine.command` (relative path, failed `{env:…}` substitution) disables
49
+ the plugin at load — that is the fail-closed posture for misconfiguration.
50
+
51
+ ## Development
52
+
53
+ ```sh
54
+ npm install
55
+ npm test # node --test (35 contract/unit tests, mock engine included)
56
+ npm run typecheck
57
+ ```
58
+
59
+ Layout:
60
+
61
+ | Module | Responsibility |
62
+ |---|---|
63
+ | `src/protocol.ts` | NDJSON framing, byte caps, frame parser, id pool |
64
+ | `src/supervisor.ts` | engine lifecycle: spawn → handshake → ready, violations → restart → unhealthy, dispose |
65
+ | `src/cache.ts` | allow-only TTL LRU verdict cache |
66
+ | `src/apply.ts` | request building, cache keys, tighten-only verdict mapping |
67
+ | `src/config.ts` | options validation (fatal vs warn+clamp) |
68
+ | `src/index.ts` | `Plugin.define` + hook registrations |
69
+
70
+ Tests run on Node ≥ 22.6 using native TypeScript type-stripping (no build step).
71
+ The engine side (segmentation, rules, verdicts) is a separate project: `agent-guardrails/shell-warden`.
72
+
73
+ ## Status
74
+
75
+ M0/M1 (shim): implemented — protocol codec, supervisor, cache, apply, config, hook wiring.
76
+ Not yet: `policy/observe` hardening, sha256 pinning is implemented at spawn only (no MDM story), engine itself.
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@agent-guardrails/opencode-shell-warden-hook",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "OpenCode V2 hook plugin: forwards shell permission decisions to the shell-warden engine over NDJSON JSON-RPC stdio",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/agent-guardrails/opencode-shell-warden-hook.git"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "dependencies": {
17
+ "@opencode/plugin": "latest"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^26.0.0",
21
+ "typescript": "^5.9.0"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test test/",
25
+ "typecheck": "tsc --noEmit"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "engines": {
31
+ "node": ">=22.6"
32
+ },
33
+ "license": "MIT"
34
+ }
package/src/apply.ts ADDED
@@ -0,0 +1,86 @@
1
+ // Reply automation per ADR-0013 — the shim resolves published permission
2
+ // requests programmatically (the same right the human exercises):
3
+ // engine allow → reply "once" (command runs)
4
+ // engine deny → reply "reject" (with reason)
5
+ // engine ask → no reply (escalation by silence — the human decides)
6
+ // The shim never mutates permission events (the evaluate-allow path is not
7
+ // honored by OpenCode v2.2.x anyway — live-proven).
8
+
9
+ import { createHash } from "node:crypto"
10
+ import type { CheckParams, CheckResult, Verdict } from "./types.ts"
11
+
12
+ export type CheckOutcome = { ok: true; result: CheckResult } | { ok: false; reason: string }
13
+
14
+ export interface AskData {
15
+ id: string // requestID, assigned at publication
16
+ sessionID: string
17
+ action: string
18
+ resources: readonly string[]
19
+ source?: { type: "tool"; messageID: string; id: string }
20
+ }
21
+
22
+ export function invocationIdOf(source?: { messageID: string; id: string }, sessionID?: string, action?: string): string {
23
+ return source ? `${source.messageID}:${source.id}` : `anon:${sessionID ?? ""}:${action ?? ""}`
24
+ }
25
+
26
+ export function cacheKey(action: string, resources: readonly string[], cwd: string, agent?: string): string {
27
+ return createHash("sha256")
28
+ .update(action)
29
+ .update("\u0000")
30
+ .update((resources ?? []).join("\u0000"))
31
+ .update("\u0000")
32
+ .update(cwd)
33
+ .update("\u0000")
34
+ .update(agent ?? "")
35
+ .digest("hex")
36
+ }
37
+
38
+ export function buildCheckRequest(args: {
39
+ data: AskData
40
+ invocationId: string
41
+ cwd: string
42
+ projectDir?: string
43
+ }): CheckParams {
44
+ const { data, invocationId, cwd, projectDir } = args
45
+ return {
46
+ invocation: {
47
+ id: invocationId,
48
+ sessionID: data.sessionID ?? "",
49
+ hook: "permission.asked",
50
+ ...(projectDir !== undefined ? { projectDir } : {}),
51
+ startedAt: new Date().toISOString(),
52
+ },
53
+ action: data.action ?? "",
54
+ commands: (data.resources ?? []).map((raw, index) => ({ index, raw, cwd })),
55
+ context: {
56
+ ...(data.source !== undefined ? { source: data.source } : {}),
57
+ },
58
+ }
59
+ }
60
+
61
+ /** Maps a verdict to the reply decision: `"once"` | `"reject"` | `null` (silence). */
62
+ export function replyDecision(v: Verdict): "once" | "reject" | null {
63
+ if (v === "allow") return "once"
64
+ if (v === "deny") return "reject"
65
+ return null
66
+ }
67
+
68
+ /** Best-effort per-invocation cache eviction registry (bounded). */
69
+ export class InvocationRegistry {
70
+ private readonly cap: number
71
+ private map = new Map<string, string>()
72
+ constructor(cap = 256) {
73
+ this.cap = cap
74
+ }
75
+ remember(invocationId: string, key: string): void {
76
+ this.map.set(invocationId, key)
77
+ while (this.map.size > this.cap) {
78
+ const oldest = this.map.keys().next()
79
+ if (oldest.done === true) break
80
+ this.map.delete(oldest.value)
81
+ }
82
+ }
83
+ keyFor(invocationId: string): string | undefined {
84
+ return this.map.get(invocationId)
85
+ }
86
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,46 @@
1
+ // Verdict cache — docs/policyd-plugin-design/03-shim.md §3.4
2
+ // Allow-only, TTL from the engine, size-bounded LRU. Denies are never cached.
3
+
4
+ export interface CacheEntry {
5
+ verdict: "allow"
6
+ message?: string
7
+ expiresAt: number
8
+ }
9
+
10
+ export class VerdictCache {
11
+ private readonly maxEntries: number
12
+ private readonly clock: () => number
13
+ private map = new Map<string, CacheEntry>()
14
+
15
+ constructor(maxEntries: number, clock: () => number = Date.now) {
16
+ this.maxEntries = maxEntries
17
+ this.clock = clock
18
+ }
19
+
20
+ get(key: string): CacheEntry | null {
21
+ const e = this.map.get(key)
22
+ if (e === undefined) return null
23
+ if (e.expiresAt <= this.clock()) {
24
+ this.map.delete(key)
25
+ return null
26
+ }
27
+ // LRU touch
28
+ this.map.delete(key)
29
+ this.map.set(key, e)
30
+ return e
31
+ }
32
+
33
+ put(key: string, ttlMs: number, message?: string): void {
34
+ if (ttlMs <= 0) return
35
+ while (this.map.size >= this.maxEntries) {
36
+ const oldest = this.map.keys().next()
37
+ if (oldest.done === true) break
38
+ this.map.delete(oldest.value)
39
+ }
40
+ this.map.set(key, { verdict: "allow", message, expiresAt: this.clock() + ttlMs })
41
+ }
42
+
43
+ invalidate(): void {
44
+ this.map.clear()
45
+ }
46
+ }
package/src/config.ts ADDED
@@ -0,0 +1,168 @@
1
+ // Shim options validation — docs/policyd-plugin-design/04-shim-config.md
2
+ // Semantics: unknown keys => warn + ignore; budgets out of clamp => warn + clamp;
3
+ // fatal defects (engine.command, protocolVersion, onEngineError:"allow") => plugin disables itself.
4
+
5
+ export interface ResolvedBudgets {
6
+ handshakeTimeoutMs: number
7
+ checkTimeoutMs: number
8
+ killGraceMs: number
9
+ maxFrameBytes: number
10
+ maxInFlight: number
11
+ maxRequestCommands: number
12
+ maxViolations: number
13
+ maxViolationsWindowMs: number
14
+ }
15
+
16
+ export interface ResolvedConfig {
17
+ engine: {
18
+ command: string[]
19
+ protocolVersion: number
20
+ sha256: string | null
21
+ cwd: string | null
22
+ restart: { maxBackoffMs: number; windowMs: number; resetAfterMs: number }
23
+ }
24
+ budgets: ResolvedBudgets
25
+ onEngineError: "ask" | "deny"
26
+ actions: string[]
27
+ actionsAll: boolean
28
+ cache: { enabled: boolean; maxEntries: number; defaultTtlMs: number }
29
+ observeShell: boolean
30
+ logLevel: "debug" | "info" | "warn" | "error"
31
+ }
32
+
33
+ export type ValidateResult =
34
+ | { ok: true; config: ResolvedConfig; warns: string[] }
35
+ | { ok: false; reason: string; warns: string[] }
36
+
37
+ const KNOWN_ACTIONS = new Set([
38
+ "read", "edit", "glob", "grep", "shell", "subagent", "skill", "question",
39
+ "webfetch", "websearch", "external_directory", "execute",
40
+ ])
41
+
42
+ const KNOWN_KEYS = new Set(["enabled", "engine", "budgets", "onEngineError", "actions", "cache", "observeShell", "logLevel"])
43
+
44
+ const clampInt = (v: unknown, min: number, max: number, def: number, label: string, warns: string[]): number => {
45
+ if (typeof v !== "number" || !Number.isFinite(v)) return def
46
+ const n = Math.floor(v)
47
+ if (n < min || n > max) {
48
+ warns.push(`budgets.${label}: ${n} out of clamp [${min}, ${max}], using ${Math.min(Math.max(n, min), max)}`)
49
+ return Math.min(Math.max(n, min), max)
50
+ }
51
+ return n
52
+ }
53
+
54
+ /** Resolves `{env:NAME}` members; returns null when substitution fails. */
55
+ export function resolveEnvMember(s: string): string | null {
56
+ const m = /^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(s)
57
+ if (!m) return s
58
+ const v = process.env[m[1] as string]
59
+ return v === undefined || v === "" ? null : v
60
+ }
61
+
62
+ export function validateOptions(raw: unknown, warn: (m: string) => void = () => {}): ValidateResult {
63
+ const warns: string[] = []
64
+ if (raw === null || raw === undefined || typeof raw !== "object") {
65
+ // Empty options are valid: everything defaults except engine.command.
66
+ raw = {}
67
+ }
68
+ const o = raw as Record<string, unknown>
69
+ for (const key of Object.keys(o)) {
70
+ if (!KNOWN_KEYS.has(key)) warns.push(`unknown option "${key}" ignored`)
71
+ }
72
+ if (o.enabled === false) return { ok: false, reason: "disabled by options.enabled=false", warns }
73
+
74
+ // --- engine (fatal on defects) ---
75
+ const eng = (o.engine ?? {}) as Record<string, unknown>
76
+ const rawCommand = eng.command
77
+ if (!Array.isArray(rawCommand) || rawCommand.length === 0 || rawCommand.some((m) => typeof m !== "string")) {
78
+ return { ok: false, reason: 'engine.command must be a non-empty string array', warns }
79
+ }
80
+ const command: string[] = []
81
+ for (const member of rawCommand as string[]) {
82
+ const resolved = resolveEnvMember(member)
83
+ if (resolved === null) {
84
+ return { ok: false, reason: `engine.command member "${member}" failed {env:…} substitution`, warns }
85
+ }
86
+ command.push(resolved)
87
+ }
88
+ if (command[0] === "" || !command[0]!.startsWith("/")) {
89
+ return { ok: false, reason: `engine.command[0] must be an absolute path, got "${command[0]}"`, warns }
90
+ }
91
+ const protocolVersion = typeof eng.protocolVersion === "number" ? eng.protocolVersion : 1
92
+ if (!Number.isInteger(protocolVersion) || protocolVersion < 1) {
93
+ return { ok: false, reason: `engine.protocolVersion must be a positive integer, got ${String(eng.protocolVersion)}`, warns }
94
+ }
95
+ const sha256 = typeof eng.sha256 === "string" && /^[0-9a-f]{64}$/.test(eng.sha256) ? eng.sha256 : null
96
+ if (eng.sha256 !== undefined && eng.sha256 !== null && sha256 === null) {
97
+ return { ok: false, reason: "engine.sha256 must be null or 64 hex chars", warns }
98
+ }
99
+ const cwd = typeof eng.cwd === "string" && eng.cwd.startsWith("/") ? eng.cwd : null
100
+
101
+ // --- restart (clamp) ---
102
+ const rs = (eng.restart ?? {}) as Record<string, unknown>
103
+ const restart = {
104
+ maxBackoffMs: clampInt(rs.maxBackoffMs, 1_000, 300_000, 30_000, "restart.maxBackoffMs", warns),
105
+ windowMs: clampInt(rs.windowMs, 10_000, 3_600_000, 300_000, "restart.windowMs", warns),
106
+ resetAfterMs: clampInt(rs.resetAfterMs, 10_000, 3_600_000, 300_000, "restart.resetAfterMs", warns),
107
+ }
108
+
109
+ // --- budgets (clamp) ---
110
+ const b = (o.budgets ?? {}) as Record<string, unknown>
111
+ const budgets: ResolvedBudgets = {
112
+ handshakeTimeoutMs: clampInt(b.handshakeTimeoutMs, 100, 60_000, 5_000, "handshakeTimeoutMs", warns),
113
+ checkTimeoutMs: clampInt(b.checkTimeoutMs, 1, 30_000, 5_000, "checkTimeoutMs", warns),
114
+ killGraceMs: clampInt(b.killGraceMs, 50, 2_000, 250, "killGraceMs", warns),
115
+ maxFrameBytes: clampInt(b.maxFrameBytes, 4_096, 1_048_576, 65_536, "maxFrameBytes", warns),
116
+ maxInFlight: clampInt(b.maxInFlight, 1, 64, 8, "maxInFlight", warns),
117
+ maxRequestCommands: clampInt(b.maxRequestCommands, 1, 128, 32, "maxRequestCommands", warns),
118
+ maxViolations: clampInt(b.maxViolations, 1, 10, 3, "maxViolations", warns),
119
+ maxViolationsWindowMs: clampInt(b.maxViolationsWindowMs, 5_000, 3_600_000, 60_000, "maxViolationsWindowMs", warns),
120
+ }
121
+
122
+ // --- onEngineError (fatal on "allow") ---
123
+ const onEngineError = o.onEngineError
124
+ if (onEngineError === "allow") {
125
+ return { ok: false, reason: 'onEngineError must be "ask" or "deny" — "allow" is never valid (fail-closed)', warns }
126
+ }
127
+ const degrade = onEngineError === "deny" ? "deny" : "ask"
128
+
129
+ // --- actions ---
130
+ let actions: string[] = []
131
+ let actionsAll = false
132
+ const rawActions = o.actions
133
+ if (Array.isArray(rawActions) && rawActions.length > 0) {
134
+ for (const a of rawActions) {
135
+ if (a === "*") actionsAll = true
136
+ else if (typeof a === "string" && KNOWN_ACTIONS.has(a)) actions.push(a)
137
+ else warns.push(`actions: unknown action "${String(a)}" dropped`)
138
+ }
139
+ }
140
+ if (!actionsAll && actions.length === 0) actions = ["shell"]
141
+
142
+ // --- cache ---
143
+ const c = (o.cache ?? {}) as Record<string, unknown>
144
+ const cache = {
145
+ enabled: c.enabled !== false,
146
+ maxEntries: clampInt(c.maxEntries, 16, 100_000, 1_024, "cache.maxEntries", warns),
147
+ defaultTtlMs: clampInt(c.defaultTtlMs, 0, 3_600_000, 0, "cache.defaultTtlMs", warns),
148
+ }
149
+
150
+ const logLevel = o.logLevel === "debug" || o.logLevel === "info" || o.logLevel === "error" ? o.logLevel : "warn"
151
+
152
+ for (const w of warns) warn(`[opencode-shell-warden-hook] ${w}`)
153
+
154
+ return {
155
+ ok: true,
156
+ warns,
157
+ config: {
158
+ engine: { command, protocolVersion, sha256, cwd, restart },
159
+ budgets,
160
+ onEngineError: degrade,
161
+ actions: actionsAll ? ["*"] : actions,
162
+ actionsAll,
163
+ cache,
164
+ observeShell: o.observeShell === true,
165
+ logLevel,
166
+ },
167
+ }
168
+ }
package/src/index.ts ADDED
@@ -0,0 +1,143 @@
1
+ // opencode-shell-warden-hook — OpenCode V2 plugin entry.
2
+ // Mechanism per ADR-0013: the native substrate is the fail-closed boundary
3
+ // (permissions: shell → ask). The shim listens for published permission
4
+ // requests, consults the shell-warden engine, and replies programmatically:
5
+ // allow → "once", deny → "reject" (with reason), ask → silence (human decides).
6
+ // Fail-closed: with the hook absent every ask reaches the human / headless
7
+ // auto-reject — nothing executes silently.
8
+
9
+ import { Plugin } from "@opencode/plugin"
10
+ import { replyDecision, buildCheckRequest, cacheKey, invocationIdOf, type AskData, InvocationRegistry } from "./apply.ts"
11
+ import { VerdictCache } from "./cache.ts"
12
+ import { validateOptions } from "./config.ts"
13
+ import { EngineSupervisor, type SupervisorOptions } from "./supervisor.ts"
14
+
15
+ const VERSION = "0.1.0"
16
+ const PLUGIN_ID = "opencode-shell-warden-hook"
17
+
18
+ export default Plugin.define({
19
+ id: PLUGIN_ID,
20
+ async setup(ctx) {
21
+ const opts = validateOptions(ctx.options, (m) => console.error(m))
22
+ if (!opts.ok) {
23
+ console.error(`[${PLUGIN_ID}] Disabled: ${opts.reason}`)
24
+ return
25
+ }
26
+ const cfg = opts.config
27
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const
28
+ type Level = keyof typeof LEVELS
29
+ const threshold = LEVELS[cfg.logLevel]
30
+ const log = (level: Level, msg: string): void => {
31
+ if (LEVELS[level] >= threshold) console.error(msg)
32
+ }
33
+
34
+ const supOpts: SupervisorOptions = {
35
+ command: cfg.engine.command,
36
+ protocolVersion: cfg.engine.protocolVersion,
37
+ sha256: cfg.engine.sha256,
38
+ cwd: cfg.engine.cwd,
39
+ clientVersion: VERSION,
40
+ budgets: {
41
+ handshakeTimeoutMs: cfg.budgets.handshakeTimeoutMs,
42
+ checkTimeoutMs: cfg.budgets.checkTimeoutMs,
43
+ killGraceMs: cfg.budgets.killGraceMs,
44
+ maxFrameBytes: cfg.budgets.maxFrameBytes,
45
+ maxInFlight: cfg.budgets.maxInFlight,
46
+ maxViolations: cfg.budgets.maxViolations,
47
+ maxViolationsWindowMs: cfg.budgets.maxViolationsWindowMs,
48
+ maxBackoffMs: cfg.engine.restart.maxBackoffMs,
49
+ windowMs: cfg.engine.restart.windowMs,
50
+ resetAfterMs: cfg.engine.restart.resetAfterMs,
51
+ },
52
+ log,
53
+ }
54
+ const supervisor = new EngineSupervisor(supOpts)
55
+ const cache = new VerdictCache(cfg.cache.enabled ? cfg.cache.maxEntries : 0)
56
+ const registry = new InvocationRegistry()
57
+
58
+ const myLocation = ctx.location?.directory ?? ""
59
+ const reply = (sessionID: string, requestID: string, decision: "once" | "reject", message?: string) =>
60
+ (ctx as any).permission.reply({
61
+ sessionID,
62
+ requestID,
63
+ decision,
64
+ ...(message !== undefined ? { message } : {}),
65
+ })
66
+
67
+ const controller = new AbortController()
68
+ void (async () => {
69
+ try {
70
+ for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
71
+ if ((ev as any).type !== "permission.asked") continue
72
+ const d = (ev as any).data as AskData | undefined
73
+ if (!d?.id || !d?.sessionID || !d?.action || !Array.isArray(d.resources) || d.resources.length === 0) continue
74
+ // Foreign projects: this shim judges only actions of its own location.
75
+ const loc = (ev as any).location?.directory ?? ""
76
+ if (myLocation && loc !== myLocation) continue
77
+ try {
78
+ if (!(cfg.actionsAll || cfg.actions.includes(d.action))) continue
79
+ const invocationId = invocationIdOf(d.source, d.sessionID, d.action)
80
+ const cwd = "" // v0.1: no OpenCode HTTP calls from the ask handler (deadlock finding). M1: session-event cache
81
+ const key = cacheKey(d.action, d.resources, cwd)
82
+
83
+ if (cfg.cache.enabled) {
84
+ const cached = cache.get(key)
85
+ if (cached !== null) {
86
+ await reply(d.sessionID, d.id, "once", cached.message)
87
+ continue
88
+ }
89
+ }
90
+
91
+ if (!cfg.actionsAll && d.resources.length > cfg.budgets.maxRequestCommands) {
92
+ await reply(d.sessionID, d.id, "reject", "too many commands for one guard evaluation")
93
+ continue
94
+ }
95
+
96
+ const outcome = await supervisor.check(buildCheckRequest({ data: d, invocationId, cwd }))
97
+ if (!outcome.ok) {
98
+ log(cfg.logLevel === "debug" ? "debug" : "warn", `degrade (${outcome.reason}) for ${invocationId}`)
99
+ // fail-closed: onError → either reject (hard-fail mode) or silence
100
+ if (cfg.onEngineError === "deny") {
101
+ await reply(d.sessionID, d.id, "reject", undefined).catch(() => { /* human resolves */ })
102
+ }
103
+ continue
104
+ }
105
+ const { verdict, message } = outcome.result
106
+ if (verdict === "allow") {
107
+ const ttl = outcome.result.commands?.find((c) => c.index === 0)?.cacheTtlMs ?? cfg.cache.defaultTtlMs
108
+ if (cfg.cache.enabled && ttl > 0) {
109
+ cache.put(key, ttl, message)
110
+ registry.remember(invocationId, key)
111
+ }
112
+ await reply(d.sessionID, d.id, "once")
113
+ continue
114
+ }
115
+ if (verdict === "deny") {
116
+ await reply(d.sessionID, d.id, "reject", message)
117
+ continue
118
+ }
119
+ // verdict "ask" → stay silent: the dialog goes to the human (escalation by silence)
120
+ } catch (e) {
121
+ log("warn", `ask handler error: ${String(e).slice(0, 200)} — request left to the human (fail-closed)`)
122
+ }
123
+ }
124
+ } catch (e) {
125
+ log("error", `event subscription error: ${String(e).slice(0, 200)}`)
126
+ }
127
+ })()
128
+
129
+ if (cfg.observeShell) {
130
+ await ctx.shell.hook("create.before", (event) => {
131
+ supervisor.observe({ phase: "create.before", command: event.command, cwd: event.cwd })
132
+ })
133
+ }
134
+ await ctx.tool.hook("execute.after", (event) => {
135
+ const source = (event as { source?: { messageID?: string; id?: string } }).source
136
+ if (source?.messageID === undefined || source.id === undefined) return
137
+ const key = registry.keyFor(`${source.messageID}:${source.id}`)
138
+ if (key !== undefined) registry.remember(`${source.messageID}:${source.id}`, key)
139
+ })
140
+
141
+ return () => { controller.abort(); supervisor.dispose() }
142
+ },
143
+ })
@@ -0,0 +1,87 @@
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
+ }
@@ -0,0 +1,412 @@
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 } }
package/src/types.ts ADDED
@@ -0,0 +1,86 @@
1
+ // Wire protocol types — mirror of docs/policyd-plugin-design/02-protocol.md (v1)
2
+ // and schemas/wire.v1.schema.json. Keep in sync with the schema.
3
+
4
+ export type Verdict = "allow" | "ask" | "deny"
5
+
6
+ export interface InitializeParams {
7
+ protocolVersion: number
8
+ client: { name: string; version: string; opencode?: string }
9
+ }
10
+
11
+ export interface InitializeResult {
12
+ protocolVersion: number
13
+ engine: { name: string; version: string }
14
+ capabilities?: Record<string, unknown>
15
+ config?: { source?: string; stages?: string[]; loadedAt?: string }
16
+ }
17
+
18
+ export interface CheckCommand {
19
+ index: number
20
+ raw: string
21
+ cwd: string
22
+ }
23
+
24
+ export interface CheckParams {
25
+ invocation: {
26
+ id: string
27
+ sessionID: string
28
+ agent?: string
29
+ hook: string
30
+ projectDir?: string
31
+ startedAt?: string
32
+ }
33
+ action: string
34
+ commands: CheckCommand[]
35
+ context?: {
36
+ source?: { type: "tool"; messageID?: string; id?: string }
37
+ metadata?: Record<string, unknown>
38
+ }
39
+ }
40
+
41
+ export interface CheckCommandResult {
42
+ index: number
43
+ verdict: Verdict
44
+ stage?: string | null
45
+ ruleId?: string | null
46
+ reason?: string
47
+ classification?: "static" | "opaque"
48
+ cacheTtlMs?: number
49
+ }
50
+
51
+ export interface CheckResult {
52
+ invocationId: string
53
+ verdict: Verdict
54
+ message?: string
55
+ commands?: CheckCommandResult[]
56
+ engine?: { checkMs?: number }
57
+ }
58
+
59
+ export type WireMessage =
60
+ | { jsonrpc: "2.0"; id: number; method: string; params: unknown }
61
+ | { jsonrpc: "2.0"; id: number; result: unknown }
62
+ | { jsonrpc: "2.0"; id: number | null; error: { code: number; message: string; data?: { fatal?: boolean } } }
63
+ | { jsonrpc: "2.0"; method: string; params?: unknown }
64
+
65
+ // OpenCode V2 permission evaluate hook event (mutable: effect, message)
66
+ export interface PermissionEvaluation {
67
+ readonly sessionID: string
68
+ readonly agent?: string
69
+ readonly action: string
70
+ readonly resources: readonly string[]
71
+ readonly metadata?: Record<string, unknown>
72
+ readonly source?: { type: "tool"; messageID: string; id: string }
73
+ effect: Verdict
74
+ message?: string
75
+ }
76
+
77
+ // shell.create.before hook event (audit-only usage)
78
+ export interface ShellCreateBefore {
79
+ command: string
80
+ cwd: string
81
+ timeout: number
82
+ shell: string
83
+ env: Record<string, string | undefined>
84
+ }
85
+
86
+ export const VERDICT_RANK: Record<Verdict, number> = { allow: 0, ask: 1, deny: 2 }