@agent-guardrails/opencode-shell-warden-hook 0.0.0-stage → 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 +52 -2
- package/package.json +32 -4
- package/src/apply.ts +56 -0
- package/src/cache.ts +46 -0
- package/src/checker.ts +181 -0
- package/src/config.ts +156 -0
- package/src/index.ts +153 -0
- package/src/types.ts +68 -0
package/README.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-shell-warden-hook
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode V2 hook plugin: forwards shell permission decisions to the shell-warden
|
|
4
|
+
checker over the one-shot letter protocol ([ADR-0016](https://github.com/agent-guardrails/docs/blob/main/adr/0016-two-tier-enforcement.md) —
|
|
5
|
+
no server, no long-lived process; the checker is spawned fresh on every check).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @agent-guardrails/opencode-shell-warden-hook
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`opencode.jsonc` (options per `docs/spec/shell-warden/04-shim-config.md` v2.0):
|
|
14
|
+
|
|
15
|
+
```jsonc
|
|
16
|
+
{
|
|
17
|
+
"plugins": [
|
|
18
|
+
{
|
|
19
|
+
"package": "@agent-guardrails/opencode-shell-warden-hook",
|
|
20
|
+
"options": {
|
|
21
|
+
"checker": {
|
|
22
|
+
"command": ["/opt/homebrew/bin/shell-warden", "--config", "/Users/me/.config/shell-warden/config.jsonc"]
|
|
23
|
+
},
|
|
24
|
+
"budgets": { "checkTimeoutMs": 2000, "killGraceMs": 250 },
|
|
25
|
+
"onCheckerError": "ask",
|
|
26
|
+
"actions": ["shell"]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
OpenCode server ── permission.asked ── shim ── spawn shell-warden (letter in → verdict out)
|
|
37
|
+
◀── allow | ask | deny ── (one JSON verdict, then EOF)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- `allow` → the request is confirmed (`once`); `deny` → rejected with the checker's
|
|
41
|
+
prescriptive message; `ask` → the request is left untouched for the native prompt.
|
|
42
|
+
- The checker is a pure function of (letter, config): one spawn per check, empty
|
|
43
|
+
child environment, no cache, no network, no state (02-letter-verdict §2.1).
|
|
44
|
+
- The shim never loosens a decision and never synthesizes `allow`: checker defects
|
|
45
|
+
(exit ≠ 0, timeout, malformed output, schema drift) degrade to `onCheckerError`
|
|
46
|
+
(`ask` or `deny`).
|
|
47
|
+
- Hook-owned state: allow-only cache (`cache.ttlMs`) and the reject budget per rule
|
|
48
|
+
per session (`rejectBudget`) — exhaustion hands the ask to the human.
|
|
49
|
+
|
|
50
|
+
All options, defaults and clamps: `04-shim-config.md` in the spec repo. Unknown keys
|
|
51
|
+
are ignored with a warning; only a broken `checker.command` / `sha256` /
|
|
52
|
+
`onCheckerError: "allow"` disables the plugin at load — the fail-closed posture
|
|
53
|
+
for misconfiguration.
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-guardrails/opencode-shell-warden-hook",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.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,56 @@
|
|
|
1
|
+
// Reply automation per ADR-0013 — the shim resolves published permission
|
|
2
|
+
// requests programmatically (the same right the human exercises):
|
|
3
|
+
// checker allow → reply "once" (command runs)
|
|
4
|
+
// checker deny → reply "reject" (with the prescriptive reason)
|
|
5
|
+
// checker ask → no reply (escalation by silence — the human decides)
|
|
6
|
+
// The shim never mutates permission events and never synthesizes allow.
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto"
|
|
9
|
+
import type { Letter, LetterVerdict } from "./types.ts"
|
|
10
|
+
|
|
11
|
+
export type { AskData, Verdict, LetterCommandVerdict } from "./types.ts"
|
|
12
|
+
import type { AskData } from "./types.ts"
|
|
13
|
+
|
|
14
|
+
export function invocationIdOf(source?: { messageID: string; id: string }, sessionID?: string, action?: string): string {
|
|
15
|
+
return source ? `${source.messageID}:${source.id}` : `anon:${sessionID ?? ""}:${action ?? ""}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function cacheKey(action: string, resources: readonly string[], cwd: string, agent?: string): string {
|
|
19
|
+
return createHash("sha256")
|
|
20
|
+
.update(action)
|
|
21
|
+
.update("\u0000")
|
|
22
|
+
.update((resources ?? []).join("\u0000"))
|
|
23
|
+
.update("\u0000")
|
|
24
|
+
.update(cwd)
|
|
25
|
+
.update("\u0000")
|
|
26
|
+
.update(agent ?? "")
|
|
27
|
+
.digest("hex")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Letter per 02-letter-verdict.md §2.2 — exactly the schema fields, no extras. */
|
|
31
|
+
export function buildLetter(args: {
|
|
32
|
+
data: AskData
|
|
33
|
+
invocationId: string
|
|
34
|
+
projectDir?: string
|
|
35
|
+
agent?: string
|
|
36
|
+
}): Letter {
|
|
37
|
+
const { data, invocationId, projectDir, agent } = args
|
|
38
|
+
return {
|
|
39
|
+
version: 1,
|
|
40
|
+
invocation: {
|
|
41
|
+
id: invocationId,
|
|
42
|
+
sessionID: data.sessionID ?? "",
|
|
43
|
+
...(agent !== undefined ? { agent } : {}),
|
|
44
|
+
...(projectDir !== undefined ? { projectDir } : {}),
|
|
45
|
+
},
|
|
46
|
+
action: data.action ?? "",
|
|
47
|
+
commands: (data.resources ?? []).map((raw, index) => ({ index, raw, cwd: "" })),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Maps a verdict to the reply decision: `"once"` | `"reject"` | `null` (silence). */
|
|
52
|
+
export function replyDecision(v: LetterVerdict["verdict"]): "once" | "reject" | null {
|
|
53
|
+
if (v === "allow") return "once"
|
|
54
|
+
if (v === "deny") return "reject"
|
|
55
|
+
return null
|
|
56
|
+
}
|
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/checker.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// One-shot checker invoker — docs/spec/shell-warden/02-letter-verdict.md (v2.0).
|
|
2
|
+
// Spawn the checker for each check with an EMPTY environment (I1), feed exactly
|
|
3
|
+
// one letter, read exactly one verdict. Any deviation (exit≠0, timeout, non-JSON,
|
|
4
|
+
// shape mismatch, oversize) is a checker defect ⇒ degrade (§2.4 / 09 §9.4):
|
|
5
|
+
// the hook never parses garbage into a verdict.
|
|
6
|
+
|
|
7
|
+
import { spawn } from "node:child_process"
|
|
8
|
+
import { createHash } from "node:crypto"
|
|
9
|
+
import { readFileSync } from "node:fs"
|
|
10
|
+
import type { Letter, LetterVerdict, Verdict } from "./types.ts"
|
|
11
|
+
import { MAX_VERDICT_BYTES, MAX_FACTS_BYTES, MAX_MESSAGE_CHARS } from "./types.ts"
|
|
12
|
+
|
|
13
|
+
export interface CheckerOptions {
|
|
14
|
+
command: string[]
|
|
15
|
+
sha256: string | null
|
|
16
|
+
checkTimeoutMs: number
|
|
17
|
+
killGraceMs: number
|
|
18
|
+
maxConcurrentSpawns: number
|
|
19
|
+
log: (level: "debug" | "info" | "warn" | "error", msg: string) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CheckerOutcome = { ok: true; verdict: LetterVerdict } | { ok: false; reason: string }
|
|
23
|
+
|
|
24
|
+
// Binary pin (I8): sha256 of checker.command[0]; mismatch = fatal for every spawn.
|
|
25
|
+
let pinState: "unverified" | "ok" | "mismatch" | "unreadable" = "unverified"
|
|
26
|
+
|
|
27
|
+
export function verifyPin(o: CheckerOptions): void {
|
|
28
|
+
if (o.sha256 === null) { pinState = "ok"; return }
|
|
29
|
+
try {
|
|
30
|
+
const digest = createHash("sha256").update(readFileSync(o.command[0] ?? "")).digest("hex")
|
|
31
|
+
pinState = digest === o.sha256 ? "ok" : "mismatch"
|
|
32
|
+
} catch {
|
|
33
|
+
pinState = "unreadable"
|
|
34
|
+
}
|
|
35
|
+
if (pinState !== "ok") o.log("warn", `checker sha256 pin (${pinState}) — every check will degrade`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class CheckerInvoker {
|
|
39
|
+
private inflight = 0
|
|
40
|
+
private readonly opts: CheckerOptions
|
|
41
|
+
|
|
42
|
+
constructor(opts: CheckerOptions) {
|
|
43
|
+
this.opts = opts
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async check(letter: Letter): Promise<CheckerOutcome> {
|
|
47
|
+
if (this.inflight >= this.opts.maxConcurrentSpawns) return { ok: false, reason: "spawn budget exceeded (local degrade)" }
|
|
48
|
+
if (pinState === "mismatch" || pinState === "unreadable") return { ok: false, reason: `checker sha256 pin ${pinState}` }
|
|
49
|
+
this.inflight++
|
|
50
|
+
try {
|
|
51
|
+
return await this.runOnce(letter)
|
|
52
|
+
} finally {
|
|
53
|
+
this.inflight--
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private runOnce(letter: Letter): Promise<CheckerOutcome> {
|
|
58
|
+
const { command, checkTimeoutMs, killGraceMs, log } = this.opts
|
|
59
|
+
return new Promise<CheckerOutcome>((resolve) => {
|
|
60
|
+
const bin = command[0] ?? ""
|
|
61
|
+
const child = spawn(bin, command.slice(1), {
|
|
62
|
+
env: {}, // I1: empty environment — no PATH, no variables
|
|
63
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
let out = ""
|
|
67
|
+
const kill = setTimeout(() => {
|
|
68
|
+
child.kill("SIGTERM")
|
|
69
|
+
setTimeout(() => { try { child.kill("SIGKILL") } catch { /* already dead */ } }, killGraceMs)
|
|
70
|
+
}, checkTimeoutMs)
|
|
71
|
+
|
|
72
|
+
child.stdout?.setEncoding("utf8")
|
|
73
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
74
|
+
out += chunk
|
|
75
|
+
if (out.length > MAX_VERDICT_BYTES) {
|
|
76
|
+
try { child.kill("SIGTERM") } catch { /* already dead */ }
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
child.on("error", (err) => {
|
|
81
|
+
clearTimeout(kill)
|
|
82
|
+
resolve({ ok: false, reason: `spawn failure: ${err.message}` })
|
|
83
|
+
})
|
|
84
|
+
child.stdin?.on("error", () => { /* EPIPE on early exit — close handler decides */ })
|
|
85
|
+
child.stdio[2]?.on("data", (chunk: string) => log("debug", `checker: ${String(chunk).trim()}`))
|
|
86
|
+
|
|
87
|
+
child.stdin?.end(JSON.stringify(letter))
|
|
88
|
+
|
|
89
|
+
let settled = false
|
|
90
|
+
const done = (o: CheckerOutcome) => {
|
|
91
|
+
if (settled) return
|
|
92
|
+
settled = true
|
|
93
|
+
clearTimeout(kill)
|
|
94
|
+
resolve(o)
|
|
95
|
+
}
|
|
96
|
+
child.on("close", (code) => {
|
|
97
|
+
if (code !== 0) { done({ ok: false, reason: `checker exit ${code ?? "signal"}` }); return }
|
|
98
|
+
if (out.length === 0) { done({ ok: false, reason: "checker produced no verdict" }); return }
|
|
99
|
+
const parsed = parseVerdict(letter, out)
|
|
100
|
+
done(parsed.ok ? { ok: true, verdict: parsed.value } : { ok: false, reason: parsed.reason ?? "verdict shape" })
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseVerdict(letter: Letter, raw: string): { ok: true; value: LetterVerdict } | { ok: false; reason: string } {
|
|
107
|
+
let value: unknown
|
|
108
|
+
try {
|
|
109
|
+
value = JSON.parse(raw.trim())
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return { ok: false, reason: `unparseable verdict: ${e instanceof Error ? e.message.slice(0, 120) : "not json"}` }
|
|
112
|
+
}
|
|
113
|
+
if (value === null || typeof value !== "object") return { ok: false, reason: "verdict is not an object" }
|
|
114
|
+
const v = value as Record<string, unknown>
|
|
115
|
+
|
|
116
|
+
if (v.invocationId !== letter.invocation.id) return { ok: false, reason: "verdict.invocationId mismatch" }
|
|
117
|
+
const verdict = v.verdict
|
|
118
|
+
if (verdict !== "allow" && verdict !== "ask" && verdict !== "deny") return { ok: false, reason: `verdict "${String(verdict)}" invalid` }
|
|
119
|
+
if (typeof v.message !== "undefined") {
|
|
120
|
+
if (typeof v.message !== "string" || v.message.length > MAX_MESSAGE_CHARS) return { ok: false, reason: "verdict.message overbound" }
|
|
121
|
+
if (verdict === "deny" && v.message.length === 0) return { ok: false, reason: "deny without prescriptive message" }
|
|
122
|
+
} else if (verdict === "deny") {
|
|
123
|
+
return { ok: false, reason: "deny without prescriptive message" }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// commands[]: exactly one entry per letter command, indexes match.
|
|
127
|
+
const cmds = v.commands
|
|
128
|
+
if (!Array.isArray(cmds) || cmds.length !== letter.commands.length) {
|
|
129
|
+
return { ok: false, reason: "verdict.commands must cover every letter command" }
|
|
130
|
+
}
|
|
131
|
+
for (let i = 0; i < letter.commands.length; i++) {
|
|
132
|
+
const c = cmds[i]
|
|
133
|
+
if (c === null || typeof c !== "object") return { ok: false, reason: `verdict.commands[${i}] is not an object` }
|
|
134
|
+
const cv = c as Record<string, unknown>
|
|
135
|
+
if (cv.index !== letter.commands[i]?.index) return { ok: false, reason: `verdict.commands[${i}].index mismatch` }
|
|
136
|
+
if (cv.verdict !== "allow" && cv.verdict !== "ask" && cv.verdict !== "deny") {
|
|
137
|
+
return { ok: false, reason: `commands[${i}].verdict invalid` }
|
|
138
|
+
}
|
|
139
|
+
if (typeof cv.classification !== "undefined" && cv.classification !== "static" && cv.classification !== "opaque") {
|
|
140
|
+
return { ok: false, reason: `commands[${i}].classification invalid` }
|
|
141
|
+
}
|
|
142
|
+
if (typeof cv.match !== "undefined" && cv.match !== "argv" && cv.match !== "raw") {
|
|
143
|
+
return { ok: false, reason: `commands[${i}].match invalid` }
|
|
144
|
+
}
|
|
145
|
+
if (cv.facts !== undefined) {
|
|
146
|
+
const bytes = JSON.stringify(cv.facts).length
|
|
147
|
+
if (bytes > MAX_FACTS_BYTES) return { ok: false, reason: `commands[${i}].facts overbound` }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, value: { invocationId: v.invocationId as string, verdict: verdict as Verdict, ...(typeof v.message === "string" ? { message: v.message } : {}) } }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Reject-budget per ruleId per session (ADR-0010 / 04 §4.2): on exhaustion the hook
|
|
154
|
+
* stops answering — the ask flows to the human; never auto-allowed. */
|
|
155
|
+
export class RejectBudgets {
|
|
156
|
+
private readonly maxRejections: number
|
|
157
|
+
private readonly windowMs: number
|
|
158
|
+
private spend = new Map<string, { count: number; until: number }>()
|
|
159
|
+
|
|
160
|
+
constructor(maxRejections: number, windowMs: number) {
|
|
161
|
+
this.maxRejections = maxRejections
|
|
162
|
+
this.windowMs = windowMs
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Returns false when the rule is exhausted for this session. */
|
|
166
|
+
trySpend(sessionID: string, ruleId: string, now = Date.now()): boolean {
|
|
167
|
+
if (this.maxRejections <= 0) return false
|
|
168
|
+
const key = `${sessionID}\u0000${ruleId}`
|
|
169
|
+
const cur = this.spend.get(key)
|
|
170
|
+
if (cur === undefined || cur.until <= now) {
|
|
171
|
+
this.spend.set(key, { count: 1, until: now + this.windowMs })
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
cur.count++
|
|
175
|
+
if (cur.count > this.maxRejections) {
|
|
176
|
+
cur.until = now + this.windowMs
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
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
|
|
11
|
+
}
|
|
12
|
+
|
|
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)
|
|
25
|
+
actions: string[]
|
|
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 }
|
|
31
|
+
logLevel: "debug" | "info" | "warn" | "error"
|
|
32
|
+
}
|
|
33
|
+
export type Opts = OptionsOpts
|
|
34
|
+
|
|
35
|
+
function isInt(v: unknown): v is number { return typeof v === "number" && Number.isInteger(v) }
|
|
36
|
+
|
|
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
|
+
}
|
|
44
|
+
|
|
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
|
|
51
|
+
}
|
|
52
|
+
|
|
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`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function validateOptions(o: unknown): Resolved {
|
|
66
|
+
const warns: string[] = []
|
|
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
|
|
72
|
+
|
|
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
|
|
78
|
+
if (!Array.isArray(rawCommand) || rawCommand.length === 0 || rawCommand.some((m) => typeof m !== "string")) {
|
|
79
|
+
return { ok: false, reason: "checker.command must be a non-empty string array" }
|
|
80
|
+
}
|
|
81
|
+
const command: string[] = []
|
|
82
|
+
for (const member of rawCommand as string[]) {
|
|
83
|
+
const resolved = substituteEnv(member)
|
|
84
|
+
if (resolved === null) return { ok: false, reason: `checker.command member "${member}" failed {env:…} substitution` }
|
|
85
|
+
command.push(resolved)
|
|
86
|
+
}
|
|
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" }
|
|
91
|
+
}
|
|
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),
|
|
102
|
+
}
|
|
103
|
+
|
|
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`)
|
|
109
|
+
}
|
|
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),
|
|
126
|
+
}
|
|
127
|
+
|
|
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),
|
|
134
|
+
}
|
|
135
|
+
|
|
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 }
|
|
146
|
+
|
|
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")
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
warns,
|
|
154
|
+
config: { enabled, checker: { command, sha256 }, budgets, onCheckerError, actions, actionsAll, cache, rejectBudget, asConfig, debug, logLevel },
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// opencode-shell-warden-hook — OpenCode V2 plugin entry (two-tier, ADR-0016).
|
|
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, 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.
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, appendFileSync } from "node:fs"
|
|
11
|
+
import { Plugin } from "@opencode/plugin"
|
|
12
|
+
import { replyDecision, buildLetter, cacheKey, invocationIdOf, type AskData } from "./apply.ts"
|
|
13
|
+
import { VerdictCache } from "./cache.ts"
|
|
14
|
+
import { validateOptions, type Opts } from "./config.ts"
|
|
15
|
+
import { CheckerInvoker, RejectBudgets, verifyPin, type CheckerOptions, type CheckerOutcome } from "./checker.ts"
|
|
16
|
+
import { MAX_LETTER_BYTES, MAX_COMMAND_BYTES } from "./types.ts"
|
|
17
|
+
|
|
18
|
+
const PLUGIN_ID = "opencode-shell-warden-hook"
|
|
19
|
+
|
|
20
|
+
export default Plugin.define({
|
|
21
|
+
id: PLUGIN_ID,
|
|
22
|
+
async setup(ctx) {
|
|
23
|
+
const opts = validateOptions(ctx.options)
|
|
24
|
+
if (!opts.ok) {
|
|
25
|
+
console.error(`[${PLUGIN_ID}] Disabled: ${opts.reason}`)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
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
|
+
|
|
32
|
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const
|
|
33
|
+
type Level = keyof typeof LEVELS
|
|
34
|
+
const threshold = LEVELS[cfg.logLevel]
|
|
35
|
+
const log = (level: Level, msg: string): void => {
|
|
36
|
+
if (LEVELS[level] >= threshold) console.error(msg)
|
|
37
|
+
}
|
|
38
|
+
|
|
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,
|
|
54
|
+
log,
|
|
55
|
+
}
|
|
56
|
+
verifyPin(checkerOpts)
|
|
57
|
+
const checker = new CheckerInvoker(checkerOpts)
|
|
58
|
+
const cache = new VerdictCache(cfg.cache.enabled ? cfg.cache.maxEntries : 0)
|
|
59
|
+
const rejectBudgets = new RejectBudgets(cfg.rejectBudget.maxRejections, cfg.rejectBudget.windowMs)
|
|
60
|
+
|
|
61
|
+
const myLocation = ctx.location?.directory ?? ""
|
|
62
|
+
const reply = (sessionID: string, requestID: string, decision: "once" | "reject", message?: string) =>
|
|
63
|
+
(ctx as any).permission.reply({
|
|
64
|
+
sessionID,
|
|
65
|
+
requestID,
|
|
66
|
+
decision,
|
|
67
|
+
...(message !== undefined ? { message } : {}),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const controller = new AbortController()
|
|
71
|
+
void (async () => {
|
|
72
|
+
try {
|
|
73
|
+
for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
|
|
74
|
+
if ((ev as any).type !== "permission.asked") continue
|
|
75
|
+
const d = (ev as any).data as AskData | undefined
|
|
76
|
+
if (!d?.id || !d?.sessionID || !d?.action || !Array.isArray(d.resources) || d.resources.length === 0) continue
|
|
77
|
+
// Foreign projects: this shim judges only actions of its own location.
|
|
78
|
+
const loc = (ev as any).location?.directory ?? ""
|
|
79
|
+
if (myLocation && loc !== myLocation) continue
|
|
80
|
+
try {
|
|
81
|
+
if (!(cfg.actionsAll || cfg.actions.includes(d.action))) continue
|
|
82
|
+
const invocationId = invocationIdOf(d.source, d.sessionID, d.action)
|
|
83
|
+
const projectDir = myLocation || undefined
|
|
84
|
+
const key = cacheKey(d.action, d.resources, projectDir ?? "")
|
|
85
|
+
|
|
86
|
+
if (cfg.cache.enabled) {
|
|
87
|
+
const cached = cache.get(key)
|
|
88
|
+
if (cached !== null) {
|
|
89
|
+
await reply(d.sessionID, d.id, "once", cached.message)
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
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(() => {})
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const outcome: CheckerOutcome = await checker.check(letter)
|
|
108
|
+
debugSink?.({ ts: new Date().toISOString(), invocationId, action: d.action, outcomeOk: outcome.ok })
|
|
109
|
+
if (!outcome.ok) {
|
|
110
|
+
log(cfg.logLevel === "debug" ? "debug" : "warn", `degrade (${outcome.reason}) for ${invocationId}`)
|
|
111
|
+
if (cfg.onCheckerError === "deny") {
|
|
112
|
+
await reply(d.sessionID, d.id, "reject", undefined).catch(() => { /* human resolves */ })
|
|
113
|
+
}
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
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)
|
|
121
|
+
await reply(d.sessionID, d.id, "once")
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
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)
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
// verdict "ask" → stay silent: the native dialog goes to the human
|
|
136
|
+
} catch (e) {
|
|
137
|
+
log("warn", `ask handler error: ${String(e).slice(0, 200)} — request left to the human (fail-closed)`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {
|
|
141
|
+
log("error", `event subscription error: ${String(e).slice(0, 200)}`)
|
|
142
|
+
}
|
|
143
|
+
})()
|
|
144
|
+
|
|
145
|
+
if (cfg.asConfig.observeShell) {
|
|
146
|
+
await ctx.shell.hook("create.before", (event) => {
|
|
147
|
+
debugSink?.({ ts: new Date().toISOString(), phase: "create.before", command: event.command, cwd: event.cwd })
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return () => { controller.abort() }
|
|
152
|
+
},
|
|
153
|
+
})
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
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.
|
|
4
|
+
|
|
5
|
+
export type Verdict = "allow" | "ask" | "deny"
|
|
6
|
+
|
|
7
|
+
export interface LetterInvocation {
|
|
8
|
+
id: string // `${messageID}:${callID}` — unique per tool call
|
|
9
|
+
sessionID: string
|
|
10
|
+
agent?: string
|
|
11
|
+
projectDir?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LetterCommand {
|
|
15
|
+
index: number
|
|
16
|
+
raw: string // command text exactly as OpenCode produced it, ≤ 8 KiB
|
|
17
|
+
cwd: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Letter {
|
|
21
|
+
version: 1
|
|
22
|
+
invocation: LetterInvocation
|
|
23
|
+
action: string
|
|
24
|
+
commands: LetterCommand[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LetterCommandVerdict {
|
|
28
|
+
index: number
|
|
29
|
+
verdict: Verdict
|
|
30
|
+
ruleId?: string
|
|
31
|
+
classification?: "static" | "opaque"
|
|
32
|
+
match?: "argv" | "raw"
|
|
33
|
+
facts?: Record<string, unknown>
|
|
34
|
+
}
|
|
35
|
+
|
|
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[]
|
|
41
|
+
}
|
|
42
|
+
|
|
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
|
|
49
|
+
|
|
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 }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// shell.create.before hook event (audit-only usage)
|
|
60
|
+
export interface ShellCreateBefore {
|
|
61
|
+
command: string
|
|
62
|
+
cwd: string
|
|
63
|
+
timeout: number
|
|
64
|
+
shell: string
|
|
65
|
+
env: Record<string, string | undefined>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const VERDICT_RANK: Record<Verdict, number> = { allow: 0, ask: 1, deny: 2 }
|