@mortz.prk/opencode-auto-mode 1.0.2 → 1.1.2
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/CONTRIBUTING.md +234 -0
- package/README.llm.md +320 -0
- package/README.md +180 -295
- package/dist/gui-experimental.cjs +1 -1
- package/package.json +9 -8
- package/src/audit.ts +130 -49
- package/src/classifier.ts +44 -47
- package/src/code-mode.ts +55 -0
- package/src/config.ts +102 -105
- package/src/debug-events.ts +223 -0
- package/src/decision.ts +72 -0
- package/src/gates.ts +16 -0
- package/src/global-options.ts +85 -0
- package/src/gui-state.ts +230 -121
- package/src/gui.tsx +56 -107
- package/src/index.ts +482 -373
- package/src/mode.ts +237 -0
- package/src/reviewer.ts +70 -94
- package/src/rpc.ts +48 -38
- package/src/score/files.ts +51 -0
- package/src/score/history.ts +141 -0
- package/src/score/index.ts +38 -0
- package/src/score/limits.ts +24 -0
- package/src/score/shell-argv.ts +74 -0
- package/src/score/shell-baseline.ts +113 -0
- package/src/score/shell-safety.ts +57 -0
- package/src/score/shell.ts +32 -0
- package/src/score/types.ts +14 -0
- package/src/score.ts +9 -0
- package/src/shell-policy.ts +672 -30
- package/src/shell.ts +276 -55
- package/src/status.ts +49 -36
- package/src/tiers.ts +292 -19
- package/src/tui.tsx +44 -34
- package/src/user-files.ts +75 -0
- package/src/worktree.ts +30 -0
- package/src/decisions.ts +0 -236
- package/src/logger.ts +0 -26
- package/src/otlp.ts +0 -77
- package/src/policy.ts +0 -36
- package/src/state.ts +0 -135
- package/src/storage.ts +0 -26
- package/src/traces.ts +0 -90
- package/src/transcript.ts +0 -204
- package/src/tui-state.ts +0 -13
package/src/shell.ts
CHANGED
|
@@ -1,26 +1,20 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
1
3
|
import type { ReviewResult } from "./reviewer.js";
|
|
4
|
+
import { isInProject, isProtected, isProtectedPathOperand, isProtectedShellWord } from "./tiers.js";
|
|
2
5
|
import {
|
|
3
6
|
BALANCED_QUOTES, CONTROL_OPERATORS, DYNAMIC, REDIRECT_OPERATORS, SCRIPT_HOSTS, SHELLS, STATEFUL,
|
|
4
7
|
denyCleanPrefix, denyInvocation, hasAmbiguousDescriptorClose, hasAssembledUndecodedSyntax, hasHeredoc,
|
|
5
|
-
hasUndecodedSyntax, heredocMarkers, isReadOnlyInvocation, logicalLines, normalizeContinuations,
|
|
6
|
-
rawHeredocScript, resolveExecutable, stripDescriptorPrefixes,
|
|
8
|
+
hasUndecodedSyntax, heredocMarkers, isLongOption, isReadOnlyInvocation, logicalLines, normalizeContinuations,
|
|
9
|
+
operandRoles, parseShell, rawHeredocScript, resolveExecutable, stripDescriptorPrefixes,
|
|
7
10
|
} from "./shell-policy.js";
|
|
8
11
|
|
|
9
12
|
const MAX_COMMAND_CHARS = 4096;
|
|
10
13
|
const MAX_COMMAND_BYTES = 65_536;
|
|
11
|
-
const WRITE_OPERATORS = new Set([">", ">>", ">|", "&>", "&>>", "<>"]);
|
|
14
|
+
const WRITE_OPERATORS = new Set([">", ">>", ">|", "&>", "&>>", "<>", ">&"]);
|
|
12
15
|
const DYNAMIC_SYNTAX = /\$\(|`|\$\(\s*\(|[<>]\(|\$\[/;
|
|
13
16
|
const FORK_BOMB = /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
|
|
14
17
|
|
|
15
|
-
export function shellWords(command: string): string[] | undefined {
|
|
16
|
-
if (!command || command.length > MAX_COMMAND_CHARS || /[$`~\r\n\0{}[\]]/.test(command) ||
|
|
17
|
-
/[^\S \t]/.test(command) || !BALANCED_QUOTES.test(command)) return undefined;
|
|
18
|
-
try {
|
|
19
|
-
const words = parseShell(command);
|
|
20
|
-
return words.length && words.every((word) => typeof word === "string") ? words as string[] : undefined;
|
|
21
|
-
} catch { return undefined; }
|
|
22
|
-
}
|
|
23
|
-
|
|
24
18
|
interface Segment { words: string[]; globs: boolean[]; write: boolean }
|
|
25
19
|
type Verdict = { decision: "deny"; reason: string } | { decision: "allow" | "review" | "ask" };
|
|
26
20
|
|
|
@@ -51,7 +45,20 @@ function analyzeSegment(segment: Segment): Verdict {
|
|
|
51
45
|
/** Token shapes this module consumes; `parse` returns a wider union. */
|
|
52
46
|
type Token = string | { comment: string } | { op: string; pattern?: string };
|
|
53
47
|
|
|
54
|
-
interface
|
|
48
|
+
export interface ParsedShellCommands {
|
|
49
|
+
/** Simple argv lists, excluding redirection operands. */
|
|
50
|
+
segments: Segment[];
|
|
51
|
+
/** Includes redirection targets for filesystem safety checks. */
|
|
52
|
+
operands: string[];
|
|
53
|
+
/** Redirection targets, including glob patterns: these always name a file. */
|
|
54
|
+
redirects: string[];
|
|
55
|
+
/** String tokens no simple command consumed, such as words inside a substitution. */
|
|
56
|
+
leftovers: string[];
|
|
57
|
+
/** False for syntax whose execution cannot be bounded by this parser. */
|
|
58
|
+
complete: boolean;
|
|
59
|
+
compound: boolean;
|
|
60
|
+
}
|
|
61
|
+
interface ParsedLine { segments: Segment[]; redirects: string[]; leftovers: string[]; complete: boolean }
|
|
55
62
|
|
|
56
63
|
/** Characters that end an unquoted shell word. JS `\s` is wider than a shell, so the set is explicit. */
|
|
57
64
|
const WORD_DELIMITER = /[ \t\n;|&()<>]/;
|
|
@@ -137,24 +144,47 @@ function wordExpansions(command: string): boolean[] {
|
|
|
137
144
|
return expansions;
|
|
138
145
|
}
|
|
139
146
|
|
|
147
|
+
/** String tokens that no simple command consumed, such as words inside `$(...)`. */
|
|
148
|
+
function leftoverWords(tokens: readonly Token[], consumed: ReadonlySet<number>): string[] {
|
|
149
|
+
return tokens.flatMap((token, index) => {
|
|
150
|
+
if (consumed.has(index)) return [];
|
|
151
|
+
if (typeof token === "string") return [token];
|
|
152
|
+
if ("op" in token && token.op === "glob" && token.pattern !== undefined) return [token.pattern];
|
|
153
|
+
return [];
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
140
157
|
/** Splits parsed tokens into simple commands, tracking whether the whole line parsed. */
|
|
141
158
|
function lineSegments(tokens: readonly Token[], expansions: readonly boolean[] = []): ParsedLine {
|
|
142
159
|
const segments: Segment[] = [{ words: [], globs: [], write: false }];
|
|
160
|
+
const redirects: string[] = [];
|
|
161
|
+
const consumed = new Set<number>();
|
|
143
162
|
let current = segments[0];
|
|
144
|
-
let redirect: { write: boolean } | undefined;
|
|
163
|
+
let redirect: { write: boolean; op: string } | undefined;
|
|
164
|
+
let uncertainRedirect = false;
|
|
145
165
|
let wordIndex = 0;
|
|
166
|
+
let position = 0;
|
|
146
167
|
const nextExpansion = () => expansions[wordIndex++] ?? false;
|
|
168
|
+
const failed = (): ParsedLine => ({ segments, redirects, leftovers: leftoverWords(tokens, consumed), complete: false });
|
|
147
169
|
for (const token of tokens) {
|
|
170
|
+
const index = position++;
|
|
148
171
|
if (typeof token === "string") {
|
|
149
172
|
if (redirect) {
|
|
150
173
|
const target = token;
|
|
151
|
-
nextExpansion();
|
|
152
|
-
|
|
174
|
+
const expanded = nextExpansion();
|
|
175
|
+
// Heredoc delimiters are quote-removed, not expanded. Their bodies and
|
|
176
|
+
// boundaries remain governed by the dedicated heredoc analyzer.
|
|
177
|
+
uncertainRedirect ||= expanded && !["<<", "<<-"].includes(redirect.op);
|
|
178
|
+
const descriptorClose = target === "-" && [">&", "<&"].includes(redirect.op);
|
|
179
|
+
if (redirect.write && target !== "/dev/null" && !descriptorClose)
|
|
153
180
|
current.write = true;
|
|
181
|
+
redirects.push(target);
|
|
182
|
+
consumed.add(index);
|
|
154
183
|
redirect = undefined;
|
|
155
184
|
} else {
|
|
156
185
|
current.words.push(token);
|
|
157
186
|
current.globs.push(nextExpansion());
|
|
187
|
+
consumed.add(index);
|
|
158
188
|
}
|
|
159
189
|
continue;
|
|
160
190
|
}
|
|
@@ -163,18 +193,22 @@ function lineSegments(tokens: readonly Token[], expansions: readonly boolean[] =
|
|
|
163
193
|
if (redirect) {
|
|
164
194
|
// A glob is still a redirection target, so it never becomes a command word.
|
|
165
195
|
nextExpansion();
|
|
196
|
+
uncertainRedirect = true;
|
|
197
|
+
redirects.push(token.pattern);
|
|
198
|
+
consumed.add(index);
|
|
166
199
|
if (redirect.write) current.write = true;
|
|
167
200
|
redirect = undefined;
|
|
168
201
|
} else {
|
|
169
202
|
nextExpansion();
|
|
170
203
|
current.words.push(token.pattern);
|
|
171
204
|
current.globs.push(true);
|
|
205
|
+
consumed.add(index);
|
|
172
206
|
}
|
|
173
207
|
continue;
|
|
174
208
|
}
|
|
175
209
|
if (CONTROL_OPERATORS.has(token.op)) {
|
|
176
210
|
// A control operator before the redirection target is invalid syntax.
|
|
177
|
-
if (redirect) return
|
|
211
|
+
if (redirect) return failed();
|
|
178
212
|
current = { words: [], globs: [], write: false };
|
|
179
213
|
segments.push(current);
|
|
180
214
|
continue;
|
|
@@ -182,15 +216,85 @@ function lineSegments(tokens: readonly Token[], expansions: readonly boolean[] =
|
|
|
182
216
|
if (REDIRECT_OPERATORS.has(token.op)) {
|
|
183
217
|
// Descriptor prefixes (`2>` in `2>/dev/null`) are stripped from the source
|
|
184
218
|
// before parsing, so a numeric word here is an operand and must survive.
|
|
185
|
-
redirect = { write: WRITE_OPERATORS.has(token.op) };
|
|
219
|
+
redirect = { write: WRITE_OPERATORS.has(token.op), op: token.op };
|
|
186
220
|
continue;
|
|
187
221
|
}
|
|
188
|
-
return
|
|
222
|
+
return failed();
|
|
189
223
|
}
|
|
190
|
-
return { segments, complete: redirect === undefined };
|
|
224
|
+
return { segments, redirects, leftovers: leftoverWords(tokens, consumed), complete: redirect === undefined && !uncertainRedirect };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The same token-to-command representation used by the guard and residual scoring. */
|
|
228
|
+
function parsedLine(command: string): ParsedShellCommands {
|
|
229
|
+
const tokens = parseShell(command);
|
|
230
|
+
const parsed = lineSegments(tokens, wordExpansions(stripDescriptorPrefixes(command)));
|
|
231
|
+
return {
|
|
232
|
+
...parsed,
|
|
233
|
+
operands: tokens.flatMap((token) => typeof token === "string" ? [token] :
|
|
234
|
+
"op" in token && token.op === "glob" && token.pattern !== undefined ? [token.pattern] : []),
|
|
235
|
+
complete: parsed.complete && !hasAmbiguousDescriptorClose(command) && !DYNAMIC_SYNTAX.test(command),
|
|
236
|
+
compound: tokens.some((token) => typeof token !== "string" && "op" in token && CONTROL_OPERATORS.has(token.op)),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Bounded literal commands only. No heredoc/script interpretation or dynamic
|
|
242
|
+
* expansion is inferred here; callers must retain guardShell's manual floor.
|
|
243
|
+
* `limits.maxChars` lets the pre-ask deny scan parse an oversized command
|
|
244
|
+
* without changing the guard's own manual-approval limit.
|
|
245
|
+
*/
|
|
246
|
+
export function parseShellCommands(command: string, limits: { maxChars?: number } = {}): ParsedShellCommands | undefined {
|
|
247
|
+
const maxChars = limits.maxChars ?? MAX_COMMAND_CHARS;
|
|
248
|
+
if (!command || command.length > maxChars || hasUndecodedSyntax(command)) return undefined;
|
|
249
|
+
const normalized = normalizeContinuations(command);
|
|
250
|
+
if (hasUndecodedSyntax(normalized) || hasHeredoc(normalized)) return undefined;
|
|
251
|
+
const { lines, certain } = logicalLines(normalized);
|
|
252
|
+
if (certain < lines.length) return undefined;
|
|
253
|
+
const result: ParsedShellCommands = { segments: [], operands: [], redirects: [], leftovers: [], complete: true, compound: false };
|
|
254
|
+
try {
|
|
255
|
+
for (const line of lines) {
|
|
256
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
257
|
+
if (!BALANCED_QUOTES.test(line)) return undefined;
|
|
258
|
+
const parsed = parsedLine(line);
|
|
259
|
+
result.segments.push(...parsed.segments);
|
|
260
|
+
result.operands.push(...parsed.operands);
|
|
261
|
+
result.redirects.push(...parsed.redirects);
|
|
262
|
+
result.leftovers.push(...parsed.leftovers);
|
|
263
|
+
result.complete &&= parsed.complete;
|
|
264
|
+
result.compound ||= parsed.compound;
|
|
265
|
+
}
|
|
266
|
+
} catch { return undefined; }
|
|
267
|
+
result.compound ||= result.segments.filter((segment) => segment.words.length).length > 1;
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* True when one parsed simple command reads or writes a protected path.
|
|
273
|
+
*
|
|
274
|
+
* The command's own grammar decides which words are files, which are data and
|
|
275
|
+
* which are ordinary options. A data word (`grep auth file`'s pattern, a `sed`
|
|
276
|
+
* script, a `jq` filter, a `--grep=` value) is never treated as a path, while a
|
|
277
|
+
* path word is checked with `isProtectedPathOperand` and every other word keeps
|
|
278
|
+
* the generic shell-word check.
|
|
279
|
+
*/
|
|
280
|
+
export function protectedSegment(segment: { words: readonly string[] }, projectDir?: string, includeGit = true): boolean {
|
|
281
|
+
const roles = operandRoles(segment.words);
|
|
282
|
+
const data = new Set(roles.data);
|
|
283
|
+
const paths = new Set(roles.paths);
|
|
284
|
+
if (roles.pathValues.some((value) => isProtectedPathOperand(value, projectDir, includeGit))) return true;
|
|
285
|
+
return segment.words.some((word, index) => {
|
|
286
|
+
if (data.has(index)) return false;
|
|
287
|
+
return paths.has(index) ? isProtectedPathOperand(word, projectDir, includeGit) : isProtectedShellWord(word, projectDir, includeGit);
|
|
288
|
+
});
|
|
191
289
|
}
|
|
192
290
|
|
|
193
|
-
function
|
|
291
|
+
function protectedCommands(parsed: ParsedLine, projectDir?: string, includeGit = true): boolean {
|
|
292
|
+
return parsed.redirects.some((target) => isProtected(target, projectDir, includeGit)) ||
|
|
293
|
+
parsed.segments.some((segment) => protectedSegment(segment, projectDir, includeGit)) ||
|
|
294
|
+
parsed.leftovers.some((word) => isProtectedShellWord(word, projectDir, includeGit));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function analyzeLine(command: string, projectDir?: string): Verdict {
|
|
194
298
|
if (!command.trim()) return { decision: "allow" };
|
|
195
299
|
if (!BALANCED_QUOTES.test(command)) return { decision: "ask" };
|
|
196
300
|
if (FORK_BOMB.test(command)) return { decision: "deny", reason: "dangerous shell operation" };
|
|
@@ -199,13 +303,26 @@ function analyzeLine(command: string): Verdict {
|
|
|
199
303
|
try { tokens = parseShell(command); }
|
|
200
304
|
catch { return { decision: "ask" }; }
|
|
201
305
|
|
|
202
|
-
const { segments, complete } =
|
|
306
|
+
const { segments, redirects, leftovers, complete } = parsedLine(command);
|
|
307
|
+
// A redirection always names a file, so its target takes the full path-aware
|
|
308
|
+
// check. Each simple command is judged by its own grammar: a pattern, script
|
|
309
|
+
// or filter is data even when it is spelled like a path, while a file operand
|
|
310
|
+
// keeps the protected-path check. Tokens no command consumed (words inside a
|
|
311
|
+
// substitution) keep the generic shell-word check. None of this depends on the
|
|
312
|
+
// line parsing completely: a deny visible in the literal text must win over the
|
|
313
|
+
// parser uncertainty.
|
|
314
|
+
if (protectedCommands({ segments, redirects, leftovers, complete }, projectDir, false))
|
|
315
|
+
return { decision: "deny", reason: "protected path access" };
|
|
203
316
|
// Deny rules that are already visible must win even when later arguments are
|
|
204
317
|
// dynamic (`sudo id $(...)`) and even when the line only parses partially.
|
|
205
318
|
for (const segment of segments) {
|
|
206
319
|
const blocked = denyInvocation(segment.words);
|
|
207
320
|
if (blocked !== undefined) return { decision: "deny", reason: blocked };
|
|
208
321
|
}
|
|
322
|
+
if (segments.some((segment) => ["curl", "wget"].includes(resolveExecutable(segment.words)?.name ?? "")) &&
|
|
323
|
+
segments.some((segment) => SHELLS.has(resolveExecutable(segment.words)?.name ?? "")) &&
|
|
324
|
+
tokens.some((token) => typeof token !== "string" && "op" in token && ["|", "|&"].includes(token.op)))
|
|
325
|
+
return { decision: "deny", reason: "remote download piped to a shell" };
|
|
209
326
|
// Bash closes the descriptor and runs the rest of the word as a command
|
|
210
327
|
// (`2>&-sudo id` runs sudo), while shell-quote reads `-sudo` as the redirect
|
|
211
328
|
// target. Neither reading may approve the invocation.
|
|
@@ -213,7 +330,7 @@ function analyzeLine(command: string): Verdict {
|
|
|
213
330
|
if (!complete || DYNAMIC_SYNTAX.test(command)) return { decision: "ask" };
|
|
214
331
|
|
|
215
332
|
let ask = false;
|
|
216
|
-
let review =
|
|
333
|
+
let review = protectedCommands({ segments, redirects, leftovers, complete }, projectDir);
|
|
217
334
|
for (const segment of segments) {
|
|
218
335
|
const verdict = analyzeSegment(segment);
|
|
219
336
|
if (verdict.decision === "deny") return verdict;
|
|
@@ -225,7 +342,7 @@ function analyzeLine(command: string): Verdict {
|
|
|
225
342
|
}
|
|
226
343
|
|
|
227
344
|
/** Analyzes every logical line, keeping deny precedence over ask and review. */
|
|
228
|
-
function analyzeLines(command: string): Verdict {
|
|
345
|
+
function analyzeLines(command: string, projectDir?: string): Verdict {
|
|
229
346
|
let ask = false;
|
|
230
347
|
let review = false;
|
|
231
348
|
const { lines, certain } = logicalLines(command);
|
|
@@ -235,7 +352,7 @@ function analyzeLines(command: string): Verdict {
|
|
|
235
352
|
for (const line of lines) {
|
|
236
353
|
const trimmed = line.trim();
|
|
237
354
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
238
|
-
const verdict = analyzeLine(trimmed);
|
|
355
|
+
const verdict = analyzeLine(trimmed, projectDir);
|
|
239
356
|
if (verdict.decision === "deny") return verdict;
|
|
240
357
|
if (verdict.decision === "ask") ask = true;
|
|
241
358
|
else if (verdict.decision === "review") review = true;
|
|
@@ -244,7 +361,7 @@ function analyzeLines(command: string): Verdict {
|
|
|
244
361
|
return { decision: review ? "review" : "allow" };
|
|
245
362
|
}
|
|
246
363
|
|
|
247
|
-
function analyzeHeredoc(command: string): Verdict {
|
|
364
|
+
function analyzeHeredoc(command: string, projectDir?: string): Verdict {
|
|
248
365
|
const lines = command.split("\n");
|
|
249
366
|
const firstLine = lines[0] ?? "";
|
|
250
367
|
const markers = heredocMarkers(firstLine);
|
|
@@ -268,7 +385,7 @@ function analyzeHeredoc(command: string): Verdict {
|
|
|
268
385
|
// the bodies really end where the raw text says. The header is analyzed on its
|
|
269
386
|
// own because its markers have already been consumed above: handing them back
|
|
270
387
|
// to the heredoc scanner would swallow the tail as a second body.
|
|
271
|
-
const header = analyzeLine(firstLine);
|
|
388
|
+
const header = analyzeLine(firstLine, projectDir);
|
|
272
389
|
if (header.decision === "deny") return header;
|
|
273
390
|
// An unquoted body is matched against its terminator after the shell removes
|
|
274
391
|
// continuations, so a body line ending in a backslash moves the boundary —
|
|
@@ -283,7 +400,7 @@ function analyzeHeredoc(command: string): Verdict {
|
|
|
283
400
|
// the same way, and a blocked command spanning a quoted newline is still seen
|
|
284
401
|
// whole.
|
|
285
402
|
const tail = lines.slice(index).join("\n");
|
|
286
|
-
const rest: Verdict = tail ? analyze(tail) : { decision: "allow" };
|
|
403
|
+
const rest: Verdict = tail ? analyze(tail, projectDir) : { decision: "allow" };
|
|
287
404
|
if (rest.decision === "deny") return rest;
|
|
288
405
|
let ask = header.decision === "ask" || rest.decision === "ask";
|
|
289
406
|
const review = header.decision === "review" || rest.decision === "review";
|
|
@@ -304,7 +421,130 @@ function analyzeHeredoc(command: string): Verdict {
|
|
|
304
421
|
return { decision: review ? "review" : "allow" };
|
|
305
422
|
}
|
|
306
423
|
|
|
307
|
-
|
|
424
|
+
/**
|
|
425
|
+
* True when a recursive or forced `rm` targets the user's home directory or a
|
|
426
|
+
* path outside the project.
|
|
427
|
+
*
|
|
428
|
+
* GNU option parsing accepts abbreviations, so `--recursi` and `--forc` are the
|
|
429
|
+
* same flags as `--recursive` and `--force`. `~` and `~/path` are expanded
|
|
430
|
+
* against the home directory and prefix-matched by containment; the home
|
|
431
|
+
* directory itself is always external, and another user's `~user` cannot be
|
|
432
|
+
* resolved locally, so it also stays external. `$HOME` is not expanded here, and
|
|
433
|
+
* the parser turns an unquoted `$HOME` into an empty word, so both spellings
|
|
434
|
+
* count as the home target.
|
|
435
|
+
*/
|
|
436
|
+
export function recursiveExternalDelete(words: readonly string[], projectDir?: string, worktreeRoot = projectDir): boolean {
|
|
437
|
+
if (words[0] !== "rm") return false;
|
|
438
|
+
let risky = false;
|
|
439
|
+
let end = false;
|
|
440
|
+
const targets: string[] = [];
|
|
441
|
+
for (const word of words.slice(1)) {
|
|
442
|
+
if (!end && word === "--") { end = true; continue; }
|
|
443
|
+
if (!end && word.startsWith("--")) {
|
|
444
|
+
// Prefix matching covers `--recursi`/`--forc` and other unambiguous prefixes.
|
|
445
|
+
if (isLongOption(word, "--recursive") || isLongOption(word, "--force")) risky = true;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (!end && word.startsWith("-") && word.length > 1) {
|
|
449
|
+
if (/^-[^-]*[rRf]/.test(word)) risky = true;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
targets.push(word);
|
|
453
|
+
}
|
|
454
|
+
if (!risky || targets.length === 0) return false;
|
|
455
|
+
const home = homedir();
|
|
456
|
+
return targets.some((target) => {
|
|
457
|
+
// `$HOME` is not expanded here, and the parser turns an unquoted `$HOME`
|
|
458
|
+
// into an empty word; both spellings name the home directory.
|
|
459
|
+
if (!target || target.startsWith("$HOME") || target.startsWith("${HOME}")) return true;
|
|
460
|
+
if (target.startsWith("~")) {
|
|
461
|
+
// `~` and `~/path` expand against the home directory; another user's
|
|
462
|
+
// `~user` cannot be resolved locally and stays external.
|
|
463
|
+
const expanded = target === "~" || target === "~/" ? home
|
|
464
|
+
: target.startsWith("~/") ? resolve(home, target.slice(2)) : undefined;
|
|
465
|
+
if (expanded === undefined || expanded === home) return true;
|
|
466
|
+
return !projectDir || !worktreeRoot || !isInProject(expanded, worktreeRoot);
|
|
467
|
+
}
|
|
468
|
+
// Without a verified project there is no inside that a delete could be bounded to.
|
|
469
|
+
if (!projectDir || !worktreeRoot) return true;
|
|
470
|
+
return !isInProject(resolve(projectDir, target), worktreeRoot);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The literal prefix of a line that precedes syntax this policy does not decode.
|
|
476
|
+
* The marker and everything after it are never inspected, because an undecoded
|
|
477
|
+
* quote or expansion can move word and line boundaries.
|
|
478
|
+
*/
|
|
479
|
+
function visiblePrefix(line: string): string {
|
|
480
|
+
let cut = line.length;
|
|
481
|
+
for (const opener of ["$'", '$"', "$["]) {
|
|
482
|
+
const index = line.indexOf(opener);
|
|
483
|
+
if (index !== -1 && index < cut) cut = index;
|
|
484
|
+
}
|
|
485
|
+
const whitespace = line.search(/[^\S \t]/);
|
|
486
|
+
if (whitespace !== -1 && whitespace < cut) cut = whitespace;
|
|
487
|
+
return line.slice(0, cut);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The executable name and effective argv after leading wrappers are removed. */
|
|
491
|
+
function effectiveInvocation(words: readonly string[]): { name: string; argv: string[] } {
|
|
492
|
+
const executable = resolveExecutable(words);
|
|
493
|
+
const argv = executable ? words.slice(executable.index) : [...words];
|
|
494
|
+
const name = basename(argv[0] ?? "");
|
|
495
|
+
return { name, argv: [name, ...argv.slice(1)] };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Mandatory denies visible before the syntax that makes the guard ask.
|
|
500
|
+
*
|
|
501
|
+
* The guard cannot approve text it cannot decode, but a deny it can already see
|
|
502
|
+
* must still win over that manual-approval floor: `cat .env # $'` is still a
|
|
503
|
+
* protected-path read, `sudo id\necho $'x'` still elevates, and `rm -rf
|
|
504
|
+
* /outside # $'` still deletes outside the project. Only the literal prefix of
|
|
505
|
+
* each line is judged; a line that is not certainly script, or whose prefix is
|
|
506
|
+
* not balanced and static, contributes no deny and keeps the ask.
|
|
507
|
+
*
|
|
508
|
+
* The protected-path, blocked-invocation and recursive-delete checks run on
|
|
509
|
+
* whatever the prefix parsed into; `complete` only gates the pipeline shape that
|
|
510
|
+
* needs the whole line. Otherwise a trailing `$(...)` or `$'...'` would erase a
|
|
511
|
+
* deny that is already visible.
|
|
512
|
+
*/
|
|
513
|
+
export function visibleShellDeny(resources: readonly string[], projectDir?: string, worktreeRoot = projectDir): string | undefined {
|
|
514
|
+
for (const resource of resources) {
|
|
515
|
+
// Per-resource cap, not a shared budget: an earlier resource must not spend
|
|
516
|
+
// the bytes that would reveal a deny in a later one.
|
|
517
|
+
const text = resource.slice(0, MAX_COMMAND_BYTES);
|
|
518
|
+
const { lines, certain } = logicalLines(normalizeContinuations(text));
|
|
519
|
+
for (const line of lines.slice(0, certain)) {
|
|
520
|
+
const visible = visiblePrefix(line).trim();
|
|
521
|
+
if (!visible || visible.startsWith("#")) continue;
|
|
522
|
+
if (FORK_BOMB.test(visible)) return "dangerous shell operation";
|
|
523
|
+
const parsed = parseShellCommands(visible, { maxChars: MAX_COMMAND_BYTES });
|
|
524
|
+
if (parsed === undefined) continue;
|
|
525
|
+
if (protectedCommands(parsed, projectDir, false))
|
|
526
|
+
return "protected path access";
|
|
527
|
+
const commands = parsed.segments.map((segment) => effectiveInvocation(segment.words));
|
|
528
|
+
for (const command of commands) {
|
|
529
|
+
const blocked = denyInvocation(command.argv);
|
|
530
|
+
if (blocked !== undefined) return blocked;
|
|
531
|
+
}
|
|
532
|
+
if (commands.some((command) => recursiveExternalDelete(command.argv, projectDir, worktreeRoot)))
|
|
533
|
+
return "Recursive/force deletion outside the project";
|
|
534
|
+
if (!parsed.complete) continue;
|
|
535
|
+
if (commands.some((command) => ["curl", "wget"].includes(command.name)) &&
|
|
536
|
+
commands.some((command) => SHELLS.has(command.name))) {
|
|
537
|
+
try {
|
|
538
|
+
if (parseShell(visible).some((token) => typeof token !== "string" && "op" in token &&
|
|
539
|
+
["|", "|&"].includes(token.op))) return "remote download piped to a shell";
|
|
540
|
+
} catch { /* The prefix parse above already succeeded; treat an unknown pipe shape as no deny. */ }
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return undefined;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function analyze(command: string, projectDir?: string): Verdict {
|
|
308
548
|
// A shell separates words only on space, tab, and newline. JS `\s` (and the
|
|
309
549
|
// parser behind it) also matches CR, FF, VT, NBSP, and Unicode spaces, which
|
|
310
550
|
// can move a heredoc boundary or turn a midword hash into a comment, so any of
|
|
@@ -335,7 +575,7 @@ function analyze(command: string): Verdict {
|
|
|
335
575
|
// The raw line may still carry a `\`-newline that the shell removes before
|
|
336
576
|
// it forms this header; removing it here only reaches the deny pass, and
|
|
337
577
|
// the header line itself is always script.
|
|
338
|
-
const verdict = analyzeLine(normalizeContinuations(line).trim());
|
|
578
|
+
const verdict = analyzeLine(normalizeContinuations(line).trim(), projectDir);
|
|
339
579
|
if (verdict.decision === "deny") return verdict;
|
|
340
580
|
}
|
|
341
581
|
return { decision: "ask" };
|
|
@@ -353,12 +593,12 @@ function analyze(command: string): Verdict {
|
|
|
353
593
|
}
|
|
354
594
|
// `logicalLines` must split on the newlines the shell actually treats as
|
|
355
595
|
// separators, and heredoc bodies stay untouched.
|
|
356
|
-
if (hasHeredoc(normalized)) return analyzeHeredoc(normalized);
|
|
357
|
-
if (normalized.includes("\n")) return analyzeLines(normalized);
|
|
358
|
-
return analyzeLine(normalized);
|
|
596
|
+
if (hasHeredoc(normalized)) return analyzeHeredoc(normalized, projectDir);
|
|
597
|
+
if (normalized.includes("\n")) return analyzeLines(normalized, projectDir);
|
|
598
|
+
return analyzeLine(normalized, projectDir);
|
|
359
599
|
}
|
|
360
600
|
|
|
361
|
-
export function guardShell(resources: readonly string[]): ReviewResult | undefined {
|
|
601
|
+
export function guardShell(resources: readonly string[], projectDir?: string): ReviewResult | undefined {
|
|
362
602
|
if (process.platform === "win32") return { decision: "ask", reason: "Shell dialect requires manual approval" };
|
|
363
603
|
if (!resources.length || resources.length > 256 || resources.some((resource) => resource.length > MAX_COMMAND_CHARS) ||
|
|
364
604
|
resources.reduce((sum, resource) => sum + Buffer.byteLength(resource), 0) > MAX_COMMAND_BYTES)
|
|
@@ -366,30 +606,11 @@ export function guardShell(resources: readonly string[]): ReviewResult | undefin
|
|
|
366
606
|
let ask = false;
|
|
367
607
|
let review = false;
|
|
368
608
|
for (const resource of resources) {
|
|
369
|
-
const verdict = analyze(resource);
|
|
609
|
+
const verdict = analyze(resource, projectDir);
|
|
370
610
|
if (verdict.decision === "deny") return { decision: "deny", reason: verdict.reason };
|
|
371
611
|
if (verdict.decision === "ask") ask = true;
|
|
372
612
|
else if (verdict.decision === "review") review = true;
|
|
373
613
|
}
|
|
374
|
-
if (ask) return { decision: "ask", reason: "Shell syntax requires
|
|
614
|
+
if (ask) return { decision: "ask", reason: "Shell syntax requires further review" };
|
|
375
615
|
return review ? undefined : { decision: "allow", reason: "read-only command" };
|
|
376
616
|
}
|
|
377
|
-
|
|
378
|
-
export interface ShellClassification {
|
|
379
|
-
tier: "auto-allow" | "auto-deny" | "review";
|
|
380
|
-
reason: string;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Static tier classification for shell actions. The shell guard is the single
|
|
385
|
-
* source of truth so the classifier can never allow or deny something the guard
|
|
386
|
-
* would not.
|
|
387
|
-
*/
|
|
388
|
-
export function classifyShell(resources: readonly string[]): ShellClassification {
|
|
389
|
-
const verdict = guardShell(resources);
|
|
390
|
-
if (verdict?.decision === "deny") return { tier: "auto-deny", reason: verdict.reason };
|
|
391
|
-
if (verdict?.decision === "allow") return { tier: "auto-allow", reason: verdict.reason };
|
|
392
|
-
if (verdict?.decision === "ask") return { tier: "review", reason: "shell syntax requires manual approval" };
|
|
393
|
-
return { tier: "review", reason: "evaluate the entire command as one action" };
|
|
394
|
-
}
|
|
395
|
-
|
package/src/status.ts
CHANGED
|
@@ -1,41 +1,51 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
MAX_STATUS_ACTION_CHARS,
|
|
3
|
+
MAX_STATUS_REASON_CHARS,
|
|
4
|
+
MAX_STATUS_REVIEWING,
|
|
5
|
+
MAX_STATUS_STAGE_CHARS,
|
|
6
|
+
type AutoModeCounts,
|
|
7
|
+
type AutoModeDecision,
|
|
8
|
+
type AutoModeErrorKind,
|
|
9
|
+
type AutoModeMode,
|
|
10
|
+
type AutoModeStatus,
|
|
11
|
+
} from "./rpc.js";
|
|
2
12
|
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
// the same action, not an unbounded copy of the permission event.
|
|
6
|
-
const MAX_ACTION_CHARS = 128;
|
|
7
|
-
const MAX_STAGE_CHARS = 64;
|
|
8
|
-
const MAX_REASON_CHARS = 200;
|
|
9
|
-
const MAX_REVIEWING = 256;
|
|
10
|
-
|
|
11
|
-
function clip(value: string, max = MAX_REASON_CHARS): string {
|
|
12
|
-
// Count code points, not UTF-16 units, so clipping cannot cut a surrogate
|
|
13
|
-
// pair in half.
|
|
13
|
+
function clip(value: string, max = MAX_STATUS_REASON_CHARS): string {
|
|
14
|
+
// Count code points so clipping cannot split a surrogate pair.
|
|
14
15
|
const characters = Array.from(value);
|
|
15
16
|
return characters.length > max ? `${characters.slice(0, max - 1).join("")}…` : value;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
function counts(value: AutoModeCounts | undefined): AutoModeCounts {
|
|
20
|
+
// Rebuild exactly three counters so arbitrary stored/transport keys never leak.
|
|
21
|
+
return { allow: value?.allow ?? 0, ask: value?.ask ?? 0, deny: value?.deny ?? 0 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StatusSnapshotInput {
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
mode: AutoModeMode;
|
|
27
|
+
configError?: string;
|
|
28
|
+
errorKind?: AutoModeErrorKind;
|
|
29
|
+
reviewTimeoutMs: number;
|
|
30
|
+
sessionID?: string;
|
|
31
|
+
/** Today's audit counts, supplied by the server rather than accumulated in UI memory. */
|
|
32
|
+
counts?: AutoModeCounts;
|
|
33
|
+
breaker?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
export interface StatusTracker {
|
|
19
37
|
start(sessionID: string, reviewID: string, at: number): void;
|
|
20
|
-
finish(decision:
|
|
21
|
-
snapshot(input:
|
|
22
|
-
recent(limit: number, sessionID?: string): AutoModeDecision[];
|
|
38
|
+
finish(decision: AutoModeDecision): AutoModeDecision;
|
|
39
|
+
snapshot(input: StatusSnapshotInput): AutoModeStatus;
|
|
23
40
|
}
|
|
24
41
|
|
|
25
|
-
/**
|
|
26
|
-
* Small in-memory view of AutoMode for clients. It holds no prompts and is
|
|
27
|
-
* bounded in both entries and text so a long session cannot grow it without
|
|
28
|
-
* limit; durable history stays in the audit log.
|
|
29
|
-
*/
|
|
42
|
+
/** Bounded active-review tracking. Finished decisions are not retained. */
|
|
30
43
|
export function createStatusTracker(): StatusTracker {
|
|
31
|
-
// Keyed by review rather than session: a session can ask about several
|
|
32
|
-
// actions at once, and finishing one must not hide the others.
|
|
33
44
|
const reviewing = new Map<string, { sessionID: string; startedAt: number }>();
|
|
34
|
-
let recent: AutoModeDecision[] = [];
|
|
35
45
|
|
|
36
46
|
return {
|
|
37
47
|
start(sessionID, reviewID, at) {
|
|
38
|
-
if (reviewing.size >=
|
|
48
|
+
if (reviewing.size >= MAX_STATUS_REVIEWING && !reviewing.has(reviewID)) {
|
|
39
49
|
const oldest = reviewing.keys().next().value;
|
|
40
50
|
if (oldest !== undefined) reviewing.delete(oldest);
|
|
41
51
|
}
|
|
@@ -43,30 +53,33 @@ export function createStatusTracker(): StatusTracker {
|
|
|
43
53
|
},
|
|
44
54
|
finish(decision) {
|
|
45
55
|
reviewing.delete(decision.reviewID);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
// Return only the bounded live-decision surface; never retain arbitrary
|
|
57
|
+
// record fields such as removed rule or shadow metadata.
|
|
58
|
+
return {
|
|
59
|
+
reviewID: decision.reviewID,
|
|
60
|
+
sessionID: decision.sessionID,
|
|
61
|
+
action: clip(decision.action, MAX_STATUS_ACTION_CHARS),
|
|
62
|
+
decision: clip(decision.decision, MAX_STATUS_STAGE_CHARS),
|
|
63
|
+
stage: clip(decision.stage, MAX_STATUS_STAGE_CHARS),
|
|
50
64
|
reason: clip(decision.reason),
|
|
65
|
+
durationMs: decision.durationMs,
|
|
66
|
+
at: decision.at,
|
|
67
|
+
...(decision.errorKind === undefined ? {} : { errorKind: clip(decision.errorKind, MAX_STATUS_STAGE_CHARS) }),
|
|
51
68
|
};
|
|
52
|
-
recent = [...recent, entry].slice(-MAX_RECENT);
|
|
53
|
-
return entry;
|
|
54
69
|
},
|
|
55
70
|
snapshot(input) {
|
|
56
71
|
return {
|
|
57
72
|
enabled: input.enabled,
|
|
73
|
+
mode: input.mode,
|
|
58
74
|
...(input.configError === undefined ? {} : { configError: clip(input.configError) }),
|
|
59
|
-
|
|
75
|
+
...(input.errorKind === undefined ? {} : { errorKind: input.errorKind }),
|
|
60
76
|
reviewTimeoutMs: input.reviewTimeoutMs,
|
|
61
77
|
reviewing: [...reviewing.entries()]
|
|
62
78
|
.filter(([, entry]) => input.sessionID === undefined || entry.sessionID === input.sessionID)
|
|
63
79
|
.map(([reviewID, entry]) => ({ sessionID: entry.sessionID, reviewID, startedAt: entry.startedAt })),
|
|
64
|
-
|
|
80
|
+
counts: counts(input.counts),
|
|
81
|
+
...(input.breaker === undefined ? {} : { breaker: input.breaker }),
|
|
65
82
|
};
|
|
66
83
|
},
|
|
67
|
-
recent(limit, sessionID) {
|
|
68
|
-
const matches = sessionID === undefined ? recent : recent.filter((entry) => entry.sessionID === sessionID);
|
|
69
|
-
return matches.slice(-limit);
|
|
70
|
-
},
|
|
71
84
|
};
|
|
72
85
|
}
|