@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/README.md +29 -52
- package/package.json +1 -1
- package/src/apply.ts +16 -46
- package/src/checker.ts +181 -0
- package/src/config.ts +121 -133
- package/src/index.ts +67 -57
- package/src/types.ts +36 -54
- package/src/protocol.ts +0 -87
- package/src/supervisor.ts +0 -412
package/src/config.ts
CHANGED
|
@@ -1,168 +1,156 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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;
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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(
|
|
65
|
+
export function validateOptions(o: unknown): Resolved {
|
|
63
66
|
const warns: string[] = []
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
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
|
-
//
|
|
75
|
-
const
|
|
76
|
-
|
|
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:
|
|
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 =
|
|
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]
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
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 =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
//
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
//
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
56
|
+
verifyPin(checkerOpts)
|
|
57
|
+
const checker = new CheckerInvoker(checkerOpts)
|
|
55
58
|
const cache = new VerdictCache(cfg.cache.enabled ? cfg.cache.maxEntries : 0)
|
|
56
|
-
const
|
|
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
|
|
81
|
-
const key = cacheKey(d.action, d.resources,
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 (
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
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()
|
|
151
|
+
return () => { controller.abort() }
|
|
142
152
|
},
|
|
143
153
|
})
|
package/src/types.ts
CHANGED
|
@@ -1,77 +1,59 @@
|
|
|
1
|
-
//
|
|
2
|
-
// and schemas/
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
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:
|
|
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
|
|
27
|
+
export interface LetterCommandVerdict {
|
|
42
28
|
index: number
|
|
43
29
|
verdict: Verdict
|
|
44
|
-
|
|
45
|
-
ruleId?: string | null
|
|
46
|
-
reason?: string
|
|
30
|
+
ruleId?: string
|
|
47
31
|
classification?: "static" | "opaque"
|
|
48
|
-
|
|
32
|
+
match?: "argv" | "raw"
|
|
33
|
+
facts?: Record<string, unknown>
|
|
49
34
|
}
|
|
50
35
|
|
|
51
|
-
export interface
|
|
52
|
-
invocationId: string
|
|
53
|
-
verdict: Verdict
|
|
54
|
-
message?: string
|
|
55
|
-
commands?:
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
66
|
-
export interface
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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)
|