@mortz.prk/opencode-auto-mode 1.0.2 → 1.1.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/CONTRIBUTING.md +234 -0
- package/README.llm.md +314 -0
- package/README.md +174 -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 +49 -0
- package/src/score/history.ts +141 -0
- package/src/score/index.ts +37 -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 +54 -0
- package/src/score/shell.ts +30 -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 +272 -52
- package/src/status.ts +49 -36
- package/src/tiers.ts +285 -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,79 @@ 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;
|
|
191
269
|
}
|
|
192
270
|
|
|
193
|
-
|
|
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): 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))) return true;
|
|
285
|
+
return segment.words.some((word, index) => {
|
|
286
|
+
if (data.has(index)) return false;
|
|
287
|
+
return paths.has(index) ? isProtectedPathOperand(word, projectDir) : isProtectedShellWord(word, projectDir);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function analyzeLine(command: string, projectDir?: string): Verdict {
|
|
194
292
|
if (!command.trim()) return { decision: "allow" };
|
|
195
293
|
if (!BALANCED_QUOTES.test(command)) return { decision: "ask" };
|
|
196
294
|
if (FORK_BOMB.test(command)) return { decision: "deny", reason: "dangerous shell operation" };
|
|
@@ -199,13 +297,28 @@ function analyzeLine(command: string): Verdict {
|
|
|
199
297
|
try { tokens = parseShell(command); }
|
|
200
298
|
catch { return { decision: "ask" }; }
|
|
201
299
|
|
|
202
|
-
const { segments, complete } =
|
|
300
|
+
const { segments, redirects, leftovers, complete } = parsedLine(command);
|
|
301
|
+
// A redirection always names a file, so its target takes the full path-aware
|
|
302
|
+
// check. Each simple command is judged by its own grammar: a pattern, script
|
|
303
|
+
// or filter is data even when it is spelled like a path, while a file operand
|
|
304
|
+
// keeps the protected-path check. Tokens no command consumed (words inside a
|
|
305
|
+
// substitution) keep the generic shell-word check. None of this depends on the
|
|
306
|
+
// line parsing completely: a deny visible in the literal text must win over the
|
|
307
|
+
// manual-approval floor.
|
|
308
|
+
if (redirects.some((target) => isProtected(target, projectDir)) ||
|
|
309
|
+
segments.some((segment) => protectedSegment(segment, projectDir)) ||
|
|
310
|
+
leftovers.some((word) => isProtectedShellWord(word, projectDir)))
|
|
311
|
+
return { decision: "deny", reason: "protected path access" };
|
|
203
312
|
// Deny rules that are already visible must win even when later arguments are
|
|
204
313
|
// dynamic (`sudo id $(...)`) and even when the line only parses partially.
|
|
205
314
|
for (const segment of segments) {
|
|
206
315
|
const blocked = denyInvocation(segment.words);
|
|
207
316
|
if (blocked !== undefined) return { decision: "deny", reason: blocked };
|
|
208
317
|
}
|
|
318
|
+
if (segments.some((segment) => ["curl", "wget"].includes(resolveExecutable(segment.words)?.name ?? "")) &&
|
|
319
|
+
segments.some((segment) => SHELLS.has(resolveExecutable(segment.words)?.name ?? "")) &&
|
|
320
|
+
tokens.some((token) => typeof token !== "string" && "op" in token && ["|", "|&"].includes(token.op)))
|
|
321
|
+
return { decision: "deny", reason: "remote download piped to a shell" };
|
|
209
322
|
// Bash closes the descriptor and runs the rest of the word as a command
|
|
210
323
|
// (`2>&-sudo id` runs sudo), while shell-quote reads `-sudo` as the redirect
|
|
211
324
|
// target. Neither reading may approve the invocation.
|
|
@@ -225,7 +338,7 @@ function analyzeLine(command: string): Verdict {
|
|
|
225
338
|
}
|
|
226
339
|
|
|
227
340
|
/** Analyzes every logical line, keeping deny precedence over ask and review. */
|
|
228
|
-
function analyzeLines(command: string): Verdict {
|
|
341
|
+
function analyzeLines(command: string, projectDir?: string): Verdict {
|
|
229
342
|
let ask = false;
|
|
230
343
|
let review = false;
|
|
231
344
|
const { lines, certain } = logicalLines(command);
|
|
@@ -235,7 +348,7 @@ function analyzeLines(command: string): Verdict {
|
|
|
235
348
|
for (const line of lines) {
|
|
236
349
|
const trimmed = line.trim();
|
|
237
350
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
238
|
-
const verdict = analyzeLine(trimmed);
|
|
351
|
+
const verdict = analyzeLine(trimmed, projectDir);
|
|
239
352
|
if (verdict.decision === "deny") return verdict;
|
|
240
353
|
if (verdict.decision === "ask") ask = true;
|
|
241
354
|
else if (verdict.decision === "review") review = true;
|
|
@@ -244,7 +357,7 @@ function analyzeLines(command: string): Verdict {
|
|
|
244
357
|
return { decision: review ? "review" : "allow" };
|
|
245
358
|
}
|
|
246
359
|
|
|
247
|
-
function analyzeHeredoc(command: string): Verdict {
|
|
360
|
+
function analyzeHeredoc(command: string, projectDir?: string): Verdict {
|
|
248
361
|
const lines = command.split("\n");
|
|
249
362
|
const firstLine = lines[0] ?? "";
|
|
250
363
|
const markers = heredocMarkers(firstLine);
|
|
@@ -268,7 +381,7 @@ function analyzeHeredoc(command: string): Verdict {
|
|
|
268
381
|
// the bodies really end where the raw text says. The header is analyzed on its
|
|
269
382
|
// own because its markers have already been consumed above: handing them back
|
|
270
383
|
// to the heredoc scanner would swallow the tail as a second body.
|
|
271
|
-
const header = analyzeLine(firstLine);
|
|
384
|
+
const header = analyzeLine(firstLine, projectDir);
|
|
272
385
|
if (header.decision === "deny") return header;
|
|
273
386
|
// An unquoted body is matched against its terminator after the shell removes
|
|
274
387
|
// continuations, so a body line ending in a backslash moves the boundary —
|
|
@@ -283,7 +396,7 @@ function analyzeHeredoc(command: string): Verdict {
|
|
|
283
396
|
// the same way, and a blocked command spanning a quoted newline is still seen
|
|
284
397
|
// whole.
|
|
285
398
|
const tail = lines.slice(index).join("\n");
|
|
286
|
-
const rest: Verdict = tail ? analyze(tail) : { decision: "allow" };
|
|
399
|
+
const rest: Verdict = tail ? analyze(tail, projectDir) : { decision: "allow" };
|
|
287
400
|
if (rest.decision === "deny") return rest;
|
|
288
401
|
let ask = header.decision === "ask" || rest.decision === "ask";
|
|
289
402
|
const review = header.decision === "review" || rest.decision === "review";
|
|
@@ -304,7 +417,132 @@ function analyzeHeredoc(command: string): Verdict {
|
|
|
304
417
|
return { decision: review ? "review" : "allow" };
|
|
305
418
|
}
|
|
306
419
|
|
|
307
|
-
|
|
420
|
+
/**
|
|
421
|
+
* True when a recursive or forced `rm` targets the user's home directory or a
|
|
422
|
+
* path outside the project.
|
|
423
|
+
*
|
|
424
|
+
* GNU option parsing accepts abbreviations, so `--recursi` and `--forc` are the
|
|
425
|
+
* same flags as `--recursive` and `--force`. `~` and `~/path` are expanded
|
|
426
|
+
* against the home directory and prefix-matched by containment; the home
|
|
427
|
+
* directory itself is always external, and another user's `~user` cannot be
|
|
428
|
+
* resolved locally, so it also stays external. `$HOME` is not expanded here, and
|
|
429
|
+
* the parser turns an unquoted `$HOME` into an empty word, so both spellings
|
|
430
|
+
* count as the home target.
|
|
431
|
+
*/
|
|
432
|
+
export function recursiveExternalDelete(words: readonly string[], projectDir?: string, worktreeRoot = projectDir): boolean {
|
|
433
|
+
if (words[0] !== "rm") return false;
|
|
434
|
+
let risky = false;
|
|
435
|
+
let end = false;
|
|
436
|
+
const targets: string[] = [];
|
|
437
|
+
for (const word of words.slice(1)) {
|
|
438
|
+
if (!end && word === "--") { end = true; continue; }
|
|
439
|
+
if (!end && word.startsWith("--")) {
|
|
440
|
+
// Prefix matching covers `--recursi`/`--forc` and other unambiguous prefixes.
|
|
441
|
+
if (isLongOption(word, "--recursive") || isLongOption(word, "--force")) risky = true;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (!end && word.startsWith("-") && word.length > 1) {
|
|
445
|
+
if (/^-[^-]*[rRf]/.test(word)) risky = true;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
targets.push(word);
|
|
449
|
+
}
|
|
450
|
+
if (!risky || targets.length === 0) return false;
|
|
451
|
+
const home = homedir();
|
|
452
|
+
return targets.some((target) => {
|
|
453
|
+
// `$HOME` is not expanded here, and the parser turns an unquoted `$HOME`
|
|
454
|
+
// into an empty word; both spellings name the home directory.
|
|
455
|
+
if (!target || target.startsWith("$HOME") || target.startsWith("${HOME}")) return true;
|
|
456
|
+
if (target.startsWith("~")) {
|
|
457
|
+
// `~` and `~/path` expand against the home directory; another user's
|
|
458
|
+
// `~user` cannot be resolved locally and stays external.
|
|
459
|
+
const expanded = target === "~" || target === "~/" ? home
|
|
460
|
+
: target.startsWith("~/") ? resolve(home, target.slice(2)) : undefined;
|
|
461
|
+
if (expanded === undefined || expanded === home) return true;
|
|
462
|
+
return !projectDir || !worktreeRoot || !isInProject(expanded, worktreeRoot);
|
|
463
|
+
}
|
|
464
|
+
// Without a verified project there is no inside that a delete could be bounded to.
|
|
465
|
+
if (!projectDir || !worktreeRoot) return true;
|
|
466
|
+
return !isInProject(resolve(projectDir, target), worktreeRoot);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The literal prefix of a line that precedes syntax this policy does not decode.
|
|
472
|
+
* The marker and everything after it are never inspected, because an undecoded
|
|
473
|
+
* quote or expansion can move word and line boundaries.
|
|
474
|
+
*/
|
|
475
|
+
function visiblePrefix(line: string): string {
|
|
476
|
+
let cut = line.length;
|
|
477
|
+
for (const opener of ["$'", '$"', "$["]) {
|
|
478
|
+
const index = line.indexOf(opener);
|
|
479
|
+
if (index !== -1 && index < cut) cut = index;
|
|
480
|
+
}
|
|
481
|
+
const whitespace = line.search(/[^\S \t]/);
|
|
482
|
+
if (whitespace !== -1 && whitespace < cut) cut = whitespace;
|
|
483
|
+
return line.slice(0, cut);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** The executable name and effective argv after leading wrappers are removed. */
|
|
487
|
+
function effectiveInvocation(words: readonly string[]): { name: string; argv: string[] } {
|
|
488
|
+
const executable = resolveExecutable(words);
|
|
489
|
+
const argv = executable ? words.slice(executable.index) : [...words];
|
|
490
|
+
const name = basename(argv[0] ?? "");
|
|
491
|
+
return { name, argv: [name, ...argv.slice(1)] };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Mandatory denies visible before the syntax that makes the guard ask.
|
|
496
|
+
*
|
|
497
|
+
* The guard cannot approve text it cannot decode, but a deny it can already see
|
|
498
|
+
* must still win over that manual-approval floor: `cat .env # $'` is still a
|
|
499
|
+
* protected-path read, `sudo id\necho $'x'` still elevates, and `rm -rf
|
|
500
|
+
* /outside # $'` still deletes outside the project. Only the literal prefix of
|
|
501
|
+
* each line is judged; a line that is not certainly script, or whose prefix is
|
|
502
|
+
* not balanced and static, contributes no deny and keeps the ask.
|
|
503
|
+
*
|
|
504
|
+
* The protected-path, blocked-invocation and recursive-delete checks run on
|
|
505
|
+
* whatever the prefix parsed into; `complete` only gates the pipeline shape that
|
|
506
|
+
* needs the whole line. Otherwise a trailing `$(...)` or `$'...'` would erase a
|
|
507
|
+
* deny that is already visible.
|
|
508
|
+
*/
|
|
509
|
+
export function visibleShellDeny(resources: readonly string[], projectDir?: string, worktreeRoot = projectDir): string | undefined {
|
|
510
|
+
for (const resource of resources) {
|
|
511
|
+
// Per-resource cap, not a shared budget: an earlier resource must not spend
|
|
512
|
+
// the bytes that would reveal a deny in a later one.
|
|
513
|
+
const text = resource.slice(0, MAX_COMMAND_BYTES);
|
|
514
|
+
const { lines, certain } = logicalLines(normalizeContinuations(text));
|
|
515
|
+
for (const line of lines.slice(0, certain)) {
|
|
516
|
+
const visible = visiblePrefix(line).trim();
|
|
517
|
+
if (!visible || visible.startsWith("#")) continue;
|
|
518
|
+
if (FORK_BOMB.test(visible)) return "dangerous shell operation";
|
|
519
|
+
const parsed = parseShellCommands(visible, { maxChars: MAX_COMMAND_BYTES });
|
|
520
|
+
if (parsed === undefined) continue;
|
|
521
|
+
if (parsed.redirects.some((target) => isProtected(target, projectDir)) ||
|
|
522
|
+
parsed.segments.some((segment) => protectedSegment(segment, projectDir)) ||
|
|
523
|
+
parsed.leftovers.some((word) => isProtectedShellWord(word, projectDir)))
|
|
524
|
+
return "protected path access";
|
|
525
|
+
const commands = parsed.segments.map((segment) => effectiveInvocation(segment.words));
|
|
526
|
+
for (const command of commands) {
|
|
527
|
+
const blocked = denyInvocation(command.argv);
|
|
528
|
+
if (blocked !== undefined) return blocked;
|
|
529
|
+
}
|
|
530
|
+
if (commands.some((command) => recursiveExternalDelete(command.argv, projectDir, worktreeRoot)))
|
|
531
|
+
return "Recursive/force deletion outside the project";
|
|
532
|
+
if (!parsed.complete) continue;
|
|
533
|
+
if (commands.some((command) => ["curl", "wget"].includes(command.name)) &&
|
|
534
|
+
commands.some((command) => SHELLS.has(command.name))) {
|
|
535
|
+
try {
|
|
536
|
+
if (parseShell(visible).some((token) => typeof token !== "string" && "op" in token &&
|
|
537
|
+
["|", "|&"].includes(token.op))) return "remote download piped to a shell";
|
|
538
|
+
} catch { /* The prefix parse above already succeeded; treat an unknown pipe shape as no deny. */ }
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function analyze(command: string, projectDir?: string): Verdict {
|
|
308
546
|
// A shell separates words only on space, tab, and newline. JS `\s` (and the
|
|
309
547
|
// parser behind it) also matches CR, FF, VT, NBSP, and Unicode spaces, which
|
|
310
548
|
// can move a heredoc boundary or turn a midword hash into a comment, so any of
|
|
@@ -335,7 +573,7 @@ function analyze(command: string): Verdict {
|
|
|
335
573
|
// The raw line may still carry a `\`-newline that the shell removes before
|
|
336
574
|
// it forms this header; removing it here only reaches the deny pass, and
|
|
337
575
|
// the header line itself is always script.
|
|
338
|
-
const verdict = analyzeLine(normalizeContinuations(line).trim());
|
|
576
|
+
const verdict = analyzeLine(normalizeContinuations(line).trim(), projectDir);
|
|
339
577
|
if (verdict.decision === "deny") return verdict;
|
|
340
578
|
}
|
|
341
579
|
return { decision: "ask" };
|
|
@@ -353,12 +591,12 @@ function analyze(command: string): Verdict {
|
|
|
353
591
|
}
|
|
354
592
|
// `logicalLines` must split on the newlines the shell actually treats as
|
|
355
593
|
// 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);
|
|
594
|
+
if (hasHeredoc(normalized)) return analyzeHeredoc(normalized, projectDir);
|
|
595
|
+
if (normalized.includes("\n")) return analyzeLines(normalized, projectDir);
|
|
596
|
+
return analyzeLine(normalized, projectDir);
|
|
359
597
|
}
|
|
360
598
|
|
|
361
|
-
export function guardShell(resources: readonly string[]): ReviewResult | undefined {
|
|
599
|
+
export function guardShell(resources: readonly string[], projectDir?: string): ReviewResult | undefined {
|
|
362
600
|
if (process.platform === "win32") return { decision: "ask", reason: "Shell dialect requires manual approval" };
|
|
363
601
|
if (!resources.length || resources.length > 256 || resources.some((resource) => resource.length > MAX_COMMAND_CHARS) ||
|
|
364
602
|
resources.reduce((sum, resource) => sum + Buffer.byteLength(resource), 0) > MAX_COMMAND_BYTES)
|
|
@@ -366,7 +604,7 @@ export function guardShell(resources: readonly string[]): ReviewResult | undefin
|
|
|
366
604
|
let ask = false;
|
|
367
605
|
let review = false;
|
|
368
606
|
for (const resource of resources) {
|
|
369
|
-
const verdict = analyze(resource);
|
|
607
|
+
const verdict = analyze(resource, projectDir);
|
|
370
608
|
if (verdict.decision === "deny") return { decision: "deny", reason: verdict.reason };
|
|
371
609
|
if (verdict.decision === "ask") ask = true;
|
|
372
610
|
else if (verdict.decision === "review") review = true;
|
|
@@ -375,21 +613,3 @@ export function guardShell(resources: readonly string[]): ReviewResult | undefin
|
|
|
375
613
|
return review ? undefined : { decision: "allow", reason: "read-only command" };
|
|
376
614
|
}
|
|
377
615
|
|
|
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
|
}
|