@raishin/vanguard-frontier-agentic 3.15.0 → 3.16.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.
Files changed (100) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/.github/plugin/marketplace.json +1 -1
  5. package/catalog/AGENTS.md +2 -1
  6. package/catalog/asset-integrity.json +476 -29
  7. package/catalog/index.json +3 -2
  8. package/catalog/mods.json +131 -0
  9. package/mods/README.md +220 -0
  10. package/mods/audit-trail/README.md +70 -0
  11. package/mods/audit-trail/bin/hook.mjs +65 -0
  12. package/mods/audit-trail/claude-code/UNSUPPORTED.md +11 -0
  13. package/mods/audit-trail/claude-code/settings.hooks.json +14 -0
  14. package/mods/audit-trail/codex/hooks.json +14 -0
  15. package/mods/audit-trail/kiro/UNSUPPORTED.md +7 -0
  16. package/mods/audit-trail/kiro/audit-trail-post-tool-use-hook.json +14 -0
  17. package/mods/audit-trail/lib/common.mjs +213 -0
  18. package/mods/audit-trail/lib/core.mjs +50 -0
  19. package/mods/audit-trail/lib/runtime.mjs +140 -0
  20. package/mods/audit-trail/mod.toml +37 -0
  21. package/mods/blast-radius-guard/.claude-plugin/plugin.json +5 -0
  22. package/mods/blast-radius-guard/README.md +53 -0
  23. package/mods/blast-radius-guard/bin/hook.mjs +18 -0
  24. package/mods/blast-radius-guard/claude-code/settings.hooks.json +14 -0
  25. package/mods/blast-radius-guard/codex/UNSUPPORTED.md +7 -0
  26. package/mods/blast-radius-guard/codex/hooks.json +14 -0
  27. package/mods/blast-radius-guard/hooks/hooks.json +5 -0
  28. package/mods/blast-radius-guard/hooks/register.test.ts +97 -0
  29. package/mods/blast-radius-guard/hooks/register.ts +13 -0
  30. package/mods/blast-radius-guard/kiro/UNSUPPORTED.md +7 -0
  31. package/mods/blast-radius-guard/kiro/blast-radius-guard-pre-tool-use-hook.json +14 -0
  32. package/mods/blast-radius-guard/lib/common.mjs +213 -0
  33. package/mods/blast-radius-guard/lib/core.mjs +66 -0
  34. package/mods/blast-radius-guard/lib/patterns.mjs +102 -0
  35. package/mods/blast-radius-guard/lib/runtime.mjs +140 -0
  36. package/mods/blast-radius-guard/mod.toml +34 -0
  37. package/mods/blast-radius-guard/patterns.json +101 -0
  38. package/mods/context-burn/.claude-plugin/plugin.json +5 -0
  39. package/mods/context-burn/README.md +66 -0
  40. package/mods/context-burn/bin/hook.mjs +38 -0
  41. package/mods/context-burn/claude-code/UNSUPPORTED.md +7 -0
  42. package/mods/context-burn/claude-code/settings.hooks.json +24 -0
  43. package/mods/context-burn/codex/UNSUPPORTED.md +7 -0
  44. package/mods/context-burn/codex/hooks.json +24 -0
  45. package/mods/context-burn/hooks/hooks.json +5 -0
  46. package/mods/context-burn/hooks/register.test.ts +151 -0
  47. package/mods/context-burn/hooks/register.ts +35 -0
  48. package/mods/context-burn/kiro/UNSUPPORTED.md +7 -0
  49. package/mods/context-burn/kiro/context-burn-post-tool-use-hook.json +14 -0
  50. package/mods/context-burn/kiro/context-burn-session-start-hook.json +14 -0
  51. package/mods/context-burn/lib/common.mjs +213 -0
  52. package/mods/context-burn/lib/core.mjs +79 -0
  53. package/mods/context-burn/lib/runtime.mjs +140 -0
  54. package/mods/context-burn/mod.toml +37 -0
  55. package/mods/live-guard-gate/.claude-plugin/plugin.json +5 -0
  56. package/mods/live-guard-gate/README.md +43 -0
  57. package/mods/live-guard-gate/bin/hook.mjs +12 -0
  58. package/mods/live-guard-gate/claude-code/settings.hooks.json +14 -0
  59. package/mods/live-guard-gate/codex/UNSUPPORTED.md +7 -0
  60. package/mods/live-guard-gate/codex/hooks.json +14 -0
  61. package/mods/live-guard-gate/hooks/hooks.json +5 -0
  62. package/mods/live-guard-gate/hooks/register.test.ts +59 -0
  63. package/mods/live-guard-gate/hooks/register.ts +11 -0
  64. package/mods/live-guard-gate/kiro/UNSUPPORTED.md +7 -0
  65. package/mods/live-guard-gate/kiro/live-guard-gate-pre-tool-use-hook.json +14 -0
  66. package/mods/live-guard-gate/lib/common.mjs +213 -0
  67. package/mods/live-guard-gate/lib/core.mjs +24 -0
  68. package/mods/live-guard-gate/lib/live-agents.mjs +65 -0
  69. package/mods/live-guard-gate/lib/patterns.mjs +523 -0
  70. package/mods/live-guard-gate/lib/runtime.mjs +140 -0
  71. package/mods/live-guard-gate/mod.toml +34 -0
  72. package/mods/live-guard-gate/patterns.json +522 -0
  73. package/mods/secret-redactor/.claude-plugin/plugin.json +5 -0
  74. package/mods/secret-redactor/README.md +86 -0
  75. package/mods/secret-redactor/bin/hook.mjs +35 -0
  76. package/mods/secret-redactor/claude-code/UNSUPPORTED.md +7 -0
  77. package/mods/secret-redactor/claude-code/settings.hooks.json +24 -0
  78. package/mods/secret-redactor/codex/UNSUPPORTED.md +11 -0
  79. package/mods/secret-redactor/codex/hooks.json +14 -0
  80. package/mods/secret-redactor/hooks/hooks.json +5 -0
  81. package/mods/secret-redactor/hooks/register.test.ts +150 -0
  82. package/mods/secret-redactor/hooks/register.ts +19 -0
  83. package/mods/secret-redactor/kiro/UNSUPPORTED.md +11 -0
  84. package/mods/secret-redactor/kiro/secret-redactor-pre-tool-use-hook.json +14 -0
  85. package/mods/secret-redactor/lib/common.mjs +213 -0
  86. package/mods/secret-redactor/lib/core.mjs +61 -0
  87. package/mods/secret-redactor/lib/patterns.mjs +75 -0
  88. package/mods/secret-redactor/lib/runtime.mjs +140 -0
  89. package/mods/secret-redactor/mod.toml +43 -0
  90. package/mods/secret-redactor/patterns.json +74 -0
  91. package/package.json +8 -4
  92. package/plugins/vanguard-frontier-agentic/.codex-plugin/plugin.json +1 -1
  93. package/scripts/generate-mods.mjs +784 -0
  94. package/scripts/mods/common.mjs +213 -0
  95. package/scripts/mods/runtime.mjs +140 -0
  96. package/tests/fixtures/README.md +1 -1
  97. package/tests/fuzz-properties.test.mjs +254 -0
  98. package/tests/mods.test.mjs +768 -0
  99. package/tests/validate-asset-integrity.py +1 -0
  100. package/tests/validate-mods.py +821 -0
@@ -0,0 +1,213 @@
1
+ // Shared helpers for the mods under mods/. scripts/generate-mods.mjs copies this file
2
+ // byte-for-byte to mods/<id>/lib/common.mjs, so edit it here and run `npm run mods:write`.
3
+ //
4
+ // Pure on purpose: no imports, no environment, no I/O. Claude Code's native mod runtime
5
+ // imports it and has no Node, and every harness must reach the same verdict on the same
6
+ // input. Regexes compile without the `u` flag so Node and Python `re.ASCII`
7
+ // (tests/validate-mods.py) read them the same way.
8
+
9
+ export const LIMITS = Object.freeze({
10
+ // A redaction that still changes after this many passes is withheld as a whole.
11
+ maxPasses: 8,
12
+ // A guard candidate longer than this is not evaluated and counts as a hit.
13
+ maxCandidateChars: 65536,
14
+ // Guard candidates are not evaluated past this combined length; the call counts as a hit.
15
+ maxTotalChars: 262144,
16
+ // Only the tail of a leaf's context (key or previous sibling) is prefixed to it.
17
+ maxContextChars: 256,
18
+ })
19
+
20
+ const MARKER_PREFIX = '[REDACTED:'
21
+ export const OVERFLOW_MARKER = '[REDACTED:overflow]'
22
+ const OVERFLOW_HIT = Object.freeze({ id: 'overflow', name: 'redaction did not converge' })
23
+ export const TOO_LARGE_HIT = Object.freeze({ id: 'too-large', name: 'input too large to evaluate' })
24
+
25
+ const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
26
+ const PATTERN_KEYS = new Set(['id', 'name', 'description', 'regex', 'flags', 'context', 'severity', 'agents'])
27
+ const isStringArray = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s !== '')
28
+
29
+ // Parser diagnostics can quote credential-bearing data; never expose it in hook logs.
30
+ export function parseJson(text, source) {
31
+ try {
32
+ return JSON.parse(text)
33
+ } catch {
34
+ throw new Error(`${source}: invalid JSON`)
35
+ }
36
+ }
37
+
38
+ // Validates a patterns.json-shaped list and compiles it. Throws on anything malformed so a
39
+ // bad extension file fails closed in the caller instead of silently matching nothing.
40
+ export function compilePatterns(list, source = 'patterns') {
41
+ if (!Array.isArray(list)) throw new Error(`${source}: expected an array of patterns`)
42
+ const seen = new Set()
43
+ return list.map((p, i) => {
44
+ const where = `${source}[${i}]`
45
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) throw new Error(`${where}: expected an object`)
46
+ for (const k of Object.keys(p)) if (!PATTERN_KEYS.has(k)) throw new Error(`${where}: unknown key ${k}`)
47
+ if (typeof p.id !== 'string' || !ID_RE.test(p.id)) throw new Error(`${where}: id must be kebab-case`)
48
+ if (seen.has(p.id)) throw new Error(`${where}: duplicate id ${p.id}`)
49
+ seen.add(p.id)
50
+ if (typeof p.name !== 'string' || p.name === '') throw new Error(`${where}: name must be a non-empty string`)
51
+ if (typeof p.regex !== 'string' || p.regex === '') throw new Error(`${where}: regex must be a non-empty string`)
52
+ if (p.flags !== '' && p.flags !== 'i') throw new Error(`${where}: flags must be "" or "i"`)
53
+ if (!isStringArray(p.context) || p.context.length === 0) throw new Error(`${where}: context must be a non-empty string array`)
54
+ for (const k of ['description', 'severity']) {
55
+ if (p[k] !== undefined && typeof p[k] !== 'string') throw new Error(`${where}: ${k} must be a string`)
56
+ }
57
+ if (p.agents !== undefined && !isStringArray(p.agents)) throw new Error(`${where}: agents must be a string array`)
58
+ let rx, rxg
59
+ try {
60
+ rx = new RegExp(p.regex, p.flags)
61
+ rxg = new RegExp(p.regex, 'g' + p.flags)
62
+ } catch {
63
+ throw new Error(`${where}: regex does not compile`)
64
+ }
65
+ return Object.freeze({
66
+ id: p.id,
67
+ name: p.name,
68
+ severity: p.severity,
69
+ context: Object.freeze([...p.context]),
70
+ agents: p.agents ? Object.freeze([...p.agents]) : undefined,
71
+ rx,
72
+ rxg,
73
+ marker: `${MARKER_PREFIX}${p.id}]`,
74
+ })
75
+ })
76
+ }
77
+
78
+ // Every string value (not key) in depth-first order. The order follows the input, so the
79
+ // same payload always yields the same list.
80
+ export function stringLeaves(value, out = []) {
81
+ if (typeof value === 'string') out.push(value)
82
+ else if (Array.isArray(value)) for (const v of value) stringLeaves(v, out)
83
+ else if (value !== null && typeof value === 'object') for (const v of Object.values(value)) stringLeaves(v, out)
84
+ return out
85
+ }
86
+
87
+ // Guard candidates: every string leaf, plus every all-string array joined with spaces so
88
+ // an argv-style command (["rm", "-rf", "/"]) reads like the shell line it runs.
89
+ export function candidates(value) {
90
+ const out = stringLeaves(value)
91
+ const joinArrays = (v) => {
92
+ if (Array.isArray(v)) {
93
+ if (v.length > 1 && v.every((s) => typeof s === 'string')) out.push(v.join(' '))
94
+ v.forEach(joinArrays)
95
+ } else if (v !== null && typeof v === 'object') {
96
+ Object.values(v).forEach(joinArrays)
97
+ }
98
+ }
99
+ joinArrays(value)
100
+ return out
101
+ }
102
+
103
+ // The patterns tagged with `context` that match any text, in catalog order. Oversized input
104
+ // is not evaluated (regex cost is unbounded there) and reports TOO_LARGE_HIT instead.
105
+ export function matchPatterns(patterns, texts, context) {
106
+ let total = 0
107
+ for (const t of texts) {
108
+ total += t.length
109
+ if (t.length > LIMITS.maxCandidateChars || total > LIMITS.maxTotalChars) return [TOO_LARGE_HIT]
110
+ }
111
+ return patterns.filter((p) => p.context.includes(context) && texts.some((t) => p.rx.test(t)))
112
+ }
113
+
114
+ // One pattern pass after another until the text stops changing. Each pass can expose a new
115
+ // match (password-in-url can swallow an earlier marker), so a single pass is not enough.
116
+ function fixedPoint(text, patterns, hits) {
117
+ let x = text
118
+ for (let pass = 0; pass <= LIMITS.maxPasses; pass++) {
119
+ let y = x
120
+ for (const p of patterns) {
121
+ y = y.replace(p.rxg, (m) => {
122
+ if (m === '') return m
123
+ hits.add(p)
124
+ return p.marker
125
+ })
126
+ }
127
+ if (y === x) return x
128
+ x = y
129
+ }
130
+ hits.add(OVERFLOW_HIT)
131
+ return OVERFLOW_MARKER
132
+ }
133
+
134
+ // Redacts `s` with `ctx` in front so a secret split across a key and its value, or across
135
+ // argv elements ("Bearer", "<token>"), still matches; then strips the context back off.
136
+ function redactLeaf(s, ctx, patterns, hits) {
137
+ if (ctx === '') return fixedPoint(s, patterns, hits)
138
+ const pre = ctx.slice(-LIMITS.maxContextChars) + ' '
139
+ const y = fixedPoint(pre + s, patterns, hits)
140
+ if (y === OVERFLOW_MARKER) return y
141
+ const r = fixedPoint(pre, patterns, new Set())
142
+ if (y.startsWith(r)) return fixedPoint(y.slice(r.length), patterns, hits)
143
+ // A match spans the boundary: keep from the marker that covers it. Slicing changes
144
+ // anchors and boundaries, so also check the standalone leaf after contextual matching.
145
+ let d = 0
146
+ while (d < r.length && y[d] === r[d]) d++
147
+ const i = y.lastIndexOf(MARKER_PREFIX, d)
148
+ return fixedPoint(i >= 0 ? y.slice(i) : y, patterns, hits)
149
+ }
150
+
151
+ function walk(v, ctx, patterns, hits) {
152
+ if (typeof v === 'string') {
153
+ const local = new Set()
154
+ const out = redactLeaf(v, ctx, patterns, local)
155
+ if (out !== v) for (const h of local) hits.add(h)
156
+ return out
157
+ }
158
+ if (Array.isArray(v)) {
159
+ let prev = null
160
+ return v.map((el) => {
161
+ const out = walk(el, typeof prev === 'string' ? prev : ctx, patterns, hits)
162
+ prev = el
163
+ return out
164
+ })
165
+ }
166
+ if (v !== null && typeof v === 'object') {
167
+ const named = typeof v.name === 'string' ? v.name : typeof v.key === 'string' ? v.key : null
168
+ // fromEntries, not assignment: `out[k] = v` would drop an own "__proto__" key.
169
+ return Object.fromEntries(
170
+ Object.entries(v).map(([k, x]) => [
171
+ walk(k, '', patterns, hits),
172
+ walk(x, k === 'value' && named !== null ? named : k, patterns, hits),
173
+ ]),
174
+ )
175
+ }
176
+ return v
177
+ }
178
+
179
+ // Returns a copy of `value` with every match replaced by `[REDACTED:<id>]`, plus the
180
+ // patterns that fired (catalog order, unique). Keys are redacted too; two keys that redact
181
+ // to the same marker collapse into one, which loses data but never leaks it.
182
+ export function redactValue(value, patterns) {
183
+ const hits = new Set()
184
+ const out = walk(value, '', patterns, hits)
185
+ const order = [...patterns, OVERFLOW_HIT]
186
+ return { value: out, hits: order.filter((p) => hits.has(p)) }
187
+ }
188
+
189
+ // Same shape as `value`, every string replaced by `text`. The fallback when redaction
190
+ // itself failed: withhold everything rather than pass anything through.
191
+ export function maskValue(value, text) {
192
+ if (typeof value === 'string') return text
193
+ if (Array.isArray(value)) return value.map((v) => maskValue(v, text))
194
+ if (value !== null && typeof value === 'object') {
195
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskValue(v, text)]))
196
+ }
197
+ return value
198
+ }
199
+
200
+ // JSON with object keys sorted at every level, so equal values hash equally.
201
+ export function canonicalJson(value) {
202
+ if (Array.isArray(value)) return '[' + value.map((v) => (v === undefined ? 'null' : canonicalJson(v))).join(',') + ']'
203
+ if (value !== null && typeof value === 'object') {
204
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort()
205
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}'
206
+ }
207
+ return JSON.stringify(value) ?? 'null'
208
+ }
209
+
210
+ export async function sha256Hex(text) {
211
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
212
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')
213
+ }
@@ -0,0 +1,79 @@
1
+ // Pure counter and threshold logic for context-burn. The native module and the Node hook
2
+ // both import it, so the status file means the same thing on every harness.
3
+ import { parseJson, sha256Hex } from './common.mjs'
4
+
5
+ const STATUS_DIR = Object.freeze({
6
+ 'claude-code': '.claude/context-burn-status',
7
+ kiro: '.kiro/context-burn-status',
8
+ codex: '.codex/context-burn-status',
9
+ })
10
+
11
+ // Never interpolate a raw session id into a filesystem path.
12
+ export async function defaultStatusPath(harness, sessionId) {
13
+ return `${STATUS_DIR[harness]}/${await sha256Hex(sessionId)}.json`
14
+ }
15
+
16
+ const THRESHOLDS = [
17
+ [70, 'WARNING'],
18
+ [90, 'CRITICAL'],
19
+ ]
20
+
21
+ // A new session starts at zero with no threshold events.
22
+ export function freshStatus(sessionId, at) {
23
+ return { session_id: sessionId, tool_call_count: 0, last_updated: at, threshold_events: [] }
24
+ }
25
+
26
+ // Rejects anything that is not a context-burn status so a stray file is never extended.
27
+ export function parseStatus(text) {
28
+ const s = parseJson(text, 'context-burn status')
29
+ const ok =
30
+ s !== null &&
31
+ typeof s === 'object' &&
32
+ !Array.isArray(s) &&
33
+ typeof s.session_id === 'string' &&
34
+ Number.isInteger(s.tool_call_count) &&
35
+ s.tool_call_count >= 0 &&
36
+ typeof s.last_updated === 'string' &&
37
+ Array.isArray(s.threshold_events) &&
38
+ s.threshold_events.every((t) =>
39
+ t !== null && typeof t === 'object' && !Array.isArray(t) &&
40
+ THRESHOLDS.some(([threshold]) => t.threshold === threshold) &&
41
+ Number.isFinite(t.tokens) && t.tokens >= 0 &&
42
+ Number.isFinite(t.window) && t.window > 0 &&
43
+ typeof t.at === 'string',
44
+ )
45
+ if (!ok) throw new Error('status file is not a context-burn status')
46
+ return s
47
+ }
48
+
49
+ // Counts one tool call and, when token data exists, records each threshold crossed for the
50
+ // first time. Never mutates prev.
51
+ export function recordCall(prev, sessionId, at, context) {
52
+ const base = prev?.session_id === sessionId ? prev : freshStatus(sessionId, at)
53
+ const status = {
54
+ ...base,
55
+ tool_call_count: base.tool_call_count + 1,
56
+ last_updated: at,
57
+ threshold_events: [...base.threshold_events],
58
+ }
59
+ const messages = []
60
+ const window = context?.window
61
+ const tokens =
62
+ context?.tokens ??
63
+ (context?.percent !== undefined && window > 0 ? Math.round((context.percent * window) / 100) : undefined)
64
+ if (tokens !== undefined && window > 0) {
65
+ const pct = context.percent ?? (tokens / window) * 100
66
+ for (const [threshold, label] of THRESHOLDS) {
67
+ if (pct >= threshold && !status.threshold_events.some((t) => t.threshold === threshold)) {
68
+ status.threshold_events.push({ threshold, tokens, window, at })
69
+ messages.push(`[context-burn] ${label}: ${threshold}% context used (${tokens} / ${window} tokens)`)
70
+ }
71
+ }
72
+ }
73
+ return { status, messages }
74
+ }
75
+
76
+ // Pretty JSON with a trailing newline; the whole file is rewritten each event.
77
+ export function serializeStatus(status) {
78
+ return JSON.stringify(status, null, 2) + '\n'
79
+ }
@@ -0,0 +1,140 @@
1
+ // Command-hook runtime for the mods under mods/. scripts/generate-mods.mjs copies this file
2
+ // byte-for-byte to mods/<id>/lib/runtime.mjs, so edit it here and run `npm run mods:write`.
3
+ //
4
+ // A mod's bin/hook.mjs hands its handlers to runHook, which reads the harness payload from
5
+ // stdin and renders the handler's decision in that harness's protocol. It writes no files
6
+ // and reads no environment; Claude Code's native runtime never imports it.
7
+ import { readFileSync } from 'node:fs'
8
+ import { isAbsolute, resolve, sep } from 'node:path'
9
+
10
+ const HARNESSES = new Set(['claude-code', 'kiro', 'codex'])
11
+ const EVENTS = new Set(['PreToolUse', 'PostToolUse', 'SessionStart'])
12
+ const DECISIONS = new Set(['allow', 'deny', 'ask', 'replace', 'context'])
13
+
14
+ const nodeIo = () => ({
15
+ argv: process.argv.slice(2),
16
+ readStdin: () => readFileSync(0, 'utf8'),
17
+ out: (s) => process.stdout.write(s),
18
+ err: (s) => process.stderr.write(s),
19
+ })
20
+
21
+ const message = (err) => (err instanceof Error ? err.message : String(err))
22
+
23
+ // JSON.parse errors quote the text they failed on, and stdin can carry a tool's input or
24
+ // output: report the failure without echoing any of it.
25
+ function parseStdin(text) {
26
+ try {
27
+ return JSON.parse(text)
28
+ } catch {
29
+ throw new Error('stdin is not valid JSON')
30
+ }
31
+ }
32
+
33
+ export function parseArgs(argv) {
34
+ const usage = 'usage: hook.mjs --harness <claude-code|kiro|codex> --event <PreToolUse|PostToolUse|SessionStart>'
35
+ if (argv.length !== 4) throw new Error(usage)
36
+ const args = {}
37
+ for (let i = 0; i < 4; i += 2) {
38
+ const key = { '--harness': 'harness', '--event': 'event' }[argv[i]]
39
+ if (!key || key in args) throw new Error(usage)
40
+ args[key] = argv[i + 1]
41
+ }
42
+ if (!HARNESSES.has(args.harness)) throw new Error(`unknown harness ${args.harness}; ${usage}`)
43
+ if (!EVENTS.has(args.event)) throw new Error(`unknown event ${args.event}; ${usage}`)
44
+ return args
45
+ }
46
+
47
+ // Where an observer writes `path`. A relative path resolves against the project root when
48
+ // the hook runs from its install location <root>/.<harness dir>/vanguard-mods/<id>/bin/,
49
+ // otherwise against the working directory. Never CLAUDE_PROJECT_DIR: mods read only their
50
+ // own VANGUARD_<ID>_* variables.
51
+ export function projectPath(path, harness, binDir) {
52
+ if (isAbsolute(path)) return path
53
+ const parts = resolve(binDir).split(sep)
54
+ const n = parts.length
55
+ const dir = { 'claude-code': '.claude', kiro: '.kiro', codex: '.codex' }[harness]
56
+ const installed = n >= 5 && parts[n - 1] === 'bin' && parts[n - 3] === 'vanguard-mods' && parts[n - 4] === dir
57
+ return resolve(installed ? parts.slice(0, n - 4).join(sep) || sep : process.cwd(), path)
58
+ }
59
+
60
+ // Rejects decisions the harness cannot express, so a handler bug surfaces as an error
61
+ // instead of a silently dropped verdict.
62
+ function normalize(d, { harness, event }) {
63
+ if (d === undefined) return { decision: 'allow', log: [] }
64
+ if (d === null || typeof d !== 'object' || !DECISIONS.has(d.decision)) {
65
+ throw new Error(`handler returned an invalid decision: ${JSON.stringify(d)}`)
66
+ }
67
+ const log = d.log ?? []
68
+ if (!Array.isArray(log) || !log.every((l) => typeof l === 'string')) throw new Error('decision log must be a string array')
69
+ const needs = { deny: 'reason', ask: 'reason', context: 'text' }[d.decision]
70
+ if (needs && (typeof d[needs] !== 'string' || d[needs] === '')) throw new Error(`${d.decision} needs a ${needs}`)
71
+ if ((d.decision === 'deny' || d.decision === 'ask') && event !== 'PreToolUse') {
72
+ throw new Error(`${d.decision} is only valid for PreToolUse, not ${event}`)
73
+ }
74
+ if (d.decision === 'replace' && (harness !== 'claude-code' || event !== 'PostToolUse')) {
75
+ throw new Error(`replace is only valid for claude-code PostToolUse, not ${harness} ${event}`)
76
+ }
77
+ return { ...d, log }
78
+ }
79
+
80
+ function render(d, { harness, event }, io) {
81
+ const json = (hookSpecificOutput) => io.out(JSON.stringify({ hookSpecificOutput: { hookEventName: event, ...hookSpecificOutput } }) + '\n')
82
+ for (const line of d.log) io.err(line + '\n')
83
+ switch (d.decision) {
84
+ case 'allow':
85
+ return 0
86
+ case 'deny':
87
+ case 'ask':
88
+ if (harness === 'claude-code') {
89
+ json({ permissionDecision: d.decision, permissionDecisionReason: d.reason })
90
+ return 0
91
+ }
92
+ // Kiro and Codex have no confirmation step; exit 2 blocks the call in both.
93
+ io.err(d.decision === 'ask' ? `${d.reason} (confirmation unsupported on ${harness}; denied)\n` : `${d.reason}\n`)
94
+ return 2
95
+ case 'replace':
96
+ json({ updatedToolOutput: d.output })
97
+ return 0
98
+ case 'context':
99
+ if (harness === 'kiro') io.err(d.text + '\n')
100
+ else json({ additionalContext: d.text })
101
+ return 0
102
+ }
103
+ }
104
+
105
+ // Runs one hook invocation and returns the exit code; bin/hook.mjs sets process.exitCode
106
+ // with it rather than calling process.exit, so buffered stdout is never cut short.
107
+ //
108
+ // - Bad arguments, or an event this mod has no handler for: exit 2, which blocks a
109
+ // PreToolUse call in every harness (a broken install must not allow silently).
110
+ // - Invalid stdin or a throwing handler: `onError(err, payload, ctx)` supplies the decision
111
+ // (guards deny, the redactor withholds). Without onError: stderr and exit 1, which is
112
+ // loud but does not block.
113
+ // - An onError that throws or returns an invalid decision: exit 2.
114
+ export async function runHook({ name, handlers, onError }, io = nodeIo()) {
115
+ let ctx
116
+ try {
117
+ ctx = parseArgs(io.argv)
118
+ if (!Object.hasOwn(handlers, ctx.event)) throw new Error(`no handler for ${ctx.event}`)
119
+ } catch (err) {
120
+ io.err(`${name}: ${message(err)}\n`)
121
+ return 2
122
+ }
123
+ let payload
124
+ let decision
125
+ try {
126
+ payload = parseStdin(io.readStdin())
127
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('stdin is not a JSON object')
128
+ decision = normalize(await handlers[ctx.event](payload, ctx), ctx)
129
+ } catch (err) {
130
+ io.err(`${name}: ${message(err)}\n`)
131
+ if (typeof onError !== 'function') return 1
132
+ try {
133
+ decision = normalize(await onError(err, payload, ctx), ctx)
134
+ } catch (err2) {
135
+ io.err(`${name}: error handler failed: ${message(err2)}\n`)
136
+ return 2
137
+ }
138
+ }
139
+ return render(decision, ctx, io)
140
+ }
@@ -0,0 +1,37 @@
1
+ [mod]
2
+ id = "context-burn"
3
+ name = "Context Burn"
4
+ description = "Tracks context use per session in a status file and warns at 70% and 90% where the harness reports token use."
5
+ version = "1.0.0"
6
+ reach_level = 1
7
+ events = ["PostToolUse", "SessionStart"]
8
+
9
+ [footprint]
10
+ env_reads = ["VANGUARD_CONTEXT_BURN_STATUS_PATH"]
11
+ file_writes = [".claude/context-burn-status/*.json", ".kiro/context-burn-status/*.json", ".codex/context-burn-status/*.json"]
12
+ spawns = []
13
+ egress_hosts = []
14
+
15
+ [harnesses.claude-code]
16
+ supported = true
17
+ surfaces = ["native", "classic"]
18
+ events = ["PostToolUse", "SessionStart"]
19
+
20
+ [harnesses.claude-code.degraded]
21
+ classic = "Classic hooks receive no token counts, so only the tool-call counter is kept and no threshold warnings are emitted; PostToolUse fires only after a tool succeeds, so failed calls are not counted."
22
+
23
+ [harnesses.kiro]
24
+ supported = true
25
+ requires = ["node"]
26
+ events = ["PostToolUse", "SessionStart"]
27
+
28
+ [harnesses.kiro.degraded]
29
+ PostToolUse = "Kiro hooks receive no token counts; only the tool-call counter is kept and no threshold warnings are emitted."
30
+
31
+ [harnesses.codex]
32
+ supported = true
33
+ requires = ["node"]
34
+ events = ["PostToolUse", "SessionStart"]
35
+
36
+ [harnesses.codex.degraded]
37
+ PostToolUse = "Codex hooks receive no token counts; only the tool-call counter is kept and no threshold warnings are emitted."
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "live-guard-gate",
3
+ "version": "1.0.0",
4
+ "description": "Asks before live cloud mutations and names the guard agent that should review them."
5
+ }
@@ -0,0 +1,43 @@
1
+ # Live Guard Gate
2
+
3
+ Asks before a tool call that would mutate live cloud infrastructure, and names the
4
+ guard agents that should review it first. It covers Terraform and Pulumi, the AWS, Azure,
5
+ Google Cloud and OCI CLIs, `kubectl`, Helm, Argo CD, Velero and Snowflake role changes.
6
+
7
+ ## Reach and declared footprint
8
+
9
+ **L0 (no declared file writes, subprocess spawns or network egress).** All four
10
+ manifest footprint arrays (`env_reads`, `file_writes`, `spawns` and `egress_hosts`)
11
+ are empty. This is capability disclosure, not enforced isolation.
12
+
13
+ ## How agent names are chosen
14
+
15
+ Each pattern in `patterns.json` carries an `agents` list. When several patterns match,
16
+ the reason lists the union of their agents in first-seen order. `lib/live-agents.mjs`
17
+ lists the live guard agents the patterns cover (`covered`) and those with no pattern yet
18
+ (`uncovered`). It is regenerated by `npm run mods:write`.
19
+
20
+ ## Per-harness behavior
21
+
22
+ | Harness | Surface | Behavior |
23
+ | --- | --- | --- |
24
+ | Claude Code | native module | `tool.check` asks; the engine's own deny is never loosened |
25
+ | Claude Code | classic hook | `PreToolUse` asks |
26
+ | Kiro | Node hook | the whole payload is scanned; a hit is denied (no confirmation protocol) |
27
+ | Codex | Node hook | a hit is denied with a reason (Codex rejects ask) |
28
+
29
+ The mod reads no environment variables, writes no files and makes no network calls.
30
+
31
+ ## Limitations
32
+
33
+ - It matches shell text in every string of the tool input, so a file whose content
34
+ mentions such a command also asks.
35
+ - It does not know which cloud account or context is live; a command against a sandbox asks too.
36
+ - An `ask` may be auto-approved under a bypass permission mode.
37
+ - Input over the size limit is not evaluated and asks.
38
+ - The native API was verified against Claude Code 2.1.296 (early access). Other harnesses
39
+ need Node 22+.
40
+
41
+ ## Install
42
+
43
+ See [mods/README.md](../README.md).
@@ -0,0 +1,12 @@
1
+ // Node entry for Kiro, Codex and classic Claude Code. Reads the hook payload on stdin.
2
+ import { runHook } from '../lib/runtime.mjs'
3
+ import { checkToolUse, toolUseFromPayload } from '../lib/core.mjs'
4
+
5
+ const handlers = {
6
+ PreToolUse: (payload) => checkToolUse(toolUseFromPayload(payload)),
7
+ }
8
+
9
+ // Fail closed: a payload we cannot read is denied rather than let through.
10
+ const onError = (err) => ({ decision: 'deny', reason: `live-guard-gate: check failed; denied (${err.message})` })
11
+
12
+ process.exitCode = await runHook({ name: 'live-guard-gate', handlers, onError })
@@ -0,0 +1,14 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/vanguard-mods/live-guard-gate/bin/hook.mjs\" --harness claude-code --event PreToolUse"
9
+ }
10
+ ]
11
+ }
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1,7 @@
1
+ # Live Guard Gate on Codex
2
+
3
+ <!-- Generated by scripts/generate-mods.mjs from mod.toml. Do not edit. -->
4
+
5
+ ## Degraded
6
+
7
+ - `PreToolUse`: Codex rejects ask; a hit is denied with a reason.
@@ -0,0 +1,14 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node .codex/vanguard-mods/live-guard-gate/bin/hook.mjs --harness codex --event PreToolUse"
9
+ }
10
+ ]
11
+ }
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "modules": [
3
+ "./register.ts"
4
+ ]
5
+ }
@@ -0,0 +1,59 @@
1
+ import { test, expect, mock } from 'claude-code/testing'
2
+
3
+ const allow = () => ({ decision: 'allow' as const })
4
+ const check = ($: any, input: unknown) => $.tool.check({ tool: 'Bash', input })
5
+
6
+ test('terraform apply asks and names the IaC guard', async ($, on) => {
7
+ mock.env(on, {})
8
+ on('tool.check', allow)
9
+ const v = await check($, { command: 'terraform apply -auto-approve' })
10
+ expect(v.decision).toBe('ask')
11
+ expect(v.reason).toContain('aws-live-iac-change-guard-agent')
12
+ })
13
+
14
+ test('az role assignment create asks and names the Entra guard', async ($, on) => {
15
+ mock.env(on, {})
16
+ on('tool.check', allow)
17
+ const v = await check($, { command: 'az role assignment create --role Reader' })
18
+ expect(v.decision).toBe('ask')
19
+ expect(v.reason).toContain('azure-live-entra-role-assignment-guard-agent')
20
+ })
21
+
22
+ test('kubectl delete asks and names the rollout guard', async ($, on) => {
23
+ mock.env(on, {})
24
+ on('tool.check', allow)
25
+ const v = await check($, { command: 'kubectl delete pod web-0' })
26
+ expect(v.decision).toBe('ask')
27
+ expect(v.reason).toContain('gcp-live-gke-rollout-guard-agent')
28
+ })
29
+
30
+ test('argv-array input asks', async ($, on) => {
31
+ mock.env(on, {})
32
+ on('tool.check', allow)
33
+ const v = await check($, { argv: ['terraform', 'apply'] })
34
+ expect(v.decision).toBe('ask')
35
+ })
36
+
37
+ test('read-only commands pass the engine verdict through', async ($, on) => {
38
+ mock.env(on, {})
39
+ on('tool.check', allow)
40
+ for (const command of ['terraform plan', 'ls', 'az account show', 'kubectl get pods']) {
41
+ expect((await check($, { command })).decision).toBe('allow')
42
+ }
43
+ })
44
+
45
+ test('engine deny stays deny', async ($, on) => {
46
+ mock.env(on, {})
47
+ on('tool.check', () => ({ decision: 'deny' as const, reason: 'engine' }))
48
+ const v = await check($, { command: 'terraform apply' })
49
+ expect(v.decision).toBe('deny')
50
+ expect(v.reason).toBe('engine')
51
+ })
52
+
53
+ test('oversized input asks', async ($, on) => {
54
+ mock.env(on, {})
55
+ on('tool.check', allow)
56
+ const v = await check($, { command: 'a'.repeat(70000) })
57
+ expect(v.decision).toBe('ask')
58
+ expect(v.reason).toContain('input too large')
59
+ })
@@ -0,0 +1,11 @@
1
+ import type { Register } from 'claude-code'
2
+ import { checkToolUse } from '../lib/core.mjs'
3
+
4
+ export const register: Register = (on) => {
5
+ // Never loosens the engine: its deny stays deny; a live mutation raises allow to ask.
6
+ on('tool.check', async ($, e, next) => {
7
+ const v = await next(e)
8
+ if (v.decision === 'deny') return v
9
+ return checkToolUse({ input: e.input }) ?? v
10
+ }).catch(() => ({ decision: 'deny', reason: 'live-guard-gate: check failed; denied' }))
11
+ }
@@ -0,0 +1,7 @@
1
+ # Live Guard Gate on Kiro
2
+
3
+ <!-- Generated by scripts/generate-mods.mjs from mod.toml. Do not edit. -->
4
+
5
+ ## Degraded
6
+
7
+ - `PreToolUse`: Kiro documents no confirmation protocol or stdin field names; the whole payload is scanned and a hit is denied.