@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 +29 -52
- package/package.json +1 -1
- package/src/apply.ts +23 -48
- package/src/checker.ts +181 -0
- package/src/config.ts +121 -133
- package/src/index.ts +76 -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,17 @@ 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
|
-
|
|
81
|
-
|
|
83
|
+
// The ask payload carries no per-command cwd; the permission event's
|
|
84
|
+
// location is the only directory the shim has. Letters with an empty
|
|
85
|
+
// commands[].cwd are rejected by the engine, so without one we degrade —
|
|
86
|
+
// never spawn a letter the engine would refuse.
|
|
87
|
+
const projectDir = loc || myLocation || ""
|
|
88
|
+
if (!projectDir) {
|
|
89
|
+
log("warn", `no project directory for ${invocationId} — degrade (${cfg.onCheckerError})`)
|
|
90
|
+
if (cfg.onCheckerError === "deny") await reply(d.sessionID, d.id, "reject", undefined).catch(() => {})
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
const key = cacheKey(d.action, d.resources, projectDir)
|
|
82
94
|
|
|
83
95
|
if (cfg.cache.enabled) {
|
|
84
96
|
const cached = cache.get(key)
|
|
@@ -88,35 +100,48 @@ export default Plugin.define({
|
|
|
88
100
|
}
|
|
89
101
|
}
|
|
90
102
|
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
// Pre-spawn bounds (02 §2.2): breach ⇒ local degrade, no spawn.
|
|
104
|
+
const letter = buildLetter({ data: d, invocationId, projectDir })
|
|
105
|
+
const letterBytes = Buffer.byteLength(JSON.stringify(letter))
|
|
106
|
+
const oversize =
|
|
107
|
+
letter.commands.length > cfg.budgets.maxRequestCommands ||
|
|
108
|
+
letterBytes > MAX_LETTER_BYTES ||
|
|
109
|
+
letter.commands.some((c) => Buffer.byteLength(c.raw) > MAX_COMMAND_BYTES)
|
|
110
|
+
if (oversize) {
|
|
111
|
+
log("warn", `letter bounds breach for ${invocationId} — degrade (${cfg.onCheckerError})`)
|
|
112
|
+
if (cfg.onCheckerError === "deny") await reply(d.sessionID, d.id, "reject", undefined).catch(() => {})
|
|
93
113
|
continue
|
|
94
114
|
}
|
|
95
115
|
|
|
96
|
-
const outcome = await
|
|
116
|
+
const outcome: CheckerOutcome = await checker.check(letter)
|
|
117
|
+
debugSink?.({ ts: new Date().toISOString(), invocationId, action: d.action, outcomeOk: outcome.ok })
|
|
97
118
|
if (!outcome.ok) {
|
|
98
119
|
log(cfg.logLevel === "debug" ? "debug" : "warn", `degrade (${outcome.reason}) for ${invocationId}`)
|
|
99
|
-
|
|
100
|
-
if (cfg.onEngineError === "deny") {
|
|
120
|
+
if (cfg.onCheckerError === "deny") {
|
|
101
121
|
await reply(d.sessionID, d.id, "reject", undefined).catch(() => { /* human resolves */ })
|
|
102
122
|
}
|
|
103
123
|
continue
|
|
104
124
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
registry.remember(invocationId, key)
|
|
111
|
-
}
|
|
125
|
+
|
|
126
|
+
const result = outcome.verdict
|
|
127
|
+
const decision = replyDecision(result.verdict)
|
|
128
|
+
if (decision === "once") {
|
|
129
|
+
if (cfg.cache.enabled && cfg.cache.ttlMs > 0) cache.put(key, cfg.cache.ttlMs, result.message)
|
|
112
130
|
await reply(d.sessionID, d.id, "once")
|
|
113
131
|
continue
|
|
114
132
|
}
|
|
115
|
-
if (
|
|
116
|
-
|
|
133
|
+
if (decision === "reject") {
|
|
134
|
+
// Reject budget (ADR-0010, hook-owned): exhausted ⇒ stop answering —
|
|
135
|
+
// the native ask reaches the human. Never auto-allowed.
|
|
136
|
+
const ruleId = result.commands?.[0]?.ruleId ?? "unknown"
|
|
137
|
+
if (!rejectBudgets.trySpend(d.sessionID, ruleId)) {
|
|
138
|
+
log("info", `reject budget exhausted for ${ruleId} (${invocationId}) — left to the human`)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
await reply(d.sessionID, d.id, "reject", result.message)
|
|
117
142
|
continue
|
|
118
143
|
}
|
|
119
|
-
// verdict "ask" → stay silent: the dialog goes to the human
|
|
144
|
+
// verdict "ask" → stay silent: the native dialog goes to the human
|
|
120
145
|
} catch (e) {
|
|
121
146
|
log("warn", `ask handler error: ${String(e).slice(0, 200)} — request left to the human (fail-closed)`)
|
|
122
147
|
}
|
|
@@ -126,18 +151,12 @@ export default Plugin.define({
|
|
|
126
151
|
}
|
|
127
152
|
})()
|
|
128
153
|
|
|
129
|
-
if (cfg.observeShell) {
|
|
154
|
+
if (cfg.asConfig.observeShell) {
|
|
130
155
|
await ctx.shell.hook("create.before", (event) => {
|
|
131
|
-
|
|
156
|
+
debugSink?.({ ts: new Date().toISOString(), phase: "create.before", command: event.command, cwd: event.cwd })
|
|
132
157
|
})
|
|
133
158
|
}
|
|
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
159
|
|
|
141
|
-
return () => { controller.abort()
|
|
160
|
+
return () => { controller.abort() }
|
|
142
161
|
},
|
|
143
162
|
})
|
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)
|