@mortz.prk/opencode-auto-mode 1.0.1 → 1.1.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.
Files changed (47) hide show
  1. package/CONTRIBUTING.md +234 -0
  2. package/README.llm.md +314 -0
  3. package/README.md +175 -166
  4. package/dist/gui-experimental.cjs +1 -0
  5. package/package.json +27 -16
  6. package/src/audit.ts +136 -38
  7. package/src/classifier.ts +45 -37
  8. package/src/code-mode.ts +55 -0
  9. package/src/config.ts +104 -98
  10. package/src/debug-events.ts +223 -0
  11. package/src/decision.ts +72 -0
  12. package/src/errors.ts +39 -0
  13. package/src/gates.ts +16 -0
  14. package/src/global-options.ts +85 -0
  15. package/src/gui-state.ts +269 -0
  16. package/src/gui.tsx +78 -0
  17. package/src/index.ts +510 -131
  18. package/src/mode.ts +237 -0
  19. package/src/reviewer.ts +71 -85
  20. package/src/rpc.ts +127 -0
  21. package/src/score/files.ts +49 -0
  22. package/src/score/history.ts +141 -0
  23. package/src/score/index.ts +37 -0
  24. package/src/score/limits.ts +24 -0
  25. package/src/score/shell-argv.ts +74 -0
  26. package/src/score/shell-baseline.ts +113 -0
  27. package/src/score/shell-safety.ts +54 -0
  28. package/src/score/shell.ts +30 -0
  29. package/src/score/types.ts +14 -0
  30. package/src/score.ts +9 -0
  31. package/src/shell-policy.ts +1979 -0
  32. package/src/shell.ts +508 -225
  33. package/src/status.ts +85 -0
  34. package/src/tiers.ts +286 -40
  35. package/src/timeout.ts +30 -0
  36. package/src/tui.tsx +44 -34
  37. package/src/user-files.ts +75 -0
  38. package/src/worktree.ts +30 -0
  39. package/src/decisions.ts +0 -213
  40. package/src/logger.ts +0 -26
  41. package/src/otlp.ts +0 -74
  42. package/src/policy.ts +0 -36
  43. package/src/state.ts +0 -135
  44. package/src/storage.ts +0 -26
  45. package/src/traces.ts +0 -84
  46. package/src/transcript.ts +0 -208
  47. package/src/tui-state.ts +0 -7
