@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 +75 -2
- package/package.json +32 -4
- package/src/apply.ts +86 -0
- package/src/cache.ts +46 -0
- package/src/config.ts +168 -0
- package/src/index.ts +143 -0
- package/src/protocol.ts +87 -0
- package/src/supervisor.ts +412 -0
- package/src/types.ts +86 -0
package/README.md
CHANGED
|
@@ -1,3 +1,76 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-shell-warden-hook
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
})
|
package/src/protocol.ts
ADDED
|
@@ -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 }
|