one-code-extension 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +32 -0
- package/README.md +202 -0
- package/agents/explore.md +24 -0
- package/agents/general-purpose.md +21 -0
- package/agents/plan.md +22 -0
- package/docs/decisions/auto-mode.md +526 -0
- package/docs/decisions/branding.md +127 -0
- package/docs/decisions/code-review-remediation.md +106 -0
- package/docs/decisions/compaction.md +140 -0
- package/docs/decisions/distribution.md +133 -0
- package/docs/decisions/hooks.md +64 -0
- package/docs/decisions/lsp.md +58 -0
- package/docs/decisions/mcp.md +104 -0
- package/docs/decisions/memory-state.md +170 -0
- package/docs/decisions/model-policy.md +126 -0
- package/docs/decisions/modes.md +178 -0
- package/docs/decisions/skills-plugins.md +56 -0
- package/docs/decisions/subagents-workflows.md +225 -0
- package/docs/decisions/system-prompt.md +98 -0
- package/docs/decisions/tools.md +407 -0
- package/docs/decisions/tui.md +202 -0
- package/docs/decisions.md +148 -0
- package/extensions/ask-user/index.ts +124 -0
- package/extensions/ask-user/questions.ts +60 -0
- package/extensions/auto-mode/actions.ts +78 -0
- package/extensions/auto-mode/classifier.ts +388 -0
- package/extensions/auto-mode/config.ts +237 -0
- package/extensions/auto-mode/decision-log.ts +60 -0
- package/extensions/auto-mode/defaults.ts +65 -0
- package/extensions/auto-mode/instructions.ts +74 -0
- package/extensions/auto-mode/model-picker.ts +225 -0
- package/extensions/auto-mode/model-select.ts +178 -0
- package/extensions/auto-mode/paths.ts +109 -0
- package/extensions/auto-mode/pause.ts +96 -0
- package/extensions/auto-mode/prompt.ts +307 -0
- package/extensions/auto-mode/rules.ts +96 -0
- package/extensions/auto-mode/safety-floor.ts +100 -0
- package/extensions/auto-mode/sensitive.ts +137 -0
- package/extensions/auto-mode/shell-analysis.ts +702 -0
- package/extensions/background/index.ts +405 -0
- package/extensions/background/registry.ts +81 -0
- package/extensions/background/wakeup.ts +41 -0
- package/extensions/bash/background.ts +136 -0
- package/extensions/bash/index.ts +149 -0
- package/extensions/branding/assistant-marker.ts +53 -0
- package/extensions/branding/index.ts +366 -0
- package/extensions/branding/prompt-editor.ts +42 -0
- package/extensions/branding/prompt-marker.ts +29 -0
- package/extensions/branding/startup.ts +152 -0
- package/extensions/claude-compat/index.ts +50 -0
- package/extensions/claude-context/index.ts +79 -0
- package/extensions/compaction/index.ts +231 -0
- package/extensions/compaction/prompt.ts +191 -0
- package/extensions/context-management/index.ts +147 -0
- package/extensions/effort/index.ts +216 -0
- package/extensions/effort/slider.ts +253 -0
- package/extensions/exit/index.ts +28 -0
- package/extensions/file-tracker/index.ts +103 -0
- package/extensions/file-tracker/tracker.ts +145 -0
- package/extensions/hooks/debug.ts +53 -0
- package/extensions/hooks/executor.ts +132 -0
- package/extensions/hooks/index.ts +310 -0
- package/extensions/hooks/matcher.ts +72 -0
- package/extensions/hooks/plugin-hooks.ts +79 -0
- package/extensions/hooks/protocol.ts +151 -0
- package/extensions/hooks/settings.ts +163 -0
- package/extensions/hooks/trust.ts +151 -0
- package/extensions/lib/branch-restore.ts +35 -0
- package/extensions/lib/claude-context.ts +148 -0
- package/extensions/lib/deferred.ts +119 -0
- package/extensions/lib/git.ts +14 -0
- package/extensions/lib/memory.ts +219 -0
- package/extensions/lib/model-policy.ts +436 -0
- package/extensions/lib/model-tier.ts +99 -0
- package/extensions/lib/notifications.ts +18 -0
- package/extensions/lib/paths.ts +26 -0
- package/extensions/lib/per-cwd.ts +21 -0
- package/extensions/lib/persisted-output.ts +70 -0
- package/extensions/lib/pi-version.ts +54 -0
- package/extensions/lib/plugins.ts +227 -0
- package/extensions/lib/reminders.ts +212 -0
- package/extensions/lib/scratchpad.ts +68 -0
- package/extensions/lib/tui-render.ts +522 -0
- package/extensions/lsp/client.ts +278 -0
- package/extensions/lsp/format.ts +46 -0
- package/extensions/lsp/index.ts +193 -0
- package/extensions/lsp/protocol.ts +64 -0
- package/extensions/lsp/servers.ts +79 -0
- package/extensions/mcp/client.ts +205 -0
- package/extensions/mcp/config.ts +199 -0
- package/extensions/mcp/index.ts +373 -0
- package/extensions/mcp/schema.ts +161 -0
- package/extensions/memory/index.ts +90 -0
- package/extensions/notebook/index.ts +77 -0
- package/extensions/notebook/notebook.ts +108 -0
- package/extensions/permissions/index.ts +876 -0
- package/extensions/permissions/matcher.ts +455 -0
- package/extensions/permissions/modes.ts +107 -0
- package/extensions/permissions/protected-paths.ts +125 -0
- package/extensions/permissions/settings.ts +98 -0
- package/extensions/plan-mode/index.ts +209 -0
- package/extensions/plan-mode/reminder.ts +33 -0
- package/extensions/plan-mode/slug.ts +28 -0
- package/extensions/plan-mode/viewer.ts +124 -0
- package/extensions/plugins/index.ts +115 -0
- package/extensions/plugins/template.ts +42 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +24 -0
- package/extensions/skill/index.ts +170 -0
- package/extensions/subagents/agents.ts +126 -0
- package/extensions/subagents/child.ts +414 -0
- package/extensions/subagents/default-model.ts +122 -0
- package/extensions/subagents/index.ts +1119 -0
- package/extensions/subagents/model-select.ts +346 -0
- package/extensions/subagents/rpc-turns.ts +123 -0
- package/extensions/subagents/runs.ts +93 -0
- package/extensions/subagents/usage.ts +53 -0
- package/extensions/subagents/worktree.ts +69 -0
- package/extensions/system-prompt/environment.ts +35 -0
- package/extensions/system-prompt/index.ts +54 -0
- package/extensions/system-prompt/template.ts +103 -0
- package/extensions/system-prompt/tiers/common.ts +63 -0
- package/extensions/system-prompt/tiers/frontier.ts +24 -0
- package/extensions/system-prompt/tiers/low.ts +71 -0
- package/extensions/system-prompt/tiers/mid.ts +56 -0
- package/extensions/system-reminder/index.ts +37 -0
- package/extensions/tasks/index.ts +140 -0
- package/extensions/tasks/store.ts +174 -0
- package/extensions/todo/index.ts +133 -0
- package/extensions/tool-search/index.ts +168 -0
- package/extensions/tool-style/index.ts +84 -0
- package/extensions/web/index.ts +43 -0
- package/extensions/web-fetch/extract.ts +139 -0
- package/extensions/web-fetch/index.ts +291 -0
- package/extensions/web-fetch/summarize.ts +78 -0
- package/extensions/workflow/agent-session.ts +368 -0
- package/extensions/workflow/globals.ts +251 -0
- package/extensions/workflow/index.ts +364 -0
- package/extensions/workflow/journal.ts +75 -0
- package/extensions/workflow/permission-gate.ts +71 -0
- package/extensions/workflow/run-manager.ts +306 -0
- package/extensions/workflow/saved-workflows.ts +45 -0
- package/extensions/workflow/script-source.ts +201 -0
- package/extensions/workflow/types.ts +99 -0
- package/extensions/workflow/vm-runtime.ts +31 -0
- package/extensions/workflow/widget.ts +56 -0
- package/extensions/worktree/index.ts +266 -0
- package/extensions/worktree/rewrite.ts +59 -0
- package/package.json +101 -0
- package/themes/one-code-light.json +76 -0
- package/themes/one-code.json +76 -0
- package/types/app.d.ts +5 -0
- package/types/community-packages.d.ts +12 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic shell pre-gate for auto mode (pure apart from path resolution).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists and what its contract is
|
|
5
|
+
*
|
|
6
|
+
* Derived from the MI Copilot shell sandbox, but with an inverted contract. In
|
|
7
|
+
* that codebase the analyzer was the *only* gate, so every tokenizer gap was an
|
|
8
|
+
* exploitable bypass — the security review found dozens. Here it sits in front
|
|
9
|
+
* of an LLM classifier which sits in front of a user prompt, so the contract is:
|
|
10
|
+
*
|
|
11
|
+
* **This module may only ever conclude "provably safe". It never denies.**
|
|
12
|
+
*
|
|
13
|
+
* A gap therefore costs one classifier call, not a bypass. `verdict: "escalate"`
|
|
14
|
+
* is the default for everything not positively recognised: unknown commands,
|
|
15
|
+
* parse failures, dynamic expansion, unresolvable paths, any redirection to a
|
|
16
|
+
* path that is not provably inside the working directory.
|
|
17
|
+
*
|
|
18
|
+
* Its second job matters as much: it extracts **deterministic evidence** —
|
|
19
|
+
* resolved write targets, credential paths touched, whether a target escapes
|
|
20
|
+
* the working directory, which wrapped command actually runs — and hands that
|
|
21
|
+
* to the classifier. Parsing shell is exactly what an LLM classifier is worst
|
|
22
|
+
* at, so giving it facts instead of a command string is the point of having
|
|
23
|
+
* both layers.
|
|
24
|
+
*
|
|
25
|
+
* Review findings fixed relative to the original: N1 (`$'…'` ANSI-C quoting),
|
|
26
|
+
* N2 (bare `.`/`..`), N3 (brace expansion), N4 (unspaced `<>&|` boundaries),
|
|
27
|
+
* N5 (transparent wrappers), N6 (archive/sync tools), N7 (`find` actions),
|
|
28
|
+
* N8 (in-script redirection), N10 (`>|`), N11 (git default-deny by subcommand),
|
|
29
|
+
* N12 (sensitive check on every token, `cd` tracked), N13/F3/F10 (one shared
|
|
30
|
+
* denylist — see `sensitive.ts`), F1 (git global flags).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { isExecutionPrimitivePath, isSensitivePath } from "./sensitive.ts";
|
|
34
|
+
import { isWithin, resolveForContainment, toAbsolute } from "./paths.ts";
|
|
35
|
+
|
|
36
|
+
export type ShellVerdict = "safe" | "escalate";
|
|
37
|
+
|
|
38
|
+
export interface ShellEvidence {
|
|
39
|
+
verdict: ShellVerdict;
|
|
40
|
+
/** Why it escalated / what the classifier should weigh. Never contains expanded variable values. */
|
|
41
|
+
notes: string[];
|
|
42
|
+
/** The commands that actually run, wrappers peeled (`env rm` → `rm`). */
|
|
43
|
+
commands: string[];
|
|
44
|
+
/** Resolved paths this command may write, with containment already decided. */
|
|
45
|
+
writes: { token: string; resolved?: string; outsideCwd: boolean }[];
|
|
46
|
+
/** Credential/secret paths named anywhere in the command (original tokens, never expanded). */
|
|
47
|
+
sensitivePaths: string[];
|
|
48
|
+
/** In-project paths whose contents execute later (`.git/hooks/*`, `.vscode/*.json`). */
|
|
49
|
+
executionPrimitives: string[];
|
|
50
|
+
/** Network-capable commands present (curl, ssh, …) — an egress signal for the classifier. */
|
|
51
|
+
network: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Commands that read and do not write, and are safe to fast-path. */
|
|
55
|
+
const READ_ONLY_COMMANDS = new Set([
|
|
56
|
+
"basename",
|
|
57
|
+
"cat",
|
|
58
|
+
"cksum",
|
|
59
|
+
"column",
|
|
60
|
+
"comm",
|
|
61
|
+
"cut",
|
|
62
|
+
"date",
|
|
63
|
+
"diff",
|
|
64
|
+
"dirname",
|
|
65
|
+
"du",
|
|
66
|
+
"echo",
|
|
67
|
+
"false",
|
|
68
|
+
"file",
|
|
69
|
+
"fold",
|
|
70
|
+
"head",
|
|
71
|
+
"hostname",
|
|
72
|
+
"id",
|
|
73
|
+
"join",
|
|
74
|
+
"jq",
|
|
75
|
+
"ls",
|
|
76
|
+
"md5sum",
|
|
77
|
+
"nl",
|
|
78
|
+
"od",
|
|
79
|
+
"paste",
|
|
80
|
+
"printenv",
|
|
81
|
+
"printf",
|
|
82
|
+
"pwd",
|
|
83
|
+
"readlink",
|
|
84
|
+
"realpath",
|
|
85
|
+
"rev",
|
|
86
|
+
"rg",
|
|
87
|
+
"sha1sum",
|
|
88
|
+
"sha256sum",
|
|
89
|
+
"shasum",
|
|
90
|
+
"sort",
|
|
91
|
+
"stat",
|
|
92
|
+
"tail",
|
|
93
|
+
"tr",
|
|
94
|
+
"tree",
|
|
95
|
+
"true",
|
|
96
|
+
"uname",
|
|
97
|
+
"uniq",
|
|
98
|
+
"wc",
|
|
99
|
+
"which",
|
|
100
|
+
"whoami",
|
|
101
|
+
"yes",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `grep` and friends take a pattern before their paths, so the first positional
|
|
106
|
+
* is not a path token. Tracked separately to avoid classifying a regex as a file.
|
|
107
|
+
*/
|
|
108
|
+
const PATTERN_FIRST_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg", "ag", "ack"]);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Commands that run whatever follows them. The original only knew three of
|
|
112
|
+
* these and never looked past them, so `env rm -rf ~/Desktop` classified as a
|
|
113
|
+
* harmless `env` (review finding N5). Every one of these is peeled and the
|
|
114
|
+
* payload command is classified instead.
|
|
115
|
+
*/
|
|
116
|
+
const TRANSPARENT_WRAPPERS = new Set([
|
|
117
|
+
"command",
|
|
118
|
+
"env",
|
|
119
|
+
"flock",
|
|
120
|
+
"ionice",
|
|
121
|
+
"nice",
|
|
122
|
+
"nohup",
|
|
123
|
+
"script",
|
|
124
|
+
"setsid",
|
|
125
|
+
"stdbuf",
|
|
126
|
+
"time",
|
|
127
|
+
"timeout",
|
|
128
|
+
"xargs",
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
/** Options of wrapper commands that consume the following token as a value. */
|
|
132
|
+
const WRAPPER_VALUE_OPTIONS = new Set(["-u", "-c", "-n", "-I", "-L", "-P", "--signal", "-s", "-k"]);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Anything here writes, deletes, or fetches-and-writes. The list is only used to
|
|
136
|
+
* *escalate* — a command missing from it still escalates via the unknown-command
|
|
137
|
+
* path, which is why the original's omissions (tar, rsync, zip — N6) are not
|
|
138
|
+
* exploitable here. It is kept current anyway so the evidence names the risk.
|
|
139
|
+
*/
|
|
140
|
+
const MUTATION_COMMANDS = new Set([
|
|
141
|
+
"7z",
|
|
142
|
+
"cp",
|
|
143
|
+
"dd",
|
|
144
|
+
"gunzip",
|
|
145
|
+
"gzip",
|
|
146
|
+
"install",
|
|
147
|
+
"ln",
|
|
148
|
+
"mkdir",
|
|
149
|
+
"mv",
|
|
150
|
+
"patch",
|
|
151
|
+
"rm",
|
|
152
|
+
"rmdir",
|
|
153
|
+
"rsync",
|
|
154
|
+
"shred",
|
|
155
|
+
"tar",
|
|
156
|
+
"tee",
|
|
157
|
+
"touch",
|
|
158
|
+
"truncate",
|
|
159
|
+
"unzip",
|
|
160
|
+
"zip",
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Interpreters whose *scripts* can write files the token-level pass never sees
|
|
165
|
+
* (review finding N8: `awk 'BEGIN{print > "f"}'`). Their programs are not
|
|
166
|
+
* parsed — they escalate unconditionally.
|
|
167
|
+
*/
|
|
168
|
+
const SCRIPT_INTERPRETERS = new Set([
|
|
169
|
+
"awk",
|
|
170
|
+
"bash",
|
|
171
|
+
"deno",
|
|
172
|
+
"gawk",
|
|
173
|
+
"node",
|
|
174
|
+
"perl",
|
|
175
|
+
"php",
|
|
176
|
+
"python",
|
|
177
|
+
"python3",
|
|
178
|
+
"ruby",
|
|
179
|
+
"sed",
|
|
180
|
+
"sh",
|
|
181
|
+
"tclsh",
|
|
182
|
+
"zsh",
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
const NETWORK_COMMANDS = new Set([
|
|
186
|
+
"curl",
|
|
187
|
+
"dig",
|
|
188
|
+
"ftp",
|
|
189
|
+
"host",
|
|
190
|
+
"nc",
|
|
191
|
+
"netcat",
|
|
192
|
+
"nmap",
|
|
193
|
+
"nslookup",
|
|
194
|
+
"ping",
|
|
195
|
+
"rsync",
|
|
196
|
+
"scp",
|
|
197
|
+
"sftp",
|
|
198
|
+
"ssh",
|
|
199
|
+
"telnet",
|
|
200
|
+
"traceroute",
|
|
201
|
+
"wget",
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* git is **default-deny by subcommand**: only these read-only subcommands can be
|
|
206
|
+
* fast-pathed, and everything else escalates. The original enumerated *mutating*
|
|
207
|
+
* subcommands and allowed the rest, so `git rm`, `git mv`, `git archive`,
|
|
208
|
+
* `git config`, and `git update-ref` all slipped through (review finding N11).
|
|
209
|
+
* Enumerating the safe set instead means a git subcommand added upstream
|
|
210
|
+
* tomorrow escalates rather than being silently permitted.
|
|
211
|
+
*/
|
|
212
|
+
const GIT_READ_ONLY_SUBCOMMANDS = new Set([
|
|
213
|
+
"blame",
|
|
214
|
+
"branch", // listing; mutation forms carry flags, and any flag we do not model escalates
|
|
215
|
+
"cat-file",
|
|
216
|
+
"describe",
|
|
217
|
+
"diff",
|
|
218
|
+
"grep",
|
|
219
|
+
"log",
|
|
220
|
+
"ls-files",
|
|
221
|
+
"ls-remote",
|
|
222
|
+
"ls-tree",
|
|
223
|
+
"rev-parse",
|
|
224
|
+
"shortlog",
|
|
225
|
+
"show",
|
|
226
|
+
"show-ref",
|
|
227
|
+
"status",
|
|
228
|
+
"tag", // listing only, same reasoning as branch
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* git global flags that take a value. `git -c k=v <sub>` can turn a read into
|
|
233
|
+
* code execution (`git -c protocol.ext.allow=always clone ext::sh …` — review
|
|
234
|
+
* finding F1, reproduced live there), so `-c`/`--config-env` are *not* skipped
|
|
235
|
+
* silently: seeing either escalates.
|
|
236
|
+
*/
|
|
237
|
+
const GIT_GLOBAL_VALUE_FLAGS = new Set(["-C", "--git-dir", "--work-tree", "--namespace", "--exec-path"]);
|
|
238
|
+
|
|
239
|
+
interface Token {
|
|
240
|
+
/** The token with quotes removed and `$'…'` decoded. */
|
|
241
|
+
value: string;
|
|
242
|
+
/** True when the token was written with any quoting or expansion syntax. */
|
|
243
|
+
hadExpansion: boolean;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface Segment {
|
|
247
|
+
tokens: Token[];
|
|
248
|
+
/** Redirection targets found in this segment, in written order. */
|
|
249
|
+
redirects: string[];
|
|
250
|
+
raw: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Decode the escapes bash understands inside `$'…'`. */
|
|
254
|
+
function decodeAnsiC(body: string): string {
|
|
255
|
+
return body.replace(/\\(n|t|r|\\|'|"|a|b|f|v|0|x[0-9a-fA-F]{1,2})/g, (match, escape: string) => {
|
|
256
|
+
switch (escape) {
|
|
257
|
+
case "n":
|
|
258
|
+
return "\n";
|
|
259
|
+
case "t":
|
|
260
|
+
return "\t";
|
|
261
|
+
case "r":
|
|
262
|
+
return "\r";
|
|
263
|
+
case "\\":
|
|
264
|
+
return "\\";
|
|
265
|
+
case "'":
|
|
266
|
+
return "'";
|
|
267
|
+
case '"':
|
|
268
|
+
return '"';
|
|
269
|
+
case "a":
|
|
270
|
+
return "\x07";
|
|
271
|
+
case "b":
|
|
272
|
+
return "\b";
|
|
273
|
+
case "f":
|
|
274
|
+
return "\f";
|
|
275
|
+
case "v":
|
|
276
|
+
return "\v";
|
|
277
|
+
case "0":
|
|
278
|
+
return "\0";
|
|
279
|
+
default:
|
|
280
|
+
return escape.startsWith("x") ? String.fromCharCode(Number.parseInt(escape.slice(1), 16)) : match;
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Syntax we do not model at all; its presence alone forces escalation. */
|
|
286
|
+
export function hasUnmodelledSyntax(command: string): string | undefined {
|
|
287
|
+
if (command.includes("\n")) return "spans multiple lines";
|
|
288
|
+
if (/\$\(/.test(command)) return "uses command substitution $( )";
|
|
289
|
+
if (/(^|[^\\])`/.test(command)) return "uses backtick command substitution";
|
|
290
|
+
if (/<<</.test(command)) return "uses a here-string";
|
|
291
|
+
if (/<</.test(command)) return "uses a heredoc";
|
|
292
|
+
if (/[<>]\(/.test(command)) return "uses process substitution";
|
|
293
|
+
// Brace expansion resolves to paths we cannot enumerate (review finding N3).
|
|
294
|
+
if (/\{[^{}]*,[^{}]*\}/.test(command)) return "uses brace expansion, whose expanded paths cannot be checked";
|
|
295
|
+
if (/\$\{?[A-Za-z_]/.test(command)) return "references environment variables, whose values are unknown here";
|
|
296
|
+
if (/\beval\b|\bexec\b/.test(command)) return "uses eval/exec";
|
|
297
|
+
if (/\|\s*(bash|sh|zsh|python|perl|node|ruby)\b/.test(command)) return "pipes into an interpreter";
|
|
298
|
+
if (/base64\s+(-d|--decode)/.test(command)) return "decodes base64, which can hide the real command";
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Split a command into pipeline/list segments and tokenize each. Unlike the
|
|
304
|
+
* original, `<`, `>`, `&`, and `|` terminate a token even without surrounding
|
|
305
|
+
* whitespace, so `cmd>file` and `a|b` are seen (review finding N4).
|
|
306
|
+
*/
|
|
307
|
+
export function parseCommand(command: string): { segments: Segment[]; parseFailed: boolean } {
|
|
308
|
+
const segments: Segment[] = [];
|
|
309
|
+
let tokens: Token[] = [];
|
|
310
|
+
let redirects: string[] = [];
|
|
311
|
+
let rawStart = 0;
|
|
312
|
+
|
|
313
|
+
let current = "";
|
|
314
|
+
let hadExpansion = false;
|
|
315
|
+
let quoted = false;
|
|
316
|
+
let inSingle = false;
|
|
317
|
+
let inDouble = false;
|
|
318
|
+
let escape = false;
|
|
319
|
+
/** Set while consuming the token that follows a redirection operator. */
|
|
320
|
+
let pendingRedirect = false;
|
|
321
|
+
|
|
322
|
+
const pushToken = () => {
|
|
323
|
+
if (!current && !quoted) return;
|
|
324
|
+
if (pendingRedirect) {
|
|
325
|
+
redirects.push(current);
|
|
326
|
+
pendingRedirect = false;
|
|
327
|
+
} else {
|
|
328
|
+
tokens.push({ value: current, hadExpansion });
|
|
329
|
+
}
|
|
330
|
+
current = "";
|
|
331
|
+
hadExpansion = false;
|
|
332
|
+
quoted = false;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const pushSegment = (at: number) => {
|
|
336
|
+
pushToken();
|
|
337
|
+
if (tokens.length > 0 || redirects.length > 0) {
|
|
338
|
+
segments.push({ tokens, redirects, raw: command.slice(rawStart, at).trim() });
|
|
339
|
+
}
|
|
340
|
+
tokens = [];
|
|
341
|
+
redirects = [];
|
|
342
|
+
rawStart = at + 1;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
for (let i = 0; i < command.length; i++) {
|
|
346
|
+
const ch = command[i];
|
|
347
|
+
|
|
348
|
+
if (escape) {
|
|
349
|
+
current += ch;
|
|
350
|
+
escape = false;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (ch === "\\" && !inSingle) {
|
|
354
|
+
escape = true;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (ch === "'" && !inDouble) {
|
|
358
|
+
inSingle = !inSingle;
|
|
359
|
+
quoted = true;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (ch === '"' && !inSingle) {
|
|
363
|
+
inDouble = !inDouble;
|
|
364
|
+
quoted = true;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
// ANSI-C quoting: `$'\x2e\x2e'` is `..`. The original kept the leading `$`,
|
|
368
|
+
// which made the token look like a variable and skipped every path check
|
|
369
|
+
// (review finding N1, reproduced there).
|
|
370
|
+
if (ch === "$" && command[i + 1] === "'" && !inSingle && !inDouble) {
|
|
371
|
+
const end = command.indexOf("'", i + 2);
|
|
372
|
+
if (end === -1) return { segments: [], parseFailed: true };
|
|
373
|
+
current += decodeAnsiC(command.slice(i + 2, end));
|
|
374
|
+
quoted = true;
|
|
375
|
+
hadExpansion = true;
|
|
376
|
+
i = end;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (inSingle || inDouble) {
|
|
381
|
+
current += ch;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (/\s/.test(ch)) {
|
|
386
|
+
pushToken();
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Redirection. `>|` is the clobber-override form and behaves as `>`
|
|
391
|
+
// (review finding N10); `2>&1` and `&>` are duplications, not paths.
|
|
392
|
+
if (ch === ">" || ch === "<") {
|
|
393
|
+
pushToken();
|
|
394
|
+
let j = i + 1;
|
|
395
|
+
if (command[j] === ">" || command[j] === "|") j++;
|
|
396
|
+
if (command[j] === "&") {
|
|
397
|
+
// fd duplication — consume the fd and move on, no path involved.
|
|
398
|
+
j++;
|
|
399
|
+
while (j < command.length && /[0-9-]/.test(command[j])) j++;
|
|
400
|
+
i = j - 1;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
pendingRedirect = ch === ">";
|
|
404
|
+
i = j - 1;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (ch === "|" || ch === ";" || ch === "&") {
|
|
409
|
+
// `&&`, `||`, `;`, `|`, `&` all end a segment. Any of them means the
|
|
410
|
+
// next command is separate, which is all we need to know.
|
|
411
|
+
pushSegment(i);
|
|
412
|
+
if (command[i + 1] === ch) i++;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
current += ch;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (escape || inSingle || inDouble) return { segments: [], parseFailed: true };
|
|
420
|
+
pushSegment(command.length);
|
|
421
|
+
return { segments, parseFailed: false };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Peel wrappers to the command that actually runs (review finding N5). */
|
|
425
|
+
function resolvePayload(tokens: Token[]): { command: string; args: Token[]; peeled: string[] } {
|
|
426
|
+
const peeled: string[] = [];
|
|
427
|
+
let index = 0;
|
|
428
|
+
|
|
429
|
+
for (;;) {
|
|
430
|
+
// Leading `VAR=value` assignments are not the command.
|
|
431
|
+
while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index].value)) index++;
|
|
432
|
+
if (index >= tokens.length) return { command: "", args: [], peeled };
|
|
433
|
+
|
|
434
|
+
const name = commandName(tokens[index].value);
|
|
435
|
+
if (!TRANSPARENT_WRAPPERS.has(name)) {
|
|
436
|
+
return { command: name, args: tokens.slice(index + 1), peeled };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
peeled.push(name);
|
|
440
|
+
index++;
|
|
441
|
+
// Step over the wrapper's own flags and their values.
|
|
442
|
+
while (index < tokens.length && tokens[index].value.startsWith("-")) {
|
|
443
|
+
const flag = tokens[index].value;
|
|
444
|
+
index++;
|
|
445
|
+
if (WRAPPER_VALUE_OPTIONS.has(flag) && index < tokens.length) index++;
|
|
446
|
+
}
|
|
447
|
+
// `timeout 30 cmd` — a bare duration is not the payload.
|
|
448
|
+
while (index < tokens.length && /^[0-9]+[smhd]?$/.test(tokens[index].value)) index++;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Basename of a command token, `.exe` stripped, lowercased. */
|
|
453
|
+
function commandName(token: string): string {
|
|
454
|
+
const withForwardSlashes = token.trim().toLowerCase().replace(/\\/g, "/");
|
|
455
|
+
const base = withForwardSlashes.slice(withForwardSlashes.lastIndexOf("/") + 1);
|
|
456
|
+
return base.endsWith(".exe") ? base.slice(0, -4) : base;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Whether a token names a path. Unlike the original, bare `.` and `..` count
|
|
461
|
+
* (review finding N2) — `rm -rf ..` deletes the parent directory and the
|
|
462
|
+
* original saw no path at all.
|
|
463
|
+
*/
|
|
464
|
+
function looksLikePath(value: string): boolean {
|
|
465
|
+
if (!value || value.startsWith("-")) return false;
|
|
466
|
+
if (value.includes("://")) return false;
|
|
467
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(value)) return false;
|
|
468
|
+
if (value === "." || value === "..") return true;
|
|
469
|
+
if (value === "~" || value.startsWith("~/")) return true;
|
|
470
|
+
return value.startsWith("/") || value.includes("/");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** `find` actions that execute or write (review finding N7 adds the last four). */
|
|
474
|
+
function findHasAction(args: Token[]): boolean {
|
|
475
|
+
return args.some(({ value }) =>
|
|
476
|
+
["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprintf", "-fls", "-fprint", "-fprint0"].includes(value),
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** The file named by a `-o FILE` / `-oFILE` / `--output=FILE` flag, if present. */
|
|
481
|
+
function outputFlagTarget(args: Token[]): string | undefined {
|
|
482
|
+
for (let i = 0; i < args.length; i++) {
|
|
483
|
+
const value = args[i].value;
|
|
484
|
+
if (value === "-o" || value === "--output") return args[i + 1]?.value;
|
|
485
|
+
if (value.startsWith("--output=")) return value.slice("--output=".length);
|
|
486
|
+
if (value.startsWith("-o") && value.length > 2) return value.slice(2);
|
|
487
|
+
}
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Decide git: only an explicitly read-only subcommand with no flags we cannot
|
|
493
|
+
* account for is safe. Returns undefined when safe, else the reason to escalate.
|
|
494
|
+
*/
|
|
495
|
+
function gitEscalationReason(args: Token[], isDirOutsideCwd: (dir: string) => boolean): string | undefined {
|
|
496
|
+
let index = 0;
|
|
497
|
+
while (index < args.length) {
|
|
498
|
+
const token = args[index].value;
|
|
499
|
+
if (!token.startsWith("-")) break;
|
|
500
|
+
// `-c key=value` can reconfigure git into executing an arbitrary helper.
|
|
501
|
+
if (token === "-c" || token.startsWith("-c=") || token === "--config-env") {
|
|
502
|
+
return "passes git -c/--config-env, which can turn a read into code execution";
|
|
503
|
+
}
|
|
504
|
+
if (GIT_GLOBAL_VALUE_FLAGS.has(token)) {
|
|
505
|
+
const value = args[index + 1]?.value;
|
|
506
|
+
// `-C`/`--git-dir`/`--work-tree` retarget git at another directory. If that
|
|
507
|
+
// directory escapes the working directory the operation is no longer
|
|
508
|
+
// provably in-project, so escalate rather than skipping the flag blindly
|
|
509
|
+
// (was review gap: `git -C /etc status` classified safe).
|
|
510
|
+
if ((token === "-C" || token === "--git-dir" || token === "--work-tree") && value && isDirOutsideCwd(value)) {
|
|
511
|
+
return `runs git ${token} ${value}, which points outside the working directory`;
|
|
512
|
+
}
|
|
513
|
+
index += 2;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
index++;
|
|
517
|
+
}
|
|
518
|
+
const subcommand = args[index]?.value?.toLowerCase();
|
|
519
|
+
if (!subcommand) return undefined; // bare `git` prints help
|
|
520
|
+
if (!GIT_READ_ONLY_SUBCOMMANDS.has(subcommand)) return `runs git ${subcommand}, which is not a read-only subcommand`;
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export interface AnalyzeInput {
|
|
525
|
+
command: string;
|
|
526
|
+
cwd: string;
|
|
527
|
+
home: string;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Classify a shell command. Never denies — see the module contract above.
|
|
532
|
+
*/
|
|
533
|
+
export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): ShellEvidence {
|
|
534
|
+
const evidence: ShellEvidence = {
|
|
535
|
+
verdict: "safe",
|
|
536
|
+
notes: [],
|
|
537
|
+
commands: [],
|
|
538
|
+
writes: [],
|
|
539
|
+
sensitivePaths: [],
|
|
540
|
+
executionPrimitives: [],
|
|
541
|
+
network: [],
|
|
542
|
+
};
|
|
543
|
+
const escalate = (note: string) => {
|
|
544
|
+
evidence.verdict = "escalate";
|
|
545
|
+
if (!evidence.notes.includes(note)) evidence.notes.push(note);
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const trimmed = command.trim();
|
|
549
|
+
if (!trimmed) return { ...evidence, verdict: "escalate", notes: ["empty command"] };
|
|
550
|
+
|
|
551
|
+
const unmodelled = hasUnmodelledSyntax(trimmed);
|
|
552
|
+
if (unmodelled) escalate(unmodelled);
|
|
553
|
+
|
|
554
|
+
const { segments, parseFailed } = parseCommand(trimmed);
|
|
555
|
+
if (parseFailed) {
|
|
556
|
+
escalate("could not be parsed (unbalanced quotes), so nothing about it is known");
|
|
557
|
+
return evidence;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Containment is checked against the *resolved* working directory. Write
|
|
562
|
+
* targets come back realpath'd, and on macOS the working directory often sits
|
|
563
|
+
* under a symlinked root (`/var/folders/…` → `/private/var/folders/…`), so
|
|
564
|
+
* comparing a resolved target against an unresolved base reports every
|
|
565
|
+
* in-project write as an escape.
|
|
566
|
+
*/
|
|
567
|
+
const containmentRoot = resolveForContainment(cwd) ?? cwd;
|
|
568
|
+
|
|
569
|
+
/** `cd` changes what later relative paths mean; the original never tracked it (F6/N12). */
|
|
570
|
+
let effectiveCwd = cwd;
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Resolve a write-target token, record it as evidence, and escalate on any
|
|
574
|
+
* target that cannot be resolved, escapes the working directory, or names a
|
|
575
|
+
* credential / execution-primitive path. Shared by redirections, mutating
|
|
576
|
+
* command positionals, and output-flag targets so all writes get one check.
|
|
577
|
+
*/
|
|
578
|
+
const checkWriteTarget = (token: string) => {
|
|
579
|
+
if (token === "/dev/null") return;
|
|
580
|
+
const absolute = toAbsolute(effectiveCwd, token, home);
|
|
581
|
+
const resolved = resolveForContainment(absolute);
|
|
582
|
+
const outsideCwd = resolved === undefined || !isWithin(containmentRoot, resolved);
|
|
583
|
+
evidence.writes.push({ token, resolved, outsideCwd });
|
|
584
|
+
if (resolved === undefined) {
|
|
585
|
+
escalate(`writes to ${token}, which could not be resolved to a real path`);
|
|
586
|
+
} else if (outsideCwd) {
|
|
587
|
+
escalate(`writes to ${token}, which is outside the working directory`);
|
|
588
|
+
}
|
|
589
|
+
if (isSensitivePath(absolute) && !evidence.sensitivePaths.includes(token)) {
|
|
590
|
+
evidence.sensitivePaths.push(token);
|
|
591
|
+
escalate(`writes to ${token}, a credential or secret path`);
|
|
592
|
+
}
|
|
593
|
+
if (isExecutionPrimitivePath(absolute) && !evidence.executionPrimitives.includes(token)) {
|
|
594
|
+
evidence.executionPrimitives.push(token);
|
|
595
|
+
escalate(`writes to ${token}, whose contents execute later without further approval`);
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
for (const segment of segments) {
|
|
600
|
+
// Redirection targets are writes regardless of the command word: a bare
|
|
601
|
+
// `> file` truncates/creates it with no command at all, and `git log > file`
|
|
602
|
+
// writes it too. Check them first so the command-specific `continue`s below
|
|
603
|
+
// (cd/git) can never skip a redirect (was review gap: bare-redirect writes and
|
|
604
|
+
// read-only-command redirects were fast-pathed to "safe").
|
|
605
|
+
for (const token of segment.redirects) checkWriteTarget(token);
|
|
606
|
+
|
|
607
|
+
const { command: name, args, peeled } = resolvePayload(segment.tokens);
|
|
608
|
+
if (!name) continue;
|
|
609
|
+
evidence.commands.push(name);
|
|
610
|
+
if (peeled.length > 0) {
|
|
611
|
+
escalate(`wraps the real command in ${peeled.join(" → ")}, so ${name} is what actually runs`);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (NETWORK_COMMANDS.has(name)) {
|
|
615
|
+
evidence.network.push(name);
|
|
616
|
+
escalate(`runs ${name}, which can reach the network`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Every positional token is checked against the credential denylist,
|
|
620
|
+
// whether or not it looks like a path: `cd .kube && cat config` reads
|
|
621
|
+
// `~/.kube/config` through two tokens that each look harmless (N12).
|
|
622
|
+
for (const { value } of args) {
|
|
623
|
+
if (!value || value.startsWith("-")) continue;
|
|
624
|
+
const absolute = toAbsolute(effectiveCwd, value, home);
|
|
625
|
+
if (isSensitivePath(value) || isSensitivePath(absolute)) {
|
|
626
|
+
// The *original* token, never the resolved/expanded form (N18).
|
|
627
|
+
if (!evidence.sensitivePaths.includes(value)) evidence.sensitivePaths.push(value);
|
|
628
|
+
escalate(`names ${value}, which is a credential or secret path`);
|
|
629
|
+
}
|
|
630
|
+
if (isExecutionPrimitivePath(absolute)) {
|
|
631
|
+
if (!evidence.executionPrimitives.includes(value)) evidence.executionPrimitives.push(value);
|
|
632
|
+
escalate(`touches ${value}, whose contents execute later without further approval`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (name === "cd") {
|
|
637
|
+
const target = args.find((token) => !token.value.startsWith("-"))?.value;
|
|
638
|
+
if (target) {
|
|
639
|
+
effectiveCwd = toAbsolute(effectiveCwd, target, home);
|
|
640
|
+
escalate(`changes directory to ${target}, so later paths in this command resolve elsewhere`);
|
|
641
|
+
}
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (name === "git") {
|
|
646
|
+
const reason = gitEscalationReason(args, (dir) => {
|
|
647
|
+
const resolved = resolveForContainment(toAbsolute(effectiveCwd, dir, home));
|
|
648
|
+
return resolved === undefined || !isWithin(containmentRoot, resolved);
|
|
649
|
+
});
|
|
650
|
+
if (reason) escalate(reason);
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (SCRIPT_INTERPRETERS.has(name)) {
|
|
655
|
+
escalate(`runs ${name}, whose script can read and write files this check cannot see`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (name === "find" && findHasAction(args)) {
|
|
659
|
+
escalate("uses a find action that executes commands or writes files");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const isMutation = MUTATION_COMMANDS.has(name);
|
|
663
|
+
if (isMutation) escalate(`runs ${name}, which modifies the filesystem`);
|
|
664
|
+
|
|
665
|
+
if (!isMutation && !READ_ONLY_COMMANDS.has(name) && !PATTERN_FIRST_COMMANDS.has(name) && name !== "find") {
|
|
666
|
+
escalate(`runs ${name}, which is not on the read-only allowlist`);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// The positional destinations of writing commands are the paths that get
|
|
670
|
+
// written. (Redirections were already handled at the top of the loop.)
|
|
671
|
+
const writeTokens: string[] = [];
|
|
672
|
+
if (isMutation) {
|
|
673
|
+
const positionals = args.filter((token) => !token.value.startsWith("-") && looksLikePath(token.value));
|
|
674
|
+
// For cp/mv/rsync/ln the destination is last; for the rest every
|
|
675
|
+
// positional is a candidate target.
|
|
676
|
+
if (["cp", "mv", "rsync", "ln", "install"].includes(name)) {
|
|
677
|
+
const last = positionals[positionals.length - 1];
|
|
678
|
+
if (last) writeTokens.push(last.value);
|
|
679
|
+
} else {
|
|
680
|
+
writeTokens.push(...positionals.map((token) => token.value));
|
|
681
|
+
}
|
|
682
|
+
for (const { value } of args) {
|
|
683
|
+
if (value.startsWith("of=")) writeTokens.push(value.slice(3)); // dd
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// A few otherwise-read-only commands write when handed an output flag.
|
|
688
|
+
// `sort -o FILE` / `sort --output=FILE` truncates/creates FILE, so it must
|
|
689
|
+
// not be fast-pathed as a pure read (was review gap: `sort -o` classified safe).
|
|
690
|
+
if (name === "sort") {
|
|
691
|
+
const outTarget = outputFlagTarget(args);
|
|
692
|
+
if (outTarget !== undefined) {
|
|
693
|
+
escalate("runs sort with -o/--output, which writes a file");
|
|
694
|
+
writeTokens.push(outTarget);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
for (const token of writeTokens) checkWriteTarget(token);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return evidence;
|
|
702
|
+
}
|