dsh-perm-gate 5.2.0 → 5.3.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,69 @@
1
+ /**
2
+ * Argument keys that carry a document body rather than the operation itself.
3
+ * A file's text is not the operation: writing a document that *mentions* a token, a
4
+ * private key, or `credentials.yaml` must not hard-deny the write, and the deny-keyword
5
+ * layer must not refuse it either. Still consulted for executing tools, whose *other*
6
+ * keys are all operation-shaped.
7
+ *
8
+ * `plan` is the case that made this list look wrong (issue #2): it was missing, so a
9
+ * planning tool's prose was scanned as an operation.
10
+ */
11
+ export declare const CONTENT_ARG_KEYS: ReadonlySet<string>;
12
+ /**
13
+ * Keys that name an action for a non-executing tool: what a command would run, where a
14
+ * request goes, which path is touched, what a search looks for. Everything else such a
15
+ * tool carries is prose.
16
+ *
17
+ * `query` / `pattern` stay in on purpose: a search term is the whole operation of a
18
+ * lookup tool, and the tool-name doc already promises that a search whose query hits
19
+ * the blacklist is still vetoed (a `curl … ?token=` shape shipped in a search field is
20
+ * exactly as leaky as one in a `url`).
21
+ */
22
+ export declare const OPERATION_ARG_KEYS: ReadonlySet<string>;
23
+ /**
24
+ * A caller's additions to the two key sets (`extraContentArgKeys` / `extraOperationArgKeys`).
25
+ *
26
+ * Purely additive: the built-in sets are never replaced or cleared, because the bug this
27
+ * rule exists to prevent (issue #2) was exactly "the list of document keys forgot one".
28
+ * A setting that could drop `content` would let a profile author re-create that bug by
29
+ * omission, silently.
30
+ */
31
+ export interface ScanKeyPolicy {
32
+ readonly content?: readonly string[];
33
+ readonly operation?: readonly string[];
34
+ }
35
+ interface KeySets {
36
+ readonly content: ReadonlySet<string>;
37
+ readonly operation: ReadonlySet<string>;
38
+ }
39
+ /** Merge the configured additions into the built-in sets. */
40
+ export declare function mergeScanKeys(policy?: ScanKeyPolicy): KeySets;
41
+ /**
42
+ * The scannable text of one call's arguments, with the rule above applied.
43
+ *
44
+ * Key precedence, in order (this ordering *is* the contract):
45
+ *
46
+ * 1. a key in the **operation** set is read, whatever the tool class and even if it also
47
+ * landed in the content set — an explicit declaration that the field describes an act
48
+ * (e.g. a workflow body that really executes) outranks a heuristic;
49
+ * 2. otherwise, for a non-executing tool, nothing else is read: its remaining fields are
50
+ * documents, and `decide()` already classified the call as needing no approval;
51
+ * 3. otherwise (executor or a name the gate does not know) every key outside the content
52
+ * set is read — an unfamiliar tool never gets the benefit of the doubt.
53
+ *
54
+ * Bounded and structure-preserving: values are joined with a single space, nesting is
55
+ * walked to a fixed depth, and nothing is escaped — the output is a haystack for
56
+ * keyword/credential matching, never something executed or logged as the call.
57
+ */
58
+ export declare function operationText(args: Record<string, unknown>, tool?: string, policy?: ScanKeyPolicy): string;
59
+ /**
60
+ * The text a blacklist keyword may match: the visible command plus the call's
61
+ * scannable arguments. Whitespace is collapsed so a command written with extra spaces
62
+ * still reads as the command it is.
63
+ *
64
+ * The command is placed first and is **not** subject to the key policy: no configuration
65
+ * of "which fields are documents" can take the executed command out of the blacklist's
66
+ * reach.
67
+ */
68
+ export declare function keywordScanText(tool: string | undefined, args: Record<string, unknown>, commandText?: string, policy?: ScanKeyPolicy): string;
69
+ export {};
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Which argument text a protective screen is allowed to read — one rule, shared.
3
+ *
4
+ * Two copies of this logic existed (`engine.operationText` for the P0 credential
5
+ * pre-screen, `runtime.denyScanText` for the deny-keyword blacklist) and both were a
6
+ * *denylist* of document-body keys. That is how GitHub issue #2 happened:
7
+ * `exit_plan_mode` carries its plan under `plan`, which was not in the list, so the
8
+ * whole plan text went into a screen built to catch *operations* — and a plan that
9
+ * honestly names `rm -rf` as a step it intends to ask about was hard-refused for
10
+ * describing it. The model then learns to reword plans to get past the gate, which is
11
+ * strictly worse than no gate.
12
+ *
13
+ * The list was also doomed to be re-filed for the next body key (`notes`, `summary`,
14
+ * `description`…), so this module changes the *shape* of the rule, per tool class:
15
+ *
16
+ * - a tool that **executes or is unknown** → every non-body key is read (the old,
17
+ * conservative behaviour — an unfamiliar name never gets the benefit of the doubt);
18
+ * - a tool in {@link NON_EXECUTOR_TOOLS} (read / internal / file-write) → only keys
19
+ * that *describe an action* are read: commands, urls, target paths, search queries.
20
+ * Its document text is data, and `decide()` already classifies it as work that needs
21
+ * no approval, so screening its prose contradicts that verdict.
22
+ *
23
+ * Residual, stated rather than hidden: a tool that *executes* a document body it was
24
+ * given (`workflow`, `skill`, `ralph`, `subagent`) is no longer read from that body
25
+ * here. Those paths delegate work that is gated as its own tool call, which is where an
26
+ * actual act becomes visible — see the comment on `INTERNAL_TOOLS` in `tool-names.ts`.
27
+ */
28
+ import { NON_EXECUTOR_TOOLS } from './tool-names.js';
29
+ /**
30
+ * Argument keys that carry a document body rather than the operation itself.
31
+ * A file's text is not the operation: writing a document that *mentions* a token, a
32
+ * private key, or `credentials.yaml` must not hard-deny the write, and the deny-keyword
33
+ * layer must not refuse it either. Still consulted for executing tools, whose *other*
34
+ * keys are all operation-shaped.
35
+ *
36
+ * `plan` is the case that made this list look wrong (issue #2): it was missing, so a
37
+ * planning tool's prose was scanned as an operation.
38
+ */
39
+ export const CONTENT_ARG_KEYS = new Set([
40
+ 'content', 'contents', 'text', 'body', 'message', 'prompt',
41
+ 'new_string', 'old_string', 'newText', 'oldText', 'file_text', 'fileText',
42
+ 'diff', 'patch', 'replacement', 'snippet', 'plan',
43
+ ]);
44
+ /**
45
+ * Keys that name an action for a non-executing tool: what a command would run, where a
46
+ * request goes, which path is touched, what a search looks for. Everything else such a
47
+ * tool carries is prose.
48
+ *
49
+ * `query` / `pattern` stay in on purpose: a search term is the whole operation of a
50
+ * lookup tool, and the tool-name doc already promises that a search whose query hits
51
+ * the blacklist is still vetoed (a `curl … ?token=` shape shipped in a search field is
52
+ * exactly as leaky as one in a `url`).
53
+ */
54
+ export const OPERATION_ARG_KEYS = new Set([
55
+ 'command', 'cmd', 'script', 'shell', 'code',
56
+ 'url', 'urls', 'href', 'uri', 'endpoint',
57
+ 'path', 'paths', 'file', 'files', 'file_path', 'filePath', 'dir', 'directory',
58
+ 'directories', 'cwd', 'workdir', 'target_path',
59
+ 'query', 'pattern',
60
+ ]);
61
+ /** Whether this tool's arguments describe an action rather than carry a document. */
62
+ function actionOnlyTool(tool) {
63
+ return tool !== undefined && NON_EXECUTOR_TOOLS.has(tool);
64
+ }
65
+ /** The built-in pair, returned as-is when nothing was configured (no per-call Set churn). */
66
+ const BUILTIN_KEYS = { content: CONTENT_ARG_KEYS, operation: OPERATION_ARG_KEYS };
67
+ /** Blank and non-string entries are dropped rather than guessed at; empty input keeps the base set. */
68
+ function withExtra(base, extra) {
69
+ if (extra === undefined)
70
+ return base;
71
+ const added = extra
72
+ .filter((key) => typeof key === 'string' && key.trim() !== '')
73
+ .map((key) => key.trim());
74
+ return added.length === 0 ? base : new Set([...base, ...added]);
75
+ }
76
+ /** Merge the configured additions into the built-in sets. */
77
+ export function mergeScanKeys(policy) {
78
+ if (policy === undefined)
79
+ return BUILTIN_KEYS;
80
+ if (policy.content === undefined && policy.operation === undefined)
81
+ return BUILTIN_KEYS;
82
+ return {
83
+ content: withExtra(CONTENT_ARG_KEYS, policy.content),
84
+ operation: withExtra(OPERATION_ARG_KEYS, policy.operation),
85
+ };
86
+ }
87
+ /**
88
+ * The scannable text of one call's arguments, with the rule above applied.
89
+ *
90
+ * Key precedence, in order (this ordering *is* the contract):
91
+ *
92
+ * 1. a key in the **operation** set is read, whatever the tool class and even if it also
93
+ * landed in the content set — an explicit declaration that the field describes an act
94
+ * (e.g. a workflow body that really executes) outranks a heuristic;
95
+ * 2. otherwise, for a non-executing tool, nothing else is read: its remaining fields are
96
+ * documents, and `decide()` already classified the call as needing no approval;
97
+ * 3. otherwise (executor or a name the gate does not know) every key outside the content
98
+ * set is read — an unfamiliar tool never gets the benefit of the doubt.
99
+ *
100
+ * Bounded and structure-preserving: values are joined with a single space, nesting is
101
+ * walked to a fixed depth, and nothing is escaped — the output is a haystack for
102
+ * keyword/credential matching, never something executed or logged as the call.
103
+ */
104
+ export function operationText(args, tool, policy) {
105
+ return collectText(args, tool, mergeScanKeys(policy), 0);
106
+ }
107
+ function collectText(args, tool, keys, depth) {
108
+ if (depth > 6)
109
+ return '';
110
+ const actionOnly = actionOnlyTool(tool);
111
+ // Whether a scalar sitting under `key` describes the operation.
112
+ const reads = (key) => keys.operation.has(key) || (!actionOnly && !keys.content.has(key));
113
+ // Whether to walk into `key`'s nested bag. A declared operation key and a neutral
114
+ // container (`{ params: { command: … } }`) both qualify; under a prose key the walk
115
+ // stops, so a document's nested fields cannot smuggle an operation back in.
116
+ const descends = (key) => !actionOnly || keys.operation.has(key) || CONTAINER_ARG_KEYS.has(key);
117
+ const parts = [];
118
+ for (const [key, value] of Object.entries(args)) {
119
+ if (typeof value === 'string') {
120
+ if (reads(key))
121
+ parts.push(value);
122
+ continue;
123
+ }
124
+ if (Array.isArray(value)) {
125
+ for (const item of value) {
126
+ // Array elements have no key of their own, so the parent decides: a `urls: […]`
127
+ // list is operation, a `notes: […]` list is prose.
128
+ if (typeof item === 'string') {
129
+ if (reads(key))
130
+ parts.push(item);
131
+ continue;
132
+ }
133
+ if (item !== null && typeof item === 'object' && descends(key)) {
134
+ parts.push(collectText(item, tool, keys, depth + 1));
135
+ }
136
+ }
137
+ continue;
138
+ }
139
+ if (value !== null && typeof value === 'object' && descends(key)) {
140
+ parts.push(collectText(value, tool, keys, depth + 1));
141
+ }
142
+ }
143
+ return parts.join(' ');
144
+ }
145
+ /** Keys whose value is a nested argument bag rather than a field of the call. */
146
+ const CONTAINER_ARG_KEYS = new Set(['args', 'arguments', 'input', 'params', 'parameters', 'tool_input']);
147
+ /**
148
+ * The text a blacklist keyword may match: the visible command plus the call's
149
+ * scannable arguments. Whitespace is collapsed so a command written with extra spaces
150
+ * still reads as the command it is.
151
+ *
152
+ * The command is placed first and is **not** subject to the key policy: no configuration
153
+ * of "which fields are documents" can take the executed command out of the blacklist's
154
+ * reach.
155
+ */
156
+ export function keywordScanText(tool, args, commandText, policy) {
157
+ const parts = [];
158
+ if (typeof commandText === 'string' && commandText !== '')
159
+ parts.push(commandText);
160
+ parts.push(operationText(args, tool, policy));
161
+ return parts.join(' ').replace(/\s+/g, ' ');
162
+ }
@@ -0,0 +1,50 @@
1
+ export interface ScriptRef {
2
+ /** Exactly as it appeared in the command, so the ask context can match coverage. */
3
+ readonly ref: string;
4
+ /** Which interpreter runs it (`bash`, `pwsh`, … or `direct` for `./x.sh`). */
5
+ readonly interpreter: string;
6
+ }
7
+ /**
8
+ * Every script file the command runs, in order of appearance, deduplicated.
9
+ *
10
+ * Shapes covered: `bash x.sh`, `pwsh -File x.ps1`, `python -m pkg` (no file — nothing
11
+ * is returned), `node -e 'code'` (inline — nothing returned), `./deploy.sh`,
12
+ * `~/scripts/clean.sh`, `source env.sh`, `cmd /c build.bat`, and a pipeline of several
13
+ * of them. A `-c`/`-Command` payload is deliberately *not* mined for a nested script
14
+ * name: the code text is already what the gate grades.
15
+ */
16
+ export declare function scriptRefs(commandText: string): readonly ScriptRef[];
17
+ /** One script the call will run, with the text the reviewer gets. */
18
+ export interface ScriptContent extends ScriptRef {
19
+ /** Absolute path actually read. */
20
+ readonly path: string;
21
+ readonly body: string;
22
+ /** True when the file is longer than the budget and `body` is a prefix. */
23
+ readonly truncated: boolean;
24
+ /** Set instead of `body` when nothing could be read (never silently empty). */
25
+ readonly unreadable?: 'missing' | 'not-a-file' | 'too-large' | 'binary' | 'unreadable';
26
+ readonly bytes: number;
27
+ }
28
+ /** Reading budget: enough for a real script, small enough for one grader round-trip. */
29
+ export declare const SCRIPT_REVIEW_MAX_BODY_CHARS = 20000;
30
+ export declare const SCRIPT_REVIEW_MAX_FILE_BYTES = 512000;
31
+ export declare const SCRIPT_REVIEW_MAX_FILES = 4;
32
+ /** `~` expansion plus cwd-relative resolution, without touching the filesystem. */
33
+ export declare function resolveScriptPath(ref: string, cwd: string): string;
34
+ /**
35
+ * Read the bodies of {@link scriptRefs}, bounded and best-effort: a file that cannot be
36
+ * read is *reported* as such (`unreadable`), because "we looked and could not see" is
37
+ * information the reviewer needs, while an empty body would read as a benign script.
38
+ *
39
+ * Never throws — a missing file, a permission error, or an undecodable blob all come
40
+ * back as a labelled entry.
41
+ */
42
+ export declare function readScriptContents(refs: readonly ScriptRef[], cwd: string, limits?: {
43
+ readonly maxFiles?: number;
44
+ readonly maxBodyChars?: number;
45
+ readonly maxFileBytes?: number;
46
+ }): readonly ScriptContent[];
47
+ /** Whether every script the call runs has been read (i.e. the payload is now visible). */
48
+ export declare function scriptsFullyCovered(refs: readonly ScriptRef[], contents: readonly ScriptContent[]): boolean;
49
+ /** The readable summary the deterministic layers judge a script body alongside. */
50
+ export declare function scriptBodyText(contents: readonly ScriptContent[]): string;
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Script-body attachment for review — "what will run", not "which file was named".
3
+ *
4
+ * A call like `bash ./clean-home.sh` or `pwsh -File ~/scripts/clean-home.ps1` names a
5
+ * file; the *operation* is inside it. Before this module the grader saw only the
6
+ * filename, so a script that wipes `$HOME` while claiming to free up cache was graded
7
+ * on a sentence that contains nothing dangerous — and out-of-workspace scripts were
8
+ * pushed to the `U` tier (uninspectable), which is honest but also means nobody ever
9
+ * looked. So: when a call runs a script, read it (bounded) and hand the same text to
10
+ * the risk model and to the human, and let the deterministic nets judge the body too.
11
+ *
12
+ * Two boundaries are kept on purpose:
13
+ * - **Reading is not executing.** Only the bytes are looked at; nothing here runs,
14
+ * sources, or resolves a shebang.
15
+ * - **The file is untrusted data.** The body reaches the grader inside a labelled
16
+ * field and must never be treated as instructions by whoever consumes it; secrets in
17
+ * it go through the same redaction as every other egress text.
18
+ *
19
+ * The pure half ({@link scriptRefs}) has no filesystem access so `ask-context.ts` can
20
+ * keep its "never touches fs" invariant; the reading half is used by the node runtime.
21
+ */
22
+ import { readFileSync, statSync } from 'node:fs';
23
+ import { homedir } from 'node:os';
24
+ import { isAbsolute, resolve } from 'node:path';
25
+ /** Extensions whose file *is* the payload of the call. */
26
+ const SCRIPT_EXT = '(?:sh|bash|zsh|dash|ksh|ps1|psm1|psd1|py|py3|js|mjs|cjs|ts|mts|rb|pl|php|bat|cmd)';
27
+ /** Interpreters that take a script file as a bare argument. */
28
+ const FILE_INTERPRETERS = /^(?:sh|bash|zsh|dash|ksh|busybox|pwsh|powershell|cmd|python[23]?|node|nodejs|deno|bun|ruby|perl|php)$/i;
29
+ /** Shell builtins that run a file in the current shell. */
30
+ const SOURCE_HEADS = /^(?:source|\.)$/i;
31
+ /**
32
+ * Flags that mean "the next argument is inline code, not a file". Attaching them as a
33
+ * script path would read a file named after a whole code string, and grading them as
34
+ * content is pointless — the code is already the command text.
35
+ *
36
+ * `cmd`'s `/c` and `/k` are deliberately **not** here: `cmd /c build.bat` is how a batch
37
+ * file is run, and the inline-code reading of `/c` (a quoted command string) still
38
+ * yields no file, because a code blob never ends in a script extension.
39
+ */
40
+ const INLINE_CODE_FLAGS = /^-{1,2}(?:c\b|e\b|command|c=|encodedcommand|eval|exec)$|^-C$/i;
41
+ /**
42
+ * Bare interpreter flags that may swallow the next token (`bash -x -c '…' script.sh`
43
+ * is not a file argument after `-c`, but `-x` leaves the next candidate alone).
44
+ */
45
+ const SWITCH_ONLY_FLAGS = /^-+[\w-]+$/;
46
+ /** Whether one token looks like a script file rather than a flag or an inline body. */
47
+ function looksLikeScript(token) {
48
+ if (token === '' || token.startsWith('-') || token.startsWith('/'))
49
+ return false;
50
+ return new RegExp(`\\.${SCRIPT_EXT}$`, 'i').test(token);
51
+ }
52
+ /**
53
+ * Every script file the command runs, in order of appearance, deduplicated.
54
+ *
55
+ * Shapes covered: `bash x.sh`, `pwsh -File x.ps1`, `python -m pkg` (no file — nothing
56
+ * is returned), `node -e 'code'` (inline — nothing returned), `./deploy.sh`,
57
+ * `~/scripts/clean.sh`, `source env.sh`, `cmd /c build.bat`, and a pipeline of several
58
+ * of them. A `-c`/`-Command` payload is deliberately *not* mined for a nested script
59
+ * name: the code text is already what the gate grades.
60
+ */
61
+ export function scriptRefs(commandText) {
62
+ const out = [];
63
+ const seen = new Set();
64
+ const push = (ref, interpreter) => {
65
+ if (ref === '' || seen.has(ref))
66
+ return;
67
+ seen.add(ref);
68
+ out.push({ ref, interpreter });
69
+ };
70
+ for (const segment of commandText.split(/[;&|\n]+/)) {
71
+ const tokens = segment.trim().split(/\s+/).filter((t) => t !== '');
72
+ if (tokens.length === 0)
73
+ continue;
74
+ const head = (tokens[0] ?? '').replace(/^["'`]+/, '').replace(/["'`]+$/, '');
75
+ const bare = head.split(/[\\/]/).pop() ?? head;
76
+ // `./deploy.sh`, `~/scripts/x.sh`, `/tmp/run.py` executed directly.
77
+ if (looksLikeScript(head)) {
78
+ push(head, 'direct');
79
+ continue;
80
+ }
81
+ if (!(FILE_INTERPRETERS.test(bare) || SOURCE_HEADS.test(head)))
82
+ continue;
83
+ const isFileFlagAware = FILE_INTERPRETERS.test(bare);
84
+ const isSource = SOURCE_HEADS.test(head);
85
+ for (let i = 1; i < tokens.length; i += 1) {
86
+ const tok = (tokens[i] ?? '').replace(/^["'`]+/, '').replace(/["'`]+$/, '');
87
+ if (tok === '')
88
+ continue;
89
+ // `source`/`.` runs the file in the current shell, and its argument need not
90
+ // carry a known extension (`source ~/.profile`, `. ./env`). Anything that is not
91
+ // a flag is the file.
92
+ if (isSource) {
93
+ if (!tok.startsWith('-'))
94
+ push(tok, 'source');
95
+ continue;
96
+ }
97
+ if (isFileFlagAware && INLINE_CODE_FLAGS.test(tok))
98
+ break; // the rest is inline code
99
+ // `-File x.ps1` / `/c build.bat`: the flag itself is skipped, the value is read.
100
+ if (isFileFlagAware && SWITCH_ONLY_FLAGS.test(tok)) {
101
+ const next = (tokens[i + 1] ?? '').replace(/^["'`]+/, '').replace(/["'`]+$/, '');
102
+ if (looksLikeScript(next))
103
+ push(next, bare.toLowerCase());
104
+ i += 1;
105
+ continue;
106
+ }
107
+ if (looksLikeScript(tok))
108
+ push(tok, bare.toLowerCase());
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+ /** Reading budget: enough for a real script, small enough for one grader round-trip. */
114
+ export const SCRIPT_REVIEW_MAX_BODY_CHARS = 20_000;
115
+ export const SCRIPT_REVIEW_MAX_FILE_BYTES = 512_000;
116
+ export const SCRIPT_REVIEW_MAX_FILES = 4;
117
+ /** `~` expansion plus cwd-relative resolution, without touching the filesystem. */
118
+ export function resolveScriptPath(ref, cwd) {
119
+ const cleaned = ref.replace(/^["'`]+/, '').replace(/["'`]+$/, '');
120
+ const expanded = cleaned === '~' || cleaned.startsWith('~/') || cleaned.startsWith('~\\')
121
+ ? resolve(homedir(), cleaned.slice(1).replace(/^[\\/]/, ''))
122
+ : cleaned;
123
+ return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
124
+ }
125
+ /** Whether a decoded body is text at all (a compiled artifact is not reviewable). */
126
+ function looksBinary(text) {
127
+ if (text.includes('\u0000'))
128
+ return true;
129
+ const sample = text.slice(0, 2000);
130
+ if (sample === '')
131
+ return false;
132
+ let weird = 0;
133
+ for (const ch of sample) {
134
+ const code = ch.charCodeAt(0);
135
+ if (code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127))
136
+ continue;
137
+ weird += 1;
138
+ }
139
+ return weird / sample.length > 0.15;
140
+ }
141
+ /**
142
+ * Read the bodies of {@link scriptRefs}, bounded and best-effort: a file that cannot be
143
+ * read is *reported* as such (`unreadable`), because "we looked and could not see" is
144
+ * information the reviewer needs, while an empty body would read as a benign script.
145
+ *
146
+ * Never throws — a missing file, a permission error, or an undecodable blob all come
147
+ * back as a labelled entry.
148
+ */
149
+ export function readScriptContents(refs, cwd, limits = {}) {
150
+ const maxFiles = limits.maxFiles ?? SCRIPT_REVIEW_MAX_FILES;
151
+ const maxBodyChars = limits.maxBodyChars ?? SCRIPT_REVIEW_MAX_BODY_CHARS;
152
+ const maxFileBytes = limits.maxFileBytes ?? SCRIPT_REVIEW_MAX_FILE_BYTES;
153
+ const out = [];
154
+ for (const ref of refs.slice(0, maxFiles)) {
155
+ const path = resolveScriptPath(ref.ref, cwd);
156
+ const entry = (body, truncated, bytes, unreadable) => ({
157
+ ...ref,
158
+ path,
159
+ body,
160
+ truncated,
161
+ bytes,
162
+ ...(unreadable === undefined ? {} : { unreadable }),
163
+ });
164
+ try {
165
+ const st = statSync(path);
166
+ if (!st.isFile()) {
167
+ out.push(entry('', false, 0, 'not-a-file'));
168
+ continue;
169
+ }
170
+ if (st.size > maxFileBytes) {
171
+ // The size still says something (a 40 MB "cleanup script" is worth a human look),
172
+ // but the body is out of budget.
173
+ out.push(entry('', true, st.size, 'too-large'));
174
+ continue;
175
+ }
176
+ const raw = readFileSync(path, 'utf8');
177
+ if (looksBinary(raw)) {
178
+ out.push(entry('', false, st.size, 'binary'));
179
+ continue;
180
+ }
181
+ out.push(entry(raw.slice(0, maxBodyChars), raw.length > maxBodyChars, st.size));
182
+ }
183
+ catch (err) {
184
+ const missing = err.code === 'ENOENT';
185
+ out.push(entry('', false, 0, missing ? 'missing' : 'unreadable'));
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+ /** Whether every script the call runs has been read (i.e. the payload is now visible). */
191
+ export function scriptsFullyCovered(refs, contents) {
192
+ if (refs.length === 0)
193
+ return false;
194
+ return refs.every((r) => contents.some((c) => c.ref === r.ref && c.unreadable === undefined && c.body !== ''));
195
+ }
196
+ /** The readable summary the deterministic layers judge a script body alongside. */
197
+ export function scriptBodyText(contents) {
198
+ return contents
199
+ .filter((c) => c.unreadable === undefined)
200
+ .map((c) => `${c.ref}\n${c.body}`)
201
+ .join('\n');
202
+ }
package/lib/shell.d.ts CHANGED
@@ -39,6 +39,24 @@ export declare function commandArgv(cmd: SimpleCommand): string;
39
39
  * undecidable command to `ask` rather than guessing.
40
40
  */
41
41
  export declare function tokenizeShell(text: string): string[];
42
+ /**
43
+ * Tokenize like {@link tokenizeShell}, but also emit the statement separators as
44
+ * their own tokens: `;`, `|`, `&&`, `||` and — the two that were missing — a **newline**
45
+ * and a **glued** operator with no spaces around it.
46
+ *
47
+ * This is the tokenizer {@link decomposeShellCommand} walks, and the reason it exists is
48
+ * a hole that two bug reports traced back to the same place. `tokenizeShell` swallows
49
+ * whitespace, so `ls\nrm -rf ~/.*` came out as one command whose head is `ls` and whose
50
+ * second statement sits in `args` — no `deny` rule matches it, and the per-segment
51
+ * checks never see it as a program. Same for `cd /tmp;rm -rf ~`. Meanwhile
52
+ * `irreversible.ts` was splitting on `[;&|\n]` all along, so the two layers disagreed
53
+ * about what "a command" is, and the layer that graded the string was the looser one.
54
+ *
55
+ * Quoted text is still opaque: `bash -c "a; b"` and `find -exec … ';'` do not produce
56
+ * separators, because the character is inside the argument's value. A lone `&` is left
57
+ * inside the token, not split out: `2>&1` and a backgrounded job must keep their shape.
58
+ */
59
+ export declare function tokenizeShellSeparators(text: string): string[];
42
60
  /**
43
61
  * Recursively decompose a command string into simple commands, descending into
44
62
  * `-c`/`-Command` inline scripts. Throws on undecidable syntax (unclosed quote);
package/lib/shell.js CHANGED
@@ -83,6 +83,107 @@ export function tokenizeShell(text) {
83
83
  tokens.push(cur);
84
84
  return tokens;
85
85
  }
86
+ /**
87
+ * Tokenize like {@link tokenizeShell}, but also emit the statement separators as
88
+ * their own tokens: `;`, `|`, `&&`, `||` and — the two that were missing — a **newline**
89
+ * and a **glued** operator with no spaces around it.
90
+ *
91
+ * This is the tokenizer {@link decomposeShellCommand} walks, and the reason it exists is
92
+ * a hole that two bug reports traced back to the same place. `tokenizeShell` swallows
93
+ * whitespace, so `ls\nrm -rf ~/.*` came out as one command whose head is `ls` and whose
94
+ * second statement sits in `args` — no `deny` rule matches it, and the per-segment
95
+ * checks never see it as a program. Same for `cd /tmp;rm -rf ~`. Meanwhile
96
+ * `irreversible.ts` was splitting on `[;&|\n]` all along, so the two layers disagreed
97
+ * about what "a command" is, and the layer that graded the string was the looser one.
98
+ *
99
+ * Quoted text is still opaque: `bash -c "a; b"` and `find -exec … ';'` do not produce
100
+ * separators, because the character is inside the argument's value. A lone `&` is left
101
+ * inside the token, not split out: `2>&1` and a backgrounded job must keep their shape.
102
+ */
103
+ export function tokenizeShellSeparators(text) {
104
+ const tokens = [];
105
+ let cur = '';
106
+ let quote = null;
107
+ let i = 0;
108
+ const n = text.length;
109
+ const flush = () => {
110
+ if (cur.length > 0) {
111
+ tokens.push(cur);
112
+ cur = '';
113
+ }
114
+ };
115
+ while (i < n) {
116
+ const ch = text[i];
117
+ if (quote === "'") {
118
+ if (ch === "'") {
119
+ quote = null;
120
+ i += 1;
121
+ continue;
122
+ }
123
+ cur += ch;
124
+ i += 1;
125
+ continue;
126
+ }
127
+ if (quote === '"') {
128
+ if (ch === '"') {
129
+ quote = null;
130
+ i += 1;
131
+ continue;
132
+ }
133
+ if (ch === '\\' && i + 1 < n) {
134
+ cur += text[i + 1];
135
+ i += 2;
136
+ continue;
137
+ }
138
+ cur += ch;
139
+ i += 1;
140
+ continue;
141
+ }
142
+ if (ch === "'" || ch === '"') {
143
+ quote = ch;
144
+ i += 1;
145
+ continue;
146
+ }
147
+ if (ch === '\\' && i + 1 < n) {
148
+ cur += text[i + 1];
149
+ i += 2;
150
+ continue;
151
+ }
152
+ // Unquoted operator: end the current token and emit the operator itself. The
153
+ // two-character forms are matched first so `||` is not two `|` and `&&` not two `&`.
154
+ if (ch === ';' || ch === '\n' || ch === '\r') {
155
+ flush();
156
+ if (ch !== '\r' || text[i + 1] !== '\n')
157
+ tokens.push(';');
158
+ i += 1;
159
+ continue;
160
+ }
161
+ if (ch === '|') {
162
+ flush();
163
+ tokens.push(text[i + 1] === '|' ? '||' : '|');
164
+ i += text[i + 1] === '|' ? 2 : 1;
165
+ continue;
166
+ }
167
+ if (ch === '&' && text[i + 1] === '&') {
168
+ flush();
169
+ tokens.push('&&');
170
+ i += 2;
171
+ continue;
172
+ }
173
+ if (/\s/.test(ch)) {
174
+ flush();
175
+ i += 1;
176
+ continue;
177
+ }
178
+ cur += ch;
179
+ i += 1;
180
+ }
181
+ if (quote !== null) {
182
+ throw new Error('unterminated quote in command string');
183
+ }
184
+ flush();
185
+ return tokens;
186
+ }
86
187
  /** Split into top-level simple-command token groups on `|`/`&&`/`;`/`||`. */
87
188
  function splitPipeline(tokens) {
88
189
  const groups = [];
@@ -141,7 +242,8 @@ function stripWrappers(args) {
141
242
  * callers route that to `ask`, never `allow`.
142
243
  */
143
244
  export function decomposeShellCommand(text) {
144
- const tokens = tokenizeShell(text);
245
+ // Separators are tokenized, not swallowed: see `tokenizeShellSeparators`.
246
+ const tokens = tokenizeShellSeparators(text);
145
247
  const groups = splitPipeline(tokens);
146
248
  const commands = [];
147
249
  for (const group of groups) {