@mortz.prk/opencode-auto-mode 0.1.3 → 1.0.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/README.md +151 -107
- package/package.json +7 -6
- package/src/audit.ts +61 -0
- package/src/classifier.ts +18 -29
- package/src/config.ts +76 -59
- package/src/decisions.ts +213 -0
- package/src/index.ts +67 -24
- package/src/otlp.ts +74 -0
- package/src/policy.ts +4 -10
- package/src/reviewer.ts +8 -0
- package/src/shell.ts +311 -13
- package/src/state.ts +75 -0
- package/src/tiers.ts +3 -1
- package/src/traces.ts +84 -0
- package/src/transcript.ts +150 -13
- package/src/tui-state.ts +7 -0
- package/src/tui.tsx +30 -302
- package/src/providers.ts +0 -103
- package/src/rules-worker.ts +0 -38
- package/src/rules.ts +0 -79
package/src/reviewer.ts
CHANGED
|
@@ -12,10 +12,18 @@ interface PermissionRequest {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export type Decision = "allow" | "deny" | "ask";
|
|
15
|
+
export type StageDecision = Decision | "abstain";
|
|
16
|
+
|
|
17
|
+
export interface StageResult {
|
|
18
|
+
decision: StageDecision;
|
|
19
|
+
reason: string;
|
|
20
|
+
stage?: string;
|
|
21
|
+
}
|
|
15
22
|
|
|
16
23
|
export interface ReviewResult {
|
|
17
24
|
decision: Decision;
|
|
18
25
|
reason: string;
|
|
26
|
+
stage?: string;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export const DENY_SUFFIX = "Find a safer path and do not try to route around this block.";
|
package/src/shell.ts
CHANGED
|
@@ -3,14 +3,29 @@ import { basename } from "node:path";
|
|
|
3
3
|
import { classify } from "./tiers.js";
|
|
4
4
|
import type { ReviewResult } from "./reviewer.js";
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
"if", "then", "else", "elif", "fi", "for", "while", "until", "case", "esac", "select", "do", "done", "function",
|
|
9
|
-
"export", "unset", "set", "shopt", "alias", "unalias", "trap", "umask", "exit", "return", "shift", "readonly", "local", "declare", "typeset"]);
|
|
6
|
+
const MAX_COMMAND_CHARS = 4096;
|
|
7
|
+
const MAX_COMMAND_BYTES = 65_536;
|
|
10
8
|
const BALANCED_QUOTES = /^(?:[^'"\\]|\\[^\r\n]|'[^']*'|"(?:[^"\\]|\\[^\r\n])*")*$/;
|
|
9
|
+
const CONTROL_OPERATORS = new Set([";", "&&", "||", "|", "|&", "&"]);
|
|
10
|
+
const REDIRECT_OPERATORS = new Set([">", ">>", ">&", "<&", "<", "<<", "<<-", "<<<", "<>", ">|", "&>", "&>>"]);
|
|
11
|
+
const WRITE_OPERATORS = new Set([">", ">>", ">|", "&>", "&>>", "<>"]);
|
|
12
|
+
const SHELLS = new Set(["sh", "bash", "dash", "zsh", "ksh", "fish", "pwsh", "powershell", "cmd"]);
|
|
13
|
+
const SCRIPT_HOSTS = new Set(["python", "python3", "node", "perl", "ruby", "php", "awk", "gawk", "lua"]);
|
|
14
|
+
const STATEFUL = new Set(["alias", "unalias", "export", "unset", "set", "shopt", "trap", "umask",
|
|
15
|
+
"readonly", "declare", "typeset", "local", "function"]);
|
|
16
|
+
const DYNAMIC = new Set(["eval", "source", ".", "exec", "xargs"]);
|
|
17
|
+
const DYNAMIC_SYNTAX = /\$\(|`|\$\(\s*\(|[<>]\(/;
|
|
18
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
19
|
+
const WRAPPERS = new Set(["env", "command", "builtin", "exec", "time", "!", "nohup", "setsid", "nice",
|
|
20
|
+
"ionice", "stdbuf", "timeout"]);
|
|
21
|
+
const VALUE_OPTIONS = new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string", "-a", "--argv0",
|
|
22
|
+
"-n", "--adjustment", "-s", "--signal", "-k", "--kill-after"]);
|
|
23
|
+
const READ_ONLY = new Set(["pwd", "whoami", "hostname", "uname", "id", "groups", "ls", "cat", "head", "tail",
|
|
24
|
+
"wc", "date", "echo", "printf", "true", ":", "rg", "grep", "sort", "uniq", "cut", "tr", "jq", "stat", "file",
|
|
25
|
+
"du", "df", "tree", "bat", "which", "type", "sleep", "test", "basename", "dirname", "realpath", "diff"]);
|
|
11
26
|
|
|
12
27
|
export function shellWords(command: string): string[] | undefined {
|
|
13
|
-
if (!command || command.length >
|
|
28
|
+
if (!command || command.length > MAX_COMMAND_CHARS || /[$`~\r\n\0{}[\]]/.test(command) ||
|
|
14
29
|
/[^\S \t]/.test(command) || !BALANCED_QUOTES.test(command)) return undefined;
|
|
15
30
|
try {
|
|
16
31
|
const words = parse(command);
|
|
@@ -18,17 +33,300 @@ export function shellWords(command: string): string[] | undefined {
|
|
|
18
33
|
} catch { return undefined; }
|
|
19
34
|
}
|
|
20
35
|
|
|
36
|
+
interface Segment { words: string[]; write: boolean }
|
|
37
|
+
type Verdict = { decision: "deny"; reason: string } | { decision: "allow" | "review" | "ask" };
|
|
38
|
+
|
|
39
|
+
function blockedCommand(command: string): string | undefined {
|
|
40
|
+
const normalized = shellWords(command);
|
|
41
|
+
const result = classify("shell", [command, ...(normalized ? [normalized.join(" ")] : [])]);
|
|
42
|
+
return result.tier === "auto-deny" ? result.reason : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True when the command contains a heredoc marker outside quotes. Heredoc bodies are data. */
|
|
46
|
+
function hasHeredoc(command: string): boolean {
|
|
47
|
+
let quote: "'" | '"' | undefined;
|
|
48
|
+
for (let index = 0; index < command.length; index++) {
|
|
49
|
+
const character = command[index];
|
|
50
|
+
if (quote === "'") { if (character === "'") quote = undefined; continue; }
|
|
51
|
+
if (quote === '"') {
|
|
52
|
+
if (character === "\\") { index++; continue; }
|
|
53
|
+
if (character === '"') quote = undefined;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (character === "'" || character === '"') { quote = character; continue; }
|
|
57
|
+
if (character === "\\") { index++; continue; }
|
|
58
|
+
if (character === "<" && command[index + 1] === "<" && command[index + 2] !== "<") return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface HeredocMarker { delimiter: string; quoted: boolean; stripTabs: boolean }
|
|
64
|
+
|
|
65
|
+
/** Reads heredoc markers on one line, ignoring markers inside quoted strings and comments. */
|
|
66
|
+
function heredocMarkers(line: string): HeredocMarker[] {
|
|
67
|
+
const markers: HeredocMarker[] = [];
|
|
68
|
+
let quote: "'" | '"' | undefined;
|
|
69
|
+
for (let index = 0; index < line.length; index++) {
|
|
70
|
+
const character = line[index];
|
|
71
|
+
if (quote === "'") { if (character === "'") quote = undefined; continue; }
|
|
72
|
+
if (quote === '"') {
|
|
73
|
+
if (character === "\\") { index++; continue; }
|
|
74
|
+
if (character === '"') quote = undefined;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (character === "'" || character === '"') { quote = character; continue; }
|
|
78
|
+
if (character === "\\") { index++; continue; }
|
|
79
|
+
if (character === "#" && (index === 0 || /\s/.test(line[index - 1] ?? ""))) break;
|
|
80
|
+
if (character !== "<" || line[index + 1] !== "<" || line[index + 2] === "<") continue;
|
|
81
|
+
let cursor = index + 2;
|
|
82
|
+
const stripTabs = line[cursor] === "-";
|
|
83
|
+
if (stripTabs) cursor++;
|
|
84
|
+
while (line[cursor] === " " || line[cursor] === "\t") cursor++;
|
|
85
|
+
let delimiter = "";
|
|
86
|
+
let quoted = false;
|
|
87
|
+
if (line[cursor] === "'" || line[cursor] === '"') {
|
|
88
|
+
const closing = line[cursor];
|
|
89
|
+
quoted = true;
|
|
90
|
+
cursor++;
|
|
91
|
+
while (cursor < line.length && line[cursor] !== closing) delimiter += line[cursor++];
|
|
92
|
+
} else {
|
|
93
|
+
while (cursor < line.length && /[A-Za-z0-9_]/.test(line[cursor])) delimiter += line[cursor++];
|
|
94
|
+
}
|
|
95
|
+
if (delimiter) markers.push({ delimiter, quoted, stripTabs });
|
|
96
|
+
index = cursor - 1;
|
|
97
|
+
}
|
|
98
|
+
return markers;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Resolves leading assignments and wrapper options to the effective executable. */
|
|
102
|
+
function resolveExecutable(words: string[]): { name: string; index: number } | undefined {
|
|
103
|
+
let index = 0;
|
|
104
|
+
while (index < words.length && ASSIGNMENT.test(words[index])) index++;
|
|
105
|
+
while (index < words.length) {
|
|
106
|
+
const name = basename(words[index]);
|
|
107
|
+
if (!WRAPPERS.has(name)) break;
|
|
108
|
+
index++;
|
|
109
|
+
if (name === "timeout") {
|
|
110
|
+
while (index < words.length && words[index].startsWith("-") && words[index] !== "-") {
|
|
111
|
+
const option = words[index].split("=", 1)[0];
|
|
112
|
+
index += VALUE_OPTIONS.has(option) && !words[index].includes("=") ? 2 : 1;
|
|
113
|
+
}
|
|
114
|
+
if (index < words.length) index++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
while (index < words.length) {
|
|
118
|
+
const word = words[index];
|
|
119
|
+
if (word === "--") { index++; break; }
|
|
120
|
+
if (word.startsWith("-") && word !== "-") {
|
|
121
|
+
const option = word.split("=", 1)[0];
|
|
122
|
+
index += VALUE_OPTIONS.has(option) && !word.includes("=") ? 2 : 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (ASSIGNMENT.test(word)) { index++; continue; }
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return index < words.length ? { name: basename(words[index]), index } : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** True when the effective executable is a shell, a dynamic builtin, or a stateful builtin. */
|
|
133
|
+
function dynamicExecution(words: string[]): boolean {
|
|
134
|
+
const executable = resolveExecutable(words);
|
|
135
|
+
if (executable === undefined) return true;
|
|
136
|
+
if (SHELLS.has(executable.name) || DYNAMIC.has(executable.name) || STATEFUL.has(executable.name)) return true;
|
|
137
|
+
return executable.name === "find" && words.slice(executable.index + 1)
|
|
138
|
+
.some((word) => /^-(?:exec|execdir|ok|okdir|delete)$/.test(word));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isReadOnlyGit(args: string[]): boolean {
|
|
142
|
+
if (args.some((arg) =>
|
|
143
|
+
/^(?:-c|--git-dir|--work-tree|--exec-path|--config-env)(?:=|$)/.test(arg) ||
|
|
144
|
+
arg === "--ext-diff" || arg === "--exec" || arg.startsWith("--output"))) return false;
|
|
145
|
+
const subcommand = args[0];
|
|
146
|
+
if (subcommand === undefined) return false;
|
|
147
|
+
if (["status", "diff", "log", "show", "rev-parse"].includes(subcommand)) return true;
|
|
148
|
+
if (subcommand !== "branch") return false;
|
|
149
|
+
if (args.length === 1) return true;
|
|
150
|
+
return args.slice(1).every((arg) =>
|
|
151
|
+
["--show-current", "--list", "-a", "-r", "-v", "-vv", "--merged", "--no-merged", "--contains", "--points-at", "--format"]
|
|
152
|
+
.some((safe) => arg === safe || arg.startsWith(`${safe}=`)));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isReadOnlySed(args: string[]): boolean {
|
|
156
|
+
if (args.some((arg) => arg === "-i" || arg.startsWith("--in-place") || arg === "-e" ||
|
|
157
|
+
arg === "--expression" || arg === "-f" || arg === "--file")) return false;
|
|
158
|
+
return args[0] === "-n" && args[1] !== undefined && /^\d+(?:,\d+)?p$/.test(args[1]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isReadOnlyGh(args: string[]): boolean {
|
|
162
|
+
const [group, subcommand] = args;
|
|
163
|
+
if (group === "label" && subcommand === "list") return true;
|
|
164
|
+
if (group === "api") {
|
|
165
|
+
return !args.some((arg) => arg === "-X" || arg === "--method" || arg.startsWith("-X") ||
|
|
166
|
+
arg.startsWith("--method=") || ["-f", "--field", "-F", "--raw-field", "--input"].includes(arg));
|
|
167
|
+
}
|
|
168
|
+
return new Set(["pr view", "pr list", "pr diff", "pr checks", "issue view", "issue list", "run view",
|
|
169
|
+
"run list", "repo view", "auth status", "status", "search"]).has(`${group} ${subcommand}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isReadOnly(words: string[]): boolean {
|
|
173
|
+
const command = basename(words[0]);
|
|
174
|
+
const args = words.slice(1);
|
|
175
|
+
if (READ_ONLY.has(command)) {
|
|
176
|
+
return command !== "date" || !args.some((arg) => arg === "-s" || arg === "--set" || arg.startsWith("--set="));
|
|
177
|
+
}
|
|
178
|
+
if (command === "git") return isReadOnlyGit(args);
|
|
179
|
+
if (command === "find") return !args.some((arg) => /^-(?:fprint|fprint0|fprintf|fls)$/.test(arg));
|
|
180
|
+
if (command === "sed") return isReadOnlySed(args);
|
|
181
|
+
if (command === "gh") return isReadOnlyGh(args);
|
|
182
|
+
if (command === "docker") return args[0] === "logs";
|
|
183
|
+
if (command === "bun")
|
|
184
|
+
return args[0] === "test" || (args[0] === "run" && (args[1] === "lint" || args[1] === "typecheck"));
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function analyzeSegment(segment: Segment): Verdict {
|
|
189
|
+
if (!segment.words.length) return segment.write ? { decision: "review" } : { decision: "allow" };
|
|
190
|
+
const command = segment.words[0];
|
|
191
|
+
if (!command || command.includes("$")) return { decision: "ask" };
|
|
192
|
+
const blocked = blockedCommand(segment.words.join(" "));
|
|
193
|
+
if (blocked !== undefined) return { decision: "deny", reason: blocked };
|
|
194
|
+
if (dynamicExecution(segment.words)) return { decision: "ask" };
|
|
195
|
+
if (!segment.write && isReadOnly(segment.words)) return { decision: "allow" };
|
|
196
|
+
return { decision: "review" };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function analyzeLine(command: string): Verdict {
|
|
200
|
+
if (!command.trim()) return { decision: "allow" };
|
|
201
|
+
if (!BALANCED_QUOTES.test(command)) return { decision: "ask" };
|
|
202
|
+
const blocked = blockedCommand(command);
|
|
203
|
+
if (blocked !== undefined) return { decision: "deny", reason: blocked };
|
|
204
|
+
|
|
205
|
+
let tokens;
|
|
206
|
+
try { tokens = parse(command); }
|
|
207
|
+
catch { return { decision: "ask" }; }
|
|
208
|
+
const blockedTokens = blockedCommand(tokens.filter((token): token is string => typeof token === "string").join(" "));
|
|
209
|
+
if (blockedTokens !== undefined) return { decision: "deny", reason: blockedTokens };
|
|
210
|
+
if (DYNAMIC_SYNTAX.test(command)) return { decision: "ask" };
|
|
211
|
+
|
|
212
|
+
const segments: Segment[] = [{ words: [], write: false }];
|
|
213
|
+
let current = segments[0];
|
|
214
|
+
let redirect: { write: boolean } | undefined;
|
|
215
|
+
for (const token of tokens) {
|
|
216
|
+
if (typeof token === "string") {
|
|
217
|
+
if (redirect) {
|
|
218
|
+
const target = token;
|
|
219
|
+
if (redirect.write && target !== "/dev/null" && !/^\d+$/.test(target) && !target.startsWith("&"))
|
|
220
|
+
current.write = true;
|
|
221
|
+
redirect = undefined;
|
|
222
|
+
} else current.words.push(token);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if ("comment" in token) continue;
|
|
226
|
+
if (token.op === "glob") { current.words.push(token.pattern); continue; }
|
|
227
|
+
if (CONTROL_OPERATORS.has(token.op)) {
|
|
228
|
+
current = { words: [], write: false };
|
|
229
|
+
segments.push(current);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (REDIRECT_OPERATORS.has(token.op)) {
|
|
233
|
+
if (current.words.length && /^\d+$/.test(current.words[current.words.length - 1])) current.words.pop();
|
|
234
|
+
redirect = { write: WRITE_OPERATORS.has(token.op) };
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
return { decision: "ask" };
|
|
238
|
+
}
|
|
239
|
+
if (redirect) return { decision: "ask" };
|
|
240
|
+
|
|
241
|
+
let ask = false;
|
|
242
|
+
let review = false;
|
|
243
|
+
for (const segment of segments) {
|
|
244
|
+
const verdict = analyzeSegment(segment);
|
|
245
|
+
if (verdict.decision === "deny") return verdict;
|
|
246
|
+
if (verdict.decision === "ask") ask = true;
|
|
247
|
+
else if (verdict.decision === "review") review = true;
|
|
248
|
+
}
|
|
249
|
+
if (ask) return { decision: "ask" };
|
|
250
|
+
return { decision: review ? "review" : "allow" };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function analyzeHeredoc(command: string): Verdict {
|
|
254
|
+
const lines = command.split("\n");
|
|
255
|
+
const firstLine = lines[0] ?? "";
|
|
256
|
+
const markers = heredocMarkers(firstLine);
|
|
257
|
+
if (!markers.length) return { decision: "ask" };
|
|
258
|
+
let index = 1;
|
|
259
|
+
const bodies: { lines: string[]; quoted: boolean }[] = [];
|
|
260
|
+
for (const marker of markers) {
|
|
261
|
+
const body: string[] = [];
|
|
262
|
+
let closed = false;
|
|
263
|
+
while (index < lines.length) {
|
|
264
|
+
const candidate = marker.stripTabs ? lines[index].replace(/^\t+/, "") : lines[index];
|
|
265
|
+
index++;
|
|
266
|
+
if (candidate === marker.delimiter) { closed = true; break; }
|
|
267
|
+
body.push(candidate);
|
|
268
|
+
}
|
|
269
|
+
if (!closed) return { decision: "ask" };
|
|
270
|
+
bodies.push({ lines: body, quoted: marker.quoted });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let ask = false;
|
|
274
|
+
let review = false;
|
|
275
|
+
for (const line of [firstLine, ...lines.slice(index)]) {
|
|
276
|
+
if (!line.trim()) continue;
|
|
277
|
+
const verdict = analyzeLine(line);
|
|
278
|
+
if (verdict.decision === "deny") return verdict;
|
|
279
|
+
if (verdict.decision === "ask") ask = true;
|
|
280
|
+
else if (verdict.decision === "review") review = true;
|
|
281
|
+
}
|
|
282
|
+
for (const body of bodies) {
|
|
283
|
+
// Unquoted heredoc bodies undergo command and parameter expansion.
|
|
284
|
+
if (!body.quoted && DYNAMIC_SYNTAX.test(body.lines.join("\n"))) return { decision: "ask" };
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const words = parse(firstLine).filter((token): token is string => typeof token === "string");
|
|
288
|
+
const executable = resolveExecutable(words);
|
|
289
|
+
if (executable === undefined || SHELLS.has(executable.name) || SCRIPT_HOSTS.has(executable.name) ||
|
|
290
|
+
DYNAMIC.has(executable.name) || STATEFUL.has(executable.name)) return { decision: "ask" };
|
|
291
|
+
} catch { return { decision: "ask" }; }
|
|
292
|
+
if (ask) return { decision: "ask" };
|
|
293
|
+
return { decision: review ? "review" : "allow" };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function analyze(command: string): Verdict {
|
|
297
|
+
if (hasHeredoc(command)) return analyzeHeredoc(command);
|
|
298
|
+
const blocked = blockedCommand(command);
|
|
299
|
+
if (blocked !== undefined) return { decision: "deny", reason: blocked };
|
|
300
|
+
if (command.includes("\n")) {
|
|
301
|
+
let ask = false;
|
|
302
|
+
let review = false;
|
|
303
|
+
for (const line of command.split("\n")) {
|
|
304
|
+
const trimmed = line.trim();
|
|
305
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
306
|
+
const verdict = analyzeLine(trimmed);
|
|
307
|
+
if (verdict.decision === "deny") return verdict;
|
|
308
|
+
if (verdict.decision === "ask") ask = true;
|
|
309
|
+
else if (verdict.decision === "review") review = true;
|
|
310
|
+
}
|
|
311
|
+
if (ask) return { decision: "ask" };
|
|
312
|
+
return { decision: review ? "review" : "allow" };
|
|
313
|
+
}
|
|
314
|
+
return analyzeLine(command);
|
|
315
|
+
}
|
|
316
|
+
|
|
21
317
|
export function guardShell(resources: readonly string[]): ReviewResult | undefined {
|
|
22
318
|
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) >
|
|
319
|
+
if (!resources.length || resources.length > 256 || resources.some((resource) => resource.length > MAX_COMMAND_CHARS) ||
|
|
320
|
+
resources.reduce((sum, resource) => sum + Buffer.byteLength(resource), 0) > MAX_COMMAND_BYTES)
|
|
25
321
|
return { decision: "ask", reason: "Shell command exceeds parsing limits" };
|
|
26
|
-
let
|
|
322
|
+
let ask = false;
|
|
323
|
+
let review = false;
|
|
27
324
|
for (const resource of resources) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
if (
|
|
325
|
+
const verdict = analyze(resource);
|
|
326
|
+
if (verdict.decision === "deny") return { decision: "deny", reason: verdict.reason };
|
|
327
|
+
if (verdict.decision === "ask") ask = true;
|
|
328
|
+
else if (verdict.decision === "review") review = true;
|
|
32
329
|
}
|
|
33
|
-
|
|
330
|
+
if (ask) return { decision: "ask", reason: "Shell syntax requires manual approval" };
|
|
331
|
+
return review ? undefined : { decision: "allow", reason: "read-only command" };
|
|
34
332
|
}
|
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/tiers.ts
CHANGED
|
@@ -5,7 +5,9 @@ export type Tier = "auto-allow" | "auto-deny" | "review" | "unknown";
|
|
|
5
5
|
export interface Classification { tier: Tier; reason: string }
|
|
6
6
|
export interface ClassifyOptions { projectDir?: string; allowInProjectEdits?: boolean }
|
|
7
7
|
|
|
8
|
-
const READ_ONLY_COMMANDS = new Set(["pwd", "whoami", "hostname", "uname", "id", "groups", "ls", "ls -a", "ls -l", "ls -la", "ls -al", "ls -A"
|
|
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
|
+
"git status", "git status --short", "git diff", "git log", "git show", "git branch --show-current",
|
|
10
|
+
"date", "date +%s", "cat", "head", "tail", "rg", "grep", "wc", "bun test", "bun run lint", "bun run typecheck"]);
|
|
9
11
|
const BLOCKED_SHELL = [
|
|
10
12
|
/\brm\s+-rf?\s+\/(\s|$|\*)/,
|
|
11
13
|
/\bsudo\b/,
|
package/src/traces.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import type { AuditRecord } from "./audit.js";
|
|
3
|
+
import type { OtlpOptions } from "./otlp.js";
|
|
4
|
+
import type { StageResult } from "./reviewer.js";
|
|
5
|
+
|
|
6
|
+
export interface ClassifierAttempt {
|
|
7
|
+
index: number;
|
|
8
|
+
type: string;
|
|
9
|
+
model?: string;
|
|
10
|
+
startedAt: number;
|
|
11
|
+
durationMs: number;
|
|
12
|
+
result: StageResult;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ReviewTrace {
|
|
16
|
+
traceID: string;
|
|
17
|
+
spanID: string;
|
|
18
|
+
attempts: ClassifierAttempt[];
|
|
19
|
+
finish(record: AuditRecord): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const attribute = (key: string, value: string | number) => ({
|
|
23
|
+
key,
|
|
24
|
+
value: typeof value === "number" ? { intValue: String(value) } : { stringValue: value },
|
|
25
|
+
});
|
|
26
|
+
const nano = (ms: number) => String(BigInt(Math.trunc(ms)) * 1_000_000n);
|
|
27
|
+
|
|
28
|
+
export function tracesUrl(base: string): string {
|
|
29
|
+
const url = new URL(base);
|
|
30
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error("Invalid OTLP endpoint");
|
|
31
|
+
url.pathname = url.pathname.replace(/\/v1\/(?:logs|traces)\/?$/, "").replace(/\/+$/, "") + "/v1/traces";
|
|
32
|
+
return url.href;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function headers(raw: string | undefined): Record<string, string> {
|
|
36
|
+
return Object.fromEntries((raw ?? "").split(",").flatMap((pair) => {
|
|
37
|
+
const index = pair.indexOf("=");
|
|
38
|
+
return index > 0 ? [[pair.slice(0, index).trim(), pair.slice(index + 1).trim()]] : [];
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createTraceExporter(setting: boolean | OtlpOptions | undefined, env: NodeJS.ProcessEnv = process.env) {
|
|
43
|
+
if (setting === undefined || setting === false) return undefined;
|
|
44
|
+
const options = setting === true ? {} : setting;
|
|
45
|
+
const base = options.endpoint ?? env.OPENCODE_OTLP_ENDPOINT ?? env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
46
|
+
if (!base) return undefined;
|
|
47
|
+
let url: string;
|
|
48
|
+
try { url = tracesUrl(base); } catch { return undefined; }
|
|
49
|
+
const requestHeaders = { "content-type": "application/json", ...headers(env.OTEL_EXPORTER_OTLP_HEADERS), ...headers(env.OPENCODE_OTLP_HEADERS), ...options.headers };
|
|
50
|
+
return {
|
|
51
|
+
begin(startedAt: number): ReviewTrace {
|
|
52
|
+
const traceID = randomBytes(16).toString("hex");
|
|
53
|
+
const spanID = randomBytes(8).toString("hex");
|
|
54
|
+
const attempts: ClassifierAttempt[] = [];
|
|
55
|
+
return {
|
|
56
|
+
traceID, spanID, attempts,
|
|
57
|
+
finish(record) {
|
|
58
|
+
const shared = [attribute("review.id", record.reviewID), attribute("session.id", record.sessionID)];
|
|
59
|
+
const spans = [{
|
|
60
|
+
traceId: traceID, spanId: spanID, name: "auto_mode.review", kind: 1,
|
|
61
|
+
startTimeUnixNano: nano(startedAt), endTimeUnixNano: nano(startedAt + record.durationMs),
|
|
62
|
+
attributes: [...shared, attribute("action", record.action), attribute("agent", record.agent ?? ""),
|
|
63
|
+
attribute("decision", record.decision), attribute("stage", record.stage)],
|
|
64
|
+
status: { code: record.error ? 2 : 1 },
|
|
65
|
+
}, ...attempts.map((attempt) => ({
|
|
66
|
+
traceId: traceID, spanId: randomBytes(8).toString("hex"), parentSpanId: spanID,
|
|
67
|
+
name: "auto_mode.classifier", kind: 1,
|
|
68
|
+
startTimeUnixNano: nano(attempt.startedAt), endTimeUnixNano: nano(attempt.startedAt + attempt.durationMs),
|
|
69
|
+
attributes: [...shared, attribute("classifier.index", attempt.index), attribute("classifier.type", attempt.type),
|
|
70
|
+
attribute("model", attempt.model ?? ""), attribute("decision", attempt.result.decision)],
|
|
71
|
+
status: { code: 1 },
|
|
72
|
+
}))];
|
|
73
|
+
const body = JSON.stringify({ resourceSpans: [{
|
|
74
|
+
resource: { attributes: [attribute("service.name", "opencode-auto-mode")] },
|
|
75
|
+
scopeSpans: [{ scope: { name: "opencode-auto-mode" }, spans }],
|
|
76
|
+
}] });
|
|
77
|
+
// Match log export: best effort, independent of permission delivery.
|
|
78
|
+
fetch(url, { method: "POST", headers: requestHeaders, body, redirect: "error",
|
|
79
|
+
signal: AbortSignal.timeout(Math.min(options.timeoutMs ?? 3_000, 10_000)) }).catch(() => {});
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|