@mortz.prk/opencode-auto-mode 0.1.3 → 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/src/config.ts CHANGED
@@ -1,27 +1,32 @@
1
- import type { PolicyOptions } from "./policy.js";
2
- import type { Decision } from "./reviewer.js";
3
- import { compileExpression } from "./rules.js";
1
+ import { Model } from "@opencode/schema/model";
2
+ import type { ReviewPolicy } from "./policy.js";
3
+ import { logsUrl, type OtlpOptions } from "./otlp.js";
4
4
 
5
- export type ReviewerModel = { id: string; providerID: string; variant?: string };
6
- export type UserRule = { expression: string; decision: Decision };
5
+ export type ReviewerModel = string;
6
+ export type ReviewerModelRef = { id: string; providerID: string; variant?: string };
7
7
  export type DecisionProvider = {
8
- model?: string;
9
8
  apiKeyEnv?: string;
10
9
  minConfidence?: number;
11
10
  timeoutMs?: number;
12
11
  };
12
+
13
+ export type DecisionStage = {
14
+ type: "decision";
15
+ model: string;
16
+ endpoint?: string;
17
+ policy?: Partial<ReviewPolicy>;
18
+ } & DecisionProvider;
19
+
13
20
  export type ClassifierStage =
14
21
  | { type: "static"; allowInProjectEdits?: boolean }
