@sakk-dev/sakk 0.0.0 → 0.4.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/src/hook.mjs ADDED
@@ -0,0 +1,730 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // The guard hook (specs/guard/spec.md): every tool call an agent makes passes
4
+ // through here before it runs.
5
+ //
6
+ // 1. normalize the agent's payload → one Event
7
+ // 2. classify the Event → actions with canonical scopes (src/classify.mjs)
8
+ // 3. check halt → spend cap (M2) → guard rules (policy kernel)
9
+ // 4. answer allow (silent) | deny | ask, in the agent's own format
10
+ // 5. log one decision line per action, hash-pinned to the policy
11
+ //
12
+ // Any error is a deny with a code (decision 2, 2026-10-21): the hook answers
13
+ // itself and never relies on the agent's handling of a crash. When the rules
14
+ // themselves cannot be read, the failure is isolated (RC-09 review,
15
+ // 2026-10-22): a broken project policy gives way to the last one that loaded,
16
+ // and with nothing to fall back to the hook still lets the agent read the
17
+ // project — every other call is denied with what to fix.
18
+
19
+ import { execFileSync } from 'node:child_process';
20
+ import { createHash, randomBytes } from 'node:crypto';
21
+ import {
22
+ existsSync,
23
+ mkdirSync,
24
+ readFileSync,
25
+ realpathSync,
26
+ renameSync,
27
+ writeFileSync,
28
+ } from 'node:fs';
29
+ import { homedir } from 'node:os';
30
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { decide, loadPolicy, logDecision } from './policy.mjs';
33
+ import { appendNoFollow, writeFileAtomic } from './append.mjs';
34
+ import { loadPrices } from './prices.mjs';
35
+ import { capCheck as spendCap } from './spend.mjs';
36
+ import { scanText } from './secret-scan.mjs';
37
+
38
+ /**
39
+ * Every string the payload carries — keys and values, at any depth — joined
40
+ * for one scan. The rule is structural (F-202): a field is scanned because it
41
+ * is there, never because its tool or field name is on a list, so an MCP
42
+ * argument, an unknown tool's input, a path or a pattern is covered like a
43
+ * Write's content. An explicit worklist (no recursion) keeps hostile nesting
44
+ * from overflowing the stack; the work is bounded by MAX_PAYLOAD, since the
45
+ * payload was parsed from at most that many bytes.
46
+ */
47
+ function payloadText(p) {
48
+ const parts = [];
49
+ const todo = [p];
50
+ while (todo.length) {
51
+ const v = todo.pop();
52
+ if (typeof v === 'string') parts.push(v);
53
+ else if (Array.isArray(v)) for (const x of v) todo.push(x);
54
+ else if (v !== null && typeof v === 'object')
55
+ for (const [k, x] of Object.entries(v)) todo.push(k, x);
56
+ }
57
+ return parts.join('\n');
58
+ }
59
+
60
+ /**
61
+ * The guard's step 3 (specs/secret-guard): a call whose payload carries a
62
+ * literal secret anywhere — write content, command or patch text, an MCP or
63
+ * unknown tool's arguments, a path, any field (F-202) — is denied. Returns
64
+ * the first finding { rule, fingerprint } (never the value), or null. A scan
65
+ * error is surfaced as a HookError, so it fails closed. The named surfaces
66
+ * remain as a floor for hand-built events that carry no scanText.
67
+ */
68
+ export function secretHit(ev) {
69
+ for (const surface of [ev.scanText, ev.content, ev.command, ev.patch]) {
70
+ if (typeof surface !== 'string' || surface === '') continue;
71
+ let r;
72
+ try {
73
+ r = scanText(surface);
74
+ } catch (e) {
75
+ throw new HookError('HOOK_SECRET_SCAN_FAILED', e.message);
76
+ }
77
+ if (r.findings.length)
78
+ return { rule: r.findings[0].rule, fingerprint: r.findings[0].fingerprint };
79
+ }
80
+ return null;
81
+ }
82
+ import { readLedger } from './ledger.mjs';
83
+ import { patchActions, shellActions, toDecisions } from './classify.mjs';
84
+
85
+ export const AGENTS = Object.freeze(['claude-code', 'cursor', 'codex']);
86
+ export const MAX_PAYLOAD = 1024 * 1024;
87
+ // sakk doctor's own probe: logged like any decision (contract row 6), but under
88
+ // its own name, so the console never counts it as something an agent tried (F-229)
89
+ export const DOCTOR_BY = 'sakk-doctor';
90
+ export const DOCTOR_COMMAND = 'git push --force origin main';
91
+ const isDoctorProbe = (ev, env) =>
92
+ env.SAKK_DOCTOR === '1' &&
93
+ ev.kind === 'shell' &&
94
+ ev.command === DOCTOR_COMMAND &&
95
+ ev.sessionId === 'doctor' &&
96
+ (ev.agent === 'cursor'
97
+ ? ev.cursorEvent === 'beforeShellExecution' && ev.callId === 'doctor'
98
+ : ev.tool === 'Bash' && ev.callId === 'doctor-fire');
99
+ export const DEADLINE_MS = 2000;
100
+ export const SHIPPED_POLICY = join(
101
+ dirname(fileURLToPath(import.meta.url)),
102
+ '..',
103
+ 'policies',
104
+ 'guard-default.json',
105
+ );
106
+
107
+ export class HookError extends Error {
108
+ constructor(code, message) {
109
+ super(`${code}: ${message}`);
110
+ this.code = code;
111
+ }
112
+ }
113
+
114
+ const noGit = () =>
115
+ Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('GIT_')));
116
+
117
+ /** Sakk's own state: config, decision log, spend index. */
118
+ export const sakkHome = (env = process.env) =>
119
+ resolve(env.SAKK_HOME || join(env.HOME || homedir(), '.sakk'));
120
+
121
+ export function readConfig(home) {
122
+ const p = join(home, 'config.json');
123
+ try {
124
+ return JSON.parse(readFileSync(p, 'utf8'));
125
+ } catch (e) {
126
+ if (e.code === 'ENOENT') return {};
127
+ throw new HookError('HOOK_CONFIG_UNREADABLE', `${p}: ${e.message}`);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Write Sakk's config atomically — only a person's CLI commands call this
133
+ * (sakk init, cap set/off); the agent is denied writing ~/.sakk by the guard,
134
+ * so it cannot set its own cap. Merges onto the current config so one key's change
135
+ * never drops another (the log folder a cap was set with, say).
136
+ */
137
+ export function writeConfig(home, patch) {
138
+ const current = readConfig(home);
139
+ const next = { ...current };
140
+ for (const [k, v] of Object.entries(patch)) {
141
+ if (v === undefined) delete next[k];
142
+ else next[k] = v;
143
+ }
144
+ mkdirSync(home, { recursive: true });
145
+ writeFileAtomic(join(home, 'config.json'), `${JSON.stringify(next, null, 2)}\n`);
146
+ return next;
147
+ }
148
+
149
+ /**
150
+ * Where the ledger is — one rule for every command and the hook, so a halt
151
+ * the CLI writes lands where the hook reads (F-172): --ledger, else the
152
+ * absolute path `sakk init` recorded in config.json, else SAKK_LEDGER_PATH,
153
+ * else the caller's fallback (the CLI's SAKK_HOME/sakk-ledger.json; the hook
154
+ * and the console have none). The config outranks the environment because it is the
155
+ * one source the CLI and the hook are both sure to see. `from` names the
156
+ * source; a set-but-empty SAKK_LEDGER_PATH resolves to no path, from 'env-empty'.
157
+ */
158
+ export function resolveLedgerPath({ flag, config = {}, env = process.env, fallback } = {}) {
159
+ if (flag !== undefined) return { path: resolve(flag), from: 'flag' };
160
+ if (typeof config.ledgerPath === 'string' && config.ledgerPath !== '')
161
+ return { path: resolve(config.ledgerPath), from: 'config' };
162
+ if (env.SAKK_LEDGER_PATH === '') return { path: null, from: 'env-empty' };
163
+ if (env.SAKK_LEDGER_PATH !== undefined)
164
+ return { path: resolve(env.SAKK_LEDGER_PATH), from: 'env' };
165
+ if (fallback !== undefined) return { path: resolve(fallback), from: 'default' };
166
+ return { path: null, from: null };
167
+ }
168
+
169
+ /** The git top level of `cwd`, or null outside a repository. */
170
+ export function repoRootOf(cwd) {
171
+ try {
172
+ const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
173
+ cwd,
174
+ encoding: 'utf8',
175
+ stdio: ['ignore', 'pipe', 'ignore'],
176
+ env: noGit(),
177
+ }).trim();
178
+ return out === '' ? null : out;
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ const text = (v, what) => {
185
+ if (typeof v !== 'string' || v === '')
186
+ throw new HookError('HOOK_PAYLOAD_INVALID', `${what} is missing or not text`);
187
+ return v;
188
+ };
189
+ const opt = (v) => (typeof v === 'string' && v !== '' ? v : undefined);
190
+
191
+ /** One Event from each agent's payload (spec "Event"). Unknown shapes refuse. */
192
+ export function normalize(agent, p) {
193
+ if (p === null || typeof p !== 'object' || Array.isArray(p))
194
+ throw new HookError('HOOK_PAYLOAD_INVALID', 'the payload is not a JSON object');
195
+ const cwd =
196
+ opt(p.cwd) ?? (Array.isArray(p.workspace_roots) ? opt(p.workspace_roots[0]) : undefined);
197
+ if (!cwd || !isAbsolute(cwd))
198
+ throw new HookError('HOOK_PAYLOAD_INVALID', 'the payload names no absolute cwd');
199
+ const ev = { agent, cwd, paths: [], kind: 'other' };
200
+ ev.scanText = payloadText(p); // every string the payload carries is scanned, whatever the tool (F-202)
201
+ if (agent === 'claude-code' || agent === 'codex') {
202
+ ev.tool = text(p.tool_name, 'tool_name');
203
+ ev.callId = opt(p.tool_use_id);
204
+ ev.sessionId = opt(p.session_id);
205
+ const ti = p.tool_input;
206
+ if (ti === null || typeof ti !== 'object' || Array.isArray(ti))
207
+ throw new HookError('HOOK_PAYLOAD_INVALID', 'tool_input is missing or not an object');
208
+ if (['Bash', 'PowerShell', 'Monitor'].includes(ev.tool)) {
209
+ ev.kind = 'shell';
210
+ ev.command = text(ti.command, 'tool_input.command');
211
+ } else if (ev.tool === 'apply_patch') {
212
+ ev.kind = 'patch';
213
+ ev.patch = text(ti.command ?? ti.patch ?? ti.input, 'tool_input.command');
214
+ } else if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(ev.tool)) {
215
+ ev.kind = 'write';
216
+ ev.paths = [text(ti.file_path ?? ti.notebook_path, 'tool_input.file_path')];
217
+ ev.content = writeContent(ti); // the new bytes, for the secret scan (M3)
218
+ } else if (ev.tool === 'Read') {
219
+ ev.kind = 'read';
220
+ ev.paths = [text(ti.file_path, 'tool_input.file_path')];
221
+ } else if (ev.tool === 'Grep' || ev.tool === 'Glob') {
222
+ ev.kind = 'read';
223
+ if (opt(ti.path)) ev.paths = [ti.path];
224
+ } else if (ev.tool.startsWith('mcp__')) {
225
+ ev.kind = 'mcp';
226
+ ev.mcp = ev.tool.slice(5).replace('__', '.');
227
+ }
228
+ return ev;
229
+ }
230
+ if (agent === 'cursor') {
231
+ const event = text(p.hook_event_name, 'hook_event_name');
232
+ ev.cursorEvent = event;
233
+ ev.callId = opt(p.tool_use_id) ?? opt(p.generation_id);
234
+ ev.sessionId = opt(p.conversation_id);
235
+ if (event === 'beforeShellExecution') {
236
+ ev.tool = 'Shell';
237
+ ev.kind = 'shell';
238
+ ev.command = text(p.command, 'command');
239
+ } else if (event === 'beforeReadFile') {
240
+ ev.tool = 'Read';
241
+ ev.kind = 'read';
242
+ ev.paths = [text(p.file_path, 'file_path')];
243
+ } else if (event === 'beforeMCPExecution') {
244
+ ev.tool = 'MCP';
245
+ ev.kind = 'mcp';
246
+ ev.mcp = `${opt(p.mcp_server_name) ?? 'unknown'}.${opt(p.tool_name) ?? 'unknown'}`;
247
+ } else if (event === 'preToolUse') {
248
+ ev.tool = text(p.tool_name, 'tool_name');
249
+ const ti = p.tool_input !== null && typeof p.tool_input === 'object' ? p.tool_input : {};
250
+ // the Write/Delete input shape is not documented: every known path key, or refuse
251
+ const path = () => text(ti.file_path ?? ti.path ?? ti.target_file, 'tool_input.file_path');
252
+ if (ev.tool === 'Shell') {
253
+ ev.kind = 'shell';
254
+ ev.command = text(ti.command, 'tool_input.command');
255
+ } else if (ev.tool === 'Read') {
256
+ ev.kind = 'read';
257
+ ev.paths = [path()];
258
+ } else if (['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(ev.tool)) {
259
+ ev.kind = 'write';
260
+ ev.paths = [
261
+ text(
262
+ ti.file_path ?? ti.notebook_path ?? ti.path ?? ti.target_file,
263
+ 'tool_input.file_path',
264
+ ),
265
+ ];
266
+ ev.content = writeContent(ti); // every write surface is scanned, not just Write (F-160)
267
+ } else if (ev.tool === 'Delete') {
268
+ ev.kind = 'delete';
269
+ ev.paths = [path()];
270
+ } else if (ev.tool.startsWith('MCP:')) {
271
+ ev.kind = 'mcp';
272
+ ev.mcp = ev.tool.slice(4);
273
+ }
274
+ } else throw new HookError('HOOK_PAYLOAD_INVALID', `unknown Cursor hook event ${event}`);
275
+ return ev;
276
+ }
277
+ throw new HookError('HOOK_USAGE', `unknown agent ${agent}`);
278
+ }
279
+
280
+ /**
281
+ * The new bytes a write would put on disk, for the secret scan (M3): Write's
282
+ * content, Edit's new_string, MultiEdit's edits joined, NotebookEdit's
283
+ * new_source. Best-effort and string-only — a shape we do not know scans as
284
+ * empty (no secret found), never throws; the guard still judges the path.
285
+ */
286
+ function writeContent(ti) {
287
+ const parts = [];
288
+ for (const k of ['content', 'new_string', 'new_str', 'new_source', 'contents']) {
289
+ const v = ti[k];
290
+ if (typeof v === 'string') parts.push(v);
291
+ }
292
+ if (Array.isArray(ti.edits))
293
+ for (const e of ti.edits) {
294
+ const v = e?.new_string ?? e?.new_str;
295
+ if (typeof v === 'string') parts.push(v);
296
+ }
297
+ return parts.join('\n');
298
+ }
299
+
300
+ const MCP_NAME = /[^A-Za-z0-9_.-]/g;
301
+
302
+ /** The Event's actions with canonical scopes. Unknown tools are shell.run (spec input table). */
303
+ export function actionsOf(ev, { repoRoot, sakkHome: home, homeDir }) {
304
+ let raw;
305
+ if (ev.kind === 'shell')
306
+ raw = shellActions(ev.command, ev.cwd, {
307
+ home: homeDir,
308
+ sakkHome: home,
309
+ repoRoot: repoRoot ?? ev.cwd,
310
+ });
311
+ else if (ev.kind === 'patch') raw = patchActions(ev.patch, ev.cwd);
312
+ else if (ev.paths.length) {
313
+ const action =
314
+ ev.kind === 'delete' ? 'file.delete' : ev.kind === 'write' ? 'file.write' : 'file.read';
315
+ raw = ev.paths.map((p) => ({ action, path: resolve(ev.cwd, p) }));
316
+ } else if (ev.kind === 'mcp') raw = [{ action: `mcp.${ev.mcp.replace(MCP_NAME, '_')}` }];
317
+ else if (ev.kind === 'read')
318
+ raw = []; // a search with no path reads the working folder
319
+ else raw = [{ action: 'shell.run' }];
320
+ return toDecisions(raw, { repoRoot: repoRoot ?? ev.cwd, sakkHome: home, homeDir });
321
+ }
322
+
323
+ /** The halt that applies to this agent, from the ledger configured in Sakk's config. */
324
+ export function haltFor(agent, ledgerPath) {
325
+ if (!ledgerPath) return { known: false, halt: null };
326
+ let doc;
327
+ try {
328
+ doc = readLedger({ ledgerPath, maxAgeMs: Number.POSITIVE_INFINITY });
329
+ } catch (e) {
330
+ throw new HookError('HOOK_LEDGER_UNREADABLE', `${ledgerPath}: ${e.code ?? e.message}`);
331
+ }
332
+ const h = doc.halt;
333
+ if (!h) return { known: true, halt: null };
334
+ if (h.global) return { known: true, halt: { scope: 'global', ...h.global } };
335
+ const a = h.agents && Object.hasOwn(h.agents, agent) ? h.agents[agent] : undefined;
336
+ return { known: true, halt: a ? { scope: 'agent', ...a } : null };
337
+ }
338
+
339
+ const RANK = { allow: 0, require_approval: 1, deny: 2 };
340
+
341
+ /**
342
+ * The policy with every rule's scopes in lower case. The default macOS volume
343
+ * opens `.CLAUDE/settings.json` as `.claude/settings.json`, so each scoped
344
+ * action is also judged with both sides folded and the stricter answer wins
345
+ * (F-183). On a case-sensitive volume this only tightens.
346
+ */
347
+ const FOLDED = new WeakMap();
348
+ function folded(policy) {
349
+ let f = FOLDED.get(policy);
350
+ if (f === undefined) {
351
+ f = {
352
+ ...policy,
353
+ rules: policy.rules.map((r) =>
354
+ Array.isArray(r.match?.scope)
355
+ ? { ...r, match: { ...r.match, scope: r.match.scope.map((x) => x.toLowerCase()) } }
356
+ : r,
357
+ ),
358
+ };
359
+ FOLDED.set(policy, f);
360
+ }
361
+ return f;
362
+ }
363
+
364
+ /** The hash decisions carry while no policy could be read (it names this mode, not a file). */
365
+ export const RULES_UNKNOWN_HASH = createHash('sha256')
366
+ .update('sakk-rules-unknown.v1')
367
+ .digest('hex');
368
+ const sha = (x) => createHash('sha256').update(x).digest('hex');
369
+
370
+ /** Where the last project policy that loaded is kept: Sakk's own state, which agents cannot write. */
371
+ export function goodPolicyPaths(home, repoRoot) {
372
+ const key = sha(realpathOr(repoRoot)).slice(0, 32);
373
+ const dir = join(home, 'policy-good');
374
+ return { dir, policy: join(dir, `${key}.json`), meta: join(dir, `${key}.meta.json`) };
375
+ }
376
+ const realpathOr = (p) => {
377
+ try {
378
+ return realpathSync(p);
379
+ } catch {
380
+ return resolve(p);
381
+ }
382
+ };
383
+
384
+ /** Best effort: an alert Sakk could not write is not a reason to refuse the call it is about. */
385
+ function alert(home, record) {
386
+ try {
387
+ mkdirSync(home, { recursive: true });
388
+ appendNoFollow(
389
+ join(home, 'alerts.jsonl'),
390
+ `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n`,
391
+ );
392
+ } catch {
393
+ // the decision log still records the call; `sakk doctor` checks this folder is writable
394
+ }
395
+ }
396
+
397
+ function keepGood(home, repoRoot, policyPath, policyHash) {
398
+ const at = goodPolicyPaths(home, repoRoot);
399
+ try {
400
+ const meta = JSON.parse(readFileSync(at.meta, 'utf8'));
401
+ if (meta.policyHash === policyHash) return;
402
+ } catch {
403
+ // no copy yet, or an unreadable one: write it
404
+ }
405
+ try {
406
+ mkdirSync(at.dir, { recursive: true });
407
+ const tmp = (f) => `${f}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;
408
+ const raw = readFileSync(policyPath);
409
+ if (sha(raw) !== policyHash) return; // changed since it loaded: keep the copy we have
410
+ const t1 = tmp(at.policy);
411
+ writeFileSync(t1, raw, { flag: 'wx' });
412
+ renameSync(t1, at.policy);
413
+ const t2 = tmp(at.meta);
414
+ writeFileSync(
415
+ t2,
416
+ JSON.stringify({ source: policyPath, policyHash, savedAt: new Date().toISOString() }),
417
+ { flag: 'wx' },
418
+ );
419
+ renameSync(t2, at.meta);
420
+ } catch (e) {
421
+ alert(home, { kind: 'POLICY_GOOD_UNSAVED', policyPath, why: e.code ?? e.message });
422
+ }
423
+ }
424
+
425
+ const policyError = (e) =>
426
+ new HookError(
427
+ e.code === 'POLICY_UNREADABLE'
428
+ ? 'HOOK_POLICY_UNREADABLE'
429
+ : (e.code ?? 'HOOK_POLICY_UNREADABLE'),
430
+ e.message,
431
+ );
432
+
433
+ /**
434
+ * The policy that judges this call: the project's, or the shipped one when
435
+ * the project has none. A project policy that does not load gives way to the
436
+ * last one that did (kept by keepGood); with no copy, or a copy that does not
437
+ * load either, the rules are unknown and only reading the project goes on.
438
+ */
439
+ export function choosePolicy({ repoRoot, home, keep }) {
440
+ const local = repoRoot ? join(repoRoot, '.sakk', 'policy.json') : null;
441
+ if (!(local && existsSync(local))) {
442
+ try {
443
+ return { loaded: loadPolicy({ policyPath: SHIPPED_POLICY }), policyPath: SHIPPED_POLICY };
444
+ } catch (e) {
445
+ return { unknown: policyError(e), policyPath: SHIPPED_POLICY };
446
+ }
447
+ }
448
+ try {
449
+ const loaded = loadPolicy({ policyPath: local });
450
+ if (keep) keepGood(home, repoRoot, local, loaded.policyHash);
451
+ return { loaded, policyPath: local };
452
+ } catch (e) {
453
+ const broken = policyError(e);
454
+ const at = goodPolicyPaths(home, repoRoot);
455
+ try {
456
+ const meta = JSON.parse(readFileSync(at.meta, 'utf8'));
457
+ const loaded = loadPolicy({ policyPath: at.policy });
458
+ if (loaded.policyHash !== meta.policyHash) throw new Error('the kept copy changed');
459
+ return { loaded, policyPath: at.policy, fallback: { broken, local, savedAt: meta.savedAt } };
460
+ } catch {
461
+ return { unknown: broken, policyPath: local };
462
+ }
463
+ }
464
+ }
465
+
466
+ // the deny sentence per failing step (rules-unknown keeps one ruleId and code)
467
+ const UNKNOWN_STEP = {
468
+ rules: 'the guard cannot read its rules',
469
+ halt: 'the guard cannot read the kill switch',
470
+ cap: 'the guard cannot check the spend cap',
471
+ secret: 'the guard cannot scan this call for secrets',
472
+ };
473
+
474
+ /**
475
+ * Decide one Event. Returns { verdict, reason, decisions, policyPath, policyHash }.
476
+ * `log` false is `sakk try`: the same path, nothing written.
477
+ */
478
+ export function evaluate(
479
+ ev,
480
+ { env = process.env, log = true, now = Date.now, capCheck = null } = {},
481
+ ) {
482
+ const started = now();
483
+ const overdue = () => {
484
+ if (now() - started > DEADLINE_MS)
485
+ throw new HookError('HOOK_DEADLINE', `no answer within ${DEADLINE_MS} ms`);
486
+ };
487
+ const home = sakkHome(env);
488
+ const repoRoot = repoRootOf(ev.cwd);
489
+ // the rules: config (the ledger it names), then the policy; what cannot be
490
+ // read is isolated to "rules unknown" instead of refusing every call
491
+ let unknown = null;
492
+ // which step could not be read: the deny names it, so a person fixes the
493
+ // right file instead of the rules (F-192: a spend-log error read "rules")
494
+ let unknownStep = 'rules';
495
+ let config = {};
496
+ try {
497
+ config = readConfig(home);
498
+ } catch (e) {
499
+ unknown = e;
500
+ }
501
+ const chosen = choosePolicy({ repoRoot, home, keep: log });
502
+ unknown ??= chosen.unknown ?? null;
503
+ const { policyPath, fallback } = chosen;
504
+ const loaded = chosen.loaded ?? { policy: null, policyHash: RULES_UNKNOWN_HASH };
505
+ if (fallback && log)
506
+ alert(home, {
507
+ kind: 'POLICY_FALLBACK',
508
+ broken: fallback.local,
509
+ why: fallback.broken.message,
510
+ using: policyPath,
511
+ savedAt: fallback.savedAt,
512
+ });
513
+ overdue();
514
+ const actions = actionsOf(ev, { repoRoot, sakkHome: home, homeDir: env.HOME || homedir() });
515
+ const callId = ev.callId ?? `${ev.agent}-${started}`;
516
+ const decisions = [];
517
+ // 1. halt: a stop outranks every rule; reading the repository stays open
518
+ let known = false;
519
+ let halt = null;
520
+ if (unknown === null)
521
+ try {
522
+ ({ known, halt } = haltFor(ev.agent, resolveLedgerPath({ config, env }).path));
523
+ } catch (e) {
524
+ unknown = e;
525
+ unknownStep = 'halt';
526
+ }
527
+ const readsProject = (a) => a.action === 'file.read' && a.scope && !a.scope.startsWith('~');
528
+ // 2. the spend cap (M2) applies to every call once reached. A cap that
529
+ // cannot be checked (unpriced or unreadable spend) is itself a deny, never a
530
+ // pass (spec "Cap": fail closed). Skipped when the rules are already unknown.
531
+ let cap = null;
532
+ if (unknown === null)
533
+ try {
534
+ if (capCheck) cap = capCheck({ home, config, env });
535
+ else if (config.capUsd !== undefined && config.capUsd !== null)
536
+ cap = spendCap({ home, config, table: loadPrices({ home }).table, env, now: now() });
537
+ } catch (e) {
538
+ unknown = e; // a cap that cannot be read refuses every call but the project read
539
+ unknownStep = 'cap';
540
+ }
541
+ // 3. the secret guard (specs/secret-guard): a write whose content, or a
542
+ // command/patch whose text, contains a literal secret is denied — scanned
543
+ // once for the whole call. A scan error fails closed via unknown.
544
+ let secret = null;
545
+ if (unknown === null)
546
+ try {
547
+ secret = secretHit(ev);
548
+ } catch (e) {
549
+ unknown = e;
550
+ unknownStep = 'secret';
551
+ }
552
+ const secretWhy =
553
+ secret &&
554
+ `a secret (${secret.rule}, fingerprint ${secret.fingerprint}) is in this ${ev.kind === 'shell' ? 'command' : 'content'}; the value is never logged. Remove it or use a reference a person approves. ${DISPUTE}`;
555
+ for (const a of actions) {
556
+ const context = {
557
+ agent: ev.agent,
558
+ action: a.action,
559
+ callId,
560
+ estimatedUsd: 0,
561
+ ...(a.scope ? { scope: a.scope } : {}),
562
+ };
563
+ let d;
564
+ if (unknown !== null)
565
+ d = readsProject(a)
566
+ ? { verdict: 'allow', ruleId: 'rules-unknown', policyHash: loaded.policyHash, context }
567
+ : {
568
+ verdict: 'deny',
569
+ ruleId: 'rules-unknown',
570
+ policyHash: loaded.policyHash,
571
+ context,
572
+ why: `${UNKNOWN_STEP[unknownStep]} (${unknown.code ?? 'HOOK_FAILED'}: ${String(unknown.message ?? unknown).replace(/^[A-Z_]+: /, '')}). Reading the project still works; a person fixes the file named above and the next call is judged normally`,
573
+ };
574
+ else if (halt && !readsProject(a))
575
+ d = {
576
+ verdict: 'deny',
577
+ ruleId: 'halt',
578
+ policyHash: loaded.policyHash,
579
+ context,
580
+ why: `halted (${halt.scope}) by ${halt.by} at ${halt.at}${halt.reason ? ` — ${halt.reason}` : ''}; a person resumes with: sakk resume --by <name>${halt.scope === 'agent' ? ` --agent ${ev.agent}` : ''}`,
581
+ };
582
+ else if (cap?.deny)
583
+ d = {
584
+ verdict: 'deny',
585
+ ruleId: 'spend-cap',
586
+ policyHash: loaded.policyHash,
587
+ context,
588
+ why: cap.reason,
589
+ };
590
+ else if (secret)
591
+ d = {
592
+ verdict: 'deny',
593
+ ruleId: 'secret',
594
+ policyHash: loaded.policyHash,
595
+ context,
596
+ why: secretWhy,
597
+ };
598
+ else {
599
+ try {
600
+ d = decide({ policy: loaded.policy, policyHash: loaded.policyHash, context });
601
+ if (typeof context.scope === 'string' && context.scope !== context.scope.toLowerCase()) {
602
+ const f = decide({
603
+ policy: folded(loaded.policy),
604
+ policyHash: loaded.policyHash,
605
+ context: { ...context, scope: context.scope.toLowerCase() },
606
+ });
607
+ if (RANK[f.verdict] > RANK[d.verdict]) d = f;
608
+ }
609
+ } catch (e) {
610
+ throw new HookError('HOOK_CONTEXT_INVALID', e.message);
611
+ }
612
+ }
613
+ decisions.push(d);
614
+ }
615
+ // "A secret is always a deny" is a call-level floor, not a per-action rule: a
616
+ // comment-only or bare `NAME=<secret>` command tokenizes to zero actions, so
617
+ // the loop above emits nothing and the call would default to allow (F-160).
618
+ if (secretWhy && decisions.length === 0)
619
+ decisions.push({
620
+ verdict: 'deny',
621
+ ruleId: 'secret',
622
+ policyHash: loaded.policyHash,
623
+ context: { agent: ev.agent, action: ev.kind, callId, estimatedUsd: 0 },
624
+ why: secretWhy,
625
+ });
626
+ overdue();
627
+ if (log) {
628
+ const decisionLogPath = join(home, 'decisions.jsonl');
629
+ try {
630
+ mkdirSync(home, { recursive: true });
631
+ } catch (e) {
632
+ throw new HookError('DECISION_UNLOGGABLE', `${home}: ${e.code ?? e.message}`);
633
+ }
634
+ const by = isDoctorProbe(ev, env) ? DOCTOR_BY : ev.agent;
635
+ for (const d of decisions) {
636
+ // the log line keeps decide()'s shape, which is public API: the reason text stays out
637
+ const decision = Object.fromEntries(Object.entries(d).filter(([k]) => k !== 'why'));
638
+ try {
639
+ logDecision({ decisionLogPath, decision, by });
640
+ } catch (e) {
641
+ throw new HookError('DECISION_UNLOGGABLE', e.message);
642
+ }
643
+ }
644
+ }
645
+ const worst = decisions.reduce((w, d) => (RANK[d.verdict] > RANK[w.verdict] ? d : w), {
646
+ verdict: 'allow',
647
+ });
648
+ return {
649
+ verdict: worst.verdict,
650
+ reason: worst.verdict === 'allow' ? '' : reasonOf(worst, policyPath, fallback),
651
+ decisions,
652
+ policyPath,
653
+ policyHash: loaded.policyHash,
654
+ fallback: fallback
655
+ ? { broken: fallback.local, why: fallback.broken.message, savedAt: fallback.savedAt }
656
+ : null,
657
+ rulesUnknown: unknown ? (unknown.code ?? 'HOOK_FAILED') : null,
658
+ haltKnown: known,
659
+ };
660
+ }
661
+
662
+ const DISPUTE = 'If this is wrong, tell the person you work for and stop; do not route around it.';
663
+
664
+ function reasonOf(d, policyPath, fallback) {
665
+ const what = `${d.context.action}${d.context.scope ? ` ${d.context.scope}` : ''}`;
666
+ if (['halt', 'spend-cap', 'rules-unknown', 'secret'].includes(d.ruleId))
667
+ return `Sakk: ${what} refused — ${d.why}. ${DISPUTE}`;
668
+ if (fallback)
669
+ return `${reasonOf(d, policyPath)} The project policy ${fallback.local} is not in effect (${fallback.broken.message}); Sakk is using the last policy that loaded, kept ${fallback.savedAt}.`;
670
+ const rule = d.ruleId ?? 'the default';
671
+ return d.verdict === 'deny'
672
+ ? `Sakk: ${what} is denied by rule ${rule} (${policyPath}). A person changes the policy. ${DISPUTE}`
673
+ : `Sakk: ${what} needs a person's approval (rule ${rule}, ${policyPath}).`;
674
+ }
675
+
676
+ /** The answer in each agent's own format (spec "Answer per agent"). */
677
+ export function answer(ev, verdict, reason) {
678
+ if (ev.agent === 'cursor') {
679
+ if (verdict === 'allow') return JSON.stringify({ permission: 'allow' });
680
+ // ask is enforced only before shell and MCP execution; elsewhere it would let the call run
681
+ const ask =
682
+ verdict === 'require_approval' &&
683
+ ['beforeShellExecution', 'beforeMCPExecution'].includes(ev.cursorEvent);
684
+ return JSON.stringify({
685
+ permission: ask ? 'ask' : 'deny',
686
+ user_message: reason,
687
+ agent_message: reason,
688
+ });
689
+ }
690
+ if (verdict === 'allow') return ''; // silent: Sakk only restricts; the agent's own permissions still apply
691
+ // Codex runs the call on "ask" (decision 3, 2026-10-21): a deny there
692
+ const decision = verdict === 'deny' || ev.agent === 'codex' ? 'deny' : 'ask';
693
+ return JSON.stringify({
694
+ hookSpecificOutput: {
695
+ hookEventName: 'PreToolUse',
696
+ permissionDecision: decision,
697
+ permissionDecisionReason: reason,
698
+ },
699
+ });
700
+ }
701
+
702
+ /** The whole hook: raw stdin text in, stdout text out. Never throws. */
703
+ export function runHook(agent, raw, opts = {}) {
704
+ let ev = { agent, cursorEvent: undefined };
705
+ try {
706
+ if (!AGENTS.includes(agent))
707
+ throw new HookError('HOOK_USAGE', `--agent must be one of ${AGENTS.join(', ')}`);
708
+ if (typeof raw !== 'string' || raw.length > MAX_PAYLOAD)
709
+ throw new HookError(
710
+ 'HOOK_INPUT_TOO_LARGE',
711
+ `the payload is larger than ${MAX_PAYLOAD} bytes`,
712
+ );
713
+ let p;
714
+ try {
715
+ p = JSON.parse(raw);
716
+ } catch {
717
+ throw new HookError('HOOK_PAYLOAD_INVALID', 'the payload is not JSON');
718
+ }
719
+ ev = normalize(agent, p);
720
+ const r = evaluate(ev, opts);
721
+ return answer(ev, r.verdict, r.reason);
722
+ } catch (e) {
723
+ const code = e?.code ?? 'HOOK_FAILED';
724
+ return answer(
725
+ ev,
726
+ 'deny',
727
+ `Sakk: refused — ${code}: ${String(e?.message ?? e).replace(/^[A-Z_]+: /, '')}. The guard could not decide, so it denies.`,
728
+ );
729
+ }
730
+ }