@agent-guardrails/opencode-shell-warden-hook 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,53 @@
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
+ OpenCode V2 hook plugin: forwards shell permission decisions to the shell-warden
4
+ checker over the one-shot letter protocol ([ADR-0016](https://github.com/agent-guardrails/docs/blob/main/adr/0016-two-tier-enforcement.md) —
5
+ no server, no long-lived process; the checker is spawned fresh on every check).
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @agent-guardrails/opencode-shell-warden-hook
11
+ ```
12
+
13
+ `opencode.jsonc` (options per `docs/spec/shell-warden/04-shim-config.md` v2.0):
14
+
15
+ ```jsonc
16
+ {
17
+ "plugins": [
18
+ {
19
+ "package": "@agent-guardrails/opencode-shell-warden-hook",
20
+ "options": {
21
+ "checker": {
22
+ "command": ["/opt/homebrew/bin/shell-warden", "--config", "/Users/me/.config/shell-warden/config.jsonc"]
23
+ },
24
+ "budgets": { "checkTimeoutMs": 2000, "killGraceMs": 250 },
25
+ "onCheckerError": "ask",
26
+ "actions": ["shell"]
27
+ }
28
+ }
29
+ ]
30
+ }
31
+ ```
32
+
33
+ ## How it works
34
+
35
+ ```
36
+ OpenCode server ── permission.asked ── shim ── spawn shell-warden (letter in → verdict out)
37
+ ◀── allow | ask | deny ── (one JSON verdict, then EOF)
38
+ ```
39
+
40
+ - `allow` → the request is confirmed (`once`); `deny` → rejected with the checker's
41
+ prescriptive message; `ask` → the request is left untouched for the native prompt.
42
+ - The checker is a pure function of (letter, config): one spawn per check, empty
43
+ child environment, no cache, no network, no state (02-letter-verdict §2.1).
44
+ - The shim never loosens a decision and never synthesizes `allow`: checker defects
45
+ (exit ≠ 0, timeout, malformed output, schema drift) degrade to `onCheckerError`
46
+ (`ask` or `deny`).
47
+ - Hook-owned state: allow-only cache (`cache.ttlMs`) and the reject budget per rule
48
+ per session (`rejectBudget`) — exhaustion hands the ask to the human.
49
+
50
+ All options, defaults and clamps: `04-shim-config.md` in the spec repo. Unknown keys
51
+ are ignored with a warning; only a broken `checker.command` / `sha256` /
52
+ `onCheckerError: "allow"` disables the plugin at load — the fail-closed posture
53
+ for misconfiguration.
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.2.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,56 @@
1
+ // Reply automation per ADR-0013 — the shim resolves published permission
2
+ // requests programmatically (the same right the human exercises):
3
+ // checker allow → reply "once" (command runs)
4
+ // checker deny → reply "reject" (with the prescriptive reason)
5
+ // checker ask → no reply (escalation by silence — the human decides)
6
+ // The shim never mutates permission events and never synthesizes allow.
7
+
8
+ import { createHash } from "node:crypto"
9
+ import type { Letter, LetterVerdict } from "./types.ts"
10
+
11
+ export type { AskData, Verdict, LetterCommandVerdict } from "./types.ts"
12
+ import type { AskData } from "./types.ts"
13
+
14
+ export function invocationIdOf(source?: { messageID: string; id: string }, sessionID?: string, action?: string): string {
15
+ return source ? `${source.messageID}:${source.id}` : `anon:${sessionID ?? ""}:${action ?? ""}`
16
+ }
17
+
18
+ export function cacheKey(action: string, resources: readonly string[], cwd: string, agent?: string): string {
19
+ return createHash("sha256")
20
+ .update(action)
21
+ .update("\u0000")
22
+ .update((resources ?? []).join("\u0000"))
23
+ .update("\u0000")
24
+ .update(cwd)
25
+ .update("\u0000")
26
+ .update(agent ?? "")
27
+ .digest("hex")
28
+ }
29
+
30
+ /** Letter per 02-letter-verdict.md §2.2 — exactly the schema fields, no extras. */
31
+ export function buildLetter(args: {
32
+ data: AskData
33
+ invocationId: string
34
+ projectDir?: string
35
+ agent?: string
36
+ }): Letter {
37
+ const { data, invocationId, projectDir, agent } = args
38
+ return {
39
+ version: 1,
40
+ invocation: {
41
+ id: invocationId,
42
+ sessionID: data.sessionID ?? "",
43
+ ...(agent !== undefined ? { agent } : {}),
44
+ ...(projectDir !== undefined ? { projectDir } : {}),
45
+ },
46
+ action: data.action ?? "",
47
+ commands: (data.resources ?? []).map((raw, index) => ({ index, raw, cwd: "" })),
48
+ }
49
+ }
50
+
51
+ /** Maps a verdict to the reply decision: `"once"` | `"reject"` | `null` (silence). */
52
+ export function replyDecision(v: LetterVerdict["verdict"]): "once" | "reject" | null {
53
+ if (v === "allow") return "once"
54
+ if (v === "deny") return "reject"
55
+ return null
56
+ }
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/checker.ts ADDED
@@ -0,0 +1,181 @@
1
+ // One-shot checker invoker — docs/spec/shell-warden/02-letter-verdict.md (v2.0).
2
+ // Spawn the checker for each check with an EMPTY environment (I1), feed exactly
3
+ // one letter, read exactly one verdict. Any deviation (exit≠0, timeout, non-JSON,
4
+ // shape mismatch, oversize) is a checker defect ⇒ degrade (§2.4 / 09 §9.4):
5
+ // the hook never parses garbage into a verdict.
6
+
7
+ import { spawn } from "node:child_process"
8
+ import { createHash } from "node:crypto"
9
+ import { readFileSync } from "node:fs"
10
+ import type { Letter, LetterVerdict, Verdict } from "./types.ts"
11
+ import { MAX_VERDICT_BYTES, MAX_FACTS_BYTES, MAX_MESSAGE_CHARS } from "./types.ts"
12
+
13
+ export interface CheckerOptions {
14
+ command: string[]
15
+ sha256: string | null
16
+ checkTimeoutMs: number
17
+ killGraceMs: number
18
+ maxConcurrentSpawns: number
19
+ log: (level: "debug" | "info" | "warn" | "error", msg: string) => void
20
+ }
21
+
22
+ export type CheckerOutcome = { ok: true; verdict: LetterVerdict } | { ok: false; reason: string }
23
+
24
+ // Binary pin (I8): sha256 of checker.command[0]; mismatch = fatal for every spawn.
25
+ let pinState: "unverified" | "ok" | "mismatch" | "unreadable" = "unverified"
26
+
27
+ export function verifyPin(o: CheckerOptions): void {
28
+ if (o.sha256 === null) { pinState = "ok"; return }
29
+ try {
30
+ const digest = createHash("sha256").update(readFileSync(o.command[0] ?? "")).digest("hex")
31
+ pinState = digest === o.sha256 ? "ok" : "mismatch"
32
+ } catch {
33
+ pinState = "unreadable"
34
+ }
35
+ if (pinState !== "ok") o.log("warn", `checker sha256 pin (${pinState}) — every check will degrade`)
36
+ }
37
+
38
+ export class CheckerInvoker {
39
+ private inflight = 0
40
+ private readonly opts: CheckerOptions
41
+
42
+ constructor(opts: CheckerOptions) {
43
+ this.opts = opts
44
+ }
45
+
46
+ async check(letter: Letter): Promise<CheckerOutcome> {
47
+ if (this.inflight >= this.opts.maxConcurrentSpawns) return { ok: false, reason: "spawn budget exceeded (local degrade)" }
48
+ if (pinState === "mismatch" || pinState === "unreadable") return { ok: false, reason: `checker sha256 pin ${pinState}` }
49
+ this.inflight++
50
+ try {
51
+ return await this.runOnce(letter)
52
+ } finally {
53
+ this.inflight--
54
+ }
55
+ }
56
+
57
+ private runOnce(letter: Letter): Promise<CheckerOutcome> {
58
+ const { command, checkTimeoutMs, killGraceMs, log } = this.opts
59
+ return new Promise<CheckerOutcome>((resolve) => {
60
+ const bin = command[0] ?? ""
61
+ const child = spawn(bin, command.slice(1), {
62
+ env: {}, // I1: empty environment — no PATH, no variables
63
+ stdio: ["pipe", "pipe", "pipe"],
64
+ })
65
+
66
+ let out = ""
67
+ const kill = setTimeout(() => {
68
+ child.kill("SIGTERM")
69
+ setTimeout(() => { try { child.kill("SIGKILL") } catch { /* already dead */ } }, killGraceMs)
70
+ }, checkTimeoutMs)
71
+
72
+ child.stdout?.setEncoding("utf8")
73
+ child.stdout?.on("data", (chunk: string) => {
74
+ out += chunk
75
+ if (out.length > MAX_VERDICT_BYTES) {
76
+ try { child.kill("SIGTERM") } catch { /* already dead */ }
77
+ }
78
+ })
79
+
80
+ child.on("error", (err) => {
81
+ clearTimeout(kill)
82
+ resolve({ ok: false, reason: `spawn failure: ${err.message}` })
83
+ })
84
+ child.stdin?.on("error", () => { /* EPIPE on early exit — close handler decides */ })
85
+ child.stdio[2]?.on("data", (chunk: string) => log("debug", `checker: ${String(chunk).trim()}`))
86
+
87
+ child.stdin?.end(JSON.stringify(letter))
88
+
89
+ let settled = false
90
+ const done = (o: CheckerOutcome) => {
91
+ if (settled) return
92
+ settled = true
93
+ clearTimeout(kill)
94
+ resolve(o)
95
+ }
96
+ child.on("close", (code) => {
97
+ if (code !== 0) { done({ ok: false, reason: `checker exit ${code ?? "signal"}` }); return }
98
+ if (out.length === 0) { done({ ok: false, reason: "checker produced no verdict" }); return }
99
+ const parsed = parseVerdict(letter, out)
100
+ done(parsed.ok ? { ok: true, verdict: parsed.value } : { ok: false, reason: parsed.reason ?? "verdict shape" })
101
+ })
102
+ })
103
+ }
104
+ }
105
+
106
+ function parseVerdict(letter: Letter, raw: string): { ok: true; value: LetterVerdict } | { ok: false; reason: string } {
107
+ let value: unknown
108
+ try {
109
+ value = JSON.parse(raw.trim())
110
+ } catch (e) {
111
+ return { ok: false, reason: `unparseable verdict: ${e instanceof Error ? e.message.slice(0, 120) : "not json"}` }
112
+ }
113
+ if (value === null || typeof value !== "object") return { ok: false, reason: "verdict is not an object" }
114
+ const v = value as Record<string, unknown>
115
+
116
+ if (v.invocationId !== letter.invocation.id) return { ok: false, reason: "verdict.invocationId mismatch" }
117
+ const verdict = v.verdict
118
+ if (verdict !== "allow" && verdict !== "ask" && verdict !== "deny") return { ok: false, reason: `verdict "${String(verdict)}" invalid` }
119
+ if (typeof v.message !== "undefined") {
120
+ if (typeof v.message !== "string" || v.message.length > MAX_MESSAGE_CHARS) return { ok: false, reason: "verdict.message overbound" }
121
+ if (verdict === "deny" && v.message.length === 0) return { ok: false, reason: "deny without prescriptive message" }
122
+ } else if (verdict === "deny") {
123
+ return { ok: false, reason: "deny without prescriptive message" }
124
+ }
125
+
126
+ // commands[]: exactly one entry per letter command, indexes match.
127
+ const cmds = v.commands
128
+ if (!Array.isArray(cmds) || cmds.length !== letter.commands.length) {
129
+ return { ok: false, reason: "verdict.commands must cover every letter command" }
130
+ }
131
+ for (let i = 0; i < letter.commands.length; i++) {
132
+ const c = cmds[i]
133
+ if (c === null || typeof c !== "object") return { ok: false, reason: `verdict.commands[${i}] is not an object` }
134
+ const cv = c as Record<string, unknown>
135
+ if (cv.index !== letter.commands[i]?.index) return { ok: false, reason: `verdict.commands[${i}].index mismatch` }
136
+ if (cv.verdict !== "allow" && cv.verdict !== "ask" && cv.verdict !== "deny") {
137
+ return { ok: false, reason: `commands[${i}].verdict invalid` }
138
+ }
139
+ if (typeof cv.classification !== "undefined" && cv.classification !== "static" && cv.classification !== "opaque") {
140
+ return { ok: false, reason: `commands[${i}].classification invalid` }
141
+ }
142
+ if (typeof cv.match !== "undefined" && cv.match !== "argv" && cv.match !== "raw") {
143
+ return { ok: false, reason: `commands[${i}].match invalid` }
144
+ }
145
+ if (cv.facts !== undefined) {
146
+ const bytes = JSON.stringify(cv.facts).length
147
+ if (bytes > MAX_FACTS_BYTES) return { ok: false, reason: `commands[${i}].facts overbound` }
148
+ }
149
+ }
150
+ return { ok: true, value: { invocationId: v.invocationId as string, verdict: verdict as Verdict, ...(typeof v.message === "string" ? { message: v.message } : {}) } }
151
+ }
152
+
153
+ /** Reject-budget per ruleId per session (ADR-0010 / 04 §4.2): on exhaustion the hook
154
+ * stops answering — the ask flows to the human; never auto-allowed. */
155
+ export class RejectBudgets {
156
+ private readonly maxRejections: number
157
+ private readonly windowMs: number
158
+ private spend = new Map<string, { count: number; until: number }>()
159
+
160
+ constructor(maxRejections: number, windowMs: number) {
161
+ this.maxRejections = maxRejections
162
+ this.windowMs = windowMs
163
+ }
164
+
165
+ /** Returns false when the rule is exhausted for this session. */
166
+ trySpend(sessionID: string, ruleId: string, now = Date.now()): boolean {
167
+ if (this.maxRejections <= 0) return false
168
+ const key = `${sessionID}\u0000${ruleId}`
169
+ const cur = this.spend.get(key)
170
+ if (cur === undefined || cur.until <= now) {
171
+ this.spend.set(key, { count: 1, until: now + this.windowMs })
172
+ return true
173
+ }
174
+ cur.count++
175
+ if (cur.count > this.maxRejections) {
176
+ cur.until = now + this.windowMs
177
+ return false
178
+ }
179
+ return true
180
+ }
181
+ }
package/src/config.ts ADDED
@@ -0,0 +1,156 @@
1
+ // Hook options — docs/spec/shell-warden/04-shim-config.md (v2.0, one-shot per-check).
2
+ // Unknown keys ⇒ warn + ignore; clamps warn + apply; ONLY checker.command / sha256 /
3
+ // onCheckerError:"allow" are fatal (plugin disables itself — the honest fail-closed
4
+ // posture for load-time misconfiguration).
5
+
6
+ type Resolved = { ok: true; config: Opts; warns: string[] } | { ok: false; reason: string }
7
+
8
+ export interface CheckerOpts {
9
+ command: string[]
10
+ sha256: string | null
11
+ }
12
+
13
+ export interface BudgetsOpts {
14
+ checkTimeoutMs: number // 100..30_000, def 2_000 — verdict deadline (SIGTERM → killGraceMs → SIGKILL)
15
+ killGraceMs: number // 50..2_000, def 250 — SIGTERM → SIGKILL
16
+ maxRequestCommands: number // 1..128, def 32 — breach ⇒ local degrade, no spawn (02 §2.2)
17
+ maxConcurrentSpawns: number // 1..64, def 8 — excess ⇒ local degrade, never queued
18
+ }
19
+
20
+ export interface OptionsOpts {
21
+ enabled: boolean
22
+ checker: CheckerOpts
23
+ budgets: BudgetsOpts
24
+ onCheckerError: "ask" | "deny" // degrade verdict; "allow" is not a valid value (I3)
25
+ actions: string[]
26
+ actionsAll: boolean // actions includes "*"
27
+ cache: { enabled: boolean; maxEntries: number; ttlMs: number }
28
+ rejectBudget: { maxRejections: number; windowMs: number }
29
+ asConfig: { observeShell: boolean }
30
+ debug: { enabled: boolean; dir: string | null }
31
+ logLevel: "debug" | "info" | "warn" | "error"
32
+ }
33
+ export type Opts = OptionsOpts
34
+
35
+ function isInt(v: unknown): v is number { return typeof v === "number" && Number.isInteger(v) }
36
+
37
+ function clampInt(v: unknown, min: number, max: number, def: number, name: string, warns: string[]): number {
38
+ let x = isInt(v) ? v : def
39
+ if (!isInt(v) && v !== undefined) warns.push(`omit/wrong type for ${name}, using ${def}`)
40
+ if (x < min) { warns.push(`${name} ${x} < ${min}, clamped to ${min}`); x = min }
41
+ if (x > max) { warns.push(`${name} ${x} > ${max}, clamped to ${max}`); x = max }
42
+ return x
43
+ }
44
+
45
+ /** "{env:NAME}" member substitution resolved at setup; unresolved is fatal (I2). */
46
+ function substituteEnv(member: string): string | null {
47
+ const m = /^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(member)
48
+ if (!m) return member
49
+ const value = process.env[m[1] ?? ""]
50
+ return value === undefined || value === "" ? null : value
51
+ }
52
+
53
+ const KNOWN_KEYS = new Set(["enabled", "checker", "budgets", "onCheckerError", "actions", "cache", "rejectBudget", "asConfig", "debug", "logLevel"])
54
+ const CHECKER_KEYS = new Set(["command", "sha256"])
55
+ const BUDGET_KEYS = new Set(["checkTimeoutMs", "killGraceMs", "maxRequestCommands", "maxConcurrentSpawns"])
56
+ const CACHE_KEYS = new Set(["enabled", "maxEntries", "ttlMs"])
57
+ const REJECT_KEYS = new Set(["maxRejections", "windowMs"])
58
+ const ASKEYS = new Set(["observeShell"])
59
+ const DEBUG_KEYS = new Set(["enabled", "dir"])
60
+
61
+ function warnUnknown(scope: string, keys: object, known: Set<string>, warns: string[]): void {
62
+ for (const k of Object.keys(keys ?? {})) if (!known.has(k)) warns.push(`${scope}: unknown key "${k}" ignored`)
63
+ }
64
+
65
+ export function validateOptions(o: unknown): Resolved {
66
+ const warns: string[] = []
67
+ if (o === null || typeof o !== "object" || Array.isArray(o)) o = {}
68
+ const rec = o as Record<string, unknown>
69
+ warnUnknown("", rec, KNOWN_KEYS, warns)
70
+
71
+ const enabled = rec.enabled === undefined ? true : rec.enabled === true
72
+
73
+ // ── checker (fatal on defects) ──
74
+ const rawChecker = (rec.checker ?? {}) as Record<string, unknown>
75
+ if (rec.checker !== undefined && (typeof rec.checker !== "object")) return { ok: false, reason: "checker must be an object" }
76
+ warnUnknown("checker", rawChecker, CHECKER_KEYS, warns)
77
+ const rawCommand = rawChecker.command
78
+ if (!Array.isArray(rawCommand) || rawCommand.length === 0 || rawCommand.some((m) => typeof m !== "string")) {
79
+ return { ok: false, reason: "checker.command must be a non-empty string array" }
80
+ }
81
+ const command: string[] = []
82
+ for (const member of rawCommand as string[]) {
83
+ const resolved = substituteEnv(member)
84
+ if (resolved === null) return { ok: false, reason: `checker.command member "${member}" failed {env:…} substitution` }
85
+ command.push(resolved)
86
+ }
87
+ if (!command[0]?.startsWith("/")) return { ok: false, reason: `checker.command[0] must be an absolute path, got "${command[0]}"` }
88
+ const sha256raw = rawChecker.sha256
89
+ if (sha256raw !== undefined && sha256raw !== null && !/^[0-9a-f]{64}$/.test(String(sha256raw))) {
90
+ return { ok: false, reason: "checker.sha256 must be null or 64 hex chars" }
91
+ }
92
+ const sha256 = sha256raw === undefined ? null : (sha256raw as string | null)
93
+
94
+ // ── budgets ──
95
+ const rawBudgets = (rec.budgets ?? {}) as Record<string, unknown>
96
+ warnUnknown("budgets", rawBudgets, BUDGET_KEYS, warns)
97
+ const budgets = {
98
+ checkTimeoutMs: clampInt(rawBudgets.checkTimeoutMs, 100, 30_000, 2_000, "checkTimeoutMs", warns),
99
+ killGraceMs: clampInt(rawBudgets.killGraceMs, 50, 2_000, 250, "killGraceMs", warns),
100
+ maxRequestCommands: clampInt(rawBudgets.maxRequestCommands, 1, 128, 32, "maxRequestCommands", warns),
101
+ maxConcurrentSpawns: clampInt(rawBudgets.maxConcurrentSpawns, 1, 64, 8, "maxConcurrentSpawns", warns),
102
+ }
103
+
104
+ // ── onCheckerError ──
105
+ const onError = (rec.onCheckerError ?? "ask") as string
106
+ if (onError === "allow") return { ok: false, reason: "onCheckerError must be 'ask' or 'deny' — never 'allow' (I3)" }
107
+ if (onError !== "ask" && onError !== "deny") {
108
+ warns.push(`onCheckerError "${onError}" is not ask|deny, using ask`)
109
+ }
110
+ const onCheckerError: "ask" | "deny" = onError === "deny" ? "deny" : "ask"
111
+
112
+ // ── actions ──
113
+ const actionsAll: boolean = rec.actions === "*" || (Array.isArray(rec.actions) && rec.actions.includes("*"))
114
+ const actions = Array.isArray(rec.actions)
115
+ ? (rec.actions as unknown[]).filter((a): a is string => typeof a === "string" && a !== "*")
116
+ : ["shell"]
117
+ if (!Array.isArray(rec.actions) && rec.actions !== undefined && rec.actions !== "*") warns.push("actions must be an array, using [shell]")
118
+
119
+ // ── cache ──
120
+ const rawCache = (rec.cache ?? {}) as Record<string, unknown>
121
+ warnUnknown("cache", rawCache, CACHE_KEYS, warns)
122
+ const cache = {
123
+ enabled: rawCache.enabled === undefined ? true : rawCache.enabled === true,
124
+ maxEntries: clampInt(rawCache.maxEntries, 16, 100_000, 1_024, "cache.maxEntries", warns),
125
+ ttlMs: clampInt(rawCache.ttlMs, 0, 3_600_000, 0, "cache.ttlMs", warns),
126
+ }
127
+
128
+ // ── rejectBudget ──
129
+ const rawReject = (rec.rejectBudget ?? {}) as Record<string, unknown>
130
+ warnUnknown("rejectBudget", rawReject, REJECT_KEYS, warns)
131
+ const rejectBudget = {
132
+ maxRejections: clampInt(rawReject.maxRejections, 1, 10, 3, "rejectBudget.maxRejections", warns),
133
+ windowMs: clampInt(rawReject.windowMs, 60_000, 86_400_000, 1_800_000, "rejectBudget.windowMs", warns),
134
+ }
135
+
136
+ // ── asConfig / debug / logLevel ──
137
+ const rawAs = (rec.asConfig ?? {}) as Record<string, unknown>
138
+ warnUnknown("asConfig", rawAs, ASKEYS, warns)
139
+ const asConfig = { observeShell: rawAs.observeShell === true }
140
+
141
+ const rawDebug = (rec.debug ?? {}) as Record<string, unknown>
142
+ warnUnknown("debug", rawDebug, DEBUG_KEYS, warns)
143
+ let debugDir: string | null = typeof rawDebug.dir === "string" ? rawDebug.dir : null
144
+ if (debugDir !== null && !debugDir.startsWith("/")) { warns.push("debug.dir must be absolute, ignored"); debugDir = null }
145
+ const debug = { enabled: rawDebug.enabled === true, dir: debugDir }
146
+
147
+ const LEVELS = ["debug", "info", "warn", "error"] as const
148
+ const logLevel = LEVELS.includes(rec.logLevel as (typeof LEVELS)[number]) ? (rec.logLevel as OptionsOpts["logLevel"]) : "warn"
149
+ if (rec.logLevel !== undefined && !LEVELS.includes(rec.logLevel as (typeof LEVELS)[number])) warns.push("logLevel invalid, using warn")
150
+
151
+ return {
152
+ ok: true,
153
+ warns,
154
+ config: { enabled, checker: { command, sha256 }, budgets, onCheckerError, actions, actionsAll, cache, rejectBudget, asConfig, debug, logLevel },
155
+ }
156
+ }
package/src/index.ts ADDED
@@ -0,0 +1,153 @@
1
+ // opencode-shell-warden-hook — OpenCode V2 plugin entry (two-tier, ADR-0016).
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, spawns the one-shot shell-warden checker per request
5
+ // (letter in on stdin → verdict out on stdout, 02-letter-verdict v2.0) and
6
+ // replies programmatically:
7
+ // allow → "once", deny → "reject" (with reason), ask → silence (human decides).
8
+ // Checker defects degrade to onCheckerError ∈ {ask, deny} — never allow.
9
+
10
+ import { mkdirSync, appendFileSync } from "node:fs"
11
+ import { Plugin } from "@opencode/plugin"
12
+ import { replyDecision, buildLetter, cacheKey, invocationIdOf, type AskData } from "./apply.ts"
13
+ import { VerdictCache } from "./cache.ts"
14
+ import { validateOptions, type Opts } from "./config.ts"
15
+ import { CheckerInvoker, RejectBudgets, verifyPin, type CheckerOptions, type CheckerOutcome } from "./checker.ts"
16
+ import { MAX_LETTER_BYTES, MAX_COMMAND_BYTES } from "./types.ts"
17
+
18
+ const PLUGIN_ID = "opencode-shell-warden-hook"
19
+
20
+ export default Plugin.define({
21
+ id: PLUGIN_ID,
22
+ async setup(ctx) {
23
+ const opts = validateOptions(ctx.options)
24
+ if (!opts.ok) {
25
+ console.error(`[${PLUGIN_ID}] Disabled: ${opts.reason}`)
26
+ return
27
+ }
28
+ const cfg: Opts = opts.config
29
+ for (const w of opts.warns) console.error(`[${PLUGIN_ID}] warn: ${w}`)
30
+ if (cfg.enabled === false) return
31
+
32
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const
33
+ type Level = keyof typeof LEVELS
34
+ const threshold = LEVELS[cfg.logLevel]
35
+ const log = (level: Level, msg: string): void => {
36
+ if (LEVELS[level] >= threshold) console.error(msg)
37
+ }
38
+
39
+ // Debug NDJSON per check (03 §3.5); default dir per 04 §4.2.
40
+ let debugSink: ((record: Record<string, unknown>) => void) | null = null
41
+ if (cfg.debug.enabled) {
42
+ const dir = cfg.debug.dir ?? `${process.env.HOME ?? ""}/.local/state/shell-warden/hook`
43
+ try { mkdirSync(dir, { recursive: true }) } catch { /* debug is best-effort */ }
44
+ const file = `${dir}/hook.ndjson`
45
+ debugSink = (record) => { try { appendFileSync(file, JSON.stringify(record) + "\n") } catch { /* debug is best-effort */ } }
46
+ }
47
+
48
+ const checkerOpts: CheckerOptions = {
49
+ command: cfg.checker.command,
50
+ sha256: cfg.checker.sha256,
51
+ checkTimeoutMs: cfg.budgets.checkTimeoutMs,
52
+ killGraceMs: cfg.budgets.killGraceMs,
53
+ maxConcurrentSpawns: cfg.budgets.maxConcurrentSpawns,
54
+ log,
55
+ }
56
+ verifyPin(checkerOpts)
57
+ const checker = new CheckerInvoker(checkerOpts)
58
+ const cache = new VerdictCache(cfg.cache.enabled ? cfg.cache.maxEntries : 0)
59
+ const rejectBudgets = new RejectBudgets(cfg.rejectBudget.maxRejections, cfg.rejectBudget.windowMs)
60
+
61
+ const myLocation = ctx.location?.directory ?? ""
62
+ const reply = (sessionID: string, requestID: string, decision: "once" | "reject", message?: string) =>
63
+ (ctx as any).permission.reply({
64
+ sessionID,
65
+ requestID,
66
+ decision,
67
+ ...(message !== undefined ? { message } : {}),
68
+ })
69
+
70
+ const controller = new AbortController()
71
+ void (async () => {
72
+ try {
73
+ for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
74
+ if ((ev as any).type !== "permission.asked") continue
75
+ const d = (ev as any).data as AskData | undefined
76
+ if (!d?.id || !d?.sessionID || !d?.action || !Array.isArray(d.resources) || d.resources.length === 0) continue
77
+ // Foreign projects: this shim judges only actions of its own location.
78
+ const loc = (ev as any).location?.directory ?? ""
79
+ if (myLocation && loc !== myLocation) continue
80
+ try {
81
+ if (!(cfg.actionsAll || cfg.actions.includes(d.action))) continue
82
+ const invocationId = invocationIdOf(d.source, d.sessionID, d.action)
83
+ const projectDir = myLocation || undefined
84
+ const key = cacheKey(d.action, d.resources, projectDir ?? "")
85
+
86
+ if (cfg.cache.enabled) {
87
+ const cached = cache.get(key)
88
+ if (cached !== null) {
89
+ await reply(d.sessionID, d.id, "once", cached.message)
90
+ continue
91
+ }
92
+ }
93
+
94
+ // Pre-spawn bounds (02 §2.2): breach ⇒ local degrade, no spawn.
95
+ const letter = buildLetter({ data: d, invocationId, projectDir })
96
+ const letterBytes = Buffer.byteLength(JSON.stringify(letter))
97
+ const oversize =
98
+ letter.commands.length > cfg.budgets.maxRequestCommands ||
99
+ letterBytes > MAX_LETTER_BYTES ||
100
+ letter.commands.some((c) => Buffer.byteLength(c.raw) > MAX_COMMAND_BYTES)
101
+ if (oversize) {
102
+ log("warn", `letter bounds breach for ${invocationId} — degrade (${cfg.onCheckerError})`)
103
+ if (cfg.onCheckerError === "deny") await reply(d.sessionID, d.id, "reject", undefined).catch(() => {})
104
+ continue
105
+ }
106
+
107
+ const outcome: CheckerOutcome = await checker.check(letter)
108
+ debugSink?.({ ts: new Date().toISOString(), invocationId, action: d.action, outcomeOk: outcome.ok })
109
+ if (!outcome.ok) {
110
+ log(cfg.logLevel === "debug" ? "debug" : "warn", `degrade (${outcome.reason}) for ${invocationId}`)
111
+ if (cfg.onCheckerError === "deny") {
112
+ await reply(d.sessionID, d.id, "reject", undefined).catch(() => { /* human resolves */ })
113
+ }
114
+ continue
115
+ }
116
+
117
+ const result = outcome.verdict
118
+ const decision = replyDecision(result.verdict)
119
+ if (decision === "once") {
120
+ if (cfg.cache.enabled && cfg.cache.ttlMs > 0) cache.put(key, cfg.cache.ttlMs, result.message)
121
+ await reply(d.sessionID, d.id, "once")
122
+ continue
123
+ }
124
+ if (decision === "reject") {
125
+ // Reject budget (ADR-0010, hook-owned): exhausted ⇒ stop answering —
126
+ // the native ask reaches the human. Never auto-allowed.
127
+ const ruleId = result.commands?.[0]?.ruleId ?? "unknown"
128
+ if (!rejectBudgets.trySpend(d.sessionID, ruleId)) {
129
+ log("info", `reject budget exhausted for ${ruleId} (${invocationId}) — left to the human`)
130
+ continue
131
+ }
132
+ await reply(d.sessionID, d.id, "reject", result.message)
133
+ continue
134
+ }
135
+ // verdict "ask" → stay silent: the native dialog goes to the human
136
+ } catch (e) {
137
+ log("warn", `ask handler error: ${String(e).slice(0, 200)} — request left to the human (fail-closed)`)
138
+ }
139
+ }
140
+ } catch (e) {
141
+ log("error", `event subscription error: ${String(e).slice(0, 200)}`)
142
+ }
143
+ })()
144
+
145
+ if (cfg.asConfig.observeShell) {
146
+ await ctx.shell.hook("create.before", (event) => {
147
+ debugSink?.({ ts: new Date().toISOString(), phase: "create.before", command: event.command, cwd: event.cwd })
148
+ })
149
+ }
150
+
151
+ return () => { controller.abort() }
152
+ },
153
+ })
package/src/types.ts ADDED
@@ -0,0 +1,68 @@
1
+ // Letter & verdict types — mirror of docs/spec/shell-warden/02-letter-verdict.md (v2.0)
2
+ // and schemas/shell-warden.letter.v1.schema.json / shell-warden.verdict.v1.schema.json.
3
+ // Keep in sync with the schemas.
4
+
5
+ export type Verdict = "allow" | "ask" | "deny"
6
+
7
+ export interface LetterInvocation {
8
+ id: string // `${messageID}:${callID}` — unique per tool call
9
+ sessionID: string
10
+ agent?: string
11
+ projectDir?: string
12
+ }
13
+
14
+ export interface LetterCommand {
15
+ index: number
16
+ raw: string // command text exactly as OpenCode produced it, ≤ 8 KiB
17
+ cwd: string
18
+ }
19
+
20
+ export interface Letter {
21
+ version: 1
22
+ invocation: LetterInvocation
23
+ action: string
24
+ commands: LetterCommand[]
25
+ }
26
+
27
+ export interface LetterCommandVerdict {
28
+ index: number
29
+ verdict: Verdict
30
+ ruleId?: string
31
+ classification?: "static" | "opaque"
32
+ match?: "argv" | "raw"
33
+ facts?: Record<string, unknown>
34
+ }
35
+
36
+ export interface LetterVerdict {
37
+ invocationId: string // echo of letter.invocation.id
38
+ verdict: Verdict // aggregate — authoritative, hook applies tighten-only
39
+ message?: string // required on deny; ≤ 512 chars
40
+ commands?: LetterCommandVerdict[]
41
+ }
42
+
43
+ // Bounds from 02-letter-verdict.md §2.2/§2.3 (hook enforces before spawning)
44
+ export const MAX_LETTER_BYTES = 64 * 1024
45
+ export const MAX_COMMAND_BYTES = 8 * 1024
46
+ export const MAX_VERDICT_BYTES = 64 * 1024
47
+ export const MAX_FACTS_BYTES = 16 * 1024
48
+ export const MAX_MESSAGE_CHARS = 512
49
+
50
+ // OpenCode V2 permission.asked event payload (relevant subset)
51
+ export interface AskData {
52
+ id: string // requestID, assigned at publication
53
+ sessionID: string
54
+ action: string
55
+ resources: readonly string[]
56
+ source?: { type: "tool"; messageID: string; id: string }
57
+ }
58
+
59
+ // shell.create.before hook event (audit-only usage)
60
+ export interface ShellCreateBefore {
61
+ command: string
62
+ cwd: string
63
+ timeout: number
64
+ shell: string
65
+ env: Record<string, string | undefined>
66
+ }
67
+
68
+ export const VERDICT_RANK: Record<Verdict, number> = { allow: 0, ask: 1, deny: 2 }