@mortz.prk/opencode-auto-mode 1.0.0 → 1.0.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/README.md +143 -10
- package/dist/gui-experimental.cjs +1 -0
- package/package.json +23 -13
- package/src/audit.ts +24 -7
- package/src/classifier.ts +19 -3
- package/src/config.ts +10 -1
- package/src/decisions.ts +38 -15
- package/src/errors.ts +39 -0
- package/src/gui-state.ts +160 -0
- package/src/gui.tsx +129 -0
- package/src/index.ts +377 -98
- package/src/otlp.ts +18 -15
- package/src/reviewer.ts +11 -1
- package/src/rpc.ts +117 -0
- package/src/shell-policy.ts +1337 -0
- package/src/shell.ts +379 -18
- package/src/state.ts +75 -0
- package/src/status.ts +72 -0
- package/src/tiers.ts +2 -20
- package/src/timeout.ts +30 -0
- package/src/traces.ts +13 -7
- package/src/transcript.ts +145 -29
- package/src/tui-state.ts +7 -1
- package/src/tui.tsx +20 -5
package/src/shell.ts
CHANGED
|
@@ -1,34 +1,395 @@
|
|
|
1
|
-
import { parse } from "shell-quote";
|
|
2
|
-
import { basename } from "node:path";
|
|
3
|
-
import { classify } from "./tiers.js";
|
|
4
1
|
import type { ReviewResult } from "./reviewer.js";
|
|
2
|
+
import {
|
|
3
|
+
BALANCED_QUOTES, CONTROL_OPERATORS, DYNAMIC, REDIRECT_OPERATORS, SCRIPT_HOSTS, SHELLS, STATEFUL,
|
|
4
|
+
denyCleanPrefix, denyInvocation, hasAmbiguousDescriptorClose, hasAssembledUndecodedSyntax, hasHeredoc,
|
|
5
|
+
hasUndecodedSyntax, heredocMarkers, isReadOnlyInvocation, logicalLines, normalizeContinuations, parseShell,
|
|
6
|
+
rawHeredocScript, resolveExecutable, stripDescriptorPrefixes,
|
|
7
|
+
} from "./shell-policy.js";
|
|
5
8
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
9
|
+
const MAX_COMMAND_CHARS = 4096;
|
|
10
|
+
const MAX_COMMAND_BYTES = 65_536;
|
|
11
|
+
const WRITE_OPERATORS = new Set([">", ">>", ">|", "&>", "&>>", "<>"]);
|
|
12
|
+
const DYNAMIC_SYNTAX = /\$\(|`|\$\(\s*\(|[<>]\(|\$\[/;
|
|
13
|
+
const FORK_BOMB = /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
|
|
11
14
|
|
|
12
15
|
export function shellWords(command: string): string[] | undefined {
|
|
13
|
-
if (!command || command.length >
|
|
16
|
+
if (!command || command.length > MAX_COMMAND_CHARS || /[$`~\r\n\0{}[\]]/.test(command) ||
|
|
14
17
|
/[^\S \t]/.test(command) || !BALANCED_QUOTES.test(command)) return undefined;
|
|
15
18
|
try {
|
|
16
|
-
const words =
|
|
19
|
+
const words = parseShell(command);
|
|
17
20
|
return words.length && words.every((word) => typeof word === "string") ? words as string[] : undefined;
|
|
18
21
|
} catch { return undefined; }
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
interface Segment { words: string[]; globs: boolean[]; write: boolean }
|
|
25
|
+
type Verdict = { decision: "deny"; reason: string } | { decision: "allow" | "review" | "ask" };
|
|
26
|
+
|
|
27
|
+
/** True when the effective executable is a shell, a dynamic builtin, or a stateful builtin. */
|
|
28
|
+
function dynamicExecution(words: string[]): boolean {
|
|
29
|
+
const executable = resolveExecutable(words);
|
|
30
|
+
if (executable === undefined) return true;
|
|
31
|
+
if (SHELLS.has(executable.name) || DYNAMIC.has(executable.name) || STATEFUL.has(executable.name)) return true;
|
|
32
|
+
return executable.name === "find" && words.slice(executable.index + 1)
|
|
33
|
+
.some((word) => /^-(?:exec|execdir|ok|okdir|delete)$/.test(word));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function analyzeSegment(segment: Segment): Verdict {
|
|
37
|
+
if (!segment.words.length) return segment.write ? { decision: "review" } : { decision: "allow" };
|
|
38
|
+
const command = segment.words[0];
|
|
39
|
+
if (!command || command.includes("$")) return { decision: "ask" };
|
|
40
|
+
// A deny that is already identifiable wins over every uncertainty below, so
|
|
41
|
+
// `sudo id *.txt` stays a mandatory deny instead of becoming a review.
|
|
42
|
+
const blocked = denyInvocation(segment.words);
|
|
43
|
+
if (blocked !== undefined) return { decision: "deny", reason: blocked };
|
|
44
|
+
if (dynamicExecution(segment.words)) return { decision: "ask" };
|
|
45
|
+
// `isReadOnlyInvocation` refuses any argument an unquoted expansion could turn
|
|
46
|
+
// into several words, so the literal flag policies only judge what will run.
|
|
47
|
+
if (!segment.write && isReadOnlyInvocation(segment.words, segment.globs)) return { decision: "allow" };
|
|
48
|
+
return { decision: "review" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Token shapes this module consumes; `parse` returns a wider union. */
|
|
52
|
+
type Token = string | { comment: string } | { op: string; pattern?: string };
|
|
53
|
+
|
|
54
|
+
interface ParsedLine { segments: Segment[]; complete: boolean }
|
|
55
|
+
|
|
56
|
+
/** Characters that end an unquoted shell word. JS `\s` is wider than a shell, so the set is explicit. */
|
|
57
|
+
const WORD_DELIMITER = /[ \t\n;|&()<>]/;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One entry per shell word, true when the word contains an expansion that can
|
|
61
|
+
* turn it into several arguments.
|
|
62
|
+
*
|
|
63
|
+
* `shell-quote` only marks `*` and `?` as globs, but bracket (`[ab]`) and brace
|
|
64
|
+
* (`{a,b}`) expansion, and parameter expansion, change the argument count too.
|
|
65
|
+
* This scan is quote- and escape-aware, so quoted or escaped bracket, brace, and
|
|
66
|
+
* glob markers are not flagged here. `shell-quote` still reports an escaped
|
|
67
|
+
* `*`/`?` as a glob token, which stays conservatively reviewed.
|
|
68
|
+
*
|
|
69
|
+
* A parameter inside double quotes counts as uncertain for the same reason: it
|
|
70
|
+
* stays one word only after expansion, and the value decides both the text and —
|
|
71
|
+
* for `"$@"` — the count. `sort "${X:--oout}" file` writes a file when `X` is
|
|
72
|
+
* unset, `find . "${X:--delete}"` deletes, and `uniq "$@"` can become two
|
|
73
|
+
* operands, while `shell-quote` sees only an empty string. Single quotes and an
|
|
74
|
+
* escaped `\$` are literal and stay safe. Matching the flags by position keeps
|
|
75
|
+
* the raw text out of the parsed logic.
|
|
76
|
+
*/
|
|
77
|
+
function wordExpansions(command: string): boolean[] {
|
|
78
|
+
const expansions: boolean[] = [];
|
|
79
|
+
let present = false;
|
|
80
|
+
let expansion = false;
|
|
81
|
+
let wordStart = true;
|
|
82
|
+
let quote: "'" | '"' | "$'" | undefined;
|
|
83
|
+
const flush = () => {
|
|
84
|
+
if (!present) return;
|
|
85
|
+
expansions.push(expansion);
|
|
86
|
+
present = false;
|
|
87
|
+
expansion = false;
|
|
88
|
+
};
|
|
89
|
+
for (let index = 0; index < command.length; index++) {
|
|
90
|
+
const character = command[index];
|
|
91
|
+
if (quote === "'") {
|
|
92
|
+
if (character === "'") quote = undefined;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (quote === "$'") {
|
|
96
|
+
if (character === "\\") index++;
|
|
97
|
+
else if (character === "'") quote = undefined;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (quote === '"') {
|
|
101
|
+
if (character === "\\") index++;
|
|
102
|
+
else if (character === '"') quote = undefined;
|
|
103
|
+
else if (character === "$") expansion = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (character === "\\") {
|
|
107
|
+
present = true;
|
|
108
|
+
if (index + 1 < command.length && command[++index] !== "\n") wordStart = false;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (character === "'" || character === '"' || (character === "$" && command[index + 1] === "'")) {
|
|
112
|
+
present = true;
|
|
113
|
+
wordStart = false;
|
|
114
|
+
if (character === "'" || character === '"') quote = character;
|
|
115
|
+
else { quote = "$'"; index++; }
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (character === "#" && wordStart) {
|
|
119
|
+
const end = command.indexOf("\n", index);
|
|
120
|
+
flush();
|
|
121
|
+
if (end === -1) break;
|
|
122
|
+
index = end;
|
|
123
|
+
wordStart = true;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (WORD_DELIMITER.test(character)) {
|
|
127
|
+
flush();
|
|
128
|
+
wordStart = true;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (character === "*" || character === "?" || character === "[" || character === "{" ||
|
|
132
|
+
character === "$") expansion = true;
|
|
133
|
+
present = true;
|
|
134
|
+
wordStart = false;
|
|
135
|
+
}
|
|
136
|
+
flush();
|
|
137
|
+
return expansions;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Splits parsed tokens into simple commands, tracking whether the whole line parsed. */
|
|
141
|
+
function lineSegments(tokens: readonly Token[], expansions: readonly boolean[] = []): ParsedLine {
|
|
142
|
+
const segments: Segment[] = [{ words: [], globs: [], write: false }];
|
|
143
|
+
let current = segments[0];
|
|
144
|
+
let redirect: { write: boolean } | undefined;
|
|
145
|
+
let wordIndex = 0;
|
|
146
|
+
const nextExpansion = () => expansions[wordIndex++] ?? false;
|
|
147
|
+
for (const token of tokens) {
|
|
148
|
+
if (typeof token === "string") {
|
|
149
|
+
if (redirect) {
|
|
150
|
+
const target = token;
|
|
151
|
+
nextExpansion();
|
|
152
|
+
if (redirect.write && target !== "/dev/null" && !/^\d+$/.test(target) && !target.startsWith("&"))
|
|
153
|
+
current.write = true;
|
|
154
|
+
redirect = undefined;
|
|
155
|
+
} else {
|
|
156
|
+
current.words.push(token);
|
|
157
|
+
current.globs.push(nextExpansion());
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if ("comment" in token) continue;
|
|
162
|
+
if (token.op === "glob" && token.pattern !== undefined) {
|
|
163
|
+
if (redirect) {
|
|
164
|
+
// A glob is still a redirection target, so it never becomes a command word.
|
|
165
|
+
nextExpansion();
|
|
166
|
+
if (redirect.write) current.write = true;
|
|
167
|
+
redirect = undefined;
|
|
168
|
+
} else {
|
|
169
|
+
nextExpansion();
|
|
170
|
+
current.words.push(token.pattern);
|
|
171
|
+
current.globs.push(true);
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (CONTROL_OPERATORS.has(token.op)) {
|
|
176
|
+
// A control operator before the redirection target is invalid syntax.
|
|
177
|
+
if (redirect) return { segments, complete: false };
|
|
178
|
+
current = { words: [], globs: [], write: false };
|
|
179
|
+
segments.push(current);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (REDIRECT_OPERATORS.has(token.op)) {
|
|
183
|
+
// Descriptor prefixes (`2>` in `2>/dev/null`) are stripped from the source
|
|
184
|
+
// before parsing, so a numeric word here is an operand and must survive.
|
|
185
|
+
redirect = { write: WRITE_OPERATORS.has(token.op) };
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
return { segments, complete: false };
|
|
189
|
+
}
|
|
190
|
+
return { segments, complete: redirect === undefined };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function analyzeLine(command: string): Verdict {
|
|
194
|
+
if (!command.trim()) return { decision: "allow" };
|
|
195
|
+
if (!BALANCED_QUOTES.test(command)) return { decision: "ask" };
|
|
196
|
+
if (FORK_BOMB.test(command)) return { decision: "deny", reason: "dangerous shell operation" };
|
|
197
|
+
|
|
198
|
+
let tokens;
|
|
199
|
+
try { tokens = parseShell(command); }
|
|
200
|
+
catch { return { decision: "ask" }; }
|
|
201
|
+
|
|
202
|
+
const { segments, complete } = lineSegments(tokens, wordExpansions(stripDescriptorPrefixes(command)));
|
|
203
|
+
// Deny rules that are already visible must win even when later arguments are
|
|
204
|
+
// dynamic (`sudo id $(...)`) and even when the line only parses partially.
|
|
205
|
+
for (const segment of segments) {
|
|
206
|
+
const blocked = denyInvocation(segment.words);
|
|
207
|
+
if (blocked !== undefined) return { decision: "deny", reason: blocked };
|
|
208
|
+
}
|
|
209
|
+
// Bash closes the descriptor and runs the rest of the word as a command
|
|
210
|
+
// (`2>&-sudo id` runs sudo), while shell-quote reads `-sudo` as the redirect
|
|
211
|
+
// target. Neither reading may approve the invocation.
|
|
212
|
+
if (hasAmbiguousDescriptorClose(command)) return { decision: "ask" };
|
|
213
|
+
if (!complete || DYNAMIC_SYNTAX.test(command)) return { decision: "ask" };
|
|
214
|
+
|
|
215
|
+
let ask = false;
|
|
216
|
+
let review = false;
|
|
217
|
+
for (const segment of segments) {
|
|
218
|
+
const verdict = analyzeSegment(segment);
|
|
219
|
+
if (verdict.decision === "deny") return verdict;
|
|
220
|
+
if (verdict.decision === "ask") ask = true;
|
|
221
|
+
else if (verdict.decision === "review") review = true;
|
|
222
|
+
}
|
|
223
|
+
if (ask) return { decision: "ask" };
|
|
224
|
+
return { decision: review ? "review" : "allow" };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Analyzes every logical line, keeping deny precedence over ask and review. */
|
|
228
|
+
function analyzeLines(command: string): Verdict {
|
|
229
|
+
let ask = false;
|
|
230
|
+
let review = false;
|
|
231
|
+
const { lines, certain } = logicalLines(command);
|
|
232
|
+
// An undecodable header or a body that can move its terminator leaves the
|
|
233
|
+
// script/data split unknown, so nothing past that line may be auto-approved.
|
|
234
|
+
if (certain < lines.length) ask = true;
|
|
235
|
+
for (const line of lines) {
|
|
236
|
+
const trimmed = line.trim();
|
|
237
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
238
|
+
const verdict = analyzeLine(trimmed);
|
|
239
|
+
if (verdict.decision === "deny") return verdict;
|
|
240
|
+
if (verdict.decision === "ask") ask = true;
|
|
241
|
+
else if (verdict.decision === "review") review = true;
|
|
242
|
+
}
|
|
243
|
+
if (ask) return { decision: "ask" };
|
|
244
|
+
return { decision: review ? "review" : "allow" };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function analyzeHeredoc(command: string): Verdict {
|
|
248
|
+
const lines = command.split("\n");
|
|
249
|
+
const firstLine = lines[0] ?? "";
|
|
250
|
+
const markers = heredocMarkers(firstLine);
|
|
251
|
+
if (!markers.length) return { decision: "ask" };
|
|
252
|
+
let index = 1;
|
|
253
|
+
const bodies: { lines: string[]; quoted: boolean }[] = [];
|
|
254
|
+
for (const marker of markers) {
|
|
255
|
+
const body: string[] = [];
|
|
256
|
+
let closed = false;
|
|
257
|
+
while (index < lines.length) {
|
|
258
|
+
const candidate = marker.stripTabs ? lines[index].replace(/^\t+/, "") : lines[index];
|
|
259
|
+
index++;
|
|
260
|
+
if (candidate === marker.delimiter) { closed = true; break; }
|
|
261
|
+
body.push(candidate);
|
|
262
|
+
}
|
|
263
|
+
if (!closed) return { decision: "ask" };
|
|
264
|
+
bodies.push({ lines: body, quoted: marker.quoted });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The header line is script, and so is the text after the bodies — but only if
|
|
268
|
+
// the bodies really end where the raw text says. The header is analyzed on its
|
|
269
|
+
// own because its markers have already been consumed above: handing them back
|
|
270
|
+
// to the heredoc scanner would swallow the tail as a second body.
|
|
271
|
+
const header = analyzeLine(firstLine);
|
|
272
|
+
if (header.decision === "deny") return header;
|
|
273
|
+
// An unquoted body is matched against its terminator after the shell removes
|
|
274
|
+
// continuations, so a body line ending in a backslash moves the boundary —
|
|
275
|
+
// including the last one, whose `\`-newline joins the terminator line into the
|
|
276
|
+
// body. That has to fail closed before the tail is judged, or a tail that is
|
|
277
|
+
// really body data would deny. A quoted body keeps its backslashes literally.
|
|
278
|
+
for (const body of bodies) {
|
|
279
|
+
if (body.quoted) continue;
|
|
280
|
+
if (body.lines.some((line) => line.endsWith("\\"))) return { decision: "ask" };
|
|
281
|
+
}
|
|
282
|
+
// The tail is analyzed as its own script so a further heredoc there is routed
|
|
283
|
+
// the same way, and a blocked command spanning a quoted newline is still seen
|
|
284
|
+
// whole.
|
|
285
|
+
const tail = lines.slice(index).join("\n");
|
|
286
|
+
const rest: Verdict = tail ? analyze(tail) : { decision: "allow" };
|
|
287
|
+
if (rest.decision === "deny") return rest;
|
|
288
|
+
let ask = header.decision === "ask" || rest.decision === "ask";
|
|
289
|
+
const review = header.decision === "review" || rest.decision === "review";
|
|
290
|
+
// Unquoted heredoc bodies undergo command and parameter expansion. That can run
|
|
291
|
+
// a substitution, so it fails closed, but it cannot move the terminator: the
|
|
292
|
+
// tail denied above stays a deny.
|
|
293
|
+
for (const body of bodies) {
|
|
294
|
+
if (body.quoted) continue;
|
|
295
|
+
if (DYNAMIC_SYNTAX.test(body.lines.join("\n"))) return { decision: "ask" };
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const words = parseShell(firstLine).filter((token): token is string => typeof token === "string");
|
|
299
|
+
const executable = resolveExecutable(words);
|
|
300
|
+
if (executable === undefined || SHELLS.has(executable.name) || SCRIPT_HOSTS.has(executable.name) ||
|
|
301
|
+
DYNAMIC.has(executable.name) || STATEFUL.has(executable.name)) ask = true;
|
|
302
|
+
} catch { return { decision: "ask" }; }
|
|
303
|
+
if (ask) return { decision: "ask" };
|
|
304
|
+
return { decision: review ? "review" : "allow" };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function analyze(command: string): Verdict {
|
|
308
|
+
// A shell separates words only on space, tab, and newline. JS `\s` (and the
|
|
309
|
+
// parser behind it) also matches CR, FF, VT, NBSP, and Unicode spaces, which
|
|
310
|
+
// can move a heredoc boundary or turn a midword hash into a comment, so any of
|
|
311
|
+
// them fails closed before normalization, the deny pass, or parsing.
|
|
312
|
+
//
|
|
313
|
+
// `$[` is the legacy arithmetic expansion: the `<<` inside it is a shift, not
|
|
314
|
+
// a heredoc, so heredoc routing would treat the rest of the script as a body
|
|
315
|
+
// and swallow a trailing command. Extended quoting has the same problem from
|
|
316
|
+
// the other side: `$'...'` lets a backslash escape its own quote, so a quote
|
|
317
|
+
// scan can end the string early and read `<<EOF` as a heredoc, and `$"..."`
|
|
318
|
+
// is a locale quote this parser does not decode. Reject all three on the raw
|
|
319
|
+
// source before normalization or heredoc routing, conservatively including
|
|
320
|
+
// quoted and heredoc data.
|
|
321
|
+
if (hasUndecodedSyntax(command)) return { decision: "ask" };
|
|
322
|
+
// Fail closed on any raw heredoc header that normalization cannot be trusted
|
|
323
|
+
// on: the raw source is the only place its marker and delimiter are still
|
|
324
|
+
// intact. Every header is validated, not just the first, because a continued
|
|
325
|
+
// header after a stable body is the same problem. Each raw line before the
|
|
326
|
+
// ambiguity is certainly script — the header line itself runs before the shell
|
|
327
|
+
// reads its body — so a deny visible there is mandatory; everything after may
|
|
328
|
+
// be data and only reaches manual approval.
|
|
329
|
+
const raw = rawHeredocScript(command);
|
|
330
|
+
if (raw.ambiguous) {
|
|
331
|
+
for (const line of raw.lines) {
|
|
332
|
+
// A line whose own normalization assembles unsupported quoting cannot be
|
|
333
|
+
// parsed safely; its deny is left to manual approval instead of guessed.
|
|
334
|
+
if (hasAssembledUndecodedSyntax(line)) continue;
|
|
335
|
+
// The raw line may still carry a `\`-newline that the shell removes before
|
|
336
|
+
// it forms this header; removing it here only reaches the deny pass, and
|
|
337
|
+
// the header line itself is always script.
|
|
338
|
+
const verdict = analyzeLine(normalizeContinuations(line).trim());
|
|
339
|
+
if (verdict.decision === "deny") return verdict;
|
|
340
|
+
}
|
|
341
|
+
return { decision: "ask" };
|
|
342
|
+
}
|
|
343
|
+
// Normalization removes `\`-newlines, so the shell can parse an unsupported
|
|
344
|
+
// form the raw check above could not see: `sort $`+newline+`'\x2doout'` is
|
|
345
|
+
// `sort -oout` to Bash once the pair is gone. The marker itself is never parsed.
|
|
346
|
+
// A deny in the clean prefix before it is still mandatory, so check that first;
|
|
347
|
+
// only then repeat the gate and fail closed before heredoc routing.
|
|
348
|
+
const normalized = normalizeContinuations(command);
|
|
349
|
+
if (hasUndecodedSyntax(normalized)) {
|
|
350
|
+
const blocked = denyCleanPrefix(normalized);
|
|
351
|
+
if (blocked !== undefined) return { decision: "deny", reason: blocked };
|
|
352
|
+
return { decision: "ask" };
|
|
353
|
+
}
|
|
354
|
+
// `logicalLines` must split on the newlines the shell actually treats as
|
|
355
|
+
// 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);
|
|
359
|
+
}
|
|
360
|
+
|
|
21
361
|
export function guardShell(resources: readonly string[]): ReviewResult | undefined {
|
|
22
362
|
if (process.platform === "win32") return { decision: "ask", reason: "Shell dialect requires manual approval" };
|
|
23
|
-
if (!resources.length || resources.length > 256 || resources.some((resource) => resource.length >
|
|
24
|
-
resources.reduce((sum, resource) => sum + Buffer.byteLength(resource), 0) >
|
|
363
|
+
if (!resources.length || resources.length > 256 || resources.some((resource) => resource.length > MAX_COMMAND_CHARS) ||
|
|
364
|
+
resources.reduce((sum, resource) => sum + Buffer.byteLength(resource), 0) > MAX_COMMAND_BYTES)
|
|
25
365
|
return { decision: "ask", reason: "Shell command exceeds parsing limits" };
|
|
26
|
-
let
|
|
366
|
+
let ask = false;
|
|
367
|
+
let review = false;
|
|
27
368
|
for (const resource of resources) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
if (
|
|
369
|
+
const verdict = analyze(resource);
|
|
370
|
+
if (verdict.decision === "deny") return { decision: "deny", reason: verdict.reason };
|
|
371
|
+
if (verdict.decision === "ask") ask = true;
|
|
372
|
+
else if (verdict.decision === "review") review = true;
|
|
32
373
|
}
|
|
33
|
-
|
|
374
|
+
if (ask) return { decision: "ask", reason: "Shell syntax requires manual approval" };
|
|
375
|
+
return review ? undefined : { decision: "allow", reason: "read-only command" };
|
|
34
376
|
}
|
|
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/state.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { privateFile, STORAGE_DIR } from "./storage.js";
|
|
4
5
|
|
|
5
6
|
export type DecisionKind = "reviewing" | "allow" | "deny" | "ask";
|
|
@@ -17,6 +18,80 @@ export interface DecisionRecord {
|
|
|
17
18
|
export const STATE_FILE = path.join(STORAGE_DIR, "state.json");
|
|
18
19
|
|
|
19
20
|
const MAX_AGE_MS = 10 * 60 * 1000;
|
|
21
|
+
const MAX_CONFIG_MESSAGE = 1024;
|
|
22
|
+
|
|
23
|
+
export interface ConfigErrorRecord {
|
|
24
|
+
directory: string;
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface StoredConfigError extends ConfigErrorRecord {
|
|
29
|
+
at: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function canonicalDirectory(directory: string): string {
|
|
33
|
+
try { return fs.realpathSync.native(directory); }
|
|
34
|
+
catch { return path.resolve(directory); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function configErrorFile(directory: string): string {
|
|
38
|
+
return `config-error-${createHash("sha256").update(directory).digest("hex")}.json`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readConfigErrorFile(directory: string): StoredConfigError | undefined {
|
|
42
|
+
let fd: number | undefined;
|
|
43
|
+
try {
|
|
44
|
+
fd = privateFile(configErrorFile(directory), false);
|
|
45
|
+
if (fs.fstatSync(fd).size > 4096) return undefined;
|
|
46
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
47
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
|
|
48
|
+
const record = parsed as Partial<StoredConfigError>;
|
|
49
|
+
if (record.directory !== directory || typeof record.message !== "string" ||
|
|
50
|
+
record.message.length > MAX_CONFIG_MESSAGE || typeof record.at !== "number" || !Number.isFinite(record.at))
|
|
51
|
+
return undefined;
|
|
52
|
+
return { directory: record.directory, message: record.message, at: record.at };
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
} finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeConfigError(record: StoredConfigError): void {
|
|
59
|
+
let fd: number | undefined;
|
|
60
|
+
const temporary = `config-error-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`;
|
|
61
|
+
const target = configErrorFile(record.directory);
|
|
62
|
+
try {
|
|
63
|
+
fd = privateFile(temporary, true);
|
|
64
|
+
fs.writeSync(fd, JSON.stringify(record), 0);
|
|
65
|
+
fs.closeSync(fd);
|
|
66
|
+
fd = undefined;
|
|
67
|
+
fs.renameSync(path.join(STORAGE_DIR, temporary), path.join(STORAGE_DIR, target));
|
|
68
|
+
} catch {
|
|
69
|
+
// Badge state is best effort and must not interrupt permission review.
|
|
70
|
+
} finally {
|
|
71
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
72
|
+
try { fs.rmSync(path.join(STORAGE_DIR, temporary), { force: true }); }
|
|
73
|
+
catch { /* best effort */ }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Records that the plugin options for a location are unusable, so the TUI can show it. */
|
|
78
|
+
export function recordConfigError(directory: string, message: string): void {
|
|
79
|
+
const canonical = canonicalDirectory(directory);
|
|
80
|
+
writeConfigError({ directory: canonical, message: message.slice(0, MAX_CONFIG_MESSAGE), at: Date.now() });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readConfigError(directory: string | undefined): ConfigErrorRecord | undefined {
|
|
84
|
+
if (directory === undefined) return undefined;
|
|
85
|
+
const canonical = canonicalDirectory(directory);
|
|
86
|
+
const record = readConfigErrorFile(canonical);
|
|
87
|
+
return record === undefined ? undefined : { directory: record.directory, message: record.message };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function clearConfigError(directory: string): void {
|
|
91
|
+
try {
|
|
92
|
+
fs.rmSync(path.join(STORAGE_DIR, configErrorFile(canonicalDirectory(directory))), { force: true });
|
|
93
|
+
} catch { /* best effort */ }
|
|
94
|
+
}
|
|
20
95
|
|
|
21
96
|
export function readDecisions(): Record<string, DecisionRecord> {
|
|
22
97
|
let fd: number | undefined;
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { AutoModeDecision, AutoModeStatus } from "./rpc.js";
|
|
2
|
+
|
|
3
|
+
const MAX_RECENT = 100;
|
|
4
|
+
// Match the audit record bounds so live status and durable history describe
|
|
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.
|
|
14
|
+
const characters = Array.from(value);
|
|
15
|
+
return characters.length > max ? `${characters.slice(0, max - 1).join("")}…` : value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StatusTracker {
|
|
19
|
+
start(sessionID: string, reviewID: string, at: number): void;
|
|
20
|
+
finish(decision: Omit<AutoModeDecision, "reason"> & { reason: string }): AutoModeDecision;
|
|
21
|
+
snapshot(input: { enabled: boolean; configError?: string; classifiers: string[]; reviewTimeoutMs: number; sessionID?: string }): AutoModeStatus;
|
|
22
|
+
recent(limit: number, sessionID?: string): AutoModeDecision[];
|
|
23
|
+
}
|
|
24
|
+
|
|
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
|
+
*/
|
|
30
|
+
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
|
+
const reviewing = new Map<string, { sessionID: string; startedAt: number }>();
|
|
34
|
+
let recent: AutoModeDecision[] = [];
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
start(sessionID, reviewID, at) {
|
|
38
|
+
if (reviewing.size >= MAX_REVIEWING && !reviewing.has(reviewID)) {
|
|
39
|
+
const oldest = reviewing.keys().next().value;
|
|
40
|
+
if (oldest !== undefined) reviewing.delete(oldest);
|
|
41
|
+
}
|
|
42
|
+
reviewing.set(reviewID, { sessionID, startedAt: at });
|
|
43
|
+
},
|
|
44
|
+
finish(decision) {
|
|
45
|
+
reviewing.delete(decision.reviewID);
|
|
46
|
+
const entry: AutoModeDecision = {
|
|
47
|
+
...decision,
|
|
48
|
+
action: clip(decision.action, MAX_ACTION_CHARS),
|
|
49
|
+
stage: clip(decision.stage, MAX_STAGE_CHARS),
|
|
50
|
+
reason: clip(decision.reason),
|
|
51
|
+
};
|
|
52
|
+
recent = [...recent, entry].slice(-MAX_RECENT);
|
|
53
|
+
return entry;
|
|
54
|
+
},
|
|
55
|
+
snapshot(input) {
|
|
56
|
+
return {
|
|
57
|
+
enabled: input.enabled,
|
|
58
|
+
...(input.configError === undefined ? {} : { configError: clip(input.configError) }),
|
|
59
|
+
classifiers: input.classifiers,
|
|
60
|
+
reviewTimeoutMs: input.reviewTimeoutMs,
|
|
61
|
+
reviewing: [...reviewing.entries()]
|
|
62
|
+
.filter(([, entry]) => input.sessionID === undefined || entry.sessionID === input.sessionID)
|
|
63
|
+
.map(([reviewID, entry]) => ({ sessionID: entry.sessionID, reviewID, startedAt: entry.startedAt })),
|
|
64
|
+
recent: input.sessionID === undefined ? recent.slice(-20) : recent.filter((entry) => entry.sessionID === input.sessionID).slice(-20),
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
recent(limit, sessionID) {
|
|
68
|
+
const matches = sessionID === undefined ? recent : recent.filter((entry) => entry.sessionID === sessionID);
|
|
69
|
+
return matches.slice(-limit);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/tiers.ts
CHANGED
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import { lstatSync, realpathSync } from "node:fs";
|
|
3
|
+
import { classifyShell } from "./shell.js";
|
|
3
4
|
|
|
4
5
|
export type Tier = "auto-allow" | "auto-deny" | "review" | "unknown";
|
|
5
6
|
export interface Classification { tier: Tier; reason: string }
|
|
6
7
|
export interface ClassifyOptions { projectDir?: string; allowInProjectEdits?: boolean }
|
|
7
8
|
|
|
8
|
-
const READ_ONLY_COMMANDS = new Set(["pwd", "whoami", "hostname", "uname", "id", "groups", "ls", "ls -a", "ls -l", "ls -la", "ls -al", "ls -A"]);
|
|
9
|
-
const BLOCKED_SHELL = [
|
|
10
|
-
/\brm\s+-rf?\s+\/(\s|$|\*)/,
|
|
11
|
-
/\bsudo\b/,
|
|
12
|
-
/\bmkfs\./,
|
|
13
|
-
/\bdd\s+if=.*\s+of=\/(dev|sd|nvme|hd)/,
|
|
14
|
-
/\b(shutdown|reboot|halt|poweroff)\b/,
|
|
15
|
-
/:\(\)\s*\{\s*:\s*\|\s*:&\s*\};:/,
|
|
16
|
-
/\bchmod\s+777\b/,
|
|
17
|
-
];
|
|
18
9
|
const SENSITIVE_PATH = /(?:^|[/\\])(?:\.[^/\\]+|opencode\.jsonc?|id_(?:rsa|dsa|ecdsa|ed25519)|[^/\\]+\.(?:pem|key|p12|pfx)|[^/\\]*(?:credentials|token|secret|password|private.key)[^/\\]*)(?:[/\\]|$)/i;
|
|
19
10
|
|
|
20
11
|
export function isInProject(target: string, projectDir: string): boolean {
|
|
@@ -44,16 +35,7 @@ function safeTarget(target: string, projectDir: string): boolean {
|
|
|
44
35
|
}
|
|
45
36
|
|
|
46
37
|
export function classify(action: string, resources: readonly string[], options?: ClassifyOptions): Classification {
|
|
47
|
-
if (action === "shell")
|
|
48
|
-
for (const command of resources) {
|
|
49
|
-
for (const pattern of BLOCKED_SHELL) {
|
|
50
|
-
if (pattern.test(command)) return { tier: "auto-deny", reason: "dangerous shell operation" };
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
if (resources.length > 0 && resources.every((command) => READ_ONLY_COMMANDS.has(command)))
|
|
54
|
-
return { tier: "auto-allow", reason: "read-only command" };
|
|
55
|
-
return { tier: "review", reason: "evaluate the entire command as one action" };
|
|
56
|
-
}
|
|
38
|
+
if (action === "shell") return classifyShell(resources);
|
|
57
39
|
if (action === "read" || ((action === "edit" || action === "write") && options?.allowInProjectEdits)) {
|
|
58
40
|
if (options?.projectDir && resources.length > 0 && resources.every((target) =>
|
|
59
41
|
target.length > 0 && safeTarget(target, options.projectDir!)))
|
package/src/timeout.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ReviewFailure } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bounds a provider promise and rejects it as soon as `signal` aborts.
|
|
5
|
+
*
|
|
6
|
+
* A provider may ignore the signal, so this wrapper settles on its own and
|
|
7
|
+
* clears both its timer and abort listener on every path. Adding a listener to
|
|
8
|
+
* an already-aborted signal never replays the abort, so check `aborted` after
|
|
9
|
+
* subscribing or a cancelled fallback stage would wait for the full timeout.
|
|
10
|
+
*/
|
|
11
|
+
export function withTimeout<T>(promise: Promise<T>, ms: number, signal?: AbortSignal): Promise<T> {
|
|
12
|
+
return new Promise<T>((resolve, reject) => {
|
|
13
|
+
let settled = false;
|
|
14
|
+
const finish = (action: () => void) => {
|
|
15
|
+
if (settled) return;
|
|
16
|
+
settled = true;
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
signal?.removeEventListener("abort", onAbort);
|
|
19
|
+
action();
|
|
20
|
+
};
|
|
21
|
+
const onAbort = () => finish(() => reject(new ReviewFailure("cancelled", "Review cancelled")));
|
|
22
|
+
const timer = setTimeout(() => finish(() => reject(new ReviewFailure("timeout", "Review timed out"))), ms);
|
|
23
|
+
// Observe the provider promise before any early return: a promise that is
|
|
24
|
+
// already rejected must not become an unhandled rejection just because the
|
|
25
|
+
// signal was aborted before this wrapper was created.
|
|
26
|
+
promise.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
|
|
27
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
28
|
+
if (signal?.aborted) onAbort();
|
|
29
|
+
});
|
|
30
|
+
}
|