@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/config.ts CHANGED
@@ -1,168 +1,156 @@
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
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
14
11
  }
15
12
 
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"
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)
26
25
  actions: string[]
27
- actionsAll: boolean
28
- cache: { enabled: boolean; maxEntries: number; defaultTtlMs: number }
29
- observeShell: boolean
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 }
30
31
  logLevel: "debug" | "info" | "warn" | "error"
31
32
  }
33
+ export type Opts = OptionsOpts
32
34
 
33
- export type ValidateResult =
34
- | { ok: true; config: ResolvedConfig; warns: string[] }
35
- | { ok: false; reason: string; warns: string[] }
35
+ function isInt(v: unknown): v is number { return typeof v === "number" && Number.isInteger(v) }
36
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"])
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
+ }
43
44
 
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
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
52
51
  }
53
52
 
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
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`)
60
63
  }
61
64
 
62
- export function validateOptions(raw: unknown, warn: (m: string) => void = () => {}): ValidateResult {
65
+ export function validateOptions(o: unknown): Resolved {
63
66
  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 }
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
73
72
 
74
- // --- engine (fatal on defects) ---
75
- const eng = (o.engine ?? {}) as Record<string, unknown>
76
- const rawCommand = eng.command
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
77
78
  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
+ return { ok: false, reason: "checker.command must be a non-empty string array" }
79
80
  }
80
81
  const command: string[] = []
81
82
  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
- }
83
+ const resolved = substituteEnv(member)
84
+ if (resolved === null) return { ok: false, reason: `checker.command member "${member}" failed {env:…} substitution` }
86
85
  command.push(resolved)
87
86
  }
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 }
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" }
94
91
  }
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),
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),
107
102
  }
108
103
 
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),
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`)
120
109
  }
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
- }
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),
139
126
  }
140
- if (!actionsAll && actions.length === 0) actions = ["shell"]
141
127
 
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),
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),
148
134
  }
149
135
 
150
- const logLevel = o.logLevel === "debug" || o.logLevel === "info" || o.logLevel === "error" ? o.logLevel : "warn"
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 }
151
146
 
152
- for (const w of warns) warn(`[opencode-shell-warden-hook] ${w}`)
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")
153
150
 
154
151
  return {
155
152
  ok: true,
156
153
  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
- },
154
+ config: { enabled, checker: { command, sha256 }, budgets, onCheckerError, actions, actionsAll, cache, rejectBudget, asConfig, debug, logLevel },
167
155
  }
168
156
  }
package/src/index.ts CHANGED
@@ -1,29 +1,34 @@
1
- // opencode-shell-warden-hook — OpenCode V2 plugin entry.
1
+ // opencode-shell-warden-hook — OpenCode V2 plugin entry (two-tier, ADR-0016).
2
2
  // Mechanism per ADR-0013: the native substrate is the fail-closed boundary
3
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.
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.
8
9
 
10
+ import { mkdirSync, appendFileSync } from "node:fs"
9
11
  import { Plugin } from "@opencode/plugin"
10
- import { replyDecision, buildCheckRequest, cacheKey, invocationIdOf, type AskData, InvocationRegistry } from "./apply.ts"
12
+ import { replyDecision, buildLetter, cacheKey, invocationIdOf, type AskData } from "./apply.ts"
11
13
  import { VerdictCache } from "./cache.ts"
12
- import { validateOptions } from "./config.ts"
13
- import { EngineSupervisor, type SupervisorOptions } from "./supervisor.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"
14
17
 
15
- const VERSION = "0.1.0"
16
18
  const PLUGIN_ID = "opencode-shell-warden-hook"
17
19
 
