@sakk-dev/sakk 0.0.0-stage → 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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +27 -0
- package/CHANGELOG.md +1421 -0
- package/LICENSE +202 -0
- package/NOTICE +82 -0
- package/README.md +620 -2
- package/README.zh-CN.md +555 -0
- package/bin/sakk.mjs +1460 -0
- package/integrations/litellm/LICENSE +202 -0
- package/integrations/litellm/NOTICE +82 -0
- package/integrations/litellm/README.md +366 -0
- package/integrations/litellm/config.example.yaml +52 -0
- package/integrations/litellm/sakk_gateway.py +823 -0
- package/package.json +88 -4
- package/policies/guard-default.json +161 -0
- package/policies/permissive-dev.json +22 -0
- package/prices/snapshot.json +1404 -0
- package/src/append.mjs +81 -0
- package/src/archive.mjs +489 -0
- package/src/classify.mjs +914 -0
- package/src/console-login.mjs +374 -0
- package/src/console.mjs +1628 -0
- package/src/gateway-status.mjs +203 -0
- package/src/guard-install.mjs +387 -0
- package/src/hook.mjs +730 -0
- package/src/ledger.mjs +1229 -0
- package/src/match.mjs +52 -0
- package/src/mcp.mjs +299 -0
- package/src/policy.mjs +423 -0
- package/src/prices.mjs +152 -0
- package/src/secret-rules.generated.mjs +225 -0
- package/src/secret-scan.mjs +489 -0
- package/src/spend.mjs +443 -0
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
|
+
}
|