@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/classify.mjs
ADDED
|
@@ -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
|
+
}
|