18
20
  export default Plugin.define({
19
21
  id: PLUGIN_ID,
20
22
  async setup(ctx) {
21
- const opts = validateOptions(ctx.options, (m) => console.error(m))
23
+ const opts = validateOptions(ctx.options)
22
24
  if (!opts.ok) {
23
25
  console.error(`[${PLUGIN_ID}] Disabled: ${opts.reason}`)
24
26
  return
25
27
  }
26
- const cfg = opts.config
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
+
27
32
  const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const
28
33
  type Level = keyof typeof LEVELS
29
34
  const threshold = LEVELS[cfg.logLevel]
@@ -31,29 +36,27 @@ export default Plugin.define({
31
36
  if (LEVELS[level] >= threshold) console.error(msg)
32
37
  }
33
38
 
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
- },
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,
52
54
  log,
53
55
  }
54
- const supervisor = new EngineSupervisor(supOpts)
56
+ verifyPin(checkerOpts)
57
+ const checker = new CheckerInvoker(checkerOpts)
55
58
  const cache = new VerdictCache(cfg.cache.enabled ? cfg.cache.maxEntries : 0)
56
- const registry = new InvocationRegistry()
59
+ const rejectBudgets = new RejectBudgets(cfg.rejectBudget.maxRejections, cfg.rejectBudget.windowMs)
57
60
 
58
61
  const myLocation = ctx.location?.directory ?? ""
59
62
  const reply = (sessionID: string, requestID: string, decision: "once" | "reject", message?: string) =>
@@ -77,8 +80,8 @@ export default Plugin.define({
77
80
  try {
78
81
  if (!(cfg.actionsAll || cfg.actions.includes(d.action))) continue
79
82
  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)
83
+ const projectDir = myLocation || undefined
84
+ const key = cacheKey(d.action, d.resources, projectDir ?? "")
82
85
 
83
86
  if (cfg.cache.enabled) {
84
87
  const cached = cache.get(key)
@@ -88,35 +91,48 @@ export default Plugin.define({
88
91
  }
89
92
  }
90
93
 
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")
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(() => {})
93
104
  continue
94
105
  }
95
106
 
96
- const outcome = await supervisor.check(buildCheckRequest({ data: d, invocationId, cwd }))
107
+ const outcome: CheckerOutcome = await checker.check(letter)
108
+ debugSink?.({ ts: new Date().toISOString(), invocationId, action: d.action, outcomeOk: outcome.ok })
97
109
  if (!outcome.ok) {
98
110
  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") {
111
+ if (cfg.onCheckerError === "deny") {
101
112
  await reply(d.sessionID, d.id, "reject", undefined).catch(() => { /* human resolves */ })
102
113
  }
103
114
  continue
104
115
  }
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
- }
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)
112
121
  await reply(d.sessionID, d.id, "once")
113
122
  continue
114
123
  }
115
- if (verdict === "deny") {
116
- await reply(d.sessionID, d.id, "reject", message)
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)
117
133
  continue
118
134
  }
119
- // verdict "ask" → stay silent: the dialog goes to the human (escalation by silence)
135
+ // verdict "ask" → stay silent: the native dialog goes to the human
120
136
  } catch (e) {
121
137
  log("warn", `ask handler error: ${String(e).slice(0, 200)} — request left to the human (fail-closed)`)
122
138
  }
@@ -126,18 +142,12 @@ export default Plugin.define({
126
142
  }
127
143
  })()
128
144
 
129
- if (cfg.observeShell) {
145
+ if (cfg.asConfig.observeShell) {
130
146
  await ctx.shell.hook("create.before", (event) => {
131
- supervisor.observe({ phase: "create.before", command: event.command, cwd: event.cwd })
147
+ debugSink?.({ ts: new Date().toISOString(), phase: "create.before", command: event.command, cwd: event.cwd })
132
148
  })
133
149
  }
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
150
 
141
- return () => { controller.abort(); supervisor.dispose() }
151
+ return () => { controller.abort() }
142
152
  },
143
153
  })
package/src/types.ts CHANGED
@@ -1,77 +1,59 @@
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.
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.
3
4
 
4
5
  export type Verdict = "allow" | "ask" | "deny"
5
6
 
6
- export interface InitializeParams {
7
- protocolVersion: number
8
- client: { name: string; version: string; opencode?: string }
7
+ export interface LetterInvocation {
8
+ id: string // `${messageID}:${callID}` — unique per tool call
9
+ sessionID: string
10
+ agent?: string
11
+ projectDir?: string
9
12
  }
10
13
 
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 {
14
+ export interface LetterCommand {
19
15
  index: number
20
- raw: string
16
+ raw: string // command text exactly as OpenCode produced it, ≤ 8 KiB
21
17
  cwd: string
22
18
  }
23
19
 
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
- }
20
+ export interface Letter {
21
+ version: 1
22
+ invocation: LetterInvocation
33
23
  action: string
34
- commands: CheckCommand[]
35
- context?: {
36
- source?: { type: "tool"; messageID?: string; id?: string }
37
- metadata?: Record<string, unknown>
38
- }
24
+ commands: LetterCommand[]
39
25
  }
40
26
 
41
- export interface CheckCommandResult {
27
+ export interface LetterCommandVerdict {
42
28
  index: number
43
29
  verdict: Verdict
44
- stage?: string | null
45
- ruleId?: string | null
46
- reason?: string
30
+ ruleId?: string
47
31
  classification?: "static" | "opaque"
48
- cacheTtlMs?: number
32
+ match?: "argv" | "raw"
33
+ facts?: Record<string, unknown>
49
34
  }
50
35
 
51
- export interface CheckResult {
52
- invocationId: string
53
- verdict: Verdict
54
- message?: string
55
- commands?: CheckCommandResult[]
56
- engine?: { checkMs?: number }
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[]
57
41
  }
58
42
 
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 }
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
64
49
 
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
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 }
75
57
  }
76
58
 
77
59
  // shell.create.before hook event (audit-only usage)