dsh-perm-gate 5.2.0 → 5.3.1
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/CHANGELOG.ja.md +64 -1
- package/CHANGELOG.ko.md +64 -1
- package/CHANGELOG.md +149 -4
- package/README.ja.md +44 -1
- package/README.ko.md +44 -2
- package/README.md +74 -1
- package/README.zh.md +42 -1
- package/cordis.patch.yml +16 -1
- package/dsh.plugin.json +2 -2
- package/lib/ask-context.d.ts +49 -1
- package/lib/ask-context.js +89 -5
- package/lib/client.js +714 -86
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +152 -1
- package/lib/config.js +20 -1
- package/lib/credential-paths.d.ts +35 -0
- package/lib/credential-paths.js +119 -0
- package/lib/engine.d.ts +4 -6
- package/lib/engine.js +53 -42
- package/lib/evaluate.d.ts +20 -0
- package/lib/evaluate.js +34 -11
- package/lib/feedback.d.ts +42 -0
- package/lib/feedback.js +85 -0
- package/lib/index.d.ts +2 -2
- package/lib/index.js +119 -17
- package/lib/irreversible.js +109 -1
- package/lib/path.d.ts +31 -0
- package/lib/path.js +35 -0
- package/lib/preset.d.ts +8 -5
- package/lib/preset.js +15 -6
- package/lib/risk.d.ts +29 -1
- package/lib/risk.js +36 -2
- package/lib/runtime.d.ts +164 -0
- package/lib/runtime.js +423 -54
- package/lib/scan-text.d.ts +69 -0
- package/lib/scan-text.js +162 -0
- package/lib/script-review.d.ts +50 -0
- package/lib/script-review.js +202 -0
- package/lib/shell.d.ts +18 -0
- package/lib/shell.js +103 -1
- package/lib/unattended.d.ts +61 -0
- package/lib/unattended.js +77 -0
- package/package.json +8 -7
- package/scripts/patch-permission-glyph.mjs +27 -13
|
@@ -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 {};
|
package/lib/scan-text.js
ADDED
|
@@ -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
|
-
|
|
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) {
|