@mortz.prk/opencode-auto-mode 0.1.3 → 1.0.0
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 +148 -107
- package/package.json +7 -6
- package/src/audit.ts +61 -0
- package/src/classifier.ts +14 -30
- package/src/config.ts +76 -59
- package/src/decisions.ts +213 -0
- package/src/index.ts +54 -20
- package/src/otlp.ts +74 -0
- package/src/policy.ts +4 -10
- package/src/reviewer.ts +8 -0
- package/src/traces.ts +84 -0
- package/src/transcript.ts +17 -0
- package/src/tui-state.ts +7 -0
- package/src/tui.tsx +15 -302
- package/src/providers.ts +0 -103
- package/src/rules-worker.ts +0 -38
- package/src/rules.ts +0 -79
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
|
+
}
|
package/src/transcript.ts
CHANGED
|
@@ -55,6 +55,23 @@ function formatTool(tool: ToolEntry): string {
|
|
|
55
55
|
return detail ? `${tool.name} ${detail}` : tool.name;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export interface ReviewContext { transcript: string; complete: boolean }
|
|
59
|
+
|
|
60
|
+
export function extractReviewContext(messages: unknown[]): ReviewContext {
|
|
61
|
+
const supported = messages.every((message) => {
|
|
62
|
+
const record = asRecord(message);
|
|
63
|
+
if (record?.type !== "user") return true;
|
|
64
|
+
// 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));
|
|
67
|
+
});
|
|
68
|
+
const transcript = extractTranscript(messages);
|
|
69
|
+
return {
|
|
70
|
+
transcript,
|
|
71
|
+
complete: supported && transcript.startsWith("User messages") && !transcript.includes("…"),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
58
75
|
export function extractTranscript(messages: unknown[]): string {
|
|
59
76
|
const entries: { role: "user" | "tool"; text: string }[] = [];
|
|
60
77
|
for (const message of messages) {
|
package/src/tui-state.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DecisionRecord } from "./state.js";
|
|
2
|
+
|
|
3
|
+
export function isReviewing(records: Record<string, DecisionRecord>, sessionID: string | undefined, now = Date.now()): boolean {
|
|
4
|
+
if (!sessionID) return false;
|
|
5
|
+
return Object.values(records).some((record) => record.sessionID === sessionID &&
|
|
6
|
+
record.decision === "reviewing" && now - record.at >= 0 && now - record.at < 90_000);
|
|
7
|
+
}
|
package/src/tui.tsx
CHANGED
|
@@ -1,320 +1,33 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import {
|
|
2
|
+
import { createSignal, Show } from "solid-js";
|
|
3
3
|
import { Plugin } from "@opencode/plugin/tui";
|
|
4
|
-
import type { Context } from "@opencode/plugin/tui/context";
|
|
5
|
-
import type { ResolvedTheme } from "@opencode/theme/tui";
|
|
6
4
|
import { readDecisions } from "./state.js";
|
|
5
|
+
import { isReviewing } from "./tui-state.js";
|
|
7
6
|
|
|
8
7
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
9
|
-
const MAX_RESOURCE = 48;
|
|
10
|
-
const POLL_MS = 1_000;
|
|
11
|
-
const STALE_MS = 90_000;
|
|
12
|
-
|
|
13
|
-
type Pending = {
|
|
14
|
-
requestID: string;
|
|
15
|
-
sessionID: string;
|
|
16
|
-
action: string;
|
|
17
|
-
resource: string;
|
|
18
|
-
at: number;
|
|
19
|
-
escalated: boolean;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
function str(value: unknown, fallback: string): string {
|
|
23
|
-
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function truncate(value: string, max: number): string {
|
|
27
|
-
return value.length > max ? value.slice(0, max - 1) + "…" : value;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// `ctx.data.session.list()` is unreliable across betas: on 0.0.0-beta-17823
|
|
31
|
-
// the host's session store memo never invalidates, so iterating sessions to
|
|
32
|
-
// find pending permissions sees an empty list forever. Instead the badge is
|
|
33
|
-
// fed by permission events plus the server plugin's decision file: tier-3
|
|
34
|
-
// reviews run inside the evaluate hook BEFORE a permission request exists,
|
|
35
|
-
// so "reviewing" records (synthetic IDs) create spinner entries, and the
|
|
36
|
-
// permission.asked event that follows an escalation adopts the matching
|
|
37
|
-
// entry so its spinner time carries over.
|
|
38
|
-
function Badge(props: {
|
|
39
|
-
pending: () => Pending | undefined;
|
|
40
|
-
answer: (reply: "once" | "reject") => Promise<void>;
|
|
41
|
-
allowKey: string;
|
|
42
|
-
denyKey: string;
|
|
43
|
-
keymap: Pick<Context["keymap"], "layer">;
|
|
44
|
-
theme: () => ResolvedTheme;
|
|
45
|
-
}): JSX.Element {
|
|
46
|
-
const theme = props.theme();
|
|
47
|
-
|
|
48
|
-
props.keymap.layer(() => ({
|
|
49
|
-
mode: "base",
|
|
50
|
-
commands: [
|
|
51
|
-
{
|
|
52
|
-
id: "auto-mode.allow",
|
|
53
|
-
title: "Allow pending permission",
|
|
54
|
-
group: "Auto-mode",
|
|
55
|
-
palette: true,
|
|
56
|
-
bind: props.allowKey,
|
|
57
|
-
// Only real, escalated requests can be answered — during review no
|
|
58
|
-
// server-side permission request exists yet.
|
|
59
|
-
enabled: () => props.pending()?.escalated === true,
|
|
60
|
-
run: () => {
|
|
61
|
-
void props.answer("once");
|
|
62
|
-
},
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
id: "auto-mode.deny",
|
|
66
|
-
title: "Deny pending permission",
|
|
67
|
-
group: "Auto-mode",
|
|
68
|
-
palette: true,
|
|
69
|
-
bind: props.denyKey,
|
|
70
|
-
enabled: () => props.pending()?.escalated === true,
|
|
71
|
-
run: () => {
|
|
72
|
-
void props.answer("reject");
|
|
73
|
-
},
|
|
74
|
-
},
|
|
75
|
-
],
|
|
76
|
-
}));
|
|
77
|
-
|
|
78
|
-
const thinking = createMemo(() => {
|
|
79
|
-
const p = props.pending();
|
|
80
|
-
return p !== undefined && !p.escalated;
|
|
81
|
-
});
|
|
82
|
-
const label = createMemo(() => (thinking() ? "reviewing" : "needs your approval"));
|
|
83
|
-
|
|
84
|
-
// The glyph comes from opentui's intrinsic <spinner>, not our own timer:
|
|
85
|
-
// npm installs can split solid-js into two instances (the plugin's exact
|
|
86
|
-
// pin vs @opentui/solid's peer pin), which freezes timer-driven signal
|
|
87
|
-
// renders. The spinner element animates on its own renderable, so the
|
|
88
|
-
// badge works regardless of how dependencies resolve.
|
|
89
|
-
return (
|
|
90
|
-
<box flexDirection="row" gap={1}>
|
|
91
|
-
<spinner frames={SPINNER_FRAMES} interval={120} color={theme.text.feedback.warning.base} />
|
|
92
|
-
<text fg={theme.text.feedback.warning.base}>auto-mode {label()}</text>
|
|
93
|
-
</box>
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
8
|
|
|
97
9
|
export default Plugin.define({
|
|
98
10
|
id: "opencode-auto-mode",
|
|
99
11
|
setup: async (ctx) => {
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// Own pending list, fed by events. Keeping it locally (instead of reading
|
|
106
|
-
// the host's session store) makes the badge independent of host store
|
|
107
|
-
// behavior across betas.
|
|
108
|
-
const [pending, setPending] = createSignal<Pending[]>([]);
|
|
109
|
-
const firstSeen = new Map<string, number>();
|
|
110
|
-
|
|
111
|
-
const addPending = (data: {
|
|
112
|
-
id: string;
|
|
113
|
-
sessionID: string;
|
|
114
|
-
action: string;
|
|
115
|
-
resources: readonly string[];
|
|
116
|
-
}) => {
|
|
117
|
-
if (pending().some((p) => p.requestID === data.id)) return;
|
|
118
|
-
// An asked event means the server plugin's review already finished with
|
|
119
|
-
// ASK. Adopt a matching in-review entry so its spinner duration carries
|
|
120
|
-
// over instead of restarting the badge from scratch.
|
|
121
|
-
const inReview = pending().find(
|
|
122
|
-
(p) => !p.escalated && p.sessionID === data.sessionID && p.action === data.action,
|
|
123
|
-
);
|
|
124
|
-
if (inReview) {
|
|
125
|
-
firstSeen.delete(inReview.requestID);
|
|
126
|
-
setPending((prev) =>
|
|
127
|
-
prev.map((p) =>
|
|
128
|
-
p === inReview ? { ...p, requestID: data.id, escalated: true } : p,
|
|
129
|
-
),
|
|
130
|
-
);
|
|
131
|
-
firstSeen.set(data.id, inReview.at);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
const at = firstSeen.get(data.id) ?? Date.now();
|
|
135
|
-
firstSeen.set(data.id, at);
|
|
136
|
-
setPending((prev) => [
|
|
137
|
-
...prev,
|
|
138
|
-
{
|
|
139
|
-
requestID: data.id,
|
|
140
|
-
sessionID: data.sessionID,
|
|
141
|
-
action: data.action,
|
|
142
|
-
resource: truncate(data.resources.join(" ") || data.action, MAX_RESOURCE),
|
|
143
|
-
at,
|
|
144
|
-
escalated: true,
|
|
145
|
-
},
|
|
146
|
-
]);
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const removePending = (requestID: string) => {
|
|
150
|
-
firstSeen.delete(requestID);
|
|
151
|
-
setPending((prev) => prev.filter((p) => p.requestID !== requestID));
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
// Primary channel: the host data bus. Some host versions forward only a
|
|
155
|
-
// subset of event types, so the raw server stream below is the fallback;
|
|
156
|
-
// handlers dedupe by request ID.
|
|
157
|
-
const unsubs = [
|
|
158
|
-
ctx.data.on("permission.asked", (event) => addPending(event.data)),
|
|
159
|
-
ctx.data.on("permission.replied", (event) => removePending(event.data.requestID)),
|
|
160
|
-
];
|
|
161
|
-
|
|
162
|
-
// Fallback channel: the raw server event stream, with reconnection.
|
|
163
|
-
const abort = new AbortController();
|
|
164
|
-
const runStream = async () => {
|
|
165
|
-
while (!abort.signal.aborted) {
|
|
166
|
-
try {
|
|
167
|
-
for await (const event of ctx.client.event.subscribe({ signal: abort.signal })) {
|
|
168
|
-
if (event.type === "permission.asked") addPending(event.data);
|
|
169
|
-
else if (event.type === "permission.replied") removePending(event.data.requestID);
|
|
170
|
-
}
|
|
171
|
-
} catch {
|
|
172
|
-
// fall through to reconnect
|
|
173
|
-
}
|
|
174
|
-
if (abort.signal.aborted) return;
|
|
175
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
176
|
-
}
|
|
12
|
+
if (ctx.options.tui === false) return;
|
|
13
|
+
const [reviewing, setReviewing] = createSignal(false);
|
|
14
|
+
const update = () => {
|
|
15
|
+
const route = ctx.ui.router.current();
|
|
16
|
+
setReviewing(isReviewing(readDecisions(), route.type === "session" ? route.sessionID : undefined));
|
|
177
17
|
};
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// - "reviewing" records create spinner entries (they precede any event,
|
|
182
|
-
// since the evaluate hook runs before the request is published)
|
|
183
|
-
// - "ask" escalates the entry, "allow"/"deny" resolves it
|
|
184
|
-
// - entries that outlived every record and the review window are pruned
|
|
185
|
-
const poll = setInterval(() => {
|
|
186
|
-
snapshotView();
|
|
187
|
-
const decisions = readDecisions();
|
|
188
|
-
const now = Date.now();
|
|
189
|
-
let next = pending();
|
|
190
|
-
let dirty = false;
|
|
191
|
-
|
|
192
|
-
for (const [reviewID, rec] of Object.entries(decisions)) {
|
|
193
|
-
if (rec.decision !== "reviewing") continue;
|
|
194
|
-
if (next.some((p) => p.requestID === reviewID)) continue;
|
|
195
|
-
if (now - rec.at >= STALE_MS) continue;
|
|
196
|
-
firstSeen.set(reviewID, rec.at);
|
|
197
|
-
next = [
|
|
198
|
-
...next,
|
|
199
|
-
{
|
|
200
|
-
requestID: reviewID,
|
|
201
|
-
sessionID: rec.sessionID,
|
|
202
|
-
action: rec.action ?? "",
|
|
203
|
-
resource: truncate(
|
|
204
|
-
(rec.resources ?? []).join(" ") || rec.action || "",
|
|
205
|
-
MAX_RESOURCE,
|
|
206
|
-
),
|
|
207
|
-
at: rec.at,
|
|
208
|
-
escalated: false,
|
|
209
|
-
},
|
|
210
|
-
];
|
|
211
|
-
dirty = true;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const reconciled: Pending[] = [];
|
|
215
|
-
for (const p of next) {
|
|
216
|
-
const record = decisions[p.requestID];
|
|
217
|
-
if (record?.decision === "allow" || record?.decision === "deny") {
|
|
218
|
-
firstSeen.delete(p.requestID);
|
|
219
|
-
dirty = true;
|
|
220
|
-
continue;
|
|
221
|
-
}
|
|
222
|
-
if (record?.decision === "ask" && !p.escalated) {
|
|
223
|
-
reconciled.push({ ...p, escalated: true });
|
|
224
|
-
dirty = true;
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
if (!record && now - p.at >= STALE_MS) {
|
|
228
|
-
firstSeen.delete(p.requestID);
|
|
229
|
-
dirty = true;
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
232
|
-
reconciled.push(p);
|
|
233
|
-
}
|
|
234
|
-
if (dirty) setPending(reconciled);
|
|
235
|
-
}, POLL_MS);
|
|
236
|
-
|
|
237
|
-
// View scoping: decision records live in a machine-global file (and on
|
|
238
|
-
// shared servers permission events are global too), so without filtering
|
|
239
|
-
// every open TUI would badge other TUIs' reviews. Router + tabs state is
|
|
240
|
-
// client-local — a foreign session is never the current route nor an open
|
|
241
|
-
// tab here — which discriminates both separate-server and shared-server
|
|
242
|
-
// setups. Snapshotted on the existing poll tick (1s granularity is fine
|
|
243
|
-
// for a status badge) instead of relying on host reactivity.
|
|
244
|
-
const [visibleIDs, setVisibleIDs] = createSignal<ReadonlySet<string>>(new Set());
|
|
245
|
-
const snapshotView = () => {
|
|
246
|
-
const ids = new Set<string>();
|
|
247
|
-
try {
|
|
248
|
-
if (ctx.ui.tabs.enabled()) {
|
|
249
|
-
for (const tab of ctx.ui.tabs.list()) ids.add(tab.sessionID);
|
|
250
|
-
}
|
|
251
|
-
} catch {
|
|
252
|
-
// tabs unavailable — route only
|
|
253
|
-
}
|
|
254
|
-
try {
|
|
255
|
-
const route = ctx.ui.router.current();
|
|
256
|
-
if (route.type === "session") ids.add(route.sessionID);
|
|
257
|
-
} catch {
|
|
258
|
-
// router unavailable — tabs only
|
|
259
|
-
}
|
|
260
|
-
const key = [...ids].sort().join(",");
|
|
261
|
-
const prev = [...visibleIDs()].sort().join(",");
|
|
262
|
-
if (key !== prev) setVisibleIDs(ids);
|
|
263
|
-
};
|
|
264
|
-
snapshotView();
|
|
265
|
-
|
|
266
|
-
const mostRecent = createMemo(() => {
|
|
267
|
-
const visible = visibleIDs();
|
|
268
|
-
const requests = pending().filter((p) => visible.has(p.sessionID));
|
|
269
|
-
return requests.length > 0 ? requests[requests.length - 1] : undefined;
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
const answer = async (reply: "once" | "reject") => {
|
|
273
|
-
const pendingRequest = mostRecent();
|
|
274
|
-
if (!pendingRequest) return;
|
|
275
|
-
try {
|
|
276
|
-
await ctx.client.permission.reply({
|
|
277
|
-
sessionID: pendingRequest.sessionID,
|
|
278
|
-
requestID: pendingRequest.requestID,
|
|
279
|
-
decision: reply,
|
|
280
|
-
});
|
|
281
|
-
} catch (err) {
|
|
282
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
283
|
-
ctx.ui.toast.show({
|
|
284
|
-
variant: "error",
|
|
285
|
-
title: "Auto-mode",
|
|
286
|
-
message: `Failed to reply: ${message}`,
|
|
287
|
-
duration: 4000,
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
// Top-right overlay, above the permission dialog (which covers the bottom
|
|
293
|
-
// bar). Only visible while this TUI has a session with a pending review:
|
|
294
|
-
// spinner while reviewing, ⚠ when it needs the user.
|
|
295
|
-
const disposeBadge = ctx.ui.slot({
|
|
18
|
+
update();
|
|
19
|
+
const poll = setInterval(update, 250);
|
|
20
|
+
const dispose = ctx.ui.slot({
|
|
296
21
|
append: "app",
|
|
297
22
|
render: () => (
|
|
298
|
-
<Show when={
|
|
299
|
-
<box position="absolute" top={0} right={0} zIndex={10_000}>
|
|
300
|
-
<
|
|
301
|
-
|
|
302
|
-
answer={answer}
|
|
303
|
-
allowKey={allowKey}
|
|
304
|
-
denyKey={denyKey}
|
|
305
|
-
keymap={ctx.keymap}
|
|
306
|
-
theme={() => ctx.theme}
|
|
307
|
-
/>
|
|
23
|
+
<Show when={reviewing()}>
|
|
24
|
+
<box position="absolute" top={0} right={0} zIndex={10_000} flexDirection="row" gap={1}>
|
|
25
|
+
<spinner frames={SPINNER_FRAMES} interval={120} color={ctx.theme.text.feedback.warning.base} />
|
|
26
|
+
<text fg={ctx.theme.text.feedback.warning.base}>auto-mode reviewing</text>
|
|
308
27
|
</box>
|
|
309
28
|
</Show>
|
|
310
29
|
),
|
|
311
30
|
});
|
|
312
|
-
|
|
313
|
-
return () => {
|
|
314
|
-
for (const unsub of unsubs) unsub();
|
|
315
|
-
abort.abort();
|
|
316
|
-
clearInterval(poll);
|
|
317
|
-
disposeBadge();
|
|
318
|
-
};
|
|
31
|
+
return () => { clearInterval(poll); dispose(); };
|
|
319
32
|
},
|
|
320
33
|
});
|
package/src/providers.ts
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { object, localEndpoint, type ClassifierStage } from "./config.js";
|
|
2
|
-
import { buildReviewPrompt, type PermissionRequest, type ReviewResult, type Decision } from "./reviewer.js";
|
|
3
|
-
import type { ReviewPolicy } from "./policy.js";
|
|
4
|
-
|
|
5
|
-
export type ProviderStage = Extract<ClassifierStage, { type: "jev" | "openai-decision" | "laya" }>;
|
|
6
|
-
const choices = {
|
|
7
|
-
allow: "The whole action is safe and authorized by the user for this exact scope.",
|
|
8
|
-
deny: "The action violates the supplied policy or attempts to bypass a block.",
|
|
9
|
-
ask: "Authorization, evidence, or impact is uncertain; the human must decide.",
|
|
10
|
-
};
|
|
11
|
-
const MAX_BYTES = 65_536;
|
|
12
|
-
|
|
13
|
-
function probability(value: unknown): value is number {
|
|
14
|
-
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function answer(value: unknown, stage: ProviderStage): ReviewResult | undefined {
|
|
18
|
-
if (!object(value) || value.type !== "choice" || typeof value.choice !== "string" || !Object.hasOwn(choices, value.choice) ||
|
|
19
|
-
!probability(value.confidence) || !object(value.probabilities))
|
|
20
|
-
throw new Error("Invalid decision response");
|
|
21
|
-
const entries = Object.entries(value.probabilities);
|
|
22
|
-
if (entries.length !== 3 || entries.some(([key, value]) => !Object.hasOwn(choices, key) || !probability(value)))
|
|
23
|
-
throw new Error("Invalid decision probabilities");
|
|
24
|
-
const probabilities = value.probabilities as Record<Decision, number>;
|
|
25
|
-
if (Math.abs(Object.values(probabilities).reduce((a, b) => a + b, 0) - 1) > 0.01 ||
|
|
26
|
-
probabilities[value.choice as Decision] < Math.max(...Object.values(probabilities)))
|
|
27
|
-
throw new Error("Inconsistent decision probabilities");
|
|
28
|
-
if (value.choice === "ask") return { decision: "ask", reason: `${stage.type} requested human review` };
|
|
29
|
-
if (value.confidence < (stage.minConfidence ?? 0.9) || probabilities[value.choice as Decision] < (stage.minConfidence ?? 0.9))
|
|
30
|
-
return undefined;
|
|
31
|
-
return { decision: value.choice as Decision, reason: `${stage.type} decision (confidence ${value.confidence})` };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function readJSON(response: Response): Promise<unknown> {
|
|
35
|
-
if (!response.ok || !response.body) throw new Error("Decision provider failed");
|
|
36
|
-
const reader = response.body.getReader();
|
|
37
|
-
const chunks: Uint8Array[] = [];
|
|
38
|
-
let size = 0;
|
|
39
|
-
try {
|
|
40
|
-
while (true) {
|
|
41
|
-
const { done, value } = await reader.read();
|
|
42
|
-
if (done) break;
|
|
43
|
-
size += value.length;
|
|
44
|
-
if (size > MAX_BYTES) throw new Error("Decision response too large");
|
|
45
|
-
chunks.push(value);
|
|
46
|
-
}
|
|
47
|
-
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
48
|
-
} finally {
|
|
49
|
-
await reader.cancel().catch(() => {});
|
|
50
|
-
reader.releaseLock();
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export async function providerDecision(
|
|
55
|
-
stage: ProviderStage,
|
|
56
|
-
request: PermissionRequest,
|
|
57
|
-
policy: ReviewPolicy,
|
|
58
|
-
fetcher: typeof fetch = fetch,
|
|
59
|
-
): Promise<ReviewResult | undefined> {
|
|
60
|
-
try {
|
|
61
|
-
const openai = stage.type === "openai-decision";
|
|
62
|
-
const endpoint = stage.type === "laya" ? localEndpoint(stage.endpoint ?? "http://127.0.0.1:8000/v1/systemone") :
|
|
63
|
-
openai ? "https://api.openai.com/v1/decisions" : "https://api.typesafe.ai/v1/systemone";
|
|
64
|
-
const key = process.env[stage.apiKeyEnv ?? (openai ? "OPENAI_API_KEY" : stage.type === "jev" ? "TYPESAFE_API_KEY" : "LAYA_API_KEY")];
|
|
65
|
-
if (stage.type !== "laya" && !key) throw new Error("Missing decision provider credentials");
|
|
66
|
-
const instructions = buildReviewPrompt(request, policy);
|
|
67
|
-
const body = openai ? {
|
|
68
|
-
model: stage.model ?? "gpt-6-luna",
|
|
69
|
-
input: JSON.stringify(request),
|
|
70
|
-
questions: [{ name: "permission", type: "choice", instructions,
|
|
71
|
-
choices: Object.entries(choices).map(([value, description]) => ({ value, description })) }],
|
|
72
|
-
} : {
|
|
73
|
-
...(stage.type === "jev" ? { model: stage.model ?? "jev-latest" } : stage.model ? { model: stage.model } : {}),
|
|
74
|
-
state: request,
|
|
75
|
-
questions: { permission: { type: "choice", instructions, criteria: choices } },
|
|
76
|
-
};
|
|
77
|
-
const payload = JSON.stringify(body);
|
|
78
|
-
if (Buffer.byteLength(payload) > MAX_BYTES) throw new Error("Decision request too large");
|
|
79
|
-
const response = await fetcher(endpoint, {
|
|
80
|
-
method: "POST", redirect: "error", signal: AbortSignal.timeout(stage.timeoutMs ?? 10_000),
|
|
81
|
-
headers: { "Content-Type": "application/json", ...(key ? { Authorization: `Bearer ${key}` } : {}) },
|
|
82
|
-
body: payload,
|
|
83
|
-
});
|
|
84
|
-
const parsed = await readJSON(response);
|
|
85
|
-
if (!object(parsed)) throw new Error("Invalid decision response");
|
|
86
|
-
if (!openai) return answer(object(parsed.answers) ? parsed.answers.permission : undefined, stage);
|
|
87
|
-
if (!Array.isArray(parsed.answers) || parsed.answers.length !== 1 || !object(parsed.answers[0]) ||
|
|
88
|
-
parsed.answers[0].name !== "permission") throw new Error("Invalid OpenAI answer");
|
|
89
|
-
const selected = parsed.answers[0];
|
|
90
|
-
if (selected.type === "refusal") return { decision: "ask", reason: "OpenAI declined the decision" };
|
|
91
|
-
if (!Array.isArray(selected.probabilities) || selected.probabilities.length !== 3)
|
|
92
|
-
throw new Error("Invalid OpenAI probabilities");
|
|
93
|
-
const distribution: Record<string, unknown> = {};
|
|
94
|
-
for (const entry of selected.probabilities) {
|
|
95
|
-
if (!object(entry) || typeof entry.value !== "string" || !Object.hasOwn(choices, entry.value) ||
|
|
96
|
-
Object.hasOwn(distribution, entry.value)) throw new Error("Invalid OpenAI probability entry");
|
|
97
|
-
distribution[entry.value] = entry.probability;
|
|
98
|
-
}
|
|
99
|
-
return answer({ ...selected, probabilities: distribution }, stage);
|
|
100
|
-
} catch {
|
|
101
|
-
return { decision: "ask", reason: `${stage.type} unavailable or returned an invalid decision` };
|
|
102
|
-
}
|
|
103
|
-
}
|
package/src/rules-worker.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { parentPort, workerData } from "node:worker_threads";
|
|
2
|
-
import { compileExpression, type RuleBindings } from "./rules.js";
|
|
3
|
-
import type { UserRule } from "./config.js";
|
|
4
|
-
import { shellWords } from "./shell.js";
|
|
5
|
-
|
|
6
|
-
const { rules, resources, bindings, hasResources } = workerData as {
|
|
7
|
-
rules: UserRule[]; resources: string[]; bindings: RuleBindings; hasResources: boolean;
|
|
8
|
-
};
|
|
9
|
-
function evaluate() {
|
|
10
|
-
const results = rules.map(() => ({ any: false, coverage: hasResources ? resources.map(() => false) : [] }));
|
|
11
|
-
for (const decision of ["deny", "ask", "allow"]) {
|
|
12
|
-
let failed = false;
|
|
13
|
-
for (const [index, rule] of rules.entries()) {
|
|
14
|
-
if (rule.decision !== decision) continue;
|
|
15
|
-
try {
|
|
16
|
-
const predicate = compileExpression(rule.expression);
|
|
17
|
-
const matches = resources.map(() => false);
|
|
18
|
-
for (const [resourceIndex, resource] of resources.entries()) {
|
|
19
|
-
try {
|
|
20
|
-
const words = bindings.action === "shell" ? shellWords(resource) ?? [] : [];
|
|
21
|
-
const result = predicate({ ...bindings, resource, command: words[0] ?? "", args: words.slice(1) });
|
|
22
|
-
if (typeof result !== "boolean") throw new Error("CEL rules must return a boolean");
|
|
23
|
-
matches[resourceIndex] = result;
|
|
24
|
-
if (result && decision !== "allow") {
|
|
25
|
-
results[index] = { any: true, coverage: hasResources ? matches : [] };
|
|
26
|
-
return results;
|
|
27
|
-
}
|
|
28
|
-
} catch { failed = true; }
|
|
29
|
-
}
|
|
30
|
-
results[index] = { any: matches.some(Boolean), coverage: hasResources ? matches : [] };
|
|
31
|
-
} catch { failed = true; }
|
|
32
|
-
}
|
|
33
|
-
if (failed) return null;
|
|
34
|
-
}
|
|
35
|
-
return results;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
parentPort!.postMessage(evaluate());
|
package/src/rules.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { celEnv, CelScalar, listType, parse, plan } from "@bufbuild/cel";
|
|
2
|
-
import { Worker } from "node:worker_threads";
|
|
3
|
-
import type { UserRule } from "./config.js";
|
|
4
|
-
import type { PermissionRequest } from "./reviewer.js";
|
|
5
|
-
|
|
6
|
-
const environment = celEnv({ variables: {
|
|
7
|
-
action: CelScalar.STRING, resource: CelScalar.STRING, agent: CelScalar.STRING,
|
|
8
|
-
projectDir: CelScalar.STRING, isDelegation: CelScalar.BOOL,
|
|
9
|
-
command: CelScalar.STRING, args: listType(CelScalar.STRING),
|
|
10
|
-
} });
|
|
11
|
-
let activeWorkers = 0;
|
|
12
|
-
|
|
13
|
-
export function compileExpression(expression: string) {
|
|
14
|
-
if (typeof expression !== "string" || !expression.trim() || expression.length > 4096)
|
|
15
|
-
throw new Error("Invalid CEL expression");
|
|
16
|
-
const parsed = parse(expression);
|
|
17
|
-
type Expr = NonNullable<typeof parsed.expr>;
|
|
18
|
-
const pending: { expr: Expr; depth: number }[] = [];
|
|
19
|
-
if (parsed.expr) pending.push({ expr: parsed.expr, depth: 1 });
|
|
20
|
-
let count = 0;
|
|
21
|
-
while (pending.length) {
|
|
22
|
-
const { expr, depth } = pending.pop()!;
|
|
23
|
-
if (++count > 512 || depth > 32) throw new Error("CEL expression is too complex");
|
|
24
|
-
const kind = expr.exprKind;
|
|
25
|
-
const children: Expr[] = [];
|
|
26
|
-
if (kind.case === "comprehensionExpr") throw new Error("CEL collection macros are disabled");
|
|
27
|
-
if (kind.case === "callExpr") {
|
|
28
|
-
children.push(...kind.value.args);
|
|
29
|
-
if (kind.value.target) children.push(kind.value.target);
|
|
30
|
-
} else if (kind.case === "selectExpr" && kind.value.operand) children.push(kind.value.operand);
|
|
31
|
-
else if (kind.case === "listExpr") children.push(...kind.value.elements);
|
|
32
|
-
else if (kind.case === "structExpr") {
|
|
33
|
-
for (const entry of kind.value.entries) {
|
|
34
|
-
if (entry.keyKind.case === "mapKey") children.push(entry.keyKind.value);
|
|
35
|
-
if (entry.value) children.push(entry.value);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
for (const child of children) pending.push({ expr: child, depth: depth + 1 });
|
|
39
|
-
}
|
|
40
|
-
return plan(environment, parsed);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export type RuleMatch = { rule: UserRule; any: boolean; coverage: boolean[] };
|
|
44
|
-
export type RuleBindings = { action: string; agent: string; projectDir: string; isDelegation: boolean };
|
|
45
|
-
|
|
46
|
-
export async function evaluateRules(rules: UserRule[], request: PermissionRequest): Promise<RuleMatch[]> {
|
|
47
|
-
if (!rules.length) return [];
|
|
48
|
-
const resources = request.resources.length ? request.resources : [""];
|
|
49
|
-
const bindings: RuleBindings = { action: request.action, agent: request.agent ?? "",
|
|
50
|
-
projectDir: request.projectDir ?? "", isDelegation: request.isDelegation === true };
|
|
51
|
-
const strings = [...resources, bindings.action, bindings.agent, bindings.projectDir];
|
|
52
|
-
if (resources.length > 256 || rules.length * resources.length > 4096 ||
|
|
53
|
-
strings.some((value) => typeof value !== "string" || value.length > 4096) ||
|
|
54
|
-
strings.reduce((sum, value) => sum + Buffer.byteLength(value), 0) > 65_536)
|
|
55
|
-
throw new Error("CEL request exceeds evaluation limits");
|
|
56
|
-
if (activeWorkers >= 4) throw new Error("CEL evaluator is busy");
|
|
57
|
-
const worker = new Worker(new URL("./rules-worker.ts", import.meta.url), {
|
|
58
|
-
workerData: { rules, resources, bindings, hasResources: request.resources.length > 0 },
|
|
59
|
-
});
|
|
60
|
-
activeWorkers++;
|
|
61
|
-
try {
|
|
62
|
-
const matches = await new Promise<{ any: boolean; coverage: boolean[] }[]>((resolve, reject) => {
|
|
63
|
-
const timer = setTimeout(() => reject(new Error("CEL evaluation timed out")), 1000);
|
|
64
|
-
const fail = () => { clearTimeout(timer); reject(new Error("CEL evaluation failed")); };
|
|
65
|
-
worker.once("error", fail);
|
|
66
|
-
worker.once("exit", fail);
|
|
67
|
-
worker.once("message", (value: unknown) => {
|
|
68
|
-
clearTimeout(timer);
|
|
69
|
-
if (!Array.isArray(value) || value.length !== rules.length || value.some((match) =>
|
|
70
|
-
!match || typeof match.any !== "boolean" || !Array.isArray(match.coverage) ||
|
|
71
|
-
match.coverage.length !== request.resources.length || match.coverage.some((item: unknown) => typeof item !== "boolean"))) { fail(); return; }
|
|
72
|
-
resolve(value);
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
return matches.map((match, index) => ({ ...match, rule: rules[index] }));
|
|
76
|
-
} finally {
|
|
77
|
-
try { await worker.terminate(); } finally { activeWorkers--; }
|
|
78
|
-
}
|
|
79
|
-
}
|