@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.
@@ -0,0 +1,914 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // The guard's classifier (specs/guard/spec.md, "Actions"): what a tool call
4
+ // will do, named in a fixed vocabulary the policy matches on. Action names are
5
+ // public API. The shell tokenizer here is also the one the self-evolution loop
6
+ // uses for its own shell gate (scripts/findings-lib.mjs), so there is one
7
+ // parser, measured in both places (F-133).
8
+ //
9
+ // No I/O except resolving real paths (symlinks), which callers may replace.
10
+
11
+ import { lstatSync, readlinkSync, realpathSync } from 'node:fs';
12
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
13
+
14
+ /** Input over this many characters is refused as unclassifiable (C-04). */
15
+ export const MAX_COMMAND = 64 * 1024;
16
+
17
+ export class ClassifyError extends Error {
18
+ constructor(code, message) {
19
+ super(`${code}: ${message}`);
20
+ this.code = code;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Split a command into segments (arrays of words) outside quotes, collecting
26
+ * output-redirect targets, whether anything opaque (heredoc, command
27
+ * substitution) appears, and whether a variable or brace expansion the guard
28
+ * cannot resolve statically does (F-176). Linear in the length; no backtracking.
29
+ */
30
+ export function shellSegments(cmd) {
31
+ const segs = [];
32
+ const redirects = [];
33
+ let words = [];
34
+ let w = '';
35
+ let inWord = false;
36
+ let q = null;
37
+ let opaque = false;
38
+ let expands = false; // a variable or brace expansion the guard cannot resolve (F-176)
39
+ let uq = ''; // the word's unquoted text: quoted or escaped braces never expand
40
+ let wantTarget = false;
41
+ let heredoc = null; // delimiter waiting for the end of the line
42
+ let hasRedirect = false;
43
+ const endWord = () => {
44
+ if (inWord) {
45
+ // a brace with a comma or range in the word's unquoted text expands (F-176)
46
+ if (/\{[^{}]*(,|\.\.)[^{}]*\}/.test(uq)) expands = true;
47
+ if (wantTarget) {
48
+ redirects.push({ seg: segs.length, target: w }); // resolved in its own segment's directory
49
+ wantTarget = false;
50
+ hasRedirect = true;
51
+ } else words.push(w);
52
+ }
53
+ w = '';
54
+ uq = '';
55
+ inWord = false;
56
+ };
57
+ const endSeg = () => {
58
+ endWord();
59
+ if (words.length || hasRedirect) segs.push(words);
60
+ words = [];
61
+ hasRedirect = false;
62
+ };
63
+ for (let i = 0; i < cmd.length; i++) {
64
+ const c = cmd[i];
65
+ if (q === "'") {
66
+ if (c === "'") q = null;
67
+ else w += c;
68
+ continue;
69
+ }
70
+ if (q === '"') {
71
+ if (c === '"') q = null;
72
+ else if (c === '\\' && i + 1 < cmd.length) w += cmd[++i];
73
+ else {
74
+ if (c === '`' || (c === '$' && cmd[i + 1] === '(')) opaque = true;
75
+ else if (c === '$' && /[\w{@*#?$!-]/.test(cmd[i + 1] ?? '')) expands = true;
76
+ w += c;
77
+ }
78
+ continue;
79
+ }
80
+ if (c === "'" || c === '"') {
81
+ q = c;
82
+ inWord = true;
83
+ continue;
84
+ }
85
+ if (c === '\\') {
86
+ if (cmd[i + 1] === '\n') i++;
87
+ else if (i + 1 < cmd.length) {
88
+ w += cmd[++i];
89
+ inWord = true;
90
+ }
91
+ continue;
92
+ }
93
+ if (c === '#' && !inWord) {
94
+ while (i + 1 < cmd.length && cmd[i + 1] !== '\n') i++;
95
+ continue;
96
+ }
97
+ if (c === '`' || (c === '$' && cmd[i + 1] === '(')) {
98
+ opaque = true;
99
+ w += c;
100
+ inWord = true;
101
+ continue;
102
+ }
103
+ if (c === '\n') {
104
+ endSeg();
105
+ if (heredoc !== null) {
106
+ // skip the body up to the delimiter line
107
+ const { delim, strip } = heredoc;
108
+ heredoc = null;
109
+ let j = i + 1;
110
+ for (;;) {
111
+ const e = cmd.indexOf('\n', j);
112
+ const line = cmd.slice(j, e < 0 ? cmd.length : e);
113
+ if ((strip ? line.replace(/^\t+/, '') : line) === delim || e < 0) {
114
+ i = e < 0 ? cmd.length : e;
115
+ break;
116
+ }
117
+ j = e + 1;
118
+ }
119
+ }
120
+ continue;
121
+ }
122
+ if (c === '<') {
123
+ endWord();
124
+ if (cmd[i + 1] === '<' && cmd[i + 2] !== '<') {
125
+ opaque = true;
126
+ i += 2;
127
+ const strip = cmd[i] === '-';
128
+ if (strip) i++;
129
+ while (cmd[i] === ' ' || cmd[i] === '\t') i++;
130
+ const m = /^(?:'([^'\n]*)'|"([^"\n]*)"|([^\s;&|<>()]+))/.exec(cmd.slice(i, i + 256));
131
+ if (m) {
132
+ heredoc = { delim: m[1] ?? m[2] ?? m[3], strip };
133
+ i += m[0].length - 1;
134
+ } else i--;
135
+ } else if (cmd[i + 1] === '<') i += 2; // here-string
136
+ continue;
137
+ }
138
+ if (c === '>') {
139
+ if (inWord && /^\d+$/.test(w)) {
140
+ w = '';
141
+ uq = '';
142
+ inWord = false;
143
+ } else endWord();
144
+ if (cmd[i + 1] === '>' || cmd[i + 1] === '|') i++;
145
+ if (cmd[i + 1] === '&') {
146
+ i++;
147
+ if (/[\d-]/.test(cmd[i + 1] ?? '')) {
148
+ while (/[\d-]/.test(cmd[i + 1] ?? '')) i++;
149
+ continue;
150
+ }
151
+ }
152
+ wantTarget = true;
153
+ continue;
154
+ }
155
+ if (c === '&' && cmd[i + 1] === '>') {
156
+ endWord();
157
+ i++;
158
+ if (cmd[i + 1] === '>') i++;
159
+ wantTarget = true;
160
+ continue;
161
+ }
162
+ if (c === '|' || c === '&' || c === ';' || c === '(' || c === ')' || c === '{' || c === '}') {
163
+ if ((c === '{' || c === '}') && inWord) {
164
+ w += c;
165
+ uq += c;
166
+ continue;
167
+ }
168
+ // a word-start brace holding anything but a lone `}` (a `{}` placeholder,
169
+ // a `{ group; }`) is a brace expansion the shell performs later (F-176)
170
+ if (c === '{' && /[^\s}]/.test(cmd[i + 1] ?? '')) expands = true;
171
+ endSeg();
172
+ continue;
173
+ }
174
+ if (c === ' ' || c === '\t') {
175
+ endWord();
176
+ continue;
177
+ }
178
+ if (c === '$' && /[\w{@*#?$!-]/.test(cmd[i + 1] ?? '')) expands = true;
179
+ w += c;
180
+ uq += c;
181
+ inWord = true;
182
+ }
183
+ endSeg();
184
+ if (q !== null) opaque = true; // unterminated quote: cannot be read
185
+ return { segs, redirects, opaque, expands };
186
+ }
187
+
188
+ // --- scopes -------------------------------------------------------------------
189
+
190
+ /** Links followed by hand before a path is refused (Linux MAXSYMLINKS); a loop ends here (F-186). */
191
+ const MAX_LINK_HOPS = 40;
192
+
193
+ /**
194
+ * The real path of the nearest existing part, plus the rest: symlinks
195
+ * resolved, `..` gone. A dangling link is followed to where it points, so a
196
+ * write through it is judged at its target, not at the link's harmless name
197
+ * (F-186).
198
+ */
199
+ export function realOf(p) {
200
+ const tail = [];
201
+ let d = resolve(p);
202
+ let hops = 0;
203
+ for (;;) {
204
+ try {
205
+ return join(realpathSync.native(d), ...tail);
206
+ } catch {
207
+ let link = false;
208
+ try {
209
+ link = lstatSync(d).isSymbolicLink();
210
+ } catch {
211
+ // absent: walk up
212
+ }
213
+ if (link) {
214
+ if (++hops > MAX_LINK_HOPS)
215
+ throw new ClassifyError(
216
+ 'HOOK_PATH_UNREADABLE',
217
+ `more than ${MAX_LINK_HOPS} symbolic links: ${p}`,
218
+ );
219
+ d = resolve(dirname(d), readlinkSync(d));
220
+ continue;
221
+ }
222
+ const up = dirname(d);
223
+ if (up === d) return resolve(p);
224
+ tail.unshift(basename(d));
225
+ d = up;
226
+ }
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Canonical scope of a path (spec "Scopes"): repo-relative inside the
232
+ * repository, `~sakk/…` under SAKK_HOME, `~home/…` under the home folder,
233
+ * otherwise `~outside/<absolute>`. The home folder is named before the
234
+ * absolute path so that an allow written for a folder (a temp folder) never
235
+ * covers it, wherever HOME lives (`HOME=/tmp` in containers; F-146). The
236
+ * kernel matches canonical relative paths only, so these are the only shapes.
237
+ */
238
+ export function scopeOf(path, { repoRoot, sakkHome, homeDir, real = realOf }) {
239
+ const abs = real(path);
240
+ const under = (base) => {
241
+ if (!base) return null;
242
+ const r = relative(real(base), abs);
243
+ if (r === '') return '.';
244
+ return r.startsWith('..') || isAbsolute(r) ? null : r.split(sep).join('/');
245
+ };
246
+ const inSakk = under(sakkHome);
247
+ if (inSakk !== null) return inSakk === '.' ? '~sakk' : `~sakk/${inSakk}`;
248
+ const inRepo = under(repoRoot);
249
+ if (inRepo !== null) {
250
+ if (inRepo === '.') return null; // the repository itself: no canonical relative name, so no scope
251
+
252
+ if (inRepo.startsWith('~'))
253
+ throw new ClassifyError(
254
+ 'HOOK_PATH_UNREADABLE',
255
+ `a repository path may not start with ~: ${inRepo}`,
256
+ );
257
+ return inRepo;
258
+ }
259
+ const inHome = under(homeDir);
260
+ if (inHome !== null) return inHome === '.' ? '~home' : `~home/${inHome}`;
261
+ const parts = abs.split(sep).filter(Boolean);
262
+ return parts.length ? `~outside/${parts.join('/')}` : '~outside';
263
+ }
264
+
265
+ // --- secret files (the guard's first line; M3 adds content checks) ---------
266
+
267
+ const SECRET_NAME =
268
+ /^(\.env(\..+)?|.*\.pem|.*\.key|id_(rsa|dsa|ecdsa|ed25519)(\.old)?|.*\.p12|.*\.pfx|.*\.keystore|\.npmrc|\.pypirc|\.netrc|\.pgpass|\.git-credentials|credentials|\.htpasswd)$/i;
269
+ const NOT_SECRET = /^\.env\.(example|sample|template|defaults?|dist)$/i;
270
+ // the process-environment pseudo-file is every exported secret at once (F-203)
271
+ const PROC_ENVIRON = /^\/proc\/(\d+|self|thread-self)(\/task\/\d+)?\/environ$/;
272
+ export const isSecretPath = (p) => {
273
+ if (PROC_ENVIRON.test(p)) return true;
274
+ const b = basename(p);
275
+ return SECRET_NAME.test(b) && !NOT_SECRET.test(b);
276
+ };
277
+
278
+ // --- shell --------------------------------------------------------------------
279
+
280
+ /**
281
+ * Wrapper commands, the flags of each that take a value (`sudo -u root`) and
282
+ * one sample of such a flag: the value is skipped with its flag, or it would
283
+ * be read as the command's name (F-145). A wrapper is added here with its
284
+ * value flags and a sample, never alone; the guard tests run every sample.
285
+ */
286
+ export const WRAPPERS = new Map([
287
+ [
288
+ 'sudo',
289
+ {
290
+ values: /^-[ugCDhprtUT]$|^--(user|group|close-from|chdir|host|prompt|role|type|other-user)$/,
291
+ sample: '-u root',
292
+ },
293
+ ],
294
+ ['doas', { values: /^-[uC]$/, sample: '-u root' }],
295
+ ['nice', { values: /^-n$|^--adjustment$/, sample: '-n 5' }],
296
+ ['time', { values: /^-[fo]$|^--(format|output)$/, sample: '-o /dev/null' }],
297
+ ['timeout', { values: /^-[sk]$|^--(signal|kill-after)$/, sample: '-s KILL 30' }],
298
+ ['exec', { values: /^-a$/, sample: '-a name' }],
299
+ ['nohup', { values: null, sample: '' }],
300
+ ['command', { values: null, sample: '-p' }],
301
+ ['builtin', { values: null, sample: '' }],
302
+ ]);
303
+ const READERS = new Set(
304
+ 'cat head tail less more bat nl od xxd hexdump strings tac wc file stat cut sort uniq diff cmp'.split(
305
+ ' ',
306
+ ),
307
+ );
308
+ const GREPS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack']);
309
+ // shell builtins that bind or unbind names: their arguments spell names, never
310
+ // files they read, so the F-203 piece scan does not judge them
311
+ const BINDERS = new Set([
312
+ 'export',
313
+ 'declare',
314
+ 'typeset',
315
+ 'local',
316
+ 'readonly',
317
+ 'set',
318
+ 'unset',
319
+ 'alias',
320
+ ]);
321
+ /**
322
+ * Commands whose whole purpose is printing or handing over a credential: a
323
+ * closed set, each member pinned by a test, so a new member is added together
324
+ * with its test (F-203). They print the secret with no secret-named file in
325
+ * the command's text.
326
+ */
327
+ const credentialPrint = (name, plain, args) => {
328
+ if (name === 'gh' && plain[0] === 'auth')
329
+ return (
330
+ plain[1] === 'token' ||
331
+ (plain[1] === 'status' && args.some((a) => /^--show-token$|^-t$/.test(a)))
332
+ );
333
+ if (name === 'aws' && plain[0] === 'configure')
334
+ return (
335
+ plain[1] === 'export-credentials' ||
336
+ (plain[1] === 'get' && /(secret|token|key|password)/i.test(plain[2] ?? ''))
337
+ );
338
+ if (name === 'aws' && plain[0] === 'sts')
339
+ return /^(get-session-token|get-federation-token|assume-role)/.test(plain[1] ?? '');
340
+ if (name === 'npm' && plain[0] === 'token') return true;
341
+ if (name === 'npm' && plain[0] === 'config' && plain[1] === 'get')
342
+ return /(_auth|token|password)/i.test(plain[2] ?? '');
343
+ if (name === 'gcloud' && plain[0] === 'auth')
344
+ return /^print-(access|identity)-token$/.test(plain[1] ?? '');
345
+ return false;
346
+ };
347
+ // commands that run another under a pseudo-terminal, and can type into it: the
348
+ // confirmation `sakk cap off` asks a person for must not be answerable this way (F-171)
349
+ const PTY_DRIVERS = new Set(['script', 'expect', 'unbuffer']);
350
+ /** Where each agent keeps the hook config `sakk guard` writes, from the base folder. */
351
+ export const AGENT_CONFIG = Object.freeze({
352
+ 'claude-code': join('.claude', 'settings.json'),
353
+ cursor: join('.cursor', 'hooks.json'),
354
+ codex: join('.codex', 'hooks.json'),
355
+ });
356
+ const INTERPRETERS = /^(python[\d.]*|node|deno|bun|ruby|perl|php)$/;
357
+ const SHELLS = /^(sh|bash|zsh|dash|ksh|fish)$/;
358
+ const DEFAULT_BRANCHES = /^(main|master|trunk|develop)$/;
359
+
360
+ /**
361
+ * The name a word runs, in lower case: the default macOS volume finds `RM`,
362
+ * `SUDO` and `GIT` as rm, sudo and git, so they are judged as those (F-183).
363
+ */
364
+ const cmdName = (w) => basename(w).toLowerCase();
365
+
366
+ /** Strip environment assignments and wrapper commands; the word that remains names the command. */
367
+ function unwrap(seg) {
368
+ let k = 0;
369
+ for (;;) {
370
+ const w = seg[k] ?? '';
371
+ const n = cmdName(w);
372
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) k++;
373
+ else if (WRAPPERS.has(n)) {
374
+ const { values } = WRAPPERS.get(n);
375
+ k++;
376
+ // the wrapper's own flags, and the value of each flag that takes one
377
+ while ((seg[k] ?? '').startsWith('-')) k += values?.test(seg[k]) ? 2 : 1;
378
+ if (n === 'timeout') k++; // the duration
379
+ } else if (n === 'env') {
380
+ k++;
381
+ while ((seg[k] ?? '').startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(seg[k] ?? ''))
382
+ k += /^-[uSC]$|^--(unset|chdir|split-string)$/.test(seg[k]) ? 2 : 1;
383
+ if (k >= seg.length) return { name: 'env', args: [] }; // bare env prints the environment
384
+ } else break;
385
+ }
386
+ const words = seg.slice(k);
387
+ return { name: cmdName(words[0] ?? ''), args: words.slice(1) };
388
+ }
389
+
390
+ const dedupe = (list) => {
391
+ const seen = new Set();
392
+ return list.filter((a) => {
393
+ const k = `${a.action}\u0000${a.path ?? ''}`;
394
+ if (seen.has(k)) return false;
395
+ seen.add(k);
396
+ return true;
397
+ });
398
+ };
399
+
400
+ /**
401
+ * Actions a shell command takes: [{ action, path? }]. Paths are absolute,
402
+ * resolved against the command's own `cd`s; the caller turns them into
403
+ * scopes. A form the classifier does not know is `shell.run`.
404
+ */
405
+ export function shellActions(
406
+ cmd,
407
+ cwd,
408
+ { home = process.env.HOME ?? '', sakkHome = '', repoRoot = null, depth = 0 } = {},
409
+ ) {
410
+ if (typeof cmd !== 'string')
411
+ throw new ClassifyError('HOOK_PAYLOAD_INVALID', 'the command is not text');
412
+ if (cmd.length > MAX_COMMAND)
413
+ throw new ClassifyError(
414
+ 'HOOK_INPUT_TOO_LARGE',
415
+ `the command is longer than ${MAX_COMMAND} characters`,
416
+ );
417
+ const out = [];
418
+ const add = (action, path) => out.push(path === undefined ? { action } : { action, path });
419
+ const { segs, redirects, opaque, expands } = shellSegments(cmd);
420
+ if (opaque) add('shell.constructed'); // command substitution, a heredoc or an unterminated quote
421
+ // a variable or brace expansion the guard cannot resolve statically: the
422
+ // shell substitutes it after the hook judged the written word, so the
423
+ // command is constructed — asked, never allowed (F-176)
424
+ if (expands) add('shell.constructed');
425
+ // curl … | sh, base64 -d | bash: the text that runs is built at run time
426
+ if (/\|\s*(sudo\s+)?(sh|bash|zsh|dash|python[\d.]*|node|perl|ruby)\b/.test(cmd))
427
+ add('shell.constructed');
428
+ let here = cwd;
429
+ const abs = (p) =>
430
+ p === '~' ? home : p.startsWith('~/') ? join(home, p.slice(2)) : resolve(here, p);
431
+ for (const [i, seg] of segs.entries()) {
432
+ for (const r of redirects.filter((x) => x.seg === i))
433
+ if (!/^\/dev\/(null|stdout|stderr|tty|fd\/\d+)$/.test(r.target))
434
+ add('file.write', abs(r.target));
435
+ const { name, args } = unwrap(seg);
436
+ if (!name) continue;
437
+ const plain = args.filter((a) => !a.startsWith('-'));
438
+ const has = (re) => args.some((a) => re.test(a));
439
+ // a secret filename is judged wherever it appears in the command — inside
440
+ // an upload reference (`file=@…`), a git version-path (`HEAD:…`), a mount
441
+ // or remote-copy spec — not only as a plain operand of a known reader (F-203)
442
+ if (!BINDERS.has(name))
443
+ for (const a of args)
444
+ if (/[=@:]/.test(a))
445
+ for (const piece of a.split(/[=@:]+/))
446
+ if (piece && isSecretPath(piece)) add('secret.read', abs(piece));
447
+ if (name === 'cd') {
448
+ if (plain[0] && plain[0] !== '-') here = abs(plain[0]);
449
+ continue;
450
+ }
451
+ if (name === 'eval' || name === 'source' || name === '.') {
452
+ add('shell.constructed');
453
+ continue;
454
+ }
455
+ if (SHELLS.test(name)) {
456
+ const ci = args.findIndex((a) => /^-[a-z]*c[a-z]*$/.test(a));
457
+ const script = ci >= 0 ? args[ci + 1] : undefined;
458
+ if (script === undefined) add('shell.run');
459
+ else if (/[$`]/.test(script) || depth >= 3) add('shell.constructed');
460
+ else out.push(...shellActions(script, here, { home, sakkHome, repoRoot, depth: depth + 1 }));
461
+ continue;
462
+ }
463
+ if (name === 'git') {
464
+ // a spelled-out secret among git's arguments is reached whatever the
465
+ // subcommand does with it — add, diff, show, stash alike (F-203)
466
+ for (const a of args) if (!a.startsWith('-') && isSecretPath(a)) add('secret.read', abs(a));
467
+ gitActions(args, here, abs, add);
468
+ continue;
469
+ }
470
+ if (['rm', 'rmdir', 'unlink', 'shred'].includes(name)) {
471
+ for (const f of plain) add('file.delete', abs(f));
472
+ continue;
473
+ }
474
+ if (name === 'dd') {
475
+ // dd names its files by if=/of=, not as positional words (F-153)
476
+ for (const a of args)
477
+ if (a.startsWith('of=')) add('file.write', abs(a.slice(3)));
478
+ else if (a.startsWith('if=')) add('file.read', abs(a.slice(3)));
479
+ continue;
480
+ }
481
+ if (name === 'tar') {
482
+ // an extraction writes into its directory; otherwise the archive is written
483
+ // and its members read; --remove-files deletes the members (F-153)
484
+ const extract = has(/^-?[a-zA-Z]*x[a-zA-Z]*$|^--extract$|^--get$/);
485
+ const fi = args.findIndex((a) => /^-?[a-zA-Z]*f$|^--file$/.test(a));
486
+ const archive = fi >= 0 ? args[fi + 1] : undefined;
487
+ const ci = args.findIndex((a) => a === '-C' || a === '--directory');
488
+ const into = ci >= 0 ? args[ci + 1] : '.';
489
+ const members = plain.filter((a) => a !== archive && a !== into);
490
+ if (extract) add('file.write', abs(into));
491
+ else if (archive && archive !== '-') add('file.write', abs(archive));
492
+ const remove = has(/^--remove-files$/);
493
+ for (const m of members) add(remove ? 'file.delete' : 'file.read', abs(m));
494
+ continue;
495
+ }
496
+ if (name === 'rsync') {
497
+ // --delete removes dest files missing from source; --remove-source-files
498
+ // deletes what it sent — both destroy, so name them (F-153)
499
+ const dest = plain.at(-1);
500
+ for (const f of plain.slice(0, -1)) add('file.read', abs(f));
501
+ if (dest) add(has(/^--delete/) ? 'file.delete' : 'file.write', abs(dest));
502
+ if (has(/^--remove-source-files$/))
503
+ for (const f of plain.slice(0, -1)) add('file.delete', abs(f));
504
+ continue;
505
+ }
506
+ if (name === 'find') {
507
+ const roots = [];
508
+ for (const a of args) {
509
+ if (a.startsWith('-') || a === '(' || a === '!') break;
510
+ roots.push(a);
511
+ }
512
+ const over = roots.length ? roots : ['.'];
513
+ if (has(/^-delete$/)) for (const r of over) add('file.delete', abs(r));
514
+ const ex = args.findIndex((a) => /^-(exec|execdir|ok|okdir)$/.test(a));
515
+ if (ex >= 0) {
516
+ // what find runs on each match is judged as if run on each root (F-154)
517
+ const inner = unwrap(
518
+ args.slice(ex + 1).filter((a) => a !== ';' && a !== '+' && a !== '{}'),
519
+ );
520
+ if (['rm', 'rmdir', 'unlink', 'shred'].includes(inner.name))
521
+ for (const r of over) add('file.delete', abs(r));
522
+ else if (READERS.has(inner.name) || GREPS.has(inner.name))
523
+ for (const r of over) add('file.read', abs(r));
524
+ else for (const r of over) add('file.write', abs(r));
525
+ } else if (!has(/^-delete$/)) add('shell.run');
526
+ continue;
527
+ }
528
+ if (name === 'xargs') {
529
+ // the words come from stdin, so the real command is built at run time;
530
+ // only a reader is safe to treat as read, anything else is constructed (F-154)
531
+ let k = 0;
532
+ while ((args[k] ?? '').startsWith('-')) k += /^-[IdLnPsE]$/.test(args[k]) ? 2 : 1;
533
+ const inner = cmdName(args[k] ?? '');
534
+ add(
535
+ READERS.has(inner) || GREPS.has(inner) || inner === 'echo'
536
+ ? 'shell.run'
537
+ : 'shell.constructed',
538
+ );
539
+ continue;
540
+ }
541
+ if (/^(perl|ruby)$/.test(name) && has(/^-[a-zA-Z]*i/)) {
542
+ // in-place edit: the operand files are written (F-153, like sed -i)
543
+ for (const f of plain) add('file.write', abs(f));
544
+ continue;
545
+ }
546
+ if (name === 'mv') {
547
+ // a move reads the source into the destination before unlinking it, so a
548
+ // secret source is a secret read, not only a delete (F-203)
549
+ for (const f of plain.slice(0, -1)) {
550
+ add('file.read', abs(f));
551
+ add('file.delete', abs(f));
552
+ }
553
+ if (plain.length) add('file.write', abs(plain.at(-1)));
554
+ continue;
555
+ }
556
+ if (['cp', 'install', 'ln', 'rsync'].includes(name)) {
557
+ // A link (ln, or cp -s/-l) created mid-chain defeats static path
558
+ // resolution: a later write/read through the new name resolves to the cwd
559
+ // at classify time but to the link's target at run time — the symlink/
560
+ // hard-link TOCTOU that let an agent reach ~/.sakk in one command (F-163).
561
+ // The guard cannot follow a link that does not exist yet, so a chained
562
+ // link-create is treated as constructed (a person approves it).
563
+ const makesLink = name === 'ln' || (name === 'cp' && has(/^-[a-z]*[sl]|^--(symbolic|link)/));
564
+ if (makesLink && segs.length > 1) add('shell.constructed');
565
+ for (const f of plain.slice(0, -1)) add('file.read', abs(f));
566
+ if (plain.length) add('file.write', abs(plain.at(-1)));
567
+ continue;
568
+ }
569
+ if (['tee', 'touch', 'mkdir', 'truncate'].includes(name)) {
570
+ for (const f of plain) add('file.write', abs(f));
571
+ continue;
572
+ }
573
+ if (name === 'chmod' || name === 'chown') {
574
+ for (const f of plain.slice(1)) add('file.write', abs(f));
575
+ continue;
576
+ }
577
+ if (name === 'sed' && has(/^-[a-zA-Z]*i|^--in-place/)) {
578
+ for (const f of plain.slice(1)) add('file.write', abs(f));
579
+ continue;
580
+ }
581
+ if (READERS.has(name)) {
582
+ for (const f of plain) add('file.read', abs(f));
583
+ continue;
584
+ }
585
+ if (name === 'sed' || name === 'awk' || name === 'jq') {
586
+ for (const f of plain.slice(1)) add('file.read', abs(f));
587
+ continue;
588
+ }
589
+ if (GREPS.has(name)) {
590
+ const skip = args.includes('-e') || args.includes('-f') ? 0 : 1;
591
+ for (const f of plain.slice(skip)) add('file.read', abs(f));
592
+ continue;
593
+ }
594
+ if (
595
+ name === 'printenv' ||
596
+ (name === 'env' && args.length === 0) ||
597
+ (name === 'set' && args.length === 0) ||
598
+ (name === 'export' && args.includes('-p')) ||
599
+ (name === 'declare' && has(/^-[a-z]*x/))
600
+ ) {
601
+ add('secret.read');
602
+ continue;
603
+ }
604
+ const verb = plain[0];
605
+ const joined = args.join(' ');
606
+ let modeled = true;
607
+ // a command whose whole purpose is printing a credential is a secret read (F-203)
608
+ if (credentialPrint(name, plain, args)) add('secret.read');
609
+ else if (name === 'kubectl' && verb === 'delete') add('prod.delete');
610
+ else if (name === 'kubectl' && ['apply', 'replace', 'rollout', 'create'].includes(verb))
611
+ add('deploy');
612
+ else if (name === 'terraform' && verb === 'destroy') add('prod.delete');
613
+ else if (name === 'terraform' && verb === 'apply') add('deploy');
614
+ else if (
615
+ name === 'aws' &&
616
+ verb === 's3' &&
617
+ (plain[1] === 'rb' || (plain[1] === 'rm' && has(/^--recursive$/)))
618
+ )
619
+ add('prod.delete');
620
+ else if (name === 'gcloud' && plain.includes('delete')) add('prod.delete');
621
+ else if (['dropdb', 'dropuser'].includes(name)) add('prod.delete');
622
+ else if (
623
+ ['psql', 'mysql', 'sqlite3', 'mongosh', 'mongo', 'redis-cli'].includes(name) &&
624
+ /\b(drop\s+(table|database|schema)|truncate\s+table|flushall|flushdb|dropDatabase)\b/i.test(
625
+ joined,
626
+ )
627
+ )
628
+ add('prod.delete');
629
+ else if (['vercel', 'netlify'].includes(name) && (has(/^--prod$/) || verb === 'deploy'))
630
+ add('deploy');
631
+ else if (['fly', 'flyctl'].includes(name) && verb === 'deploy') add('deploy');
632
+ else if (name === 'railway' && verb === 'up') add('deploy');
633
+ else if (name === 'npm' && verb === 'publish') add('deploy');
634
+ else if (name === 'gh' && verb === 'release' && plain[1] === 'create') add('deploy');
635
+ else if (
636
+ ['npm', 'pnpm', 'yarn', 'bun'].includes(name) &&
637
+ ['install', 'i', 'add'].includes(verb) &&
638
+ plain.length > 1
639
+ )
640
+ add('package.install');
641
+ else if (
642
+ ['pip', 'pip3', 'brew', 'gem', 'apt', 'apt-get', 'cargo', 'go'].includes(name) &&
643
+ ['install', 'add'].includes(verb)
644
+ )
645
+ add('package.install');
646
+ else if (name === 'uv' && (verb === 'add' || (verb === 'pip' && plain[1] === 'install')))
647
+ add('package.install');
648
+ else if (name === 'curl' && has(/^-(o|O)$|^--output|^--remote-name|^-[a-zA-Z]*[oO]$/))
649
+ add('net.download');
650
+ else if (name === 'wget') add('net.download');
651
+ else if (INTERPRETERS.test(name) && has(/^-(c|e)$|^--eval$/)) add('shell.constructed');
652
+ else if (PTY_DRIVERS.has(name)) add('shell.constructed');
653
+ else modeled = false;
654
+ // a word naming the sakk program, anywhere in the command, starts sakk's
655
+ // arguments: npx sakk, pnpm dlx, deno npm:sakk, node .../bin/sakk.mjs and
656
+ // launchers nobody listed all run the same controls (F-185)
657
+ const words = [name, ...args];
658
+ const si = name === 'echo' || name === 'printf' ? -1 : words.findIndex(namesSakk);
659
+ if (si >= 0 && sakkControl(words.slice(si + 1), { abs, home, here, repoRoot, sakkHome }, add))
660
+ modeled = true;
661
+ // a spelled-out secret operand is a secret read whether or not the command
662
+ // is otherwise modeled — the reader list is not the whole world (F-155, F-203)
663
+ const secrets = plain.filter((a) => isSecretPath(a));
664
+ for (const a of secrets) add('secret.read', abs(a));
665
+ if (!modeled && secrets.length === 0) add('shell.run');
666
+ }
667
+ return dedupe(out);
668
+ }
669
+
670
+ /** A word that runs the sakk program: sakk, sakk@0.4.0, npm:sakk, @scope/sakk, .../bin/sakk.mjs (F-185). */
671
+ const namesSakk = (w) =>
672
+ /^sakk(\.[cm]?js)?$/i.test(basename(w.replace(/^npm:/i, '').replace(/(?<=.)@[^/@]*$/, '')));
673
+
674
+ /**
675
+ * Sakk's own commands that change its controls are the writes they make, so
676
+ * the agent is judged as if it wrote the file: the cap, and the ledger the
677
+ * hook reads halts from, are guard state (denied); the hook config is agent
678
+ * config (denied, F-188). A person runs them (F-171, F-181). Every subcommand
679
+ * is declared by its effect in test/kill-switch.test.mjs (R-24). `sk` is the
680
+ * words after the one naming sakk, however it was launched (F-185).
681
+ */
682
+ function sakkControl(sk, { abs, home, here, repoRoot, sakkHome }, add) {
683
+ const plain = sk.filter((a) => !a.startsWith('-') && !namesSakk(a));
684
+ const verb = plain[0];
685
+ if ((verb === 'cap' || verb === 'init') && sakkHome) {
686
+ add('file.write', join(sakkHome, 'config.json'));
687
+ return true;
688
+ }
689
+ // resume, reconcile, unlock and settle lift a halt, clear the books, break
690
+ // the lock or release a reservation — the inverse of the controls that bind
691
+ // the agent, judged as the guard-state write they make: denied, never asked
692
+ // (F-177, D11, F-242). `halt` tightens and stays allowed.
693
+ if (['resume', 'reconcile', 'unlock', 'settle'].includes(verb) && sakkHome) {
694
+ add('file.write', join(sakkHome, 'sakk-ledger.json'));
695
+ return true;
696
+ }
697
+ // gateway init writes the start script an operator later runs, naming the
698
+ // ledger the gateway charges and the executable it starts: judged as that
699
+ // write, in the folder it really writes (--dir, else SAKK_HOME/gateway)
700
+ if (verb === 'gateway' && sakkHome) {
701
+ const d = sk.indexOf('--dir');
702
+ const dir = d >= 0 && sk[d + 1] ? abs(sk[d + 1]) : join(sakkHome, 'gateway');
703
+ add('file.write', join(dir, 'start.sh'));
704
+ return true;
705
+ }
706
+ if (
707
+ verb === 'guard' &&
708
+ ['install', 'uninstall'].includes(plain[1]) &&
709
+ Object.hasOwn(AGENT_CONFIG, plain[2] ?? '')
710
+ ) {
711
+ const base = sk.includes('--user') ? home : (repoRoot ?? here);
712
+ add('file.write', join(base, AGENT_CONFIG[plain[2]]));
713
+ return true;
714
+ }
715
+ return false;
716
+ }
717
+
718
+ /** The git subcommands gitActions judges; everything else is shell.run. */
719
+ const GIT_JUDGED = new Set([
720
+ 'push',
721
+ 'reset',
722
+ 'branch',
723
+ 'filter-branch',
724
+ 'filter-repo',
725
+ 'clean',
726
+ 'rm',
727
+ 'mv',
728
+ 'checkout',
729
+ 'restore',
730
+ 'clone',
731
+ 'credential',
732
+ 'credential-store',
733
+ 'credential-cache',
734
+ ]);
735
+
736
+ /**
737
+ * A long option git reads as one of `names`. git takes any unambiguous prefix
738
+ * (`--forc` is --force) and refuses an ambiguous one itself, so matching every
739
+ * prefix only tightens (F-184).
740
+ */
741
+ const longIs = (a, names) => {
742
+ const m = /^--([a-z][a-z-]*)(=.*)?$/.exec(a);
743
+ return m !== null && names.some((n) => n.startsWith(m[1]));
744
+ };
745
+
746
+ /**
747
+ * The letters of a short-option cluster (`-fu` is -f -u), up to the first
748
+ * letter in `valued`, which takes the rest of the word as its value (F-184).
749
+ */
750
+ const shortFlags = (a, valued = '') => {
751
+ if (!/^-[^-]/.test(a)) return '';
752
+ let s = '';
753
+ for (const c of a.slice(1)) {
754
+ s += c;
755
+ if (valued.includes(c)) break;
756
+ }
757
+ return s;
758
+ };
759
+
760
+ function gitActions(args, here, abs, add) {
761
+ let j = 0;
762
+ // git's own options, some taking the next word as a value; an unregistered
763
+ // value option must not let its value be read as the subcommand (F-156), so
764
+ // if the word we land on is not a judged subcommand we scan on for one
765
+ while (j < args.length && args[j].startsWith('-')) {
766
+ const a = args[j];
767
+ const takes =
768
+ /^-(C|c)$|^--(git-dir|work-tree|namespace|super-prefix|config-env|exec-path)$/.test(a);
769
+ // an alias set for this one run renames any subcommand, so the word after
770
+ // it says nothing about what runs (F-184)
771
+ const set = /^-c$|^--config-env$/.test(a)
772
+ ? (args[j + 1] ?? '')
773
+ : a.startsWith('--config-env=')
774
+ ? a.slice('--config-env='.length)
775
+ : '';
776
+ if (/^alias\./i.test(set)) add('shell.constructed');
777
+ j += takes ? 2 : 1;
778
+ }
779
+ // the default macOS volume runs `git PUSH` as git push (F-183)
780
+ const lc = (w) => (w ?? '').toLowerCase();
781
+ if (!GIT_JUDGED.has(lc(args[j]))) {
782
+ const k = args.findIndex((a, i) => i > j && GIT_JUDGED.has(lc(a)));
783
+ if (k >= 0) j = k;
784
+ }
785
+ const sub = lc(args[j]);
786
+ const rest = args.slice(j + 1);
787
+ const plain = rest.filter((a) => !a.startsWith('-'));
788
+ // option words compared in lower case; paths and refspecs keep theirs
789
+ const opts = rest.filter((a) => a.startsWith('-')).map(lc);
790
+ const has = (test) => opts.some(test);
791
+ const dd = rest.indexOf('--');
792
+ const paths = dd >= 0 ? rest.slice(dd + 1) : [];
793
+ switch (sub) {
794
+ case 'push':
795
+ if (
796
+ has(
797
+ (a) =>
798
+ /[fd]/.test(shortFlags(a, 'o')) ||
799
+ longIs(a, [
800
+ 'force',
801
+ 'force-with-lease',
802
+ 'force-if-includes',
803
+ 'mirror',
804
+ 'delete',
805
+ 'prune',
806
+ ]),
807
+ ) ||
808
+ plain.slice(1).some((r) => r.startsWith('+') || r.startsWith(':'))
809
+ )
810
+ add('git.destructive');
811
+ else add('shell.run');
812
+ return;
813
+ case 'reset':
814
+ add(has((a) => longIs(a, ['hard', 'merge', 'keep'])) ? 'git.destructive' : 'shell.run');
815
+ return;
816
+ case 'branch':
817
+ if (
818
+ has((a) => shortFlags(a, 'u').includes('d') || longIs(a, ['delete'])) &&
819
+ plain.some((b) => DEFAULT_BRANCHES.test(b))
820
+ )
821
+ add('git.destructive');
822
+ else add('shell.run');
823
+ return;
824
+ case 'filter-branch':
825
+ case 'filter-repo':
826
+ add('git.destructive');
827
+ return;
828
+ case 'clean':
829
+ add(
830
+ has((a) => /^-[a-z]*f/.test(a) || longIs(a, ['force'])) ? 'file.delete' : 'shell.run',
831
+ here,
832
+ );
833
+ return;
834
+ case 'rm':
835
+ if (rest.includes('--cached')) add('shell.run');
836
+ else for (const f of plain) add('file.delete', abs(f));
837
+ return;
838
+ case 'mv':
839
+ for (const f of plain.slice(0, -1)) add('file.delete', abs(f));
840
+ if (plain.length) add('file.write', abs(plain.at(-1)));
841
+ return;
842
+ case 'checkout':
843
+ case 'restore':
844
+ if (paths.length) for (const f of paths) add('file.write', abs(f));
845
+ else if (sub === 'restore')
846
+ for (const f of plain.length ? plain : ['.']) add('file.write', abs(f));
847
+ else add('shell.run');
848
+ return;
849
+ case 'clone':
850
+ add('net.download');
851
+ return;
852
+ case 'credential':
853
+ case 'credential-store':
854
+ case 'credential-cache':
855
+ // their whole purpose is reading or storing a credential (F-203)
856
+ add('secret.read');
857
+ return;
858
+ default:
859
+ add('shell.run');
860
+ }
861
+ }
862
+
863
+ // --- patches (Codex apply_patch) ------------------------------------------
864
+
865
+ /** Files an apply_patch body touches: `*** Add|Update|Delete File: p` and `*** Move to: p`. */
866
+ export function patchActions(patch, cwd) {
867
+ if (typeof patch !== 'string')
868
+ throw new ClassifyError('HOOK_PAYLOAD_INVALID', 'the patch is not text');
869
+ if (patch.length > 8 * MAX_COMMAND)
870
+ throw new ClassifyError('HOOK_INPUT_TOO_LARGE', 'the patch is too large to classify');
871
+ const out = [];
872
+ for (const m of patch.matchAll(/^\*\*\* (Add File|Update File|Delete File|Move to): (.+)$/gm))
873
+ out.push({
874
+ action: m[1] === 'Delete File' ? 'file.delete' : 'file.write',
875
+ path: resolve(cwd, m[2].trim()),
876
+ });
877
+ if (out.length === 0) out.push({ action: 'shell.constructed' }); // a patch whose files cannot be read
878
+ return dedupe(out);
879
+ }
880
+
881
+ /** Final step: paths become scopes; reads of secret files become secret.read. */
882
+ /**
883
+ * What a delete would take with it (F-146): a target that contains the
884
+ * repository, the home folder or Sakk's state is named `~protected/<what>`,
885
+ * so no allow written for a folder (a temp folder, the repository) can match
886
+ * it — deleting a parent deletes everything under it.
887
+ */
888
+ function protectedBy(path, { repoRoot, sakkHome, homeDir, real = realOf }) {
889
+ const abs = real(path);
890
+ const contains = (base) => {
891
+ if (!base) return false;
892
+ const r = relative(abs, real(base));
893
+ return r === '' || !(r.startsWith('..') || isAbsolute(r));
894
+ };
895
+ if (contains(repoRoot)) return 'repo';
896
+ if (contains(homeDir)) return 'home';
897
+ if (contains(sakkHome)) return 'sakk';
898
+ return null;
899
+ }
900
+
901
+ export function toDecisions(actions, ctx) {
902
+ return actions.map(({ action, path }) => {
903
+ if (path === undefined) return { action };
904
+ if (action === 'file.delete') {
905
+ const what = protectedBy(path, ctx);
906
+ if (what !== null) return { action, scope: `~protected/${what}` };
907
+ }
908
+ const scope = scopeOf(path, ctx);
909
+ // a link with a harmless name to a secret file reads the secret (F-186)
910
+ const secret = isSecretPath(path) || isSecretPath((ctx.real ?? realOf)(path));
911
+ const named = action === 'file.read' && secret ? 'secret.read' : action;
912
+ return scope === null ? { action: named } : { action: named, scope };
913
+ });
914
+ }