@agent-guardrails/opencode-shell-warden-hook 0.1.0 → 0.2.1

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,42 +1,28 @@
1
1
  # opencode-shell-warden-hook
2
2
 
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.
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).
23
6
 
24
7
  ## Install
25
8
 
26
9
  ```sh
27
- npm install opencode-shell-warden-hook # or reference a local checkout
10
+ npm install @agent-guardrails/opencode-shell-warden-hook
28
11
  ```
29
12
 
30
- `opencode.jsonc`:
13
+ `opencode.jsonc` (options per `docs/spec/shell-warden/04-shim-config.md` v2.0):
31
14
 
32
15
  ```jsonc
33
16
  {
34
17
  "plugins": [
35
18
  {
36
- "package": "opencode-shell-warden-hook",
19
+ "package": "@agent-guardrails/opencode-shell-warden-hook",
37
20
  "options": {
38
- "engine": { "command": ["/usr/local/bin/shell-warden", "serve"] },
39
- "onEngineError": "ask",
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",
40
26
  "actions": ["shell"]
41
27
  }
42
28
  }
@@ -44,33 +30,24 @@ npm install opencode-shell-warden-hook # or reference a local checkout
44
30
  }
45
31
  ```
46
32
 
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
33
+ ## How it works
52
34
 
53
- ```sh
54
- npm install
55
- npm test # node --test (35 contract/unit tests, mock engine included)
56
- npm run typecheck
35
+ ```
36
+ OpenCode server ── permission.asked ── shim ── spawn shell-warden (letter in → verdict out)
37
+ ◀── allow | ask | deny ── (one JSON verdict, then EOF)
57
38
  ```
58
39
 
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.
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,6 @@
1
1
  {
2
2
  "name": "@agent-guardrails/opencode-shell-warden-hook",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "OpenCode V2 hook plugin: forwards shell permission decisions to the shell-warden engine over NDJSON JSON-RPC stdio",
5
5
  "type": "module",
6
6
  "repository": {
package/src/apply.ts CHANGED
@@ -1,23 +1,15 @@
1
1
  // Reply automation per ADR-0013 — the shim resolves published permission
2
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).
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.
8
7
 
9
8
  import { createHash } from "node:crypto"
10
- import type { CheckParams, CheckResult, Verdict } from "./types.ts"
9
+ import type { Letter, LetterVerdict } from "./types.ts"
11
10
 
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
- }
11
+ export type { AskData, Verdict, LetterCommandVerdict } from "./types.ts"
12
+ import type { AskData } from "./types.ts"
21
13
 
22
14
  export function invocationIdOf(source?: { messageID: string; id: string }, sessionID?: string, action?: string): string {
23
15
  return source ? `${source.messageID}:${source.id}` : `anon:${sessionID ?? ""}:${action ?? ""}`
@@ -35,52 +27,35 @@ export function cacheKey(action: string, resources: readonly string[], cwd: stri
35
27
  .digest("hex")
36
28
  }
37
29
 
38
- export function buildCheckRequest(args: {
30
+ /** Letter per 02-letter-verdict.md §2.2 — exactly the schema fields, no extras.
31
+ * `projectDir` is required and non-empty: the engine invalidates every letter whose
32
+ * commands[].cwd is empty ("letter commands[0].cwd must be 1..4096 chars"), and the
33
+ * ask payload carries no per-command cwd — the permission request's directory is the
34
+ * only cwd the shim has. Callers without a directory must degrade before calling. */
35
+ export function buildLetter(args: {
39
36
  data: AskData
40
37
  invocationId: string
41
- cwd: string
42
- projectDir?: string
43
- }): CheckParams {
44
- const { data, invocationId, cwd, projectDir } = args
38
+ projectDir: string
39
+ agent?: string
40
+ }): Letter {
41
+ const { data, invocationId, projectDir, agent } = args
42
+ if (!projectDir) throw new Error("buildLetter: projectDir is required — the engine rejects letters with an empty commands[].cwd")
45
43
  return {
44
+ version: 1,
46
45
  invocation: {
47
46
  id: invocationId,
48
47
  sessionID: data.sessionID ?? "",
49
- hook: "permission.asked",
50
- ...(projectDir !== undefined ? { projectDir } : {}),
51
- startedAt: new Date().toISOString(),
48
+ ...(agent !== undefined ? { agent } : {}),
49
+ projectDir,
52
50
  },
53
51
  action: data.action ?? "",
54
- commands: (data.resources ?? []).map((raw, index) => ({ index, raw, cwd })),
55
- context: {
56
- ...(data.source !== undefined ? { source: data.source } : {}),
57
- },
52
+ commands: (data.resources ?? []).map((raw, index) => ({ index, raw, cwd: projectDir })),
58
53
  }
59
54
  }
60
55
 
61
56
  /** Maps a verdict to the reply decision: `"once"` | `"reject"` | `null` (silence). */
62
- export function replyDecision(v: Verdict): "once" | "reject" | null {
57
+ export function replyDecision(v: LetterVerdict["verdict"]): "once" | "reject" | null {
63
58
  if (v === "allow") return "once"
64
59
  if (v === "deny") return "reject"
65
60
  return null
66
61
  }
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/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
+ }