15
- | { type: "rules"; rules: UserRule[] }
16
- | ({ type: "jev" | "openai-decision" } & DecisionProvider)
17
- | ({ type: "laya"; endpoint?: string } & DecisionProvider)
18
- | { type: "llm"; model?: ReviewerModel };
19
- export interface AutoModeOptions extends PolicyOptions {
20
- model?: ReviewerModel;
21
- review?: "all" | "prompts";
22
- agents?: string[];
23
- allowInProjectEdits?: boolean;
24
- classifier?: { stages: ClassifierStage[] };
22
+ | DecisionStage
23
+ | { type: "llm"; model?: ReviewerModel; policy?: Partial<ReviewPolicy> };
24
+
25
+ export interface AutoModeOptions {
26
+ classifiers: ClassifierStage[];
27
+ tui?: boolean;
28
+ auditLog?: boolean | string;
29
+ otlp?: boolean | OtlpOptions;
25
30
  }
26
31
 
27
32
  export function object(value: unknown): value is Record<string, unknown> {
@@ -36,38 +41,53 @@ function only(value: Record<string, unknown>, keys: string[]): boolean {
36
41
  return Object.keys(value).every((key) => keys.includes(key));
37
42
  }
38
43
 
44
+ export function parseReviewerModel(value: string): ReviewerModelRef {
45
+ const ref = Model.Ref.parse(value);
46
+ return {
47
+ id: ref.id,
48
+ providerID: ref.providerID,
49
+ ...(ref.variant === undefined ? {} : { variant: ref.variant }),
50
+ };
51
+ }
52
+
39
53
  function model(value: unknown): value is ReviewerModel {
40
- return object(value) && only(value, ["id", "providerID", "variant"]) &&
41
- text(value.id) && text(value.providerID) && (value.variant === undefined || text(value.variant));
54
+ if (typeof value !== "string" || !text(value)) return false;
55
+ try { parseReviewerModel(value); return true; } catch { return false; }
42
56
  }
43
57
 
44
- function rule(value: unknown): value is UserRule {
45
- if (!object(value) || !only(value, ["expression", "decision"]) || !text(value.expression) ||
46
- typeof value.decision !== "string" || !["allow", "deny", "ask"].includes(value.decision)) return false;
47
- try { compileExpression(value.expression); return true; } catch { return false; }
58
+ function policy(value: unknown): value is Partial<ReviewPolicy> {
59
+ return object(value) && only(value, ["trusted", "blocks", "exceptions"]) &&
60
+ Object.values(value).every((list) => Array.isArray(list) && list.length <= 256 && list.every(text));
48
61
  }
49
62
 
50
63
  export function localEndpoint(value: string): string {
51
64
  const url = new URL(value);
52
65
  if (!['http:', 'https:'].includes(url.protocol) ||
53
66
  !['127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password || url.search || url.hash ||
54
- url.pathname !== '/v1/systemone') throw new Error("Laya requires a loopback /v1/systemone endpoint");
67
+ url.pathname !== '/v1/systemone') throw new Error("Local System One requires a loopback /v1/systemone endpoint");
55
68
  return url.href;
56
69
  }
57
70
 
58
- function stage(value: unknown): value is ClassifierStage {
59
- if (!object(value)) return false;
60
- if (value.type === "static") return only(value, ["type", "allowInProjectEdits"]) &&
61
- (value.allowInProjectEdits === undefined || typeof value.allowInProjectEdits === "boolean");
62
- if (value.type === "rules") return only(value, ["type", "rules"]) && Array.isArray(value.rules) &&
63
- value.rules.length <= 256 && value.rules.every(rule);
64
- if (value.type === "llm") return only(value, ["type", "model"]) &&
65
- (value.model === undefined || model(value.model));
66
- if (typeof value.type !== "string" || !["jev", "openai-decision", "laya"].includes(value.type)) return false;
67
- if (!only(value, value.type === "laya" ?
68
- ["type", "model", "apiKeyEnv", "minConfidence", "timeoutMs", "endpoint"] :
69
- ["type", "model", "apiKeyEnv", "minConfidence", "timeoutMs"])) return false;
70
- if (value.model !== undefined && !text(value.model)) return false;
71
+ function otlp(value: unknown): value is OtlpOptions {
72
+ if (!object(value) || !only(value, ["endpoint", "headers", "timeoutMs"])) return false;
73
+ if (value.endpoint !== undefined) {
74
+ if (!text(value.endpoint)) return false;
75
+ try { logsUrl(value.endpoint); } catch { return false; }
76
+ }
77
+ if (value.headers !== undefined && (!object(value.headers) || Object.keys(value.headers).length > 32 ||
78
+ !Object.entries(value.headers).every(([key, header]) => /^[A-Za-z0-9-]{1,128}$/.test(key) && text(header)))) return false;
79
+ return value.timeoutMs === undefined || (typeof value.timeoutMs === "number" && Number.isInteger(value.timeoutMs) &&
80
+ value.timeoutMs >= 1 && value.timeoutMs <= 10_000);
81
+ }
82
+
83
+ export const DECISION_PROVIDERS = ["typesafe-ai", "laya", "openai"] as const;
84
+
85
+ function decision(value: Record<string, unknown>): value is DecisionStage {
86
+ if (!only(value, ["type", "model", "apiKeyEnv", "minConfidence", "timeoutMs", "endpoint", "policy"])) return false;
87
+ if (value.policy !== undefined && !policy(value.policy)) return false;
88
+ if (!model(value.model)) return false;
89
+ const ref = parseReviewerModel(value.model);
90
+ if (ref.variant !== undefined || !(DECISION_PROVIDERS as readonly string[]).includes(ref.providerID)) return false;
71
91
  if (value.apiKeyEnv !== undefined &&
72
92
  (typeof value.apiKeyEnv !== "string" || !/^[A-Z_][A-Z0-9_]{0,127}$/.test(value.apiKeyEnv))) return false;
73
93
  if (value.minConfidence !== undefined &&
@@ -77,35 +97,32 @@ function stage(value: unknown): value is ClassifierStage {
77
97
  (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) ||
78
98
  value.timeoutMs < 1 || value.timeoutMs > 30_000)) return false;
79
99
  if (value.endpoint !== undefined) {
80
- if (!text(value.endpoint)) return false;
100
+ if (ref.providerID !== "laya" || !text(value.endpoint)) return false;
81
101
  try { localEndpoint(value.endpoint); } catch { return false; }
82
102
  }
83
103
  return true;
84
104
  }
85
105
 
106
+ function stage(value: unknown): value is ClassifierStage {
107
+ if (!object(value)) return false;
108
+ if (value.type === "static") return only(value, ["type", "allowInProjectEdits"]) &&
109
+ (value.allowInProjectEdits === undefined || typeof value.allowInProjectEdits === "boolean");
110
+ if (value.type === "llm") return only(value, ["type", "model", "policy"]) &&
111
+ (value.model === undefined || model(value.model)) &&
112
+ (value.policy === undefined || policy(value.policy));
113
+ return value.type === "decision" && decision(value);
114
+ }
115
+
86
116
  export function parseOptions(value: unknown): AutoModeOptions {
87
117
  if (!object(value)) throw new Error("Invalid auto-mode options");
88
- if (!only(value, ["model", "review", "agents", "allowInProjectEdits", "classifier", "trusted", "blocks", "exceptions", "keybinds"]))
118
+ if (!only(value, ["classifiers", "tui", "auditLog", "otlp"]))
89
119
  throw new Error("Unknown auto-mode option");
90
- if (value.model !== undefined && !model(value.model)) throw new Error("Invalid reviewer model");
91
- if (value.review !== undefined && value.review !== "all" && value.review !== "prompts")
92
- throw new Error("Invalid review scope");
93
- for (const key of ["trusted", "blocks", "exceptions", "agents"]) {
94
- if (value[key] !== undefined && (!Array.isArray(value[key]) || value[key].length > 256 || !value[key].every(text)))
95
- throw new Error(`Invalid ${key}`);
96
- }
97
- if (value.allowInProjectEdits !== undefined && typeof value.allowInProjectEdits !== "boolean")
98
- throw new Error("Invalid edit configuration");
99
- if (value.classifier !== undefined && (!object(value.classifier) || !only(value.classifier, ["stages"]) ||
100
- !Array.isArray(value.classifier.stages) || value.classifier.stages.length === 0 ||
101
- value.classifier.stages.length > 16 || !value.classifier.stages.every(stage)))
102
- throw new Error("Invalid classifier stages");
103
- return value as AutoModeOptions;
104
- }
105
-
106
- export function stagesFor(options: AutoModeOptions): ClassifierStage[] {
107
- return options.classifier?.stages ?? [
108
- { type: "static", allowInProjectEdits: options.allowInProjectEdits === true },
109
- { type: "llm", model: options.model },
110
- ];
120
+ if (value.tui !== undefined && typeof value.tui !== "boolean") throw new Error("Invalid TUI setting");
121
+ if (value.auditLog !== undefined && typeof value.auditLog !== "boolean" && !text(value.auditLog))
122
+ throw new Error("Invalid audit log");
123
+ if (value.otlp !== undefined && typeof value.otlp !== "boolean" && !otlp(value.otlp)) throw new Error("Invalid OTLP options");
124
+ if (!Array.isArray(value.classifiers) || value.classifiers.length === 0 ||
125
+ value.classifiers.length > 16 || !value.classifiers.every(stage))
126
+ throw new Error("A valid nonempty classifiers array is required");
127
+ return value as unknown as AutoModeOptions;
111
128
  }
@@ -0,0 +1,213 @@
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/index.ts CHANGED
@@ -1,36 +1,50 @@
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
- import { extractTranscript } from "./transcript.js";
5
+ import { extractReviewContext } from "./transcript.js";
6
6
  import { scanResultContent, withInjectionWarning } from "./probe.js";
7
- import { buildStage1Prompt, buildStage2Prompt, parseDecision, parseStage1, withDenySuffix, type PermissionRequest, type ReviewResult } from "./reviewer.js";
8
- import { parseOptions, stagesFor, type ReviewerModel } from "./config.js";
7
+ import { buildStage1Prompt, buildStage2Prompt, parseDecision, parseStage1, withDenySuffix, type PermissionRequest, type ReviewResult, type StageResult } from "./reviewer.js";
8
+ import { parseOptions, parseReviewerModel, type ReviewerModelRef } from "./config.js";
9
9
  import { compositeDecision } from "./classifier.js";
10
- import { providerDecision } from "./providers.js";
10
+ import { decisionStage } from "./decisions.js";
11
+ import { createAuditLog } from "./audit.js";
12
+ import { createOtlpExporter } from "./otlp.js";
13
+ import { createTraceExporter } from "./traces.js";
11
14
 
12
- export type { AutoModeOptions, ClassifierStage, DecisionProvider, UserRule, ReviewerModel } from "./config.js";
15
+ export type { AutoModeOptions, ClassifierStage, DecisionProvider, DecisionStage, ReviewerModel } from "./config.js";
13
16
 
14
17
  export default Plugin.define({
15
18
  id: "opencode-auto-mode",
16
19
  setup: async (ctx) => {
17
20
  const log = createLogger("events.log");
21
+ const directory = ctx.location?.directory;
18
22
  let options: ReturnType<typeof parseOptions> | undefined;
19
- try { options = parseOptions(ctx.options); }
20
- catch { log.log("Invalid configuration; automatic approval disabled"); }
23
+ try {
24
+ options = parseOptions(ctx.options);
25
+ if (directory !== undefined) clearConfigError(directory);
26
+ } catch (error) {
27
+ const detail = error instanceof Error ? error.message : String(error);
28
+ log.log(`Invalid configuration; automatic approval disabled: ${detail}`);
29
+ if (directory !== undefined) recordConfigError(directory, detail);
30
+ }
31
+ const sinks = [createAuditLog(options?.auditLog), createOtlpExporter(options?.otlp)].filter((sink) => sink !== undefined);
32
+ const traces = createTraceExporter(options?.otlp);
21
33
  const counts = new Map<string, number>();
22
34
  let reviewCounter = 0;
23
35
  const permissionRegistration = await ctx.permission.hook("evaluate", async (event) => {
24
- if (event.effect === "deny") return;
36
+ if (event.effect !== "ask") return;
25
37
  if (!options) {
26
38
  event.effect = "ask";
27
39
  event.message = "Invalid auto-mode configuration; please decide yourself";
28
40
  return;
29
41
  }
30
- if (options.agents && (!event.agent || !options.agents.includes(event.agent))) return;
31
- if (options.review === "prompts" && event.effect !== "ask") return;
32
42
  const reviewID = `review-${process.pid}-${Date.now()}-${reviewCounter++}`;
33
43
  recordDecision({ requestID: reviewID, sessionID: event.sessionID, decision: "reviewing", action: event.action.slice(0, 128), reason: "", at: Date.now() });
44
+ const startedAt = Date.now();
45
+ const trace = traces?.begin(startedAt);
46
+ const hostEffect = event.effect;
47
+ let failure: string | undefined;
34
48
  let result: ReviewResult;
35
49
  try {
36
50
  const info = await withTimeout(ctx.session.get({ sessionID: event.sessionID }), 3_000).catch(() => undefined);
@@ -40,20 +54,38 @@ export default Plugin.define({
40
54
  isDelegation: event.action === "task" || event.action === "subagent",
41
55
  };
42
56
  let contextLoaded = false;
43
- result = await compositeDecision(stagesFor(options), request, async (stage) => {
57
+ let contextComplete = false;
58
+ let contextReason = "User authorization context is unavailable";
59
+ result = await compositeDecision(options.classifiers, request, async (stage) => {
44
60
  if (!contextLoaded) {
45
61
  const messages = await withTimeout(ctx.session.context({ sessionID: event.sessionID }), 3_000);
46
- request.transcript = extractTranscript(messages);
62
+ const context = extractReviewContext(messages);
63
+ request.transcript = context.transcript;
64
+ contextComplete = context.complete;
65
+ contextReason = context.reason ?? contextReason;
47
66
  contextLoaded = true;
48
67
  }
49
- if (!request.transcript?.startsWith("User messages") || request.transcript.includes("…"))
50
- return { decision: "ask", reason: "User authorization context is unavailable" };
51
- const policy = defaultPolicy(options);
52
- return stage.type === "llm" ? llmDecision(ctx, request, policy, stage.model ?? options?.model) :
53
- providerDecision(stage, request, policy);
68
+ if (!contextComplete)
69
+ return { decision: "ask", reason: contextReason };
70
+ const policy = defaultPolicy(stage.policy);
71
+ const attemptStartedAt = Date.now();
72
+ let outcome: StageResult;
73
+ try {
74
+ outcome = stage.type === "llm" ? await llmDecision(ctx, request, policy, stage.model, info?.model) :
75
+ await decisionStage(stage, request, policy);
76
+ } catch {
77
+ outcome = { decision: "abstain", reason: `${stage.type} reviewer unavailable` };
78
+ }
79
+ trace?.attempts.push({
80
+ index: options.classifiers.indexOf(stage), type: stage.type,
81
+ model: stage.model ?? (info?.model ? `${info.model.providerID}/${info.model.id}` : undefined),
82
+ startedAt: attemptStartedAt, durationMs: Date.now() - attemptStartedAt, result: outcome,
83
+ });
84
+ return outcome;
54
85
  });
55
- } catch {
56
- result = { decision: "ask", reason: "Auto-mode review failed; please decide yourself" };
86
+ } catch (error) {
87
+ failure = error instanceof Error ? error.message : String(error);
88
+ result = { decision: "ask", reason: "Auto-mode review failed; please decide yourself", stage: "error" };
57
89
  }
58
90
  event.effect = result.decision;
59
91
  event.message = result.decision === "deny" ? withDenySuffix(result.reason) : result.reason;
@@ -63,6 +95,15 @@ export default Plugin.define({
63
95
  counts.set(event.sessionID, count);
64
96
  log.log(`Action denied (${count} in session)`);
65
97
  }
98
+ const auditRecord = {
99
+ ts: new Date().toISOString(), reviewID, sessionID: event.sessionID, agent: event.agent,
100
+ action: event.action, resources: [...event.resources], hostEffect,
101
+ decision: result.decision, stage: result.stage ?? "unknown", reason: result.reason,
102
+ durationMs: Date.now() - startedAt, ...(failure !== undefined ? { error: true, errorDetail: failure } : {}),
103
+ ...(trace ? { traceID: trace.traceID, spanID: trace.spanID } : {}),
104
+ };
105
+ trace?.finish(auditRecord);
106
+ for (const sink of sinks) sink.write(auditRecord);
66
107
  recordDecision({ requestID: reviewID, sessionID: event.sessionID, decision: event.effect, reason: "", at: Date.now() });
67
108
  });
68
109
  const toolRegistration = await ctx.tool.hook("execute.after", (event) => {
@@ -85,15 +126,17 @@ export default Plugin.define({
85
126
  });
86
127
 
87
128
  interface ReviewerContext {
88
- generate: { text(input: { prompt: string; model?: ReviewerModel | null }): Promise<{ text: string }> };
129
+ generate: { text(input: { prompt: string; model?: ReviewerModelRef | null }): Promise<{ text: string }> };
89
130
  }
90
131
 
91
- async function llmDecision(ctx: ReviewerContext, request: PermissionRequest, policy: ReturnType<typeof defaultPolicy>, model?: ReviewerModel): Promise<ReviewResult> {
132
+ async function llmDecision(ctx: ReviewerContext, request: PermissionRequest, policy: ReturnType<typeof defaultPolicy>, model?: string, sessionModel?: ReviewerModelRef): Promise<StageResult> {
133
+ const ref = model ? parseReviewerModel(model) : sessionModel;
134
+ if (!ref) return { decision: "abstain", reason: "Session model is unavailable; configure a reviewer model" };
92
135
  const prompt = buildStage1Prompt(request, policy);
93
136
  if (Buffer.byteLength(prompt) > 65_536) return { decision: "ask", reason: "Request too large for automatic review" };
94
- const triage = await withTimeout(ctx.generate.text({ prompt, model: model ?? null }), 30_000);
137
+ const triage = await withTimeout(ctx.generate.text({ prompt, model: ref }), 30_000);
95
138
  if (parseStage1(triage.text) === "allow") return { decision: "allow", reason: "LLM triage approved" };
96
- const response = await withTimeout(ctx.generate.text({ prompt: buildStage2Prompt(request, policy), model: model ?? null }), 30_000);
139
+ const response = await withTimeout(ctx.generate.text({ prompt: buildStage2Prompt(request, policy), model: ref }), 30_000);
97
140
  return parseDecision(response.text);
98
141
  }
99
142
 
package/src/otlp.ts ADDED
@@ -0,0 +1,74 @@
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 CHANGED
@@ -4,12 +4,6 @@ export interface ReviewPolicy {
4
4
  exceptions: string[];
5
5
  }
6
6
 
7
- export interface PolicyOptions {
8
- trusted?: string[];
9
- blocks?: string[];
10
- exceptions?: string[];
11
- }
12
-
13
7
  const DEFAULT_TRUSTED = [
14
8
  "the git repo in the session's working directory (reviewable via version control)",
15
9
  ];
@@ -33,10 +27,10 @@ const DEFAULT_EXCEPTIONS = [
33
27
  "Read-only inspection (listing, diffing, dry runs) before a guarded action.",
34
28
  ];
35
29
 
36
- export function defaultPolicy(options?: PolicyOptions): ReviewPolicy {
30
+ export function defaultPolicy(options?: Partial<ReviewPolicy>): ReviewPolicy {
37
31
  return {
38
- trusted: options?.trusted?.length ? options.trusted : [...DEFAULT_TRUSTED],
39
- blocks: options?.blocks?.length ? options.blocks : [...DEFAULT_BLOCKS],
40
- exceptions: options?.exceptions?.length ? options.exceptions : [...DEFAULT_EXCEPTIONS],
32
+ trusted: [...(options?.trusted ?? DEFAULT_TRUSTED)],
33
+ blocks: [...(options?.blocks ?? DEFAULT_BLOCKS)],
34
+ exceptions: [...(options?.exceptions ?? DEFAULT_EXCEPTIONS)],
41
35
  };
42
36
  }