@klarkxy/dsh-safe-auto 0.1.0-rc.6 → 0.2.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,547 @@
1
+ import { parse } from 'unbash';
2
+
3
+ /**
4
+ * Strict bash command analysis for permission decisions.
5
+ * Design references (concepts, not code): ZCode build/edit static policy and
6
+ * Codex execpolicy. A strict, fully literal parse may PROVE safety; anything
7
+ * the parser cannot fully explain fails closed into the model review path —
8
+ * partial parsing is never used to prove safety.
9
+ */
10
+
11
+ const MAX_PARSE_LENGTH = 10000;
12
+ // Refusal decoding covers every command that can fit the existing maximum
13
+ // 32768-byte approval argument budget, even beyond the smaller proof limit.
14
+ const MAX_REFUSAL_PARSE_LENGTH = 32768;
15
+ // Characters the shell expands even though the parser reports no expansion
16
+ // parts (plain globs and braces arrive as plain unquoted words).
17
+ const NON_LITERAL = /[{}\[\]*?\\^#$`!]/;
18
+ // Tilde expansion applies at word start, or after : and = in assignments.
19
+ const TILDE_EXPANSION = /(?:^|[:=])~/;
20
+
21
+ function literalPart(part, quoted = false, strict = true) {
22
+ switch (part?.type) {
23
+ case 'Literal': {
24
+ const text = part.text ?? part.value;
25
+ return typeof text === 'string' && (!strict || quoted || !NON_LITERAL.test(text) && !TILDE_EXPANSION.test(text));
26
+ }
27
+ case 'SingleQuoted': return true;
28
+ case 'DoubleQuoted':
29
+ return Array.isArray(part.parts) && part.parts.every(child => literalPart(child, true, strict));
30
+ default: return false;
31
+ }
32
+ }
33
+
34
+ /** Literal argv value of one word, or undefined when the shell would expand it. */
35
+ function literalWord(word, strict = true) {
36
+ if (!word || word.type !== 'Word') return undefined;
37
+ if (word.parts) return word.parts.every(part => literalPart(part, false, strict)) ? (word.value ?? word.text) : undefined;
38
+ const text = word.value ?? word.text;
39
+ if (typeof text !== 'string' || text === '' || strict && (text.startsWith('=') || NON_LITERAL.test(text) || TILDE_EXPANSION.test(text))) return undefined;
40
+ return text;
41
+ }
42
+
43
+ /** Decoded literal values for hard refusals only, NEVER a safety proof.
44
+ * Unlike analyzeShell, an unrelated dynamic word/control-flow node must not
45
+ * hide a known literal secret or protected path elsewhere in the command.
46
+ */
47
+ export function shellRefusals(command) {
48
+ const result = { values: [], dangerousRoot: undefined };
49
+ if (typeof command !== 'string' || !command.length || command.length > MAX_REFUSAL_PARSE_LENGTH) return result;
50
+ let script;
51
+ try { script = parse(command); } catch { return result; }
52
+ if (!script || script.errors?.length) return result;
53
+ const values = result.values, pending = [script];
54
+ while (pending.length) {
55
+ const node = pending.pop();
56
+ if (!node || typeof node !== 'object') continue;
57
+ if (node.type === 'Word') {
58
+ const value = literalWord(node, false);
59
+ if (typeof value === 'string') { values.push(value); continue; }
60
+ }
61
+ if (node.type === 'Assignment' && typeof node.name === 'string') {
62
+ const value = node.value === undefined ? '' : literalWord(node.value, false);
63
+ if (typeof value === 'string') values.push(node.name + '=' + value);
64
+ }
65
+ if (node.type === 'Command' && result.dangerousRoot === undefined) {
66
+ const argv = [];
67
+ for (const word of [node.name, ...(node.suffix ?? [])]) {
68
+ if (word?.type === 'Redirect') continue;
69
+ const value = literalWord(word, false);
70
+ if (value === undefined) break;
71
+ argv.push(value);
72
+ }
73
+ // A literal dangerous program remains a refusal even if later arguments
74
+ // expand. Wrappers are checked only as far as their identity is known.
75
+ result.dangerousRoot = dangerousArgv(argv, 0);
76
+ }
77
+ for (const child of Object.values(node)) {
78
+ if (Array.isArray(child)) pending.push(...child);
79
+ else if (child && typeof child === 'object') pending.push(child);
80
+ }
81
+ }
82
+ return result;
83
+ }
84
+
85
+ function invocationOf(node, inheritedRedirects) {
86
+ const env = [];
87
+ for (const assignment of node.prefix ?? []) {
88
+ if (assignment?.type !== 'Assignment' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(assignment.name ?? '')) return undefined;
89
+ const value = assignment.value === undefined ? '' : literalWord(assignment.value);
90
+ if (value === undefined) return undefined;
91
+ env.push({ name: assignment.name, value });
92
+ }
93
+ const argv = [];
94
+ const ownRedirects = [];
95
+ // unbash v5 interleaves Redirect nodes inside the suffix stream.
96
+ for (const item of [node.name, ...(node.suffix ?? [])]) {
97
+ if (item === null || item === undefined) continue;
98
+ if (item.type === 'Redirect') { ownRedirects.push(item); continue; }
99
+ const value = literalWord(item);
100
+ if (value === undefined) return undefined;
101
+ argv.push(value);
102
+ }
103
+ const redirects = [];
104
+ for (const redirect of [...inheritedRedirects, ...ownRedirects, ...(node.redirects ?? [])]) {
105
+ if (redirect?.type !== 'Redirect' || typeof redirect.operator !== 'string') return undefined;
106
+ if (redirect.body !== undefined) return undefined; // heredoc bodies may expand
107
+ const target = redirect.target !== undefined ? literalWord(redirect.target) : (redirect.content ?? '');
108
+ if (target === undefined) return undefined;
109
+ redirects.push({ operator: redirect.operator, target, fileDescriptor: redirect.descriptor?.value ?? redirect.fileDescriptor });
110
+ }
111
+ return { argv, env, redirects };
112
+ }
113
+
114
+ /**
115
+ * Fully literal parse of one shell command into simple invocations.
116
+ * { ok: false } means the command is beyond static proof: pipes with dynamic
117
+ * words, substitutions, control flow, backgrounding or parse errors.
118
+ */
119
+ export function analyzeShell(command) {
120
+ const fail = { ok: false, invocations: [] };
121
+ if (typeof command !== 'string' || command.length === 0 || command.length > MAX_PARSE_LENGTH) return fail;
122
+ let script;
123
+ try { script = parse(command); } catch { return fail; }
124
+ if (!script || script.errors?.length || !Array.isArray(script.commands) || script.commands.length === 0) return fail;
125
+ const invocations = [];
126
+ const visit = (node, inherited) => {
127
+ if (!node || typeof node !== 'object') return false;
128
+ switch (node.type) {
129
+ case 'Statement':
130
+ if (node.background) return false;
131
+ return visit(node.command, [...inherited, ...(node.redirects ?? [])]);
132
+ case 'AndOr':
133
+ case 'Pipeline':
134
+ return Array.isArray(node.commands) && node.commands.length > 0 &&
135
+ node.commands.every(child => visit(child, inherited));
136
+ case 'Time': // `time [-p] cmd` is transparent; the inner command governs
137
+ return visit(node.command, inherited);
138
+ case 'Command': {
139
+ const invocation = invocationOf(node, inherited);
140
+ if (!invocation) return false;
141
+ invocations.push(invocation);
142
+ return true;
143
+ }
144
+ default: return false;
145
+ }
146
+ };
147
+ return script.commands.every(statement => visit(statement, [])) && invocations.length > 0
148
+ ? { ok: true, invocations } : fail;
149
+ }
150
+
151
+ /** Program identity without directory or Windows extension decoration. */
152
+ export function rootName(argv0) {
153
+ return String(argv0).split(/[\\/]/).pop().toLowerCase().replace(/\.(?:exe|bat|cmd|ps1|com)$/i, '');
154
+ }
155
+
156
+ const DANGEROUS_ROOTS = new Set([
157
+ 'sudo', 'su', 'doas', 'rm', 'rmdir', 'del', 'erase', 'dd', 'shutdown', 'reboot', 'poweroff', 'halt',
158
+ 'mount', 'umount', 'fdisk', 'mkfs', 'chmod', 'chown', 'chgrp', 'chattr', 'curl', 'wget', 'nc', 'ncat', 'netcat',
159
+ 'ssh', 'scp', 'sftp', 'bash', 'sh', 'zsh', 'fish', 'dash', 'ksh', 'cmd', 'powershell', 'pwsh',
160
+ 'eval', 'exec', 'xargs', 'env',
161
+ ]);
162
+ const DANGEROUS_ROOT_PATTERN = /^mkfs(?:\..*)?$/;
163
+ const TRANSPARENT_WRAPPERS = new Set(['command', 'builtin', 'nohup', 'time']);
164
+ const MAX_UNWRAP_DEPTH = 8;
165
+
166
+ function dangerousArgv(argv, depth) {
167
+ if (argv.length === 0 || depth > MAX_UNWRAP_DEPTH) return undefined;
168
+ const root = rootName(argv[0]);
169
+ if (DANGEROUS_ROOTS.has(root) || DANGEROUS_ROOT_PATTERN.test(root)) return root;
170
+ if (argv[0] === 'command') {
171
+ // Bash command [-pVv]: -v/-V only describe names, including clustered
172
+ // -pv/-pV. Options stop at -- or the first operand; later -v is an
173
+ // argument to the executed program, not a metadata query.
174
+ let index = 1, query = false;
175
+ while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') {
176
+ const option = argv[index++];
177
+ if (option === '--') break;
178
+ if (!/^-[pvV]+$/.test(option)) return undefined;
179
+ if (/[vV]/.test(option)) query = true;
180
+ }
181
+ return query ? undefined : dangerousArgv(argv.slice(index), depth + 1);
182
+ }
183
+ if (TRANSPARENT_WRAPPERS.has(root)) {
184
+ let index = 1;
185
+ while (index < argv.length && argv[index].startsWith('-')) index++;
186
+ if (index < argv.length) return dangerousArgv(argv.slice(index), depth + 1);
187
+ }
188
+ return undefined;
189
+ }
190
+
191
+ /** First provably dangerous program root across every invocation, if any. */
192
+ export function firstDangerousRoot(analysis) {
193
+ if (!analysis.ok) return undefined;
194
+ for (const invocation of analysis.invocations) {
195
+ const hit = dangerousArgv(invocation.argv, 0);
196
+ if (hit) return hit;
197
+ }
198
+ return undefined;
199
+ }
200
+
201
+ // PATH resolution and the installed runtimes are operator-trusted (ADR-0006).
202
+ // Prefixes cannot choose programs, config, loaders or executable pagers.
203
+ const SAFE_ENV = {
204
+ CI: /^(?:true|false|0|1)$/,
205
+ LANG: /^(?:C|POSIX|[a-z]{2,3}_[A-Z]{2}(?:\.[A-Za-z0-9-]+)?(?:@[A-Za-z0-9-]+)?)$/,
206
+ LC_ALL: /^(?:C|POSIX|[a-z]{2,3}_[A-Z]{2}(?:\.[A-Za-z0-9-]+)?(?:@[A-Za-z0-9-]+)?)$/,
207
+ LC_CTYPE: /^(?:C|POSIX|[a-z]{2,3}_[A-Z]{2}(?:\.[A-Za-z0-9-]+)?(?:@[A-Za-z0-9-]+)?)$/,
208
+ NO_COLOR: /^(?:|1)$/, FORCE_COLOR: /^[0-3]$/,
209
+ TERM: /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/,
210
+ COLUMNS: /^[1-9][0-9]{0,3}$/, LINES: /^[1-9][0-9]{0,3}$/,
211
+ TZ: /^(?:UTC|GMT)(?:[+-](?:[0-9]|1[0-2]))?$/,
212
+ };
213
+ function safeEnvironment(env) {
214
+ const seen = new Set();
215
+ return env.every(({ name, value }) => {
216
+ if (seen.has(name) || !Object.hasOwn(SAFE_ENV, name) || !SAFE_ENV[name].test(value)) return false;
217
+ seen.add(name); return true;
218
+ });
219
+ }
220
+
221
+ function safeRedirect({ operator, target }) {
222
+ if (operator === '<<<') return true; // literal here-string, not a path open
223
+ // Bash implements these pathname redirects as sockets, including input-only
224
+ // and numbered fd redirects. Quoting does not disable that special meaning.
225
+ if (/^\/dev\/(?:tcp|udp)(?:\/|$)/.test(target)) return false;
226
+ if (operator === '<') return true;
227
+ if (operator === '>&' && /^\d+$/.test(target)) return true;
228
+ return (operator === '>' || operator === '>>' || operator === '>&') && target === '/dev/null';
229
+ }
230
+
231
+ // Exact tokens, never flag-prefix deny lists, long-option abbreviations, or
232
+ // arbitrary short-option clusters. A value option consumes exactly one value.
233
+ // '--' changes all remaining tokens to operands, including flag-shaped names.
234
+ const textValue = value => value.length > 0 && !value.startsWith('-');
235
+ const numberValue = value => /^[0-9]+$/.test(value);
236
+ const positiveValue = value => /^[1-9][0-9]*$/.test(value);
237
+ const choice = (...values) => value => values.includes(value);
238
+ function options(args, flags = '', values = {}, { min = 0, max = Infinity, operand = () => true, numeric = false } = {}) {
239
+ const allowed = new Set(flags.split(' ').filter(Boolean)), found = new Map(), operands = [];
240
+ let end = false;
241
+ for (let index = 0; index < args.length; index++) {
242
+ const arg = args[index];
243
+ if (!end && arg === '--') { end = true; continue; }
244
+ if (!end && arg.startsWith('-') && arg !== '-') {
245
+ if (numeric && /^-[0-9]+$/.test(arg)) {
246
+ if (found.has('#')) return undefined;
247
+ found.set('#', arg.slice(1)); continue;
248
+ }
249
+ if (allowed.has(arg)) {
250
+ if (found.has(arg)) return undefined;
251
+ found.set(arg, true); continue;
252
+ }
253
+ const equal = arg.startsWith('--') ? arg.indexOf('=') : -1;
254
+ const key = equal < 0 ? arg : arg.slice(0, equal);
255
+ if (found.has(key)) return undefined;
256
+ if (!Object.hasOwn(values, key)) return undefined;
257
+ const value = equal < 0 ? args[++index] : arg.slice(equal + 1);
258
+ if (typeof value !== 'string' || !values[key](value)) return undefined;
259
+ found.set(key, value); continue;
260
+ }
261
+ if (!operand(arg)) return undefined;
262
+ operands.push(arg);
263
+ }
264
+ return operands.length >= min && operands.length <= max ? { found, operands } : undefined;
265
+ }
266
+ const grammar = (flags = '', values = {}, limits = {}) => args => Boolean(options(args, flags, values, limits));
267
+ const oneMode = (parsed, names) => parsed && names.filter(name => parsed.found.has(name)).length <= 1;
268
+
269
+ function dateReadonly(args) {
270
+ const parsed = options(args, '-u --utc --universal -R --rfc-email -I --debug --resolution', {
271
+ '-d': textValue, '--date': textValue, '-f': textValue, '--file': textValue, '-r': textValue, '--reference': textValue,
272
+ '--iso-8601': choice('date', 'hours', 'minutes', 'seconds', 'ns'),
273
+ '--rfc-3339': choice('date', 'seconds', 'ns'),
274
+ }, { max: 1, operand: value => value.startsWith('+') });
275
+ return Boolean(oneMode(parsed, ['-d', '--date', '-f', '--file', '-r', '--reference']) &&
276
+ oneMode(parsed, ['-R', '--rfc-email', '-I', '--iso-8601', '--rfc-3339', '--resolution']));
277
+ }
278
+
279
+ function findReadonly(args) {
280
+ let index = 0;
281
+ if (['-H', '-L', '-P'].includes(args[index])) index++;
282
+ while (index < args.length && !args[index].startsWith('-') && !['!', '(', ')'].includes(args[index])) index++;
283
+ const expressionStart = index;
284
+ const flags = new Set(['-print', '-print0', '-ls', '-prune', '-quit', '-true', '-false', '-empty',
285
+ '-readable', '-writable', '-executable', '-xdev', '-mount', '-depth', '-noleaf']);
286
+ const values = {
287
+ '-name': textValue, '-iname': textValue, '-path': textValue, '-ipath': textValue,
288
+ '-regex': textValue, '-iregex': textValue, '-newer': textValue,
289
+ '-type': choice('b', 'c', 'd', 'p', 'f', 'l', 's'),
290
+ '-maxdepth': numberValue, '-mindepth': numberValue,
291
+ '-mtime': value => /^[+-]?[0-9]+$/.test(value), '-mmin': value => /^[+-]?[0-9]+$/.test(value),
292
+ '-size': value => /^[+-]?[0-9]+[bcwkMG]?$/.test(value),
293
+ };
294
+ let needTest = true, depth = 0;
295
+ for (; index < args.length; index++) {
296
+ const arg = args[index];
297
+ if (arg === '(') { depth++; needTest = true; continue; }
298
+ if (arg === ')') { if (needTest || --depth < 0) return false; continue; }
299
+ if (arg === '!' || arg === '-not') { needTest = true; continue; }
300
+ if (['-a', '-and', '-o', '-or'].includes(arg)) { if (needTest) return false; needTest = true; continue; }
301
+ if (flags.has(arg)) { needTest = false; continue; }
302
+ if (!Object.hasOwn(values, arg) || typeof args[index + 1] !== 'string' || !values[arg](args[++index])) return false;
303
+ needTest = false;
304
+ }
305
+ return depth === 0 && (expressionStart === args.length || !needTest);
306
+ }
307
+
308
+ function printfReadonly(args) {
309
+ if (args[0] === '--') args = args.slice(1);
310
+ const [format] = args;
311
+ // Bash printf %n and -v assign shell variables and can affect later commands.
312
+ if (typeof format !== 'string' || format.startsWith('-')) return false;
313
+ return !/%/.test(format.replace(/%[-+ #0]*[0-9]*(?:\.[0-9]+)?[bdiouxXfFeEgGcs%]/g, ''));
314
+ }
315
+ function searchReadonly(args, ripgrep = false) {
316
+ const parsed = options(args, ripgrep
317
+ ? '-n -i -v -l -L -q -s -S -w -x -F -U --files --hidden --no-ignore --line-number --ignore-case --fixed-strings --count --files-with-matches'
318
+ : '-n -r -R -rn -nr -rni -i -v -l -L -q -s -w -x -E -F -P -c -h -H --line-number --recursive --ignore-case --fixed-strings --extended-regexp --count',
319
+ { '-e': textValue, '--regexp': textValue, '-f': textValue, '--file': textValue,
320
+ '-m': positiveValue, '--max-count': positiveValue, '-A': numberValue, '-B': numberValue, '-C': numberValue,
321
+ '--after-context': numberValue, '--before-context': numberValue, '--context': numberValue,
322
+ ...(ripgrep ? { '-g': textValue, '--glob': textValue, '-t': textValue, '--type': textValue } : {}) });
323
+ if (!parsed || !oneMode(parsed, ['-E', '-F', '-P'])) return false;
324
+ const explicitPattern = ['-e', '--regexp', '-f', '--file'].some(key => parsed.found.has(key));
325
+ if (parsed.found.has('--files')) return !explicitPattern;
326
+ return explicitPattern || parsed.operands.length > 0;
327
+ }
328
+
329
+ // Git's built-in commands can still run a configured pager, fsmonitor,
330
+ // textconv, external diff, signature verifier, submodule or transport helper.
331
+ // Proof only recognizes disabling controls already present in ORIGINAL argv.
332
+ const GIT_GLOBAL_FLAGS = new Set(['--no-pager', '--no-optional-locks', '--no-lazy-fetch', '--literal-pathspecs', '--no-literal-pathspecs']);
333
+ const GIT_DIFF_FLAGS = '--no-ext-diff --no-textconv -p --patch -s --no-patch --stat --numstat --shortstat --name-only --name-status --raw --check --cached --staged --exit-code --quiet --no-color --color=never';
334
+ const GIT_DIFF_VALUES = { '--ignore-submodules': choice('all'), '--unified': numberValue, '-U': numberValue };
335
+ const GIT_HISTORY_FLAGS = '--oneline --no-show-signature --no-decorate --all --first-parent --reverse --no-merges --merges --date-order --topo-order';
336
+ // Signature-format placeholders (%G*) can launch gpg even with no-show-signature.
337
+ const gitFormat = value => ['oneline', 'short', 'medium', 'full', 'fuller', 'reference', '%H', '%h', '%s', '%h %s'].includes(value);
338
+ function gitReadonly(args) {
339
+ if (args.length === 1 && ['--version', 'version'].includes(args[0])) return true;
340
+ const controls = new Set();
341
+ let index = 0;
342
+ while (index < args.length && args[index].startsWith('-')) {
343
+ const arg = args[index++];
344
+ if (GIT_GLOBAL_FLAGS.has(arg)) {
345
+ if (controls.has(arg)) return false;
346
+ controls.add(arg); continue;
347
+ }
348
+ if (arg === '-c' && args[index] === 'core.fsmonitor=false' && !controls.has('fsmonitor')) {
349
+ index++; controls.add('fsmonitor'); continue;
350
+ }
351
+ return false;
352
+ }
353
+ const sub = args[index++], rest = args.slice(index);
354
+ if (!controls.has('--no-pager')) return false;
355
+ if (sub === 'config') {
356
+ // One read operation and its exact arity; no mutation, file, editor or
357
+ // implicit get/set interpretation is inferred from a mixed option list.
358
+ const parsed = options(rest, '--get --get-all --get-regexp -l --list', {}, { max: 2 });
359
+ if (!parsed || !oneMode(parsed, ['--get', '--get-all', '--get-regexp', '-l', '--list']) || parsed.found.size !== 1) return false;
360
+ if (parsed.found.has('-l') || parsed.found.has('--list')) return parsed.operands.length === 0;
361
+ return parsed.operands.length === 1 || parsed.found.has('--get-regexp') && parsed.operands.length === 2;
362
+ }
363
+ if (!controls.has('--no-optional-locks') || !controls.has('--no-lazy-fetch') || !controls.has('fsmonitor')) return false;
364
+ switch (sub) {
365
+ // Index refresh and worktree comparisons can run arbitrary named clean/
366
+ // process filters. No argv-only generic switch disables all such filters.
367
+ case 'status': return false;
368
+ case 'diff':
369
+ case 'log':
370
+ case 'show': {
371
+ const history = sub !== 'diff';
372
+ const parsed = options(rest, (history ? GIT_DIFF_FLAGS.replace(' --cached --staged', '') + ' ' + GIT_HISTORY_FLAGS : GIT_DIFF_FLAGS), {
373
+ ...GIT_DIFF_VALUES,
374
+ ...(history ? { '-n': positiveValue, '--max-count': positiveValue, '--format': gitFormat, '--pretty': gitFormat } : {}),
375
+ }, { numeric: history, max: history ? Infinity : 1 });
376
+ if (!parsed?.found.has('--no-ext-diff') || !parsed.found.has('--no-textconv') ||
377
+ parsed.found.get('--ignore-submodules') !== 'all') return false;
378
+ // Cached/staged diff reads index/object content, not worktree conversion.
379
+ // At most one operand also excludes implicit two-path --no-index mode.
380
+ if (!history) return Boolean(oneMode(parsed, ['--cached', '--staged']) &&
381
+ (parsed.found.has('--cached') || parsed.found.has('--staged')));
382
+ return parsed.found.has('--no-show-signature') && oneMode(parsed, ['--oneline', '--format', '--pretty']) &&
383
+ ['--oneline', '--format', '--pretty'].some(flag => parsed.found.has(flag));
384
+ }
385
+ case 'branch':
386
+ return grammar('-a --all -r --remotes -v -vv --verbose --list --show-current --no-color', {},
387
+ { max: rest.includes('--list') ? Infinity : 0 })(rest);
388
+ case 'tag':
389
+ return rest.includes('--list') || rest.includes('-l')
390
+ ? grammar('-l --list -n -i --ignore-case --no-color')(rest) : false;
391
+ case 'remote': return rest.length === 0 || rest.length === 1 && ['-v', '--verbose'].includes(rest[0]);
392
+ case 'cat-file': {
393
+ const parsed = options(rest, '-p -s -t -e', {}, { min: 1, max: 1 });
394
+ return Boolean(parsed && parsed.found.size === 1);
395
+ }
396
+ case 'grep': {
397
+ const parsed = options(rest, '--no-textconv -n -i -v -l -L -q -w -E -F --cached --no-index',
398
+ { '-e': textValue }, { min: 1 });
399
+ return Boolean(parsed?.found.has('--no-textconv') && oneMode(parsed, ['-E', '-F']));
400
+ }
401
+ case 'rev-parse': {
402
+ const parsed = options(rest, '--show-toplevel --show-prefix --show-cdup --git-dir --is-inside-work-tree --is-bare-repository --verify --short --abbrev-ref --symbolic-full-name', {}, { max: 1 });
403
+ if (!parsed || !parsed.found.size && !parsed.operands.length) return false;
404
+ return !['--verify', '--short', '--abbrev-ref', '--symbolic-full-name'].some(flag => parsed.found.has(flag)) || parsed.operands.length === 1;
405
+ }
406
+ // --modified calls ie_modified -> index_fd -> configured clean/process
407
+ // filters. Deleted/other listings only stat or enumerate; no refresh.
408
+ case 'ls-files': return grammar('-s --stage -o --others -c --cached -d --deleted -z --exclude-standard')(rest);
409
+ case 'ls-tree': return grammar('-r -t -l --name-only --name-status -z', {}, { min: 1 })(rest);
410
+ default: return false; // includes reflog mutations, stash and transport helpers
411
+ }
412
+ }
413
+
414
+ /** Each supported program has a positive grammar and operand constraints.
415
+ * Unsupported syntax means reviewer/manual fallback, never a hard denial. */
416
+ const READONLY_COMMANDS = new Map(Object.entries({
417
+ ls: grammar('-a -A -l -la -al -h -lh -hl -lah -alh -R -d -i -s -t -r -S -1 --all --almost-all --long --human-readable --recursive --directory --help --version',
418
+ { '--color': choice('always', 'auto', 'never'), '--sort': choice('none', 'time', 'size', 'extension', 'version'), '--time-style': textValue }),
419
+ cat: grammar('-n -b -s -v -E -T -A --number --number-nonblank --squeeze-blank --show-all --help --version'),
420
+ head: grammar('-q -v --quiet --verbose', { '-n': numberValue, '--lines': numberValue, '-c': numberValue, '--bytes': numberValue }, { numeric: true }),
421
+ tail: grammar('-q -v --quiet --verbose', { '-n': numberValue, '--lines': numberValue, '-c': numberValue, '--bytes': numberValue }, { numeric: true }),
422
+ wc: grammar('-l -w -c -m -L --lines --words --bytes --chars --max-line-length'),
423
+ file: grammar('-b -i -I -L -h -N --brief --mime --mime-type --mime-encoding --dereference --no-dereference', {}, { min: 1 }),
424
+ stat: grammar('-L -f --dereference --file-system', { '-c': textValue, '--format': textValue, '--printf': textValue }, { min: 1 }),
425
+ pwd: grammar('-L -P --logical --physical', {}, { max: 0 }),
426
+ which: grammar('-a --all', {}, { min: 1 }),
427
+ echo: grammar('-n -e -E'),
428
+ printf: printfReadonly,
429
+ uname: grammar('-a -s -n -r -v -m -p -i -o --all --kernel-name --nodename --kernel-release --kernel-version --machine --processor --hardware-platform --operating-system', {}, { max: 0 }),
430
+ id: grammar('-u -g -G -n -r -un -gn --user --group --groups --name --real', {}, { max: 1 }),
431
+ whoami: grammar('', {}, { max: 0 }), groups: grammar(),
432
+ uptime: grammar('-p -s --pretty --since', {}, { max: 0 }),
433
+ df: grammar('-h -H -k -i -a -l -T --human-readable --local --print-type'),
434
+ du: grammar('-h -s -sh -hs -a -c -k -x --human-readable --summarize --total --one-file-system', { '-d': numberValue, '--max-depth': numberValue }),
435
+ ps: args => grammar('-ef -e -f -A -a -x --forest', { '-p': value => /^[0-9]+(?:,[0-9]+)*$/.test(value),
436
+ '-o': value => /^[A-Za-z0-9_,=% -]+$/.test(value), '--sort': value => /^[A-Za-z0-9_,+-]+$/.test(value) }, { max: 0 })(['aux', 'ax'].includes(args[0]) ? args.slice(1) : args),
437
+ free: grammar('-h -b -k -m -g --human --bytes --kibi --mebi --gibi', {}, { max: 0 }),
438
+ true: grammar('', {}, { max: 0 }), false: grammar('', {}, { max: 0 }),
439
+ basename: grammar('-a --multiple', { '-s': textValue, '--suffix': textValue }, { min: 1, max: 2 }),
440
+ dirname: grammar('-z --zero', {}, { min: 1 }),
441
+ realpath: grammar('-e -m -s -z --canonicalize-existing --canonicalize-missing --no-symlinks --zero', {}, { min: 1 }),
442
+ readlink: grammar('-f -e -m -n -z --canonicalize --canonicalize-existing --canonicalize-missing --no-newline --zero', {}, { min: 1 }),
443
+ diff: grammar('-u -q -r -s -w -b -i -B --unified --brief --recursive --report-identical-files --ignore-all-space --ignore-space-change --ignore-case', {}, { min: 2, max: 2 }),
444
+ comm: grammar('-1 -2 -3 -12 -13 -23 --check-order --nocheck-order', {}, { min: 2, max: 2 }),
445
+ uniq: grammar('-c -d -u -i -z --count --repeated --unique --ignore-case --zero-terminated',
446
+ { '-f': numberValue, '--skip-fields': numberValue, '-s': numberValue, '--skip-chars': numberValue, '-w': numberValue, '--check-chars': numberValue }, { max: 1 }),
447
+ tr: args => {
448
+ const parsed = options(args, '-d -s -c -C --delete --squeeze-repeats --complement', {}, { min: 1, max: 2 });
449
+ if (!parsed) return false;
450
+ const deleting = parsed.found.has('-d') || parsed.found.has('--delete');
451
+ const squeezing = parsed.found.has('-s') || parsed.found.has('--squeeze-repeats');
452
+ return squeezing && !deleting || parsed.operands.length === (deleting ? squeezing ? 2 : 1 : 2);
453
+ },
454
+ cut: args => {
455
+ const fields = value => /^[0-9,-]+$/.test(value);
456
+ const parsed = options(args, '-s --only-delimited --complement -z --zero-terminated', { '-b': fields, '--bytes': fields,
457
+ '-c': fields, '--characters': fields, '-f': fields, '--fields': fields,
458
+ '-d': value => value.length === 1, '--delimiter': value => value.length === 1 });
459
+ return Boolean(parsed && ['-b', '--bytes', '-c', '--characters', '-f', '--fields'].filter(key => parsed.found.has(key)).length === 1);
460
+ },
461
+ paste: grammar('-s --serial -z --zero-terminated', { '-d': textValue, '--delimiters': textValue }),
462
+ join: grammar('-i --ignore-case --check-order --nocheck-order', { '-1': positiveValue, '-2': positiveValue, '-t': textValue }, { min: 2, max: 2 }),
463
+ grep: args => searchReadonly(args), egrep: args => searchReadonly(args), fgrep: args => searchReadonly(args),
464
+ rg: args => searchReadonly(args, true),
465
+ jq: grammar('-r -c -s -e -n -R -S -M --raw-output --compact-output --slurp --exit-status --null-input --raw-input --sort-keys --monochrome-output', {}, { min: 1 }),
466
+ strings: grammar('-a -d -f --all --data --print-file-name', { '-n': positiveValue, '--bytes': positiveValue, '-t': choice('d', 'o', 'x'), '--radix': choice('d', 'o', 'x') }),
467
+ od: grammar('-a -b -c -d -f -o -s -x -v', { '-A': choice('d', 'o', 'x', 'n'), '-j': numberValue, '-N': numberValue, '-t': textValue }),
468
+ xxd: args => {
469
+ const parsed = options(args, '-p -i -a -u', { '-l': numberValue, '-c': positiveValue, '-g': positiveValue, '-s': numberValue }, { max: 1 });
470
+ return Boolean(oneMode(parsed, ['-p', '-i']));
471
+ },
472
+ hexdump: grammar('-b -c -C -d -o -x -v', { '-n': numberValue, '-s': numberValue }),
473
+ sort: args => {
474
+ const parsed = options(args, '-r -n -g -h -V -f -b -d -i -u -c --reverse --numeric-sort --general-numeric-sort --human-numeric-sort --version-sort --unique --check',
475
+ { '-k': textValue, '--key': textValue, '-t': textValue, '--field-separator': textValue });
476
+ // An ordinary sort may spill to temporary files; only streaming check mode
477
+ // is proven read-only from argv without reading/attesting the input size.
478
+ return Boolean(oneMode(parsed, ['-n', '-g', '-h', '-V', '--numeric-sort', '--general-numeric-sort', '--human-numeric-sort', '--version-sort']) &&
479
+ (parsed.found.has('-c') || parsed.found.has('--check')));
480
+ },
481
+ tree: grammar('-a -d -f -i -l -p -s -h -u -g --dirsfirst', { '-L': numberValue, '-P': textValue, '-I': textValue }),
482
+ find: findReadonly, date: dateReadonly, git: gitReadonly,
483
+ }));
484
+
485
+ function readonlyInvocation(invocation) {
486
+ if (!invocation.redirects.every(safeRedirect) || !safeEnvironment(invocation.env)) return false;
487
+ // Assignment-only statements persist into later invocations (PATH=.; ls).
488
+ if (invocation.argv.length === 0) return false;
489
+ const [program, ...args] = invocation.argv;
490
+ // Proof is case-sensitive POSIX identity, not danger detection's rootName.
491
+ // Paths, suffixes and case variants do not inherit another program's policy.
492
+ const check = READONLY_COMMANDS.get(program);
493
+ return check ? check(args) : false;
494
+ }
495
+
496
+ /** Every invocation must satisfy its whole positive grammar. */
497
+ export function isReadonly(analysis) {
498
+ return analysis.ok && analysis.invocations.length > 0 && analysis.invocations.every(readonlyInvocation);
499
+ }
500
+
501
+ // Read-only is an integrity property, not a privacy guarantee: cat, grep,
502
+ // Git object/config readers and ps can expose secrets without changing a file.
503
+ // Static approval only covers metadata and already-literal output. Every
504
+ // invocation in a pipeline must qualify; filters do not sanitize prior output.
505
+ const STATIC_METADATA_COMMANDS = new Set([
506
+ 'ls', 'stat', 'pwd', 'which', 'uname', 'id', 'whoami', 'groups', 'uptime',
507
+ 'df', 'du', 'free', 'true', 'false', 'basename', 'dirname', 'realpath',
508
+ 'readlink', 'tree', 'find', 'echo', 'printf',
509
+ ]);
510
+
511
+ function gitStaticExposure(args) {
512
+ if (args.length === 1 && ['--version', 'version'].includes(args[0])) return true;
513
+ let index = 0;
514
+ // gitReadonly has already validated these exact original controls.
515
+ while (args[index]?.startsWith('-')) index += args[index] === '-c' ? 2 : 1;
516
+ const sub = args[index++], rest = args.slice(index);
517
+ switch (sub) {
518
+ case 'config': return rest.length === 2 && rest[0] === '--get' && ['user.name', 'user.email'].includes(rest[1]);
519
+ case 'branch': return grammar('-a --all -r --remotes --list --show-current --no-color', {},
520
+ { max: rest.includes('--list') ? Infinity : 0 })(rest);
521
+ case 'tag': return (rest.includes('--list') || rest.includes('-l')) && grammar('-l --list -i --ignore-case --no-color')(rest);
522
+ case 'rev-parse':
523
+ case 'ls-files':
524
+ case 'ls-tree': return true;
525
+ default: return false; // includes history, object contents, remote URLs and config enumeration
526
+ }
527
+ }
528
+
529
+ function staticExposureInvocation(invocation) {
530
+ if (!readonlyInvocation(invocation)) return false;
531
+ // Even a metadata command must not inherit arbitrary input file descriptors.
532
+ // Here-strings are fully literal; output redirects were checked above.
533
+ if (invocation.redirects.some(redirect => redirect.operator === '<')) return false;
534
+ const [program, ...args] = invocation.argv;
535
+ if (STATIC_METADATA_COMMANDS.has(program)) return true;
536
+ if (program === 'git') return gitStaticExposure(args);
537
+ // date --file can include raw input in parse-error output. Timestamp and
538
+ // literal date/format forms are safe; reference only reads file metadata.
539
+ if (program === 'date') return !args.some(arg => arg === '-f' || arg === '--file' || arg.startsWith('--file='));
540
+ return false;
541
+ }
542
+
543
+ /** Privacy proof kept separate from the no-mutation proof. Unknown or
544
+ * content-reading commands fall back to review, not a hard refusal. */
545
+ export function isSafeStaticExposure(analysis) {
546
+ return analysis.ok && analysis.invocations.length > 0 && analysis.invocations.every(staticExposureInvocation);
547
+ }