@mortz.prk/opencode-auto-mode 1.0.0 → 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 CHANGED
@@ -36,7 +36,9 @@ opencode service restart
36
36
 
37
37
  There is no default review pipeline, and empty options are invalid. Add at least
38
38
  one review entry. Built-in checks deny dangerous shell commands, allow common
39
- read-only commands, and leave everything else as `ask`.
39
+ read-only commands, and send other parsed commands to the configured reviewers.
40
+ Dynamic syntax such as command substitution, nested shells, and stateful builtins
41
+ still requires manual approval.
40
42
 
41
43
  The snippets below change parts of that configuration; keep your `classifiers`
42
44
  list when adding other options.
@@ -125,7 +127,8 @@ permissions and leave global permissions unchanged:
125
127
 
126
128
  A passive indicator shows when the plugin is reviewing the current session.
127
129
  It is enabled by default. Set `tui: false` in plugin options to hide it.
128
- OpenCode's own permission dialog handles manual approvals.
130
+ Invalid options show `auto-mode: invalid config` in the same place and an error
131
+ toast at startup. OpenCode's own permission dialog handles manual approvals.
129
132
 
130
133
  ## Review options
131
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mortz.prk/opencode-auto-mode",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Configurable automatic permission review for OpenCode v2.",
5
5
  "author": {
6
6
  "name": "Andrew Mellen",
package/src/classifier.ts CHANGED
@@ -15,7 +15,12 @@ export async function compositeDecision(
15
15
  const result = classify(request.action, request.resources);
16
16
  if (result.tier === "auto-deny") return { decision: "deny", reason: result.reason, stage: "static" };
17
17
  }
18
- if (shellResult) return { ...shellResult, stage: "shell-guard" };
18
+ if (shellResult) {
19
+ // Parsed read-only results feed the static stage, but never bypass a model-only pipeline.
20
+ const hasStatic = stages.some((stage) => stage.type === "static");
21
+ if (shellResult.decision !== "allow" || hasStatic)
22
+ return { ...shellResult, stage: shellResult.decision === "allow" ? "static" : "shell-guard" };
23
+ }
19
24
  let abstained: string | undefined;
20
25
  for (const stage of stages) {
21
26
  if (stage.type === "static") {
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Plugin } from "@opencode/plugin";
2
2
  import { createLogger } from "./logger.js";
3
- import { recordDecision } from "./state.js";
3
+ import { clearConfigError, recordConfigError, recordDecision } from "./state.js";
4
4
  import { defaultPolicy } from "./policy.js";
5
5
  import { extractReviewContext } from "./transcript.js";
6
6
  import { scanResultContent, withInjectionWarning } from "./probe.js";
@@ -18,9 +18,16 @@ export default Plugin.define({
18
18
  id: "opencode-auto-mode",
19
19
  setup: async (ctx) => {
20
20
  const log = createLogger("events.log");
21
+ const directory = ctx.location?.directory;
21
22
  let options: ReturnType<typeof parseOptions> | undefined;
22
- try { options = parseOptions(ctx.options); }
23
- catch { log.log("Invalid configuration; automatic approval disabled"); }
23
+ try {
24
+ options = parseOptions(ctx.options);
25
+ if (directory !== undefined) clearConfigError(directory);
26
+ } catch (error) {
27
+ const detail = error instanceof Error ? error.message : String(error);
28
+ log.log(`Invalid configuration; automatic approval disabled: ${detail}`);
29
+ if (directory !== undefined) recordConfigError(directory, detail);
30
+ }
24
31
  const sinks = [createAuditLog(options?.auditLog), createOtlpExporter(options?.otlp)].filter((sink) => sink !== undefined);
25
32
  const traces = createTraceExporter(options?.otlp);
26
33
  const counts = new Map<string, number>();
@@ -48,16 +55,18 @@ export default Plugin.define({
48
55
  };
49
56
  let contextLoaded = false;
50
57
  let contextComplete = false;
58
+ let contextReason = "User authorization context is unavailable";
51
59
  result = await compositeDecision(options.classifiers, request, async (stage) => {
52
60
  if (!contextLoaded) {
53
61
  const messages = await withTimeout(ctx.session.context({ sessionID: event.sessionID }), 3_000);
54
62
  const context = extractReviewContext(messages);
55
63
  request.transcript = context.transcript;
56
64
  contextComplete = context.complete;
65
+ contextReason = context.reason ?? contextReason;
57
66
  contextLoaded = true;
58
67
  }
59
68
  if (!contextComplete)
60
- return { decision: "ask", reason: "User authorization context is unavailable" };
69
+ return { decision: "ask", reason: contextReason };
61
70
  const policy = defaultPolicy(stage.policy);
62
71
  const attemptStartedAt = Date.now();
63
72
  let outcome: StageResult;
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 WRAPPERS = new Set(["env", "command", "builtin", "exec", "eval", "source", ".", "cd", "pushd", "popd",
7
- "sh", "bash", "dash", "zsh", "ksh", "fish", "pwsh", "powershell", "cmd", "!", "time", "coproc",
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 > 4096 || /[$`~\r\n\0{}[\]]/.test(command) ||
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 > 4096) ||
24
- resources.reduce((sum, resource) => sum + Buffer.byteLength(resource), 0) > 65_536)
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 uncertain = false;
322
+ let ask = false;
323
+ let review = false;
27
324
  for (const resource of resources) {
28
- const words = shellWords(resource);
29
- const result = classify("shell", [resource, ...(words ? [words.join(" ")] : [])]);
30
- if (result.tier === "auto-deny") return { decision: "deny", reason: result.reason };
31
- if (!words || words[0].includes("=") || WRAPPERS.has(basename(words[0]))) uncertain = true;
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
- return uncertain ? { decision: "ask", reason: "Shell syntax requires manual approval" } : undefined;
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/transcript.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  const MAX_USERS = 10;
2
2
  const MAX_TOOLS = 20;
3
+ const MAX_USER_CHARS = 8_000;
3
4
  const MAX_ENTRY_CHARS = 500;
4
- const MAX_TRANSCRIPT_CHARS = 6000;
5
+ const MAX_TRANSCRIPT_BYTES = 12_000;
5
6
 
6
7
  interface ToolEntry {
7
8
  name: string;
8
9
  input: unknown;
9
10
  }
10
11
 
12
+ interface Entry {
13
+ role: "user" | "tool";
14
+ text: string;
15
+ }
16
+
11
17
  function truncate(value: string, max: number): string {
12
18
  return value.length > max ? `${value.slice(0, max - 1)}…` : value;
13
19
  }
@@ -42,47 +48,161 @@ function assistantTools(message: unknown): ToolEntry[] {
42
48
  return tools;
43
49
  }
44
50
 
45
- function formatTool(tool: ToolEntry): string {
51
+ function formatTool(tool: ToolEntry): { text: string; truncated: boolean } {
46
52
  let detail = "";
53
+ let truncated = false;
47
54
  try {
48
55
  const state = asRecord(tool.input);
49
56
  const input = state?.["input"] ?? tool.input;
50
57
  const raw = typeof input === "string" ? input : JSON.stringify(input);
51
- if (raw) detail = truncate(raw, MAX_ENTRY_CHARS);
58
+ if (raw) {
59
+ truncated = raw.length > MAX_ENTRY_CHARS;
60
+ detail = truncate(raw, MAX_ENTRY_CHARS);
61
+ }
52
62
  } catch {
53
63
  detail = "";
54
64
  }
55
- return detail ? `${tool.name} ${detail}` : tool.name;
65
+ return { text: detail ? `${tool.name} ${detail}` : tool.name, truncated };
66
+ }
67
+
68
+ function escapedSize(value: string): number {
69
+ return Buffer.byteLength(JSON.stringify(value));
70
+ }
71
+
72
+ function prefixByEscapedBytes(characters: string[], maxBytes: number): string {
73
+ let low = 0;
74
+ let high = characters.length;
75
+ while (low < high) {
76
+ const middle = Math.ceil((low + high) / 2);
77
+ if (escapedSize(characters.slice(0, middle).join("")) <= maxBytes) low = middle;
78
+ else high = middle - 1;
79
+ }
80
+ return characters.slice(0, low).join("");
81
+ }
82
+
83
+ function suffixByEscapedBytes(characters: string[], maxBytes: number): string {
84
+ let low = 0;
85
+ let high = characters.length;
86
+ while (low < high) {
87
+ const middle = Math.ceil((low + high) / 2);
88
+ if (escapedSize(characters.slice(characters.length - middle).join("")) <= maxBytes) low = middle;
89
+ else high = middle - 1;
90
+ }
91
+ return characters.slice(characters.length - low).join("");
56
92
  }
57
93
 
58
- export interface ReviewContext { transcript: string; complete: boolean }
94
+ /** Clips the head and tail so escaping cannot hide a trailing constraint. */
95
+ function clipEscaped(value: string, maxBytes: number): string {
96
+ if (escapedSize(value) <= maxBytes) return value;
97
+ const characters = Array.from(value);
98
+ const marker = "…";
99
+ const budget = Math.max(0, maxBytes - escapedSize(marker));
100
+ const headBudget = Math.floor(budget * 0.6);
101
+ return `${prefixByEscapedBytes(characters, headBudget)}${marker}${suffixByEscapedBytes(characters, budget - headBudget)}`;
102
+ }
103
+
104
+ function clipChars(value: string, max: number): string {
105
+ if (value.length <= max) return value;
106
+ const characters = Array.from(value);
107
+ const head = Math.floor(max * 0.6);
108
+ return `${characters.slice(0, head).join("")}…${characters.slice(characters.length - (max - head)).join("")}`;
109
+ }
110
+
111
+ interface TranscriptData {
112
+ transcript: string;
113
+ userEvidence: boolean;
114
+ missingUserText: boolean;
115
+ notes: string[];
116
+ }
117
+
118
+ function renderTranscript(entries: Entry[], toolTruncated: boolean, userClipped: boolean): { transcript: string; notes: string[] } {
119
+ if (!entries.some((entry) => entry.role === "user")) return { transcript: "(no prior user messages in this session)", notes: [] };
120
+ let users = 0;
121
+ let tools = 0;
122
+ const recent = entries.slice().reverse().filter((entry) => entry.role === "user" ? ++users <= MAX_USERS : ++tools <= MAX_TOOLS).reverse();
123
+ const notes: string[] = [];
124
+ if (recent.length < entries.length) notes.push("older messages omitted");
125
+ if (toolTruncated) notes.push("some tool details truncated");
126
+ if (userClipped) notes.push("latest user message truncated");
127
+ const render = () => `User messages and prior tool calls (chronological):\n${JSON.stringify(recent)}` +
128
+ (notes.length ? `\n… partial session history (${notes.join("; ")})` : "");
129
+
130
+ while (Buffer.byteLength(render()) > MAX_TRANSCRIPT_BYTES && recent.length > 1) {
131
+ // Keep at least the latest user message.
132
+ const onlyOneUser = recent.filter((entry) => entry.role === "user").length <= 1;
133
+ const index = onlyOneUser && recent[0].role === "user" ? recent.findIndex((entry) => entry.role === "tool") : 0;
134
+ if (index < 0) break;
135
+ recent.splice(index, 1);
136
+ if (!notes.includes("older messages omitted")) notes.push("older messages omitted");
137
+ }
138
+
139
+ const latestUser = [...recent].reverse().find((entry) => entry.role === "user");
140
+ for (let attempt = 0; latestUser !== undefined && Buffer.byteLength(render()) > MAX_TRANSCRIPT_BYTES && attempt < 10; attempt++) {
141
+ if (!notes.includes("latest user message truncated")) notes.push("latest user message truncated");
142
+ const excess = Buffer.byteLength(render()) - MAX_TRANSCRIPT_BYTES;
143
+ latestUser.text = clipEscaped(latestUser.text, Math.max(128, escapedSize(latestUser.text) - excess - 64));
144
+ }
145
+ return { transcript: render(), notes };
146
+ }
147
+
148
+ function buildTranscript(messages: unknown[]): TranscriptData {
149
+ const entries: Entry[] = [];
150
+ let userEvidence = false;
151
+ let missingUserText = false;
152
+ let userClipped = false;
153
+ let toolTruncated = false;
154
+ for (const message of messages) {
155
+ const record = asRecord(message);
156
+ if (record?.type === "user") {
157
+ const text = userText(message);
158
+ if (text === undefined) {
159
+ missingUserText = true;
160
+ continue;
161
+ }
162
+ userEvidence = true;
163
+ const clipped = clipChars(text, MAX_USER_CHARS);
164
+ if (clipped !== text) userClipped = true;
165
+ entries.push({ role: "user", text: clipped });
166
+ }
167
+ for (const tool of assistantTools(message)) {
168
+ const formatted = formatTool(tool);
169
+ if (formatted.truncated) toolTruncated = true;
170
+ entries.push({ role: "tool", text: formatted.text });
171
+ }
172
+ }
173
+ const rendered = renderTranscript(entries, toolTruncated, userClipped);
174
+ return { transcript: rendered.transcript, userEvidence, missingUserText, notes: rendered.notes };
175
+ }
176
+
177
+ export interface ReviewContext { transcript: string; complete: boolean; reason?: string; notes?: string[] }
59
178
 
60
179
  export function extractReviewContext(messages: unknown[]): ReviewContext {
61
- const supported = messages.every((message) => {
180
+ const attachment = messages.some((message) => {
62
181
  const record = asRecord(message);
63
- if (record?.type !== "user") return true;
182
+ if (record?.type !== "user") return false;
64
183
  // These attachments carry evidence that the text-only reviewer cannot see.
65
- return userText(message) !== undefined && ["files", "skills", "agents"].every((key) =>
66
- record[key] === undefined || (Array.isArray(record[key]) && record[key].length === 0));
184
+ return ["files", "skills", "agents"].some((key) =>
185
+ Array.isArray(record[key]) && record[key].length > 0);
67
186
  });
68
- const transcript = extractTranscript(messages);
69
- return {
70
- transcript,
71
- complete: supported && transcript.startsWith("User messages") && !transcript.includes("…"),
72
- };
187
+ const data = buildTranscript(messages);
188
+ if (attachment)
189
+ return {
190
+ transcript: data.transcript, complete: false, notes: data.notes,
191
+ reason: "User authorization includes attachments that cannot be reviewed automatically",
192
+ };
193
+ if (data.missingUserText)
194
+ return {
195
+ transcript: data.transcript, complete: false, notes: data.notes,
196
+ reason: "A user message has no text that can be reviewed",
197
+ };
198
+ if (!data.userEvidence)
199
+ return {
200
+ transcript: data.transcript, complete: false, notes: data.notes,
201
+ reason: "No user authorization message is available",
202
+ };
203
+ return { transcript: data.transcript, complete: true, notes: data.notes };
73
204
  }
74
205
 
75
206
  export function extractTranscript(messages: unknown[]): string {
76
- const entries: { role: "user" | "tool"; text: string }[] = [];
77
- for (const message of messages) {
78
- const text = userText(message);
79
- if (text !== undefined) entries.push({ role: "user", text: truncate(text, MAX_ENTRY_CHARS) });
80
- for (const tool of assistantTools(message)) entries.push({ role: "tool", text: formatTool(tool) });
81
- }
82
- if (!entries.some((entry) => entry.role === "user")) return "(no prior user messages in this session)";
83
- let users = 0;
84
- let tools = 0;
85
- const recent = entries.slice().reverse().filter((entry) => entry.role === "user" ? ++users <= MAX_USERS : ++tools <= MAX_TOOLS).reverse();
86
- const incomplete = recent.length < entries.length ? "\n… incomplete session context" : "";
87
- return truncate(`User messages and prior tool calls (chronological):\n${JSON.stringify(recent)}${incomplete}`, MAX_TRANSCRIPT_CHARS);
207
+ return buildTranscript(messages).transcript;
88
208
  }
package/src/tui.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { createSignal, Show } from "solid-js";
3
3
  import { Plugin } from "@opencode/plugin/tui";
4
- import { readDecisions } from "./state.js";
4
+ import { readConfigError, readDecisions } from "./state.js";
5
5
  import { isReviewing } from "./tui-state.js";
6
6
 
7
7
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -11,21 +11,36 @@ export default Plugin.define({
11
11
  setup: async (ctx) => {
12
12
  if (ctx.options.tui === false) return;
13
13
  const [reviewing, setReviewing] = createSignal(false);
14
+ const [configError, setConfigError] = createSignal(false);
15
+ let announced = false;
14
16
  const update = () => {
15
17
  const route = ctx.ui.router.current();
16
18
  setReviewing(isReviewing(readDecisions(), route.type === "session" ? route.sessionID : undefined));
19
+ const error = readConfigError(ctx.location?.directory);
20
+ setConfigError(error !== undefined);
21
+ if (error === undefined) announced = false;
22
+ else if (!announced) {
23
+ announced = true;
24
+ ctx.ui.toast.show({
25
+ variant: "error", title: "auto-mode", duration: 10_000,
26
+ message: "Invalid configuration; automatic approval is disabled.",
27
+ });
28
+ }
17
29
  };
18
30
  update();
19
31
  const poll = setInterval(update, 250);
20
32
  const dispose = ctx.ui.slot({
21
33
  append: "app",
22
34
  render: () => (
23
- <Show when={reviewing()}>
24
- <box position="absolute" top={0} right={0} zIndex={10_000} flexDirection="row" gap={1}>
35
+ <box position="absolute" top={0} right={0} zIndex={10_000} flexDirection="row" gap={1}>
36
+ <Show when={configError()}>
37
+ <text fg={ctx.theme.text.feedback.error.base}>auto-mode: invalid config</text>
38
+ </Show>
39
+ <Show when={reviewing()}>
25
40
  <spinner frames={SPINNER_FRAMES} interval={120} color={ctx.theme.text.feedback.warning.base} />
26
41
  <text fg={ctx.theme.text.feedback.warning.base}>auto-mode reviewing</text>
27
- </box>
28
- </Show>
42
+ </Show>
43
+ </box>
29
44
  ),
30
45
  });
31
46
  return () => { clearInterval(poll); dispose(); };