hookgate 0.0.2 → 0.0.3

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.
@@ -0,0 +1,34 @@
1
+ // State never carries secrets. A command line can hold a token; a tool result can hold
2
+ // a whole .env. Redact before the request, then cap the size — Jev's state limit is
3
+ // 32k tokens and a hook should not be the thing that ships a log file to an API.
4
+ const PATTERNS = [
5
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '<redacted:private-key>'],
6
+ [/\b(sk|rk|pk)[-_](?:live|test|ant|proj)?[-_]?[A-Za-z0-9_-]{16,}/g, '<redacted:key>'],
7
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '<redacted:github-token>'],
8
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, '<redacted:github-token>'],
9
+ [/\bAKIA[0-9A-Z]{16}\b/g, '<redacted:aws-key-id>'],
10
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, '<redacted:slack-token>'],
11
+ [/\bAIza[0-9A-Za-z_-]{30,}\b/g, '<redacted:google-key>'],
12
+ [/(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/g, '$1 <redacted>'],
13
+ [/\b(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b/g, '<redacted:jwt>'],
14
+ [/(\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?)[A-Z0-9_]*\s*[=:]\s*)(["']?)[^\s"'&]{6,}\2/gi, '$1$2<redacted>$2'],
15
+ [/(--?(?:password|passwd|token|api[-_]?key|secret)[= ])\S+/gi, '$1<redacted>'],
16
+ [/(https?:\/\/[^\s/@:]+:)[^\s/@]+@/g, '$1<redacted>@'],
17
+ [/\b[A-Fa-f0-9]{40,}\b/g, '<redacted:hex>'],
18
+ ]
19
+
20
+ export function redact(text) {
21
+ let out = String(text ?? '')
22
+ for (const [re, rep] of PATTERNS) out = out.replace(re, rep)
23
+ return out
24
+ }
25
+
26
+ export function truncate(text, max) {
27
+ const s = String(text ?? '')
28
+ if (s.length <= max) return s
29
+ const head = Math.floor(max * 0.7)
30
+ const tail = max - head
31
+ return `${s.slice(0, head)}\n…[${s.length - max} chars elided by hookgate]…\n${s.slice(-tail)}`
32
+ }
33
+
34
+ export const prepare = (text, max) => truncate(redact(text), max)
@@ -0,0 +1,70 @@
1
+ // `hookgate report`: the README's columns, from the audit log (HG-10), including what
2
+ // the decisions cost (HG-28).
3
+ import { readDecisions } from './store.mjs'
4
+
5
+ // TypeSafe's published input price, $42 per billion tokens; output is free.
6
+ // One constant, dated, shared with evals/run.mjs — a stale price is a bug.
7
+ export const PRICE_PER_INPUT_TOKEN = 42 / 1e9
8
+ export const PRICE_DATE = '2026-09-22'
9
+
10
+ const q = (xs, p) => {
11
+ if (!xs.length) return null
12
+ const s = [...xs].sort((a, b) => a - b)
13
+ return s[Math.min(s.length - 1, Math.floor(p * s.length))]
14
+ }
15
+
16
+ export function summarize(records) {
17
+ const byGate = {}
18
+ for (const r of records) {
19
+ const g = (byGate[r.gate] ??= { total: 0, outcomes: {}, latencies: [], cached: 0, errors: 0, models: new Set(), confidences: [], tokens: 0, judged: 0, unknown: 0 })
20
+ g.total += 1
21
+ g.outcomes[r.outcome ?? 'unknown'] = (g.outcomes[r.outcome ?? 'unknown'] ?? 0) + 1
22
+ if (r.outcome === 'error') g.errors += 1
23
+ if (r.cached) g.cached += 1
24
+ else if (typeof r.latencyMs === 'number') g.latencies.push(r.latencyMs)
25
+ if (r.model) g.models.add(r.model)
26
+ if (typeof r.confidence === 'number') g.confidences.push(r.confidence)
27
+ // Judged = a request happened (or a cache hit stood in for one). Skipped, error
28
+ // and too-long outcomes cost nothing and are not counted here.
29
+ if (r.model && !r.skipped && r.outcome !== 'error') {
30
+ g.judged += 1
31
+ if (typeof r.inputTokens === 'number') g.tokens += r.inputTokens
32
+ else g.unknown += 1
33
+ }
34
+ }
35
+ return Object.fromEntries(
36
+ Object.entries(byGate).map(([gate, g]) => [
37
+ gate,
38
+ {
39
+ total: g.total,
40
+ outcomes: g.outcomes,
41
+ p50: q(g.latencies, 0.5),
42
+ p95: q(g.latencies, 0.95),
43
+ cacheHitRate: g.total ? g.cached / g.total : 0,
44
+ errors: g.errors,
45
+ models: [...g.models],
46
+ askShareAt: Object.fromEntries([0.5, 0.6, 0.7, 0.8, 0.9].map((t) => [t, g.confidences.length ? g.confidences.filter((c) => c < t).length / g.confidences.length : null])),
47
+ judged: g.judged,
48
+ inputTokens: g.tokens,
49
+ tokensUnknown: g.unknown,
50
+ costTotal: g.tokens * PRICE_PER_INPUT_TOKEN,
51
+ costPerJudged: g.judged - g.unknown ? (g.tokens * PRICE_PER_INPUT_TOKEN) / (g.judged - g.unknown) : null,
52
+ },
53
+ ]),
54
+ )
55
+ }
56
+
57
+ export function render(summary) {
58
+ const lines = []
59
+ for (const [gate, s] of Object.entries(summary)) {
60
+ lines.push(`## ${gate} — ${s.total} decisions`)
61
+ lines.push(`outcomes: ${Object.entries(s.outcomes).map(([k, v]) => `${k} ${v}`).join(' · ')}${s.outcomes.skipped ? ` — ${Math.round((100 * s.outcomes.skipped) / s.total)}% never reached Jev` : ''}`)
62
+ lines.push(`latency (uncached): p50 ${s.p50 ?? '—'} ms · p95 ${s.p95 ?? '—'} ms · cache hits ${Math.round(s.cacheHitRate * 100)}% · errors ${s.errors}`)
63
+ if (s.judged) lines.push(`cost: ${s.inputTokens.toLocaleString('en-US')} input tokens over ${s.judged} judged · $${s.costTotal.toFixed(4)} total · ${s.costPerJudged === null ? 'unknown' : `$${s.costPerJudged.toFixed(6)}`} per judged decision at $${(PRICE_PER_INPUT_TOKEN * 1e9).toFixed(0)}/B input tokens (${PRICE_DATE})${s.tokensUnknown ? ` · ${s.tokensUnknown} without usage` : ''}`)
64
+ lines.push(`share that would be "ask" at threshold: ${Object.entries(s.askShareAt).map(([t, v]) => `${t}→${v === null ? '—' : `${Math.round(v * 100)}%`}`).join(' ')}`)
65
+ lines.push(`models: ${s.models.join(', ') || '—'}`, '')
66
+ }
67
+ return lines.join('\n') || 'no decisions logged yet'
68
+ }
69
+
70
+ export const report = (dir) => render(summarize(readDecisions(dir)))
@@ -0,0 +1,149 @@
1
+ // Everything the plugin remembers lives under the harness's plugin data directory:
2
+ // the audit log (HG-10), the per-session cache (HG-12), the promotion counters (HG-13)
3
+ // and the one-block-per-stop marker. Every write is best-effort — a full disk must
4
+ // not turn into a blocked tool call.
5
+ import { createHash } from 'node:crypto'
6
+ import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ const safe = (fn) => {
10
+ try {
11
+ return fn()
12
+ } catch {
13
+ return undefined
14
+ }
15
+ }
16
+
17
+ export const hash = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16)
18
+
19
+ // The audit log rotates once at LOG_MAX bytes: decisions.jsonl → decisions.1.jsonl,
20
+ // the previous .1 dropped. Two files bound the disk; `report` reads both.
21
+ export const LOG_MAX = 8 * 1024 * 1024
22
+
23
+ export function appendDecision(dir, record) {
24
+ safe(() => {
25
+ mkdirSync(dir, { recursive: true })
26
+ const p = join(dir, 'decisions.jsonl')
27
+ // No exists-then-stat: read the size in one call and let a missing file throw into `safe`.
28
+ const size = safe(() => statSync(p).size) ?? 0
29
+ if (size > LOG_MAX) renameSync(p, join(dir, 'decisions.1.jsonl'))
30
+ appendFileSync(p, `${JSON.stringify(record)}\n`)
31
+ })
32
+ }
33
+
34
+ export function readDecisions(dir) {
35
+ return ['decisions.1.jsonl', 'decisions.jsonl']
36
+ .map((f) => safe(() => readFileSync(join(dir, f), 'utf8')) ?? '')
37
+ .flatMap((text) => text.split('\n'))
38
+ .filter(Boolean)
39
+ .map((l) => safe(() => JSON.parse(l)))
40
+ .filter(Boolean)
41
+ }
42
+
43
+ // Per-session state: cache entries and promotion counters, one JSON file per session.
44
+ export function sessionFile(dir, sessionId) {
45
+ return join(dir, 'sessions', `${(sessionId || 'no-session').replace(/[^\w-]/g, '_')}.json`)
46
+ }
47
+
48
+ export function loadSession(dir, sessionId) {
49
+ return safe(() => JSON.parse(readFileSync(sessionFile(dir, sessionId), 'utf8'))) ?? { cache: {}, prefixes: {}, proposed: [], blockedPrompts: [] }
50
+ }
51
+
52
+ // Atomic: write beside, then rename, so two hooks racing (parallel tool calls) can
53
+ // lose an update but never leave a torn file. Session files older than SESSION_TTL
54
+ // are pruned on the way, one in ~20 writes.
55
+ export const SESSION_TTL = 7 * 24 * 3600 * 1000
56
+
57
+ export function saveSession(dir, sessionId, state, now = Date.now()) {
58
+ safe(() => {
59
+ const d = join(dir, 'sessions')
60
+ mkdirSync(d, { recursive: true })
61
+ const p = sessionFile(dir, sessionId)
62
+ const tmp = `${p}.${process.pid}.tmp`
63
+ writeFileSync(tmp, JSON.stringify(state))
64
+ renameSync(tmp, p)
65
+ if (Math.random() < 0.05) pruneSessions(dir, now)
66
+ })
67
+ }
68
+
69
+ export function pruneSessions(dir, now = Date.now()) {
70
+ safe(() => {
71
+ const d = join(dir, 'sessions')
72
+ for (const f of readdirSync(d)) {
73
+ const p = join(d, f)
74
+ if (now - statSync(p).mtimeMs > SESSION_TTL) unlinkSync(p)
75
+ }
76
+ })
77
+ }
78
+
79
+ export function cacheKey(gate, cwd, state) {
80
+ return hash(`${gate}\n${cwd}\n${JSON.stringify(state)}`)
81
+ }
82
+
83
+ export function cacheGet(session, key, ttlMs, now = Date.now()) {
84
+ const e = session.cache?.[key]
85
+ if (!e) return null
86
+ if (now - e.at > ttlMs) return null
87
+ return e
88
+ }
89
+
90
+ export function cachePut(session, key, value, now = Date.now()) {
91
+ session.cache ??= {}
92
+ session.cache[key] = { ...value, at: now }
93
+ // Keep the file bounded: drop the oldest beyond 500 entries.
94
+ const keys = Object.keys(session.cache)
95
+ if (keys.length > 500) for (const k of keys.sort((a, b) => session.cache[a].at - session.cache[b].at).slice(0, keys.length - 500)) delete session.cache[k]
96
+ }
97
+
98
+ // Promotion (HG-13): count confident, identical decisions per command prefix.
99
+ export function commandPrefix(command) {
100
+ let s = String(command).trim()
101
+ // Skip what is not the command: leading VAR=value assignments and `cd <dir> &&` /
102
+ // `cd <dir>;` hops — 47% of real commands start with a cd, and a promotion rule for
103
+ // "cd" would be meaningless.
104
+ for (let guard = 0; guard < 8; guard++) {
105
+ // `cd X && cmd`, `cd X; cmd` and `cd X⏎cmd` — the last is how most real commands
106
+ // arrive (9,408 of 12,700 cd-led commands in one maintainer's transcripts).
107
+ const next = s.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, '').replace(/^cd\s+(?:"[^"]*"|'[^']*'|\S+)[ \t]*(?:&&|;|\n)\s*/, '')
108
+ if (next === s) break
109
+ s = next
110
+ }
111
+ const words = s.split(/\s+/)
112
+ const first = words[0] ?? ''
113
+ const takesSub = ['git', 'npm', 'npx', 'pnpm', 'yarn', 'docker', 'kubectl', 'gh', 'cargo', 'go', 'make', 'python', 'python3', 'node', 'pip']
114
+ return takesSub.includes(first) && words[1] && !words[1].startsWith('-') ? `${first} ${words[1]}` : first
115
+ }
116
+
117
+ // What a promoted rule may cover (HG-24, HG-25). A rule is a prefix, so it covers
118
+ // every command that starts that way: it must only ever be proposed for a command
119
+ // that IS that prefix plus arguments — one simple command, no chaining, no
120
+ // substitution — and never for a program that runs whatever follows.
121
+ const NEVER_PROMOTE = new Set(['python', 'python3', 'node', 'deno', 'bun', 'bash', 'sh', 'zsh', 'fish', 'dash', 'ksh', 'sudo', 'doas', 'su', 'env', 'xargs', 'eval', 'exec', 'source', '.', 'perl', 'ruby', 'php', 'lua', 'osascript', 'nohup', 'time', 'timeout', 'command', 'builtin', 'nice', 'watch', 'caffeinate', 'ssh', 'script', 'expect', 'parallel', 'find'])
122
+
123
+ export function promotablePrefix(command) {
124
+ let s = String(command).trim()
125
+ for (let guard = 0; guard < 8; guard++) {
126
+ const next = s.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, '').replace(/^cd\s+(?:"[^"]*"|'[^']*'|\S+)[ \t]*(?:&&|;|\n)\s*/, '')
127
+ if (next === s) break
128
+ s = next
129
+ }
130
+ // Operators outside quotes make it more than one command: 92.5% of real commands.
131
+ const unquoted = s.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""')
132
+ if (/&&|\|\||;|\||\n|`|\$\(/.test(unquoted)) return null
133
+ const prefix = commandPrefix(s)
134
+ if (!prefix || NEVER_PROMOTE.has(prefix.split(' ')[0])) return null
135
+ return prefix
136
+ }
137
+
138
+ export function notePrefix(session, prefix, decision, confidence, cfg) {
139
+ if (!prefix || !['allow', 'deny'].includes(decision) || confidence < cfg.promote.confidence) return null
140
+ session.prefixes ??= {}
141
+ session.proposed ??= []
142
+ const p = (session.prefixes[prefix] ??= { allow: 0, deny: 0 })
143
+ p[decision] += 1
144
+ if (p[decision] >= cfg.promote.after && !session.proposed.includes(prefix)) {
145
+ session.proposed.push(prefix)
146
+ return { prefix, decision, count: p[decision] }
147
+ }
148
+ return null
149
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "description": "hookgate — calibrated gates for Codex CLI. Every handler fails open: no key, no network or an API error means no decision, and Codex's normal approval flow takes over.",
3
+ "hooks": {
4
+ "PreToolUse": [
5
+ {
6
+ "matcher": "Bash",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "node \"${PLUGIN_ROOT}/bin/hookgate.mjs\" pre-tool-use",
11
+ "timeout": 5,
12
+ "statusMessage": "hookgate: judging the command"
13
+ }
14
+ ]
15
+ }
16
+ ],
17
+ "PostToolUse": [
18
+ {
19
+ "matcher": "WebFetch|WebSearch|Read|Bash",
20
+ "hooks": [
21
+ {
22
+ "type": "command",
23
+ "command": "node \"${PLUGIN_ROOT}/bin/hookgate.mjs\" post-tool-use",
24
+ "timeout": 5,
25
+ "statusMessage": "hookgate: screening the result for injected instructions"
26
+ }
27
+ ]
28
+ }
29
+ ],
30
+ "Stop": [
31
+ {
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "command": "node \"${PLUGIN_ROOT}/bin/hookgate.mjs\" stop",
36
+ "timeout": 5,
37
+ "statusMessage": "hookgate: checking the claim of completion"
38
+ }
39
+ ]
40
+ }
41
+ ]
42
+ }
43
+ }
package/hooks/hooks.json CHANGED
@@ -5,26 +5,22 @@
5
5
  {
6
6
  "matcher": "Bash",
7
7
  "hooks": [
8
- {
9
- "type": "command",
10
- "command": "${CLAUDE_PLUGIN_ROOT}/bin/hookgate.mjs",
11
- "args": ["pre-tool-use"],
12
- "timeout": 5,
13
- "statusMessage": "hookgate: judging the command"
14
- }
8
+ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/bin/hookgate.mjs", "args": ["pre-tool-use"], "timeout": 5, "statusMessage": "hookgate: judging the command" }
9
+ ]
10
+ }
11
+ ],
12
+ "PostToolUse": [
13
+ {
14
+ "matcher": "WebFetch|WebSearch|Read|Bash",
15
+ "hooks": [
16
+ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/bin/hookgate.mjs", "args": ["post-tool-use"], "timeout": 5, "statusMessage": "hookgate: screening the result for injected instructions" }
15
17
  ]
16
18
  }
17
19
  ],
18
20
  "Stop": [
19
21
  {
20
22
  "hooks": [
21
- {
22
- "type": "command",
23
- "command": "${CLAUDE_PLUGIN_ROOT}/bin/hookgate.mjs",
24
- "args": ["stop"],
25
- "timeout": 5,
26
- "statusMessage": "hookgate: checking the claim of completion"
27
- }
23
+ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/bin/hookgate.mjs", "args": ["stop"], "timeout": 5, "statusMessage": "hookgate: checking the claim of completion" }
28
24
  ]
29
25
  }
30
26
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookgate",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Calibrated, sub-second decisions in Claude Code's hooks: a PreToolUse gate on shell commands and a Stop gate on unverified completion, answered by TypeSafe's Jev with a confidence score, escalating to the human when unsure.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,8 +9,11 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "hooks",
12
+ "codex",
12
13
  ".claude-plugin",
14
+ ".codex-plugin",
13
15
  "README.md",
16
+ "CHANGELOG.md",
14
17
  "LICENSE"
15
18
  ],
16
19
  "engines": {
@@ -18,7 +21,8 @@
18
21
  },
19
22
  "scripts": {
20
23
  "build:site": "node site/build.mjs",
21
- "test": "node bin/hookgate.mjs check",
24
+ "test": "node bin/hookgate.mjs check && node --test",
25
+ "scorecard": "node evals/scorecard.mjs",
22
26
  "backlog": "node scripts/backlog.mjs check && node scripts/backlog_test.mjs"
23
27
  },
24
28
  "keywords": [