@mortz.prk/opencode-auto-mode 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -10
- package/dist/gui-experimental.cjs +1 -0
- package/package.json +23 -13
- package/src/audit.ts +24 -7
- package/src/classifier.ts +13 -2
- package/src/config.ts +10 -1
- package/src/decisions.ts +38 -15
- package/src/errors.ts +39 -0
- package/src/gui-state.ts +160 -0
- package/src/gui.tsx +129 -0
- package/src/index.ts +367 -97
- package/src/otlp.ts +18 -15
- package/src/reviewer.ts +11 -1
- package/src/rpc.ts +117 -0
- package/src/shell-policy.ts +1337 -0
- package/src/shell.ts +272 -209
- package/src/status.ts +72 -0
- package/src/tiers.ts +2 -22
- package/src/timeout.ts +30 -0
- package/src/traces.ts +13 -7
- package/src/transcript.ts +17 -21
- package/src/tui-state.ts +7 -1
package/src/tiers.ts
CHANGED
|
@@ -1,22 +1,11 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import { lstatSync, realpathSync } from "node:fs";
|
|
3
|
+
import { classifyShell } from "./shell.js";
|
|
3
4
|
|
|
4
5
|
export type Tier = "auto-allow" | "auto-deny" | "review" | "unknown";
|
|
5
6
|
export interface Classification { tier: Tier; reason: string }
|
|
6
7
|
export interface ClassifyOptions { projectDir?: string; allowInProjectEdits?: boolean }
|
|
7
8
|
|
|
8
|
-
const READ_ONLY_COMMANDS = new Set(["pwd", "whoami", "hostname", "uname", "id", "groups", "ls", "ls -a", "ls -l", "ls -la", "ls -al", "ls -A",
|
|
9
|
-
"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"]);
|
|
11
|
-
const BLOCKED_SHELL = [
|
|
12
|
-
/\brm\s+-rf?\s+\/(\s|$|\*)/,
|
|
13
|
-
/\bsudo\b/,
|
|
14
|
-
/\bmkfs\./,
|
|
15
|
-
/\bdd\s+if=.*\s+of=\/(dev|sd|nvme|hd)/,
|
|
16
|
-
/\b(shutdown|reboot|halt|poweroff)\b/,
|
|
17
|
-
/:\(\)\s*\{\s*:\s*\|\s*:&\s*\};:/,
|
|
18
|
-
/\bchmod\s+777\b/,
|
|
19
|
-
];
|
|
20
9
|
const SENSITIVE_PATH = /(?:^|[/\\])(?:\.[^/\\]+|opencode\.jsonc?|id_(?:rsa|dsa|ecdsa|ed25519)|[^/\\]+\.(?:pem|key|p12|pfx)|[^/\\]*(?:credentials|token|secret|password|private.key)[^/\\]*)(?:[/\\]|$)/i;
|
|
21
10
|
|
|
22
11
|
export function isInProject(target: string, projectDir: string): boolean {
|
|
@@ -46,16 +35,7 @@ function safeTarget(target: string, projectDir: string): boolean {
|
|
|
46
35
|
}
|
|
47
36
|
|
|
48
37
|
export function classify(action: string, resources: readonly string[], options?: ClassifyOptions): Classification {
|
|
49
|
-
if (action === "shell")
|
|
50
|
-
for (const command of resources) {
|
|
51
|
-
for (const pattern of BLOCKED_SHELL) {
|
|
52
|
-
if (pattern.test(command)) return { tier: "auto-deny", reason: "dangerous shell operation" };
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
if (resources.length > 0 && resources.every((command) => READ_ONLY_COMMANDS.has(command)))
|
|
56
|
-
return { tier: "auto-allow", reason: "read-only command" };
|
|
57
|
-
return { tier: "review", reason: "evaluate the entire command as one action" };
|
|
58
|
-
}
|
|
38
|
+
if (action === "shell") return classifyShell(resources);
|
|
59
39
|
if (action === "read" || ((action === "edit" || action === "write") && options?.allowInProjectEdits)) {
|
|
60
40
|
if (options?.projectDir && resources.length > 0 && resources.every((target) =>
|
|
61
41
|
target.length > 0 && safeTarget(target, options.projectDir!)))
|
package/src/timeout.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ReviewFailure } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bounds a provider promise and rejects it as soon as `signal` aborts.
|
|
5
|
+
*
|
|
6
|
+
* A provider may ignore the signal, so this wrapper settles on its own and
|
|
7
|
+
* clears both its timer and abort listener on every path. Adding a listener to
|
|
8
|
+
* an already-aborted signal never replays the abort, so check `aborted` after
|
|
9
|
+
* subscribing or a cancelled fallback stage would wait for the full timeout.
|
|
10
|
+
*/
|
|
11
|
+
export function withTimeout<T>(promise: Promise<T>, ms: number, signal?: AbortSignal): Promise<T> {
|
|
12
|
+
return new Promise<T>((resolve, reject) => {
|
|
13
|
+
let settled = false;
|
|
14
|
+
const finish = (action: () => void) => {
|
|
15
|
+
if (settled) return;
|
|
16
|
+
settled = true;
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
signal?.removeEventListener("abort", onAbort);
|
|
19
|
+
action();
|
|
20
|
+
};
|
|
21
|
+
const onAbort = () => finish(() => reject(new ReviewFailure("cancelled", "Review cancelled")));
|
|
22
|
+
const timer = setTimeout(() => finish(() => reject(new ReviewFailure("timeout", "Review timed out"))), ms);
|
|
23
|
+
// Observe the provider promise before any early return: a promise that is
|
|
24
|
+
// already rejected must not become an unhandled rejection just because the
|
|
25
|
+
// signal was aborted before this wrapper was created.
|
|
26
|
+
promise.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
|
|
27
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
28
|
+
if (signal?.aborted) onAbort();
|
|
29
|
+
});
|
|
30
|
+
}
|
package/src/traces.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import type { AuditRecord } from "./audit.js";
|
|
3
|
+
import { sanitizeRecord } from "./audit.js";
|
|
3
4
|
import type { OtlpOptions } from "./otlp.js";
|
|
4
5
|
import type { StageResult } from "./reviewer.js";
|
|
5
6
|
|
|
@@ -55,20 +56,25 @@ export function createTraceExporter(setting: boolean | OtlpOptions | undefined,
|
|
|
55
56
|
return {
|
|
56
57
|
traceID, spanID, attempts,
|
|
57
58
|
finish(record) {
|
|
58
|
-
|
|
59
|
+
// Both log sinks sanitize before serialising; traces are a third sink
|
|
60
|
+
// and must not export unbounded record fields.
|
|
61
|
+
const safe = sanitizeRecord(record);
|
|
62
|
+
const shared = [attribute("review.id", safe.reviewID), attribute("session.id", safe.sessionID)];
|
|
59
63
|
const spans = [{
|
|
60
64
|
traceId: traceID, spanId: spanID, name: "auto_mode.review", kind: 1,
|
|
61
|
-
startTimeUnixNano: nano(startedAt), endTimeUnixNano: nano(startedAt +
|
|
62
|
-
attributes: [...shared, attribute("action",
|
|
63
|
-
attribute("decision",
|
|
64
|
-
|
|
65
|
+
startTimeUnixNano: nano(startedAt), endTimeUnixNano: nano(startedAt + safe.durationMs),
|
|
66
|
+
attributes: [...shared, attribute("action", safe.action), attribute("agent", safe.agent ?? ""),
|
|
67
|
+
attribute("decision", safe.decision), attribute("stage", safe.stage),
|
|
68
|
+
...(safe.errorKind === undefined ? [] : [attribute("error.kind", safe.errorKind)])],
|
|
69
|
+
status: { code: safe.error ? 2 : 1 },
|
|
65
70
|
}, ...attempts.map((attempt) => ({
|
|
66
71
|
traceId: traceID, spanId: randomBytes(8).toString("hex"), parentSpanId: spanID,
|
|
67
72
|
name: "auto_mode.classifier", kind: 1,
|
|
68
73
|
startTimeUnixNano: nano(attempt.startedAt), endTimeUnixNano: nano(attempt.startedAt + attempt.durationMs),
|
|
69
74
|
attributes: [...shared, attribute("classifier.index", attempt.index), attribute("classifier.type", attempt.type),
|
|
70
|
-
attribute("model", attempt.model ?? ""), attribute("decision", attempt.result.decision)
|
|
71
|
-
|
|
75
|
+
attribute("model", attempt.model ?? ""), attribute("decision", attempt.result.decision),
|
|
76
|
+
...(attempt.result.errorKind === undefined ? [] : [attribute("error.kind", attempt.result.errorKind)])],
|
|
77
|
+
status: { code: attempt.result.errorKind === undefined ? 1 : 2 },
|
|
72
78
|
}))];
|
|
73
79
|
const body = JSON.stringify({ resourceSpans: [{
|
|
74
80
|
resource: { attributes: [attribute("service.name", "opencode-auto-mode")] },
|
package/src/transcript.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const MAX_USERS = 10;
|
|
2
2
|
const MAX_TOOLS = 20;
|
|
3
3
|
const MAX_USER_CHARS = 8_000;
|
|
4
|
-
const
|
|
4
|
+
const MAX_ENTRY_BYTES = 500;
|
|
5
5
|
const MAX_TRANSCRIPT_BYTES = 12_000;
|
|
6
6
|
|
|
7
7
|
interface ToolEntry {
|
|
@@ -14,10 +14,6 @@ interface Entry {
|
|
|
14
14
|
text: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
function truncate(value: string, max: number): string {
|
|
18
|
-
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
17
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
22
18
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
23
19
|
return value as Record<string, unknown>;
|
|
@@ -56,8 +52,11 @@ function formatTool(tool: ToolEntry): { text: string; truncated: boolean } {
|
|
|
56
52
|
const input = state?.["input"] ?? tool.input;
|
|
57
53
|
const raw = typeof input === "string" ? input : JSON.stringify(input);
|
|
58
54
|
if (raw) {
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
// Keep the tail as well as the head: written payloads and patches often
|
|
56
|
+
// carry their constraints at the end. Clip once, on escaped bytes, so a
|
|
57
|
+
// later UTF-16 length check cannot cut the preserved tail back off.
|
|
58
|
+
detail = clipEscaped(raw, MAX_ENTRY_BYTES);
|
|
59
|
+
truncated = detail !== raw;
|
|
61
60
|
}
|
|
62
61
|
} catch {
|
|
63
62
|
detail = "";
|
|
@@ -101,9 +100,12 @@ function clipEscaped(value: string, maxBytes: number): string {
|
|
|
101
100
|
return `${prefixByEscapedBytes(characters, headBudget)}${marker}${suffixByEscapedBytes(characters, budget - headBudget)}`;
|
|
102
101
|
}
|
|
103
102
|
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
/** Clips text to `max` code points, keeping both ends for review evidence. */
|
|
104
|
+
export function clipChars(value: string, max: number): string {
|
|
106
105
|
const characters = Array.from(value);
|
|
106
|
+
// Compare code points, not UTF-16 units, so astral characters cannot make
|
|
107
|
+
// this return a longer string than it received.
|
|
108
|
+
if (characters.length <= max) return value;
|
|
107
109
|
const head = Math.floor(max * 0.6);
|
|
108
110
|
return `${characters.slice(0, head).join("")}…${characters.slice(characters.length - (max - head)).join("")}`;
|
|
109
111
|
}
|
|
@@ -111,7 +113,6 @@ function clipChars(value: string, max: number): string {
|
|
|
111
113
|
interface TranscriptData {
|
|
112
114
|
transcript: string;
|
|
113
115
|
userEvidence: boolean;
|
|
114
|
-
missingUserText: boolean;
|
|
115
116
|
notes: string[];
|
|
116
117
|
}
|
|
117
118
|
|
|
@@ -148,17 +149,15 @@ function renderTranscript(entries: Entry[], toolTruncated: boolean, userClipped:
|
|
|
148
149
|
function buildTranscript(messages: unknown[]): TranscriptData {
|
|
149
150
|
const entries: Entry[] = [];
|
|
150
151
|
let userEvidence = false;
|
|
151
|
-
let missingUserText = false;
|
|
152
152
|
let userClipped = false;
|
|
153
153
|
let toolTruncated = false;
|
|
154
154
|
for (const message of messages) {
|
|
155
155
|
const record = asRecord(message);
|
|
156
156
|
if (record?.type === "user") {
|
|
157
157
|
const text = userText(message);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
158
|
+
// An empty turn (attachments only, synthetic input) says nothing about
|
|
159
|
+
// authorization; it must not invalidate the rest of the session.
|
|
160
|
+
if (text === undefined) continue;
|
|
162
161
|
userEvidence = true;
|
|
163
162
|
const clipped = clipChars(text, MAX_USER_CHARS);
|
|
164
163
|
if (clipped !== text) userClipped = true;
|
|
@@ -171,7 +170,7 @@ function buildTranscript(messages: unknown[]): TranscriptData {
|
|
|
171
170
|
}
|
|
172
171
|
}
|
|
173
172
|
const rendered = renderTranscript(entries, toolTruncated, userClipped);
|
|
174
|
-
return { transcript: rendered.transcript, userEvidence,
|
|
173
|
+
return { transcript: rendered.transcript, userEvidence, notes: rendered.notes };
|
|
175
174
|
}
|
|
176
175
|
|
|
177
176
|
export interface ReviewContext { transcript: string; complete: boolean; reason?: string; notes?: string[] }
|
|
@@ -179,6 +178,8 @@ export interface ReviewContext { transcript: string; complete: boolean; reason?:
|
|
|
179
178
|
export function extractReviewContext(messages: unknown[]): ReviewContext {
|
|
180
179
|
const attachment = messages.some((message) => {
|
|
181
180
|
const record = asRecord(message);
|
|
181
|
+
// Skill messages carry model-facing instructions the text-only reviewer cannot see.
|
|
182
|
+
if (record?.type === "skill") return true;
|
|
182
183
|
if (record?.type !== "user") return false;
|
|
183
184
|
// These attachments carry evidence that the text-only reviewer cannot see.
|
|
184
185
|
return ["files", "skills", "agents"].some((key) =>
|
|
@@ -190,11 +191,6 @@ export function extractReviewContext(messages: unknown[]): ReviewContext {
|
|
|
190
191
|
transcript: data.transcript, complete: false, notes: data.notes,
|
|
191
192
|
reason: "User authorization includes attachments that cannot be reviewed automatically",
|
|
192
193
|
};
|
|
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
194
|
if (!data.userEvidence)
|
|
199
195
|
return {
|
|
200
196
|
transcript: data.transcript, complete: false, notes: data.notes,
|
package/src/tui-state.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { DecisionRecord } from "./state.js";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* How long a "reviewing" record keeps the TUI indicator alive. It must exceed
|
|
5
|
+
* the largest configurable review deadline so a slow review stays visible.
|
|
6
|
+
*/
|
|
7
|
+
export const REVIEW_DISPLAY_WINDOW_MS = 180_000;
|
|
8
|
+
|
|
3
9
|
export function isReviewing(records: Record<string, DecisionRecord>, sessionID: string | undefined, now = Date.now()): boolean {
|
|
4
10
|
if (!sessionID) return false;
|
|
5
11
|
return Object.values(records).some((record) => record.sessionID === sessionID &&
|
|
6
|
-
record.decision === "reviewing" && now - record.at >= 0 && now - record.at <
|
|
12
|
+
record.decision === "reviewing" && now - record.at >= 0 && now - record.at < REVIEW_DISPLAY_WINDOW_MS);
|
|
7
13
|
}
|