@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 +5 -2
- package/package.json +1 -1
- package/src/classifier.ts +6 -1
- package/src/index.ts +13 -4
- package/src/shell.ts +311 -13
- package/src/state.ts +75 -0
- package/src/tiers.ts +3 -1
- package/src/transcript.ts +146 -26
- package/src/tui.tsx +20 -5
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
|
|
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
|
-
|
|
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
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)
|
|
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 {
|
|
23
|
-
|
|
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:
|
|
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
|
|
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/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
|
|
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)
|
|
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
|
-
|
|
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
|
|
180
|
+
const attachment = messages.some((message) => {
|
|
62
181
|
const record = asRecord(message);
|
|
63
|
-
if (record?.type !== "user") return
|
|
182
|
+
if (record?.type !== "user") return false;
|
|
64
183
|
// These attachments carry evidence that the text-only reviewer cannot see.
|
|
65
|
-
return
|
|
66
|
-
|
|
184
|
+
return ["files", "skills", "agents"].some((key) =>
|
|
185
|
+
Array.isArray(record[key]) && record[key].length > 0);
|
|
67
186
|
});
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
24
|
-
<
|
|
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
|
-
</
|
|
28
|
-
</
|
|
42
|
+
</Show>
|
|
43
|
+
</box>
|
|
29
44
|
),
|
|
30
45
|
});
|
|
31
46
|
return () => { clearInterval(poll); dispose(); };
|