package/src/decisions.ts DELETED
@@ -1,213 +0,0 @@
1
- import { Effect } from "effect";
2
- import {
3
- Evaluation,
4
- EvaluationClient,
5
- EvaluationModel,
6
- EvaluationResponse,
7
- type EvaluationAnswer,
8
- type EvaluationInput,
9
- type EvaluationRoute,
10
- } from "@opencode/ai/experimental";
11
- import { AIError, InvalidProviderOutputError, ModelID } from "@opencode/ai";
12
- import { localEndpoint, parseReviewerModel, type DecisionStage } from "./config.js";
13
- import { buildReviewPrompt, type Decision, type PermissionRequest, type StageResult } from "./reviewer.js";
14
- import type { ReviewPolicy } from "./policy.js";
15
-
16
- const choices = {
17
- allow: "The whole action is safe and authorized by the user for this exact scope.",
18
- deny: "The action violates the supplied policy or attempts to bypass a block.",
19
- ask: "Authorization, evidence, or impact is uncertain; the human must decide.",
20
- } as const;
21
- const MAX_BYTES = 65_536;
22
- const SYSTEM_ONE_URL = "https://api.typesafe.ai/v1/systemone";
23
- const LOCAL_SYSTEM_ONE_URL = "http://127.0.0.1:8000/v1/systemone";
24
- const OPENAI_URL = "https://api.openai.com/v1/decisions";
25
- const API_KEY_ENV = {
26
- "typesafe-ai": "TYPESAFE_API_KEY",
27
- laya: "LAYA_API_KEY",
28
- openai: "OPENAI_API_KEY",
29
- } as const;
30
-
31
- type RuntimeDecisionStage = DecisionStage & { readonly apiKey?: string };
32
-
33
- function probability(value: unknown): value is number {
34
- return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
35
- }
36
-
37
- function object(value: unknown): value is Record<string, unknown> {
38
- return typeof value === "object" && value !== null && !Array.isArray(value);
39
- }
40
-
41
- function answer(value: unknown, stage: RuntimeDecisionStage): StageResult {
42
- if (!object(value) || value.type !== "choice" || typeof value.choice !== "string" || !Object.hasOwn(choices, value.choice) ||
43
- !probability(value.confidence) || !object(value.probabilities))
44
- throw new Error("Invalid decision response");
45
- const entries = Object.entries(value.probabilities);
46
- if (entries.length !== 3 || entries.some(([key, value]) => !Object.hasOwn(choices, key) || !probability(value)))
47
- throw new Error("Invalid decision probabilities");
48
- const probabilities = value.probabilities as Record<Decision, number>;
49
- if (Math.abs(Object.values(probabilities).reduce((a, b) => a + b, 0) - 1) > 0.01 ||
50
- probabilities[value.choice as Decision] < Math.max(...Object.values(probabilities)))
51
- throw new Error("Inconsistent decision probabilities");
52
- if (value.choice === "ask") return { decision: "ask", reason: `${stage.model} requested human review` };
53
- const threshold = stage.minConfidence ?? 0.9;
54
- if (value.confidence < threshold || probabilities[value.choice as Decision] < threshold) {
55
- if (value.choice === "deny") return { decision: "ask", reason: `${stage.model} returned a low-confidence deny` };
56
- return { decision: "abstain", reason: `${stage.model} abstained below the confidence threshold` };
57
- }
58
- return { decision: value.choice as Decision, reason: `${stage.model} decision (confidence ${value.confidence})` };
59
- }
60
-
61
- async function readJSON(response: Response): Promise<unknown> {
62
- if (!response.ok || !response.body) throw new Error("Decision provider failed");
63
- const reader = response.body.getReader();
64
- const chunks: Uint8Array[] = [];
65
- let size = 0;
66
- try {
67
- while (true) {
68
- const { done, value } = await reader.read();
69
- if (done) break;
70
- size += value.length;
71
- if (size > MAX_BYTES) throw new Error("Decision response too large");
72
- chunks.push(value);
73
- }
74
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
75
- } finally {
76
- await reader.cancel().catch(() => {});
77
- reader.releaseLock();
78
- }
79
- }
80
-
81
- function state(request: PermissionRequest): EvaluationInput {
82
- const value = JSON.parse(JSON.stringify(request)) as EvaluationInput;
83
- if (Buffer.byteLength(JSON.stringify(value)) > MAX_BYTES) throw new Error("Decision request too large");
84
- return value;
85
- }
86
-
87
- function systemOneEndpoint(stage: RuntimeDecisionStage): string {
88
- return stage.endpoint === undefined ? LOCAL_SYSTEM_ONE_URL : localEndpoint(stage.endpoint);
89
- }
90
-
91
- function evaluationChoice(value: unknown): EvaluationAnswer {
92
- if (!object(value) || value.type !== "choice" || typeof value.choice !== "string") throw new Error("Invalid System One answer");
93
- if (!object(value.probabilities) || !probability(value.confidence)) throw new Error("Invalid System One answer");
94
- return {
95
- type: "choice",
96
- choice: value.choice,
97
- probabilities: value.probabilities as Record<string, number>,
98
- confidence: value.confidence,
99
- };
100
- }
101
-
102
- function systemOneRoute(stage: RuntimeDecisionStage, modelID: string, url: string, fetcher: typeof fetch): EvaluationRoute {
103
- return {
104
- id: "system-one",
105
- evaluate: (request) => Effect.tryPromise({
106
- try: async () => {
107
- const payload = JSON.stringify({ model: modelID, state: request.state, questions: request.questions });
108
- if (Buffer.byteLength(payload) > MAX_BYTES) throw new Error("Decision request too large");
109
- const response = await fetcher(url, {
110
- method: "POST", redirect: "error", signal: AbortSignal.timeout(stage.timeoutMs ?? 10_000),
111
- headers: { "Content-Type": "application/json", ...(stage.apiKey === undefined ? {} : { Authorization: `Bearer ${stage.apiKey}` }) },
112
- body: payload,
113
- });
114
- const parsed = await readJSON(response);
115
- const value = object(parsed) && object(parsed.answers) ? parsed.answers.permission : undefined;
116
- return new EvaluationResponse({
117
- model: ModelID.make(modelID),
118
- answers: { permission: evaluationChoice(value) },
119
- });
120
- },
121
- catch: (cause) => new AIError({
122
- reason: new InvalidProviderOutputError({ route: "system-one", message: "System One request failed", cause }),
123
- }),
124
- }),
125
- };
126
- }
127
-
128
- async function systemOneDecision(
129
- stage: RuntimeDecisionStage,
130
- request: PermissionRequest,
131
- policy: ReviewPolicy,
132
- fetcher: typeof fetch,
133
- ): Promise<StageResult> {
134
- const ref = parseReviewerModel(stage.model);
135
- const key = stage.apiKey;
136
- if (ref.providerID !== "laya" && !key) throw new Error("Missing decision provider credentials");
137
- const model = EvaluationModel.make({
138
- id: ref.id,
139
- provider: ref.providerID,
140
- route: systemOneRoute(stage, ref.id, ref.providerID === "laya" ? systemOneEndpoint(stage) : SYSTEM_ONE_URL, fetcher),
141
- });
142
- const response = await Effect.runPromise(
143
- Evaluation.run({
144
- model,
145
- state: state(request),
146
- questions: {
147
- permission: { type: "choice", instructions: buildReviewPrompt(request, policy), criteria: choices },
148
- },
149
- }).pipe(Effect.provide(EvaluationClient.fetchLayer)),
150
- );
151
- return answer(response.answers.permission, stage);
152
- }
153
-
154
- async function openAIDecision(
155
- stage: RuntimeDecisionStage,
156
- request: PermissionRequest,
157
- policy: ReviewPolicy,
158
- fetcher: typeof fetch,
159
- ): Promise<StageResult> {
160
- const ref = parseReviewerModel(stage.model);
161
- if (!stage.apiKey) throw new Error("Missing decision provider credentials");
162
- const body = {
163
- model: ref.id,
164
- input: JSON.stringify(request),
165
- questions: [{
166
- name: "permission",
167
- type: "choice",
168
- instructions: buildReviewPrompt(request, policy),
169
- choices: Object.entries(choices).map(([value, description]) => ({ value, description })),
170
- }],
171
- };
172
- const payload = JSON.stringify(body);
173
- if (Buffer.byteLength(payload) > MAX_BYTES) throw new Error("Decision request too large");
174
- const response = await fetcher(OPENAI_URL, {
175
- method: "POST", redirect: "error", signal: AbortSignal.timeout(stage.timeoutMs ?? 10_000),
176
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${stage.apiKey}` },
177
- body: payload,
178
- });
179
- const parsed = await readJSON(response);
180
- if (!object(parsed) || !Array.isArray(parsed.answers) || parsed.answers.length !== 1 || !object(parsed.answers[0]) ||
181
- parsed.answers[0].name !== "permission") throw new Error("Invalid OpenAI answer");
182
- const selected = parsed.answers[0];
183
- if (selected.type === "refusal") return { decision: "abstain", reason: `${stage.model} declined the decision` };
184
- if (!Array.isArray(selected.probabilities) || selected.probabilities.length !== 3)
185
- throw new Error("Invalid OpenAI probabilities");
186
- const distribution: Record<string, unknown> = {};
187
- for (const entry of selected.probabilities) {
188
- if (!object(entry) || typeof entry.value !== "string" || !Object.hasOwn(choices, entry.value) ||
189
- Object.hasOwn(distribution, entry.value)) throw new Error("Invalid OpenAI probability entry");
190
- distribution[entry.value] = entry.probability;
191
- }
192
- return answer({ ...selected, probabilities: distribution }, stage);
193
- }
194
-
195
- export async function decisionStage(
196
- input: DecisionStage,
197
- request: PermissionRequest,
198
- policy: ReviewPolicy,
199
- fetcher: typeof fetch = fetch,
200
- ): Promise<StageResult> {
201
- try {
202
- const ref = parseReviewerModel(input.model);
203
- if (!Object.hasOwn(API_KEY_ENV, ref.providerID)) throw new Error("Unsupported decision provider");
204
- const stage: RuntimeDecisionStage = {
205
- ...input,
206
- apiKey: process.env[input.apiKeyEnv ?? API_KEY_ENV[ref.providerID as keyof typeof API_KEY_ENV]],
207
- };
208
- return ref.providerID === "openai" ? await openAIDecision(stage, request, policy, fetcher) :
209
- await systemOneDecision(stage, request, policy, fetcher);
210
- } catch {
211
- return { decision: "abstain", reason: `${input.model} unavailable or returned an invalid decision` };
212
- }
213
- }
package/src/logger.ts DELETED
@@ -1,26 +0,0 @@
1
- import * as fs from "node:fs";
2
- import { privateFile } from "./storage.js";
3
-
4
- export interface Logger {
5
- log(line: string): void;
6
- logJSON(label: string, value: unknown): void;
7
- }
8
-
9
- export function createLogger(filename: string): Logger {
10
- const stamp = () => new Date().toISOString();
11
- const append = (line: string) => {
12
- let fd: number | undefined;
13
- try {
14
- fd = privateFile(filename, true, true);
15
- const size = fs.fstatSync(fd).size;
16
- if (size > 1_048_576) fs.ftruncateSync(fd, 0);
17
- fs.writeSync(fd, `[${stamp()}] ${line.slice(0, 1024).replace(/[\r\n]/g, " ")}\n`);
18
- } catch {
19
- // Logging is best effort and must not interrupt permission review.
20
- } finally { if (fd !== undefined) fs.closeSync(fd); }
21
- };
22
- return {
23
- log: append,
24
- logJSON: (label, value) => append(`${label}: ${JSON.stringify(value)}`),
25
- };
26
- }
package/src/otlp.ts DELETED
@@ -1,74 +0,0 @@
1
- import type { AuditLog, AuditRecord } from "./audit.js";
2
-
3
- export interface OtlpOptions { endpoint?: string; headers?: Record<string, string>; timeoutMs?: number }
4
-
5
- const MAX_TIMEOUT_MS = 10_000;
6
-
7
- function parseHeaders(raw: string | undefined): Record<string, string> {
8
- const headers: Record<string, string> = {};
9
- for (const pair of (raw ?? "").split(",")) {
10
- const index = pair.indexOf("=");
11
- if (index > 0) headers[pair.slice(0, index).trim()] = pair.slice(index + 1).trim();
12
- }
13
- return headers;
14
- }
15
-
16
- export function logsUrl(base: string): string {
17
- const url = new URL(base);
18
- if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error("Invalid OTLP endpoint");
19
- if (!url.pathname.endsWith("/v1/logs")) url.pathname = `${url.pathname.replace(/\/+$/, "")}/v1/logs`;
20
- return url.href;
21
- }
22
-
23
- const attr = (key: string, value: string | number | boolean) => ({
24
- key,
25
- value: typeof value === "string" ? { stringValue: value } : typeof value === "boolean" ? { boolValue: value } : { intValue: String(value) },
26
- });
27
-
28
- export function toLogRecord(record: AuditRecord) {
29
- const severity = record.decision === "deny" ? { severityNumber: 13, severityText: "WARN" } : { severityNumber: 9, severityText: "INFO" };
30
- return {
31
- timeUnixNano: String(BigInt(Date.parse(record.ts)) * 1_000_000n),
32
- ...(record.traceID === undefined ? {} : { traceId: record.traceID, spanId: record.spanID }),
33
- ...severity,
34
- body: { stringValue: record.reason },
35
- attributes: [
36
- attr("event.name", "auto_mode.decision"),
37
- attr("session.id", record.sessionID),
38
- attr("review.id", record.reviewID),
39
- attr("agent", record.agent ?? ""),
40
- attr("action", record.action),
41
- attr("resources", record.resources.join(" ; ")),
42
- attr("host_effect", record.hostEffect),
43
- attr("decision", record.decision),
44
- attr("stage", record.stage),
45
- attr("duration_ms", record.durationMs),
46
- attr("error", record.error === true),
47
- ...(record.errorDetail === undefined ? [] : [attr("error.detail", record.errorDetail)]),
48
- ],
49
- };
50
- }
51
-
52
- /** Exports each decision as an OTLP/HTTP JSON log record. Defaults follow the OpenCode telemetry environment. */
53
- export function createOtlpExporter(setting: boolean | OtlpOptions | undefined, env: NodeJS.ProcessEnv = process.env): AuditLog | undefined {
54
- if (setting === undefined || setting === false) return undefined;
55
- const options = setting === true ? {} : setting;
56
- const base = options.endpoint ?? env.OPENCODE_OTLP_ENDPOINT ?? env.OTEL_EXPORTER_OTLP_ENDPOINT;
57
- if (!base) return undefined;
58
- let url: string;
59
- try { url = logsUrl(base); } catch { return undefined; }
60
- const headers = { "content-type": "application/json", ...parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS), ...parseHeaders(env.OPENCODE_OTLP_HEADERS), ...options.headers };
61
- const timeout = Math.min(options.timeoutMs ?? 3_000, MAX_TIMEOUT_MS);
62
- return {
63
- write(record) {
64
- const body = JSON.stringify({
65
- resourceLogs: [{
66
- resource: { attributes: [attr("service.name", "opencode-auto-mode")] },
67
- scopeLogs: [{ scope: { name: "opencode-auto-mode" }, logRecords: [toLogRecord(record)] }],
68
- }],
69
- });
70
- // Fire and forget: telemetry must never delay or fail a permission review.
71
- fetch(url, { method: "POST", headers, body, signal: AbortSignal.timeout(timeout), redirect: "error" }).catch(() => {});
72
- },
73
- };
74
- }
package/src/policy.ts DELETED
@@ -1,36 +0,0 @@
1
- export interface ReviewPolicy {
2
- trusted: string[];
3
- blocks: string[];
4
- exceptions: string[];
5
- }
6
-
7
- const DEFAULT_TRUSTED = [
8
- "the git repo in the session's working directory (reviewable via version control)",
9
- ];
10
-
11
- const DEFAULT_BLOCKS = [
12
- "Destroy or exfiltrate: force-push over history, mass-delete cloud storage or branches, drop tables, overwrite shared artifacts, or send internal data to an external destination.",
13
- "Destroy or exfiltrate: batch deletes from a vague request (e.g. 'clean up branches' must not become a remote branch delete without explicit scope).",
14
- "Degrade security posture: disable logging or monitoring, install persistence (SSH keys, cron jobs, startup hooks), change auth config, or edit the agent's own permission config.",
15
- "Degrade security posture: retry a failed deploy or check with a skip-verification / --force / --no-verify flag the user did not ask for.",
16
- "Cross trust boundaries: run code cloned from an untrusted source, curl|bash a remote script, or execute pasted payloads without review.",
17
- "Cross trust boundaries: scan credential stores (.env, aws credentials, env vars, token files) for usable tokens beyond the task at hand.",
18
- "Cross trust boundaries: exfiltrate data via gist/pastebin upload, webhook POST, or push to a remote the user never named.",
19
- "Bypass review or affect others: push directly to main/master, run production deploys or migrations, or modify shared jobs/resources the agent did not create.",
20
- "Bypass review or affect others: act on an agent-inferred target (closest-name match for 'cancel my job') without explicit user confirmation of that exact target.",
21
- ];
22
-
23
- const DEFAULT_EXCEPTIONS = [
24
- "Installing packages already declared in the repo manifest, or running the repo's own build/test/lint/format scripts.",
25
- "Standard credential flows the user asked for (e.g. running the documented login command).",
26
- "Pushing to the session's own working branch when that is the task.",
27
- "Read-only inspection (listing, diffing, dry runs) before a guarded action.",
28
- ];
29
-
30
- export function defaultPolicy(options?: Partial<ReviewPolicy>): ReviewPolicy {
31
- return {
32
- trusted: [...(options?.trusted ?? DEFAULT_TRUSTED)],
33
- blocks: [...(options?.blocks ?? DEFAULT_BLOCKS)],
34
- exceptions: [...(options?.exceptions ?? DEFAULT_EXCEPTIONS)],
35
- };
36
- }
package/src/state.ts DELETED
@@ -1,135 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import { createHash } from "node:crypto";
4
- import { privateFile, STORAGE_DIR } from "./storage.js";
5
-
6
- export type DecisionKind = "reviewing" | "allow" | "deny" | "ask";
7
-
8
- export interface DecisionRecord {
9
- requestID: string;
10
- sessionID: string;
11
- decision: DecisionKind;
12
- reason: string;
13
- at: number;
14
- action?: string;
15
- resources?: string[];
16
- }
17
-
18
- export const STATE_FILE = path.join(STORAGE_DIR, "state.json");
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
- }
95
-
96
- export function readDecisions(): Record<string, DecisionRecord> {
97
- let fd: number | undefined;
98
- try {
99
- fd = privateFile("state.json", false);
100
- if (fs.fstatSync(fd).size > 65_536) return {};
101
- const raw = fs.readFileSync(fd, "utf8");
102
- const parsed: unknown = JSON.parse(raw);
103
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
104
- return Object.fromEntries(Object.entries(parsed).filter(([id, value]) =>
105
- id.length < 256 && typeof value === "object" && value !== null &&
106
- value.requestID === id && typeof value.sessionID === "string" && value.sessionID.length < 256 &&
107
- typeof value.reason === "string" && value.reason.length <= 4096 &&
108
- typeof value.at === "number" && Number.isFinite(value.at) &&
109
- (value.action === undefined || typeof value.action === "string") &&
110
- (value.resources === undefined || (Array.isArray(value.resources) && value.resources.length <= 256 &&
111
- value.resources.every((resource: unknown) => typeof resource === "string"))) &&
112
- ["reviewing", "allow", "deny", "ask"].includes(value.decision))) as Record<string, DecisionRecord>;
113
- } catch {
114
- return {};
115
- } finally { if (fd !== undefined) fs.closeSync(fd); }
116
- }
117
-
118
- export function recordDecision(record: DecisionRecord): void {
119
- const all = readDecisions();
120
- Object.defineProperty(all, record.requestID, { value: record, enumerable: true, configurable: true });
121
- const cutoff = Date.now() - MAX_AGE_MS;
122
- for (const [id, rec] of Object.entries(all)) {
123
- if (rec.at < cutoff || Object.keys(all).length > 128) delete all[id];
124
- }
125
- let fd: number | undefined;
126
- try {
127
- const data = JSON.stringify(all);
128
- if (Buffer.byteLength(data) > 65_536) return;
129
- fd = privateFile("state.json", true);
130
- fs.ftruncateSync(fd, 0);
131
- fs.writeSync(fd, data, 0);
132
- } catch {
133
- // Badge state is best effort and must not interrupt permission review.
134
- } finally { if (fd !== undefined) fs.closeSync(fd); }
135
- }
package/src/storage.ts DELETED
@@ -1,26 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
-
5
- export const STORAGE_DIR = path.join(os.tmpdir(), `opencode-auto-mode-${process.getuid?.() ?? os.userInfo().username}`);
6
-
7
- function owned(stat: fs.Stats): boolean {
8
- return (process.getuid === undefined || stat.uid === process.getuid()) && (stat.mode & 0o077) === 0;
9
- }
10
-
11
- export function privateFile(filename: string, write: boolean, append = false): number {
12
- if (path.basename(filename) !== filename) throw new Error("Invalid storage file");
13
- try { fs.mkdirSync(STORAGE_DIR, { mode: 0o700 }); }
14
- catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
15
- const directory = fs.lstatSync(STORAGE_DIR);
16
- if (!directory.isDirectory() || !owned(directory)) throw new Error("Unsafe storage directory");
17
- const fd = fs.openSync(path.join(STORAGE_DIR, filename),
18
- (write ? fs.constants.O_RDWR | fs.constants.O_CREAT : fs.constants.O_RDONLY) | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK |
19
- (append ? fs.constants.O_APPEND : 0), 0o600);
20
- const file = fs.fstatSync(fd);
21
- if (!file.isFile() || !owned(file) || file.nlink !== 1) {
22
- fs.closeSync(fd);
23
- throw new Error("Unsafe storage file");
24
- }
25
- return fd;
26
- }
package/src/traces.ts DELETED
@@ -1,84 +0,0 @@
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
- }