@mortz.prk/opencode-auto-mode 1.0.0 → 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 CHANGED
@@ -28,19 +28,38 @@ needed:
28
28
  }
29
29
  ```
30
30
 
31
- Restart OpenCode after saving:
32
-
33
- ```sh
34
- opencode service restart
35
- ```
31
+ OpenCode watches configuration files and reloads plugins after a save, so no
32
+ restart is needed. If a change does not take effect, run `/reload` in the TUI to
33
+ force a full reload.
36
34
 
37
35
  There is no default review pipeline, and empty options are invalid. Add at least
38
36
  one review entry. Built-in checks deny dangerous shell commands, allow common
39
- read-only commands, and leave everything else as `ask`.
37
+ read-only commands, and send other parsed commands to the configured reviewers.
38
+ Dynamic syntax such as command substitution, nested shells, and stateful builtins
39
+ still requires manual approval.
40
40
 
41
41
  The snippets below change parts of that configuration; keep your `classifiers`
42
42
  list when adding other options.
43
43
 
44
+ ### How a shell command is classified
45
+
46
+ Commands are parsed before they are judged, so text that merely mentions a
47
+ dangerous word — a commit message, a search pattern, a heredoc body — is not
48
+ treated as a dangerous operation. Denials apply to the parsed executable and its
49
+ arguments, and nested shells are inspected recursively.
50
+
51
+ Read-only allowlisting checks the executable **and** its flags. Options that
52
+ write files or run programs (`rg --pre`, `sort -o`, `tree -o`, `file -C`,
53
+ `bat --pager`, `hostname NAME`, `uniq IN OUT`) are handed to the reviewers
54
+ instead of being auto-approved.
55
+
56
+ A shell result is final when it is `deny` or `ask`: `ask` means manual approval,
57
+ even if a model reviewer is configured. Read-only auto-approval is narrower than
58
+ the pipeline: for shell actions, the static stage auto-approves only commands the
59
+ guard recognizes as read-only. Every other parsed command that is not denied —
60
+ including a write such as `pwd > marker` — goes to the configured reviewers, and a
61
+ model reviewer may allow it. With no reviewer configured, it stays `ask`.
62
+
44
63
  ## Add a typed decision model
45
64
 
46
65
  ```jsonc
@@ -101,8 +120,31 @@ Entries run in order. Add as many as you need:
101
120
  }
102
121
  ```
103
122
 
104
- An entry can allow, deny, or ask, which stops the pipeline. If it abstains or
105
- fails, the next entry runs. If nothing decides, the action remains `ask`.
123
+ An entry can allow, deny, or ask, which stops the pipeline. An entry that abstains
124
+ hands the action to the next entry. If nothing decides, the action remains `ask`.
125
+
126
+ ## Time limit and failures
127
+
128
+ A review never blocks a permission prompt indefinitely. One deadline covers every
129
+ entry in the pipeline, including both model calls of an `llm` entry:
130
+
131
+ ```jsonc
132
+ { "options": { "reviewTimeoutMs": 60000 } }
133
+ ```
134
+
135
+ `reviewTimeoutMs` accepts 1000–120000 and defaults to 60000. When the deadline
136
+ passes, provider and context requests are aborted and the action is asked with
137
+ `Auto-mode review deadline exceeded`. Interrupting a session also abandons its
138
+ review, so a stopped turn cannot later receive an automatic approval.
139
+
140
+ Failures are reported with a bounded cause — `deadline`, `timeout`, `cancelled`,
141
+ `provider`, `invalid-output`, or `context` — in the audit log and in OTLP
142
+ (`error.kind`), while the user-facing message stays generic.
143
+
144
+ A typed decision reviewer that is unavailable or returns a malformed response
145
+ abstains, so the next entry runs. An `llm` entry behaves differently: a timeout
146
+ or provider error abstains, but an unparseable final answer is an `ask` (manual
147
+ approval) with `invalid-output`, and the pipeline stops there.
106
148
 
107
149
  ## Use it for specific agents
108
150
 
@@ -125,7 +167,40 @@ permissions and leave global permissions unchanged:
125
167
 
126
168
  A passive indicator shows when the plugin is reviewing the current session.
127
169
  It is enabled by default. Set `tui: false` in plugin options to hide it.
128
- OpenCode's own permission dialog handles manual approvals.
170
+ Invalid options show `auto-mode: invalid config` in the same place and raise an
171
+ error toast when the configuration is loaded. OpenCode's own permission dialog
172
+ handles manual approvals.
173
+
174
+ The indicator window is longer than the maximum review deadline, so a slow
175
+ review stays visible until it resolves.
176
+
177
+ ## Desktop app (preview)
178
+
179
+ OpenCode 2.2.0+ can load the plugin's GUI entry in the desktop app. It adds a
180
+ composer pill and an **Auto-mode** side panel showing whether the current session
181
+ is being reviewed and how recent actions were decided, plus a command-palette
182
+ entry to open the panel.
183
+
184
+ The GUI only reads state. Every permission decision still comes from the server
185
+ part, so disabling the GUI never disables automatic review.
186
+
187
+ The GUI is loaded from the desktop app's own background server and reads that
188
+ server's auto-mode state. If you connect the app to another server, that server's
189
+ reviews do not appear in the panel.
190
+
191
+ The desktop app reads plugins only from the **global** `opencode.json(c)`. If
192
+ your TUI configuration lives in `cli.json`, add the package to the global file as
193
+ well:
194
+
195
+ ```jsonc
196
+ {
197
+ "plugins": [{ "package": "@mortz.prk/opencode-auto-mode", "options": { "classifiers": [{ "type": "static" }] } }]
198
+ }
199
+ ```
200
+
201
+ Turn the GUI off without removing the plugin in **Settings → Extensions →
202
+ Plugins**. The GUI plugin API is experimental and may change between OpenCode
203
+ releases; the server and TUI parts do not depend on it.
129
204
 
130
205
  ## Review options
131
206
 
@@ -153,6 +228,59 @@ Each LLM or typed decision reviewer can have its own policy:
153
228
  policy; an empty list clears that list. Policies are independent between
154
229
  reviewers and do not apply to static checks or replace OpenCode permissions.
155
230
 
231
+ ## All plugin options
232
+
233
+ | Option | Default | Meaning |
234
+ | --- | --- | --- |
235
+ | `classifiers` | — | Ordered review entries; at least one is required. |
236
+ | `reviewTimeoutMs` | `60000` | Deadline for one complete review (1000–120000). |
237
+ | `auditLog` | off | `true` writes the default JSONL file; a string sets the path. |
238
+ | `otlp` | off | `true` uses OpenCode's OTLP environment; an object sets `endpoint`, `headers`, and `timeoutMs`. |
239
+ | `tui` | on | Set `false` to hide the TUI indicator. |
240
+
241
+ Static entries accept `allowInProjectEdits`. Decision entries accept
242
+ `minConfidence`, `timeoutMs`, `apiKeyEnv`, and `endpoint` (loopback `laya` only;
243
+ defaults to `http://127.0.0.1:8000/v1/systemone`). OpenCode reloads the plugin
244
+ when you save, so option changes apply without a restart; run `/reload` in the
245
+ TUI if a change does not take effect. Invalid options disable automatic approval
246
+ and are reported in the TUI and over the status RPC.
247
+
248
+ ## Data that leaves the machine
249
+
250
+ Reviewers see the proposed action, its resources, the session directory, the
251
+ requesting agent, the pending permission `metadata` and `source`, and a bounded
252
+ transcript of recent user messages and tool inputs (12 KB, with the head and tail
253
+ of each tool input). Nothing else about the session is sent.
254
+
255
+ * A model reviewer receives that material through OpenCode's own session model or
256
+ the `model` you configure.
257
+ * A typed decision reviewer receives it at the configured provider endpoint.
258
+ * Audit records and OTLP log records keep the action, agent, up to 16 resources,
259
+ a short reason, and, on failure, a short error detail. Both pass the record
260
+ through one sanitizer, which bounds these fields — 128 characters for the
261
+ action and agent, 64 for the stage, 512 for each resource, reason, and error
262
+ detail — but does **not** redact text.
263
+ * OTLP exports logs and traces as separate streams (`/v1/logs` and `/v1/traces`).
264
+ Trace root attributes use the same sanitizer, so their action, agent, and stage
265
+ are bounded like the log fields. Each reviewer attempt adds a child span with
266
+ its model and outcome; the `model` attribute is not clipped. Traces do not
267
+ include resources, reasons, or error-detail text.
268
+ * Text in every stream is unredacted, including the action and agent. Traces
269
+ carry the action and agent; logs carry those plus resources, reasons, and
270
+ error details. These fields may be sensitive, and resources or reasons can
271
+ contain inline file contents or shell commands, including credentials such as
272
+ an `Authorization` header. Audit JSONL keeps the same unredacted fields as OTLP
273
+ logs. Enable `auditLog` or `otlp` only if you are comfortable storing or
274
+ exporting that material.
275
+ * Records do not include the reviewer prompt, the session transcript, or API keys
276
+ configured for providers.
277
+
278
+ ## Platform limits
279
+
280
+ * Windows always asks for shell actions; the guard only parses POSIX syntax.
281
+ * Reviews happen inside the OpenCode server, so a headless or remote TUI behaves
282
+ the same as a local one. The desktop GUI needs OpenCode 2.2.0+.
283
+
156
284
  ## Auditing
157
285
 
158
286
  Enable the audit log in plugin options:
@@ -167,7 +295,8 @@ Enable the audit log in plugin options:
167
295
 
168
296
  Decisions are appended as JSON lines to
169
297
  `$XDG_STATE_HOME/opencode-auto-mode/decisions.jsonl`. Replay recorded tool calls
170
- without executing anything:
298
+ without executing anything (run from a checkout; it reads the OpenCode database
299
+ read-only and needs `--config` if your global config is not at a default path):
171
300
 
172
301
  ```sh
173
302
  bun scripts/replay.ts [--days 14] [--session ID] [--agent build|any] [--json]
@@ -192,9 +321,13 @@ bun install
192
321
  bun test
193
322
  bun run lint
194
323
  bun run typecheck
324
+ bun run build:gui
195
325
  bun run test:integration
196
326
  ```
197
327
 
328
+ `build:gui` writes `dist/gui-experimental.cjs`, the file the `./gui-experimental`
329
+ export points at. It runs automatically before publishing.
330
+
198
331
  ## Credit
199
332
 
200
333
  Created originally by [Andrew Mellen (mellena1)](https://github.com/mellena1) in
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const t=require("solid-js/web"),c=require("solid-js"),u=require("@opencode/plugin/gui"),C=require("@opencode/plugin/rpc"),q={type:"object",properties:{enabled:{type:"boolean"},configError:{type:"string"},classifiers:{type:"array",items:{type:"string"}},reviewTimeoutMs:{type:"number"},reviewing:{type:"array",items:{type:"object",properties:{sessionID:{type:"string"},reviewID:{type:"string"},startedAt:{type:"number"}},required:["sessionID","reviewID","startedAt"],additionalProperties:!1}},recent:{type:"array",items:{type:"object",properties:{reviewID:{type:"string"},sessionID:{type:"string"},action:{type:"string"},decision:{type:"string"},stage:{type:"string"},reason:{type:"string"},durationMs:{type:"number"},at:{type:"number"},errorKind:{type:"string"}},required:["reviewID","sessionID","action","decision","stage","reason","durationMs","at"],additionalProperties:!1}}},required:["enabled","classifiers","reviewTimeoutMs","reviewing","recent"],additionalProperties:!1},$={type:"object",properties:{reviewID:{type:"string"},sessionID:{type:"string"},action:{type:"string"},decision:{type:"string"},stage:{type:"string"},reason:{type:"string"},durationMs:{type:"number"},at:{type:"number"},errorKind:{type:"string"}},required:["reviewID","sessionID","action","decision","stage","reason","durationMs","at"],additionalProperties:!1},j=C.Rpc.define({id:"opencode-auto-mode",methods:{status:{input:{type:"object",properties:{sessionID:{type:"string"}},additionalProperties:!1},output:q},decisions:{input:{type:"object",properties:{sessionID:{type:"string"},limit:{type:"number"}},additionalProperties:!1},output:{type:"array",items:$}}},events:{decision:{schema:$}}});function K(e){return e===void 0?void 0:{key:e.key,sessionID:e.id,ownServer:e.server.builtin}}function T(){let e,i=0,l=0,s=0;return{select(o){return o===e?!1:(e=o,l=i,s=0,!0)},begin(o){if(o===e)return++i},accept(o){return o<=l||o<=s?!1:(s=o,!0)}}}function k(e,i){const l=T();return async s=>{if(l.select(s?.key)&&i(void 0),s===void 0||!s.ownServer)return;const o=l.begin(s.key);if(o!==void 0)try{const v=await e(s.sessionID);l.accept(o)&&i(v)}catch{}}}function y(e){return e?.recent.at(-1)}function A(e){return e===void 0||e.reviewing.length===0&&e.recent.length===0?"empty":"entries"}function L(e){if(e===void 0)return;if(e.reviewing.length>0)return"auto-mode reviewing…";const i=y(e);if(i!==void 0)return i.errorKind!==void 0?`auto-mode ${i.decision}: ${i.errorKind}`:`auto-mode ${i.decision}`}function O(e,i,l){if(e===void 0||e.reviewing.length>0)return!1;const s=y(e);return s!==void 0&&i-s.at>l}var E=t.template('<div style="padding:8px 12px;font-size:12px;display:flex;flex-direction:column;gap:6px">'),_=t.template("<div>"),N=t.template("<div> "),F=t.template("<div style=display:flex;flex-direction:column;gap:2px><span style=font-weight:600> · </span><span></span><span style=opacity:0.6> · <!>ms · ");const R=3e3,x=3e4,D={allow:"success",ask:"warning",deny:"danger"},z=u.Plugin.define({i18n:{en:{"command.open":"Open auto-mode decisions","panel.title":"Auto-mode","panel.empty":"No decisions in this session yet.","panel.disabled":"Auto-mode has no valid configuration; automatic review is disabled. Host permissions still apply.","panel.reviewing":"Reviewing…","pill.title":"Auto-mode decisions"}},setup(e){const[i,l]=c.createSignal(void 0),s=k(r=>e.client.rpc(j).status({sessionID:r}),l),o=()=>K(e.sessions.current()),[v,I]=c.createSignal(Date.now()),m=()=>{I(Date.now()),s(o())};u.createKeyed(()=>o()?.key,m,{otherwise:m});const S=setInterval(m,R);c.onCleanup(()=>clearInterval(S));const M={id:"decisions",get title(){return e.t("panel.title")},closable:"compact",fallback:!0};e.add(u.Panel,{id:"decisions",region:"side",mobile:{get title(){return e.t("panel.title")},order:60,kind:"menu",icon:"shield"},list:r=>r.open.includes("decisions")?[M]:[],render:()=>(()=>{var r=E();return t.insert(r,(()=>{var p=t.memo(()=>i()?.enabled===!1);return()=>p()?(()=>{var n=_();return t.insert(n,()=>e.t("panel.disabled")),n})():null})(),null),t.insert(r,(()=>{var p=t.memo(()=>A(i())==="empty");return()=>p()?(()=>{var n=_();return t.insert(n,()=>e.t("panel.empty")),n})():[t.createComponent(c.For,{get each(){return[...i()?.reviewing??[]]},children:n=>(()=>{var a=N(),d=a.firstChild;return t.insert(a,()=>e.t("panel.reviewing"),d),t.insert(a,()=>n.reviewID,null),a})()}),t.createComponent(c.For,{get each(){return[...i()?.recent??[]].reverse()},children:n=>(()=>{var a=F(),d=a.firstChild,f=d.firstChild,w=d.nextSibling,g=w.nextSibling,b=g.firstChild,h=b.nextSibling;return h.nextSibling,t.insert(d,()=>n.decision,f),t.insert(d,(()=>{var P=t.memo(()=>n.errorKind===void 0);return()=>P()?"":` (${n.errorKind})`})(),f),t.insert(d,()=>n.action,null),t.insert(w,()=>n.reason),t.insert(g,()=>n.stage,b),t.insert(g,()=>Math.round(n.durationMs),h),t.insert(g,()=>new Date(n.at).toLocaleTimeString(),null),a})()})]})(),null),r})()}),e.add(u.Command,()=>({id:"open",title:e.t("command.open"),group:"tools",run:()=>{const r=e.sessions.current();r&&e.layout.toggle(`${e.id}:decisions`,r)}})),e.add(u.ComposerPill,{id:"status",order:20,show:()=>{const r=i(),p=L(r);if(p===void 0||O(r,v(),x))return;const n=(r?.reviewing.length??0)>0;return{label:p,tone:n?"warning":D[y(r)?.decision??"allow"],title:e.t("pill.title"),rows:(r?.recent??[]).slice(-5).reverse().map(a=>({id:a.reviewID,label:`${a.decision} · ${a.action}`,tone:D[a.decision],detail:a.reason}))}}})}});exports.default=z;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mortz.prk/opencode-auto-mode",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Configurable automatic permission review for OpenCode v2.",
5
5
  "author": {
6
6
  "name": "Andrew Mellen",
@@ -17,7 +17,8 @@
17
17
  "type": "module",
18
18
  "license": "MIT",
19
19
  "files": [
20
- "src"
20
+ "src",
21
+ "dist"
21
22
  ],
22
23
  "exports": {
23
24
  ".": {
@@ -27,38 +28,47 @@
27
28
  "./tui": {
28
29
  "import": "./src/tui.tsx",
29
30
  "types": "./src/tui.tsx"
30
- }
31
+ },
32
+ "./rpc": {
33
+ "import": "./src/rpc.ts",
34
+ "types": "./src/rpc.ts"
35
+ },
36
+ "./gui-experimental": "./dist/gui-experimental.cjs"
31
37
  },
32
38
  "scripts": {
33
39
  "lint": "eslint . --max-warnings=0",
34
40
  "typecheck": "tsc --noEmit",
35
41
  "test": "bun test",
36
- "test:integration": "bun run integration/opencode.ts"
42
+ "test:integration": "bun run integration/opencode.ts",
43
+ "build:gui": "vite build",
44
+ "prepublishOnly": "bun run build:gui"
37
45
  },
38
46
  "dependencies": {
39
47
  "@opencode/ai": "2.0.30",
40
- "@opencode/plugin": "^2.0.14",
41
- "@opencode/schema": "^2.0.30",
48
+ "@opencode/plugin": "2.2.0",
49
+ "@opencode/schema": "2.2.0",
42
50
  "effect": "4.0.1",
43
51
  "shell-quote": "1.12.0"
44
52
  },
45
53
  "peerDependencies": {
46
- "@opentui/core": ">=0.5.10",
47
- "@opentui/solid": ">=0.5.10",
54
+ "@opentui/core": ">=0.6.6",
55
+ "@opentui/solid": ">=0.6.6",
48
56
  "solid-js": ">=1.9.0"
49
57
  },
50
58
  "devDependencies": {
51
59
  "@eslint/js": "^10.0.1",
52
- "@opencode/theme": "^2.0.14",
53
- "@opentui/core": "0.6.6",
60
+ "@opencode/theme": "2.2.0",
61
+ "@opentui/core": "0.7.0",
54
62
  "@opentui/keymap": "^0.6.6",
55
- "@opentui/solid": "0.6.6",
63
+ "@opentui/solid": "0.7.0",
56
64
  "@types/node": "^26.6.4",
57
65
  "@types/shell-quote": "1.7.5",
58
66
  "@typescript/native": "npm:typescript@^7.0.2",
59
67
  "eslint": "^10.12.0",
60
- "solid-js": "1.9.17",
68
+ "solid-js": "1.9.12",
61
69
  "typescript": "npm:@typescript/typescript6@^6.0.2",
62
- "typescript-eslint": "^8.71.1"
70
+ "typescript-eslint": "^8.71.1",
71
+ "vite": "^7.0.0",
72
+ "vite-plugin-solid": "^2.11.0"
63
73
  }
64
74
  }
package/src/audit.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import type { ReviewErrorKind } from "./errors.js";
4
5
 
5
6
  export interface AuditRecord {
6
7
  ts: string;
@@ -15,6 +16,7 @@ export interface AuditRecord {
15
16
  reason: string;
16
17
  durationMs: number;
17
18
  error?: boolean;
19
+ errorKind?: ReviewErrorKind;
18
20
  errorDetail?: string;
19
21
  traceID?: string;
20
22
  spanID?: string;
@@ -26,6 +28,10 @@ const MAX_BYTES = 10 * 1024 * 1024;
26
28
  const MAX_RESOURCES = 16;
27
29
  const MAX_RESOURCE_CHARS = 512;
28
30
  const MAX_REASON_CHARS = 512;
31
+ const MAX_ACTION_CHARS = 128;
32
+ const MAX_AGENT_CHARS = 128;
33
+ const MAX_STAGE_CHARS = 64;
34
+ const ERROR_KINDS: readonly ReviewErrorKind[] = ["deadline", "timeout", "cancelled", "provider", "invalid-output", "context"];
29
35
 
30
36
  export function defaultAuditPath(): string {
31
37
  const base = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
@@ -36,6 +42,23 @@ function clip(value: string, max: number): string {
36
42
  return value.length > max ? `${value.slice(0, max - 1)}…` : value;
37
43
  }
38
44
 
45
+ /**
46
+ * Bounds every exported record. Local audit and OTLP must describe the same
47
+ * payload, so both call this before serialising anything.
48
+ */
49
+ export function sanitizeRecord(record: AuditRecord): AuditRecord {
50
+ return {
51
+ ...record,
52
+ action: clip(record.action, MAX_ACTION_CHARS),
53
+ resources: record.resources.slice(0, MAX_RESOURCES).map((resource) => clip(resource, MAX_RESOURCE_CHARS)),
54
+ reason: clip(record.reason, MAX_REASON_CHARS),
55
+ stage: clip(record.stage, MAX_STAGE_CHARS),
56
+ ...(record.agent === undefined ? {} : { agent: clip(record.agent, MAX_AGENT_CHARS) }),
57
+ ...(record.errorKind === undefined ? {} : { errorKind: ERROR_KINDS.includes(record.errorKind) ? record.errorKind : "provider" }),
58
+ ...(record.errorDetail === undefined ? {} : { errorDetail: clip(record.errorDetail, MAX_REASON_CHARS) }),
59
+ };
60
+ }
61
+
39
62
  export function createAuditLog(setting: boolean | string | undefined): AuditLog | undefined {
40
63
  if (setting === undefined || setting === false) return undefined;
41
64
  const file = typeof setting === "string" ? setting : defaultAuditPath();
@@ -46,13 +69,7 @@ export function createAuditLog(setting: boolean | string | undefined): AuditLog
46
69
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
47
70
  try { if (fs.statSync(file).size > MAX_BYTES) fs.renameSync(file, `${file}.1`); } catch { /* no file yet */ }
48
71
  fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW, 0o600);
49
- const safe: AuditRecord = {
50
- ...record,
51
- resources: record.resources.slice(0, MAX_RESOURCES).map((resource) => clip(resource, MAX_RESOURCE_CHARS)),
52
- reason: clip(record.reason, MAX_REASON_CHARS),
53
- ...(record.errorDetail === undefined ? {} : { errorDetail: clip(record.errorDetail, MAX_REASON_CHARS) }),
54
- };
55
- fs.writeSync(fd, `${JSON.stringify(safe)}\n`);
72
+ fs.writeSync(fd, `${JSON.stringify(sanitizeRecord(record))}\n`);
56
73
  } catch {
57
74
  // Auditing is best effort and must not interrupt permission review.
58
75
  } finally { if (fd !== undefined) fs.closeSync(fd); }
package/src/classifier.ts CHANGED
@@ -2,6 +2,7 @@ import { classify } from "./tiers.js";
2
2
  import type { ClassifierStage } from "./config.js";
3
3
  import type { PermissionRequest, ReviewResult, StageResult } from "./reviewer.js";
4
4
  import { guardShell } from "./shell.js";
5
+ import type { ReviewErrorKind } from "./errors.js";
5
6
 
6
7
  export async function compositeDecision(
7
8
  stages: ClassifierStage[],
@@ -15,8 +16,14 @@ export async function compositeDecision(
15
16
  const result = classify(request.action, request.resources);
16
17
  if (result.tier === "auto-deny") return { decision: "deny", reason: result.reason, stage: "static" };
17
18
  }
18
- if (shellResult) return { ...shellResult, stage: "shell-guard" };
19
+ if (shellResult) {
20
+ // Parsed read-only results feed the static stage, but never bypass a model-only pipeline.
21
+ const hasStatic = stages.some((stage) => stage.type === "static");
22
+ if (shellResult.decision !== "allow" || hasStatic)
23
+ return { ...shellResult, stage: shellResult.decision === "allow" ? "static" : "shell-guard" };
24
+ }
19
25
  let abstained: string | undefined;
26
+ let abstainedKind: ReviewErrorKind | undefined;
20
27
  for (const stage of stages) {
21
28
  if (stage.type === "static") {
22
29
  const result = classify(request.action, request.resources, {
@@ -25,13 +32,22 @@ export async function compositeDecision(
25
32
  if (result.tier === "auto-allow") return { decision: "allow", reason: result.reason, stage: "static" };
26
33
  } else {
27
34
  const result = await review(stage);
28
- if (result.decision === "abstain") { abstained = result.reason; continue; }
29
- return { decision: result.decision, reason: result.reason, stage: result.stage ?? stage.type };
35
+ if (result.decision === "abstain") {
36
+ abstained = result.reason;
37
+ abstainedKind ??= result.errorKind;
38
+ continue;
39
+ }
40
+ const terminal: ReviewResult = {
41
+ decision: result.decision, reason: result.reason, stage: result.stage ?? stage.type,
42
+ ...(result.errorKind === undefined ? {} : { errorKind: result.errorKind }),
43
+ };
44
+ return terminal;
30
45
  }
31
46
  }
32
47
  return {
33
48
  decision: "ask",
34
49
  reason: abstained ? `No classifier made a decision (${abstained})` : "No classifier made a decision",
35
50
  stage: "none",
51
+ ...(abstainedKind === undefined ? {} : { errorKind: abstainedKind }),
36
52
  };
37
53
  }
package/src/config.ts CHANGED
@@ -27,8 +27,13 @@ export interface AutoModeOptions {
27
27
  tui?: boolean;
28
28
  auditLog?: boolean | string;
29
29
  otlp?: boolean | OtlpOptions;
30
+ reviewTimeoutMs?: number;
30
31
  }
31
32
 
33
+ /** Longest a single review may block the permission prompt, across every stage. */
34
+ export const DEFAULT_REVIEW_TIMEOUT_MS = 60_000;
35
+ export const MAX_REVIEW_TIMEOUT_MS = 120_000;
36
+
32
37
  export function object(value: unknown): value is Record<string, unknown> {
33
38
  return typeof value === "object" && value !== null && !Array.isArray(value);
34
39
  }
@@ -115,9 +120,13 @@ function stage(value: unknown): value is ClassifierStage {
115
120
 
116
121
  export function parseOptions(value: unknown): AutoModeOptions {
117
122
  if (!object(value)) throw new Error("Invalid auto-mode options");
118
- if (!only(value, ["classifiers", "tui", "auditLog", "otlp"]))
123
+ if (!only(value, ["classifiers", "tui", "auditLog", "otlp", "reviewTimeoutMs"]))
119
124
  throw new Error("Unknown auto-mode option");
120
125
  if (value.tui !== undefined && typeof value.tui !== "boolean") throw new Error("Invalid TUI setting");
126
+ if (value.reviewTimeoutMs !== undefined &&
127
+ (typeof value.reviewTimeoutMs !== "number" || !Number.isInteger(value.reviewTimeoutMs) ||
128
+ value.reviewTimeoutMs < 1_000 || value.reviewTimeoutMs > MAX_REVIEW_TIMEOUT_MS))
129
+ throw new Error("Invalid review timeout");
121
130
  if (value.auditLog !== undefined && typeof value.auditLog !== "boolean" && !text(value.auditLog))
122
131
  throw new Error("Invalid audit log");
123
132
  if (value.otlp !== undefined && typeof value.otlp !== "boolean" && !otlp(value.otlp)) throw new Error("Invalid OTLP options");
package/src/decisions.ts CHANGED
@@ -12,6 +12,7 @@ import { AIError, InvalidProviderOutputError, ModelID } from "@opencode/ai";
12
12
  import { localEndpoint, parseReviewerModel, type DecisionStage } from "./config.js";
13
13
  import { buildReviewPrompt, type Decision, type PermissionRequest, type StageResult } from "./reviewer.js";
14
14
  import type { ReviewPolicy } from "./policy.js";
15
+ import { errorKindOf, ReviewFailure } from "./errors.js";
15
16
 
16
17
  const choices = {
17
18
  allow: "The whole action is safe and authorized by the user for this exact scope.",
@@ -19,6 +20,8 @@ const choices = {
19
20
  ask: "Authorization, evidence, or impact is uncertain; the human must decide.",
20
21
  } as const;
21
22
  const MAX_BYTES = 65_536;
23
+ /** Bounds one typed provider request so a hung provider cannot hold the review. */
24
+ export const DEFAULT_DECISION_TIMEOUT_MS = 10_000;
22
25
  const SYSTEM_ONE_URL = "https://api.typesafe.ai/v1/systemone";
23
26
  const LOCAL_SYSTEM_ONE_URL = "http://127.0.0.1:8000/v1/systemone";
24
27
  const OPENAI_URL = "https://api.openai.com/v1/decisions";
@@ -28,7 +31,13 @@ const API_KEY_ENV = {
28
31
  openai: "OPENAI_API_KEY",
29
32
  } as const;
30
33
 
31
- type RuntimeDecisionStage = DecisionStage & { readonly apiKey?: string };
34
+ type RuntimeDecisionStage = DecisionStage & { readonly apiKey?: string; readonly signal?: AbortSignal };
35
+
36
+ /** Per-request abort signal: the stage timeout bound by the overall review deadline. */
37
+ function abortSignal(stage: RuntimeDecisionStage): AbortSignal {
38
+ const timeout = AbortSignal.timeout(stage.timeoutMs ?? DEFAULT_DECISION_TIMEOUT_MS);
39
+ return stage.signal === undefined ? timeout : AbortSignal.any([timeout, stage.signal]);
40
+ }
32
41
 
33
42
  function probability(value: unknown): value is number {
34
43
  return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
@@ -41,14 +50,14 @@ function object(value: unknown): value is Record<string, unknown> {
41
50
  function answer(value: unknown, stage: RuntimeDecisionStage): StageResult {
42
51
  if (!object(value) || value.type !== "choice" || typeof value.choice !== "string" || !Object.hasOwn(choices, value.choice) ||
43
52
  !probability(value.confidence) || !object(value.probabilities))
44
- throw new Error("Invalid decision response");
53
+ throw new ReviewFailure("invalid-output", "Invalid decision response");
45
54
  const entries = Object.entries(value.probabilities);
46
55
  if (entries.length !== 3 || entries.some(([key, value]) => !Object.hasOwn(choices, key) || !probability(value)))
47
- throw new Error("Invalid decision probabilities");
56
+ throw new ReviewFailure("invalid-output", "Invalid decision probabilities");
48
57
  const probabilities = value.probabilities as Record<Decision, number>;
49
58
  if (Math.abs(Object.values(probabilities).reduce((a, b) => a + b, 0) - 1) > 0.01 ||
50
59
  probabilities[value.choice as Decision] < Math.max(...Object.values(probabilities)))
51
- throw new Error("Inconsistent decision probabilities");
60
+ throw new ReviewFailure("invalid-output", "Inconsistent decision probabilities");
52
61
  if (value.choice === "ask") return { decision: "ask", reason: `${stage.model} requested human review` };
53
62
  const threshold = stage.minConfidence ?? 0.9;
54
63
  if (value.confidence < threshold || probabilities[value.choice as Decision] < threshold) {
@@ -58,6 +67,11 @@ function answer(value: unknown, stage: RuntimeDecisionStage): StageResult {
58
67
  return { decision: value.choice as Decision, reason: `${stage.model} decision (confidence ${value.confidence})` };
59
68
  }
60
69
 
70
+ function parseJSON(value: string): unknown {
71
+ try { return JSON.parse(value); }
72
+ catch (error) { throw new ReviewFailure("invalid-output", "Decision response is not valid JSON", { cause: error }); }
73
+ }
74
+
61
75
  async function readJSON(response: Response): Promise<unknown> {
62
76
  if (!response.ok || !response.body) throw new Error("Decision provider failed");
63
77
  const reader = response.body.getReader();
@@ -68,10 +82,10 @@ async function readJSON(response: Response): Promise<unknown> {
68
82
  const { done, value } = await reader.read();
69
83
  if (done) break;
70
84
  size += value.length;
71
- if (size > MAX_BYTES) throw new Error("Decision response too large");
85
+ if (size > MAX_BYTES) throw new ReviewFailure("invalid-output", "Decision response too large");
72
86
  chunks.push(value);
73
87
  }
74
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
88
+ return parseJSON(Buffer.concat(chunks).toString("utf8"));
75
89
  } finally {
76
90
  await reader.cancel().catch(() => {});
77
91
  reader.releaseLock();
@@ -89,8 +103,10 @@ function systemOneEndpoint(stage: RuntimeDecisionStage): string {
89
103
  }
90
104
 
91
105
  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");
106
+ if (!object(value) || value.type !== "choice" || typeof value.choice !== "string")
107
+ throw new ReviewFailure("invalid-output", "Invalid System One answer");
108
+ if (!object(value.probabilities) || !probability(value.confidence))
109
+ throw new ReviewFailure("invalid-output", "Invalid System One answer");
94
110
  return {
95
111
  type: "choice",
96
112
  choice: value.choice,
@@ -107,7 +123,7 @@ function systemOneRoute(stage: RuntimeDecisionStage, modelID: string, url: strin
107
123
  const payload = JSON.stringify({ model: modelID, state: request.state, questions: request.questions });
108
124
  if (Buffer.byteLength(payload) > MAX_BYTES) throw new Error("Decision request too large");
109
125
  const response = await fetcher(url, {
110
- method: "POST", redirect: "error", signal: AbortSignal.timeout(stage.timeoutMs ?? 10_000),
126
+ method: "POST", redirect: "error", signal: abortSignal(stage),
111
127
  headers: { "Content-Type": "application/json", ...(stage.apiKey === undefined ? {} : { Authorization: `Bearer ${stage.apiKey}` }) },
112
128
  body: payload,
113
129
  });
@@ -172,21 +188,22 @@ async function openAIDecision(
172
188
  const payload = JSON.stringify(body);
173
189
  if (Buffer.byteLength(payload) > MAX_BYTES) throw new Error("Decision request too large");
174
190
  const response = await fetcher(OPENAI_URL, {
175
- method: "POST", redirect: "error", signal: AbortSignal.timeout(stage.timeoutMs ?? 10_000),
191
+ method: "POST", redirect: "error", signal: abortSignal(stage),
176
192
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${stage.apiKey}` },
177
193
  body: payload,
178
194
  });
179
195
  const parsed = await readJSON(response);
180
196
  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");
197
+ parsed.answers[0].name !== "permission") throw new ReviewFailure("invalid-output", "Invalid OpenAI answer");
182
198
  const selected = parsed.answers[0];
183
199
  if (selected.type === "refusal") return { decision: "abstain", reason: `${stage.model} declined the decision` };
184
200
  if (!Array.isArray(selected.probabilities) || selected.probabilities.length !== 3)
185
- throw new Error("Invalid OpenAI probabilities");
201
+ throw new ReviewFailure("invalid-output", "Invalid OpenAI probabilities");
186
202
  const distribution: Record<string, unknown> = {};
187
203
  for (const entry of selected.probabilities) {
188
204
  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");
205
+ Object.hasOwn(distribution, entry.value))
206
+ throw new ReviewFailure("invalid-output", "Invalid OpenAI probability entry");
190
207
  distribution[entry.value] = entry.probability;
191
208
  }
192
209
  return answer({ ...selected, probabilities: distribution }, stage);
@@ -197,6 +214,7 @@ export async function decisionStage(
197
214
  request: PermissionRequest,
198
215
  policy: ReviewPolicy,
199
216
  fetcher: typeof fetch = fetch,
217
+ signal?: AbortSignal,
200
218
  ): Promise<StageResult> {
201
219
  try {
202
220
  const ref = parseReviewerModel(input.model);
@@ -204,10 +222,15 @@ export async function decisionStage(
204
222
  const stage: RuntimeDecisionStage = {
205
223
  ...input,
206
224
  apiKey: process.env[input.apiKeyEnv ?? API_KEY_ENV[ref.providerID as keyof typeof API_KEY_ENV]],
225
+ signal,
207
226
  };
208
227
  return ref.providerID === "openai" ? await openAIDecision(stage, request, policy, fetcher) :
209
228
  await systemOneDecision(stage, request, policy, fetcher);
210
- } catch {
211
- return { decision: "abstain", reason: `${input.model} unavailable or returned an invalid decision` };
229
+ } catch (error) {
230
+ return {
231
+ decision: "abstain",
232
+ reason: `${input.model} unavailable or returned an invalid decision`,
233
+ errorKind: errorKindOf(error),
234
+ };
212
235
  }
213
236
  }
package/src/errors.ts ADDED
@@ -0,0 +1,39 @@
1
+ /** Stable, bounded categories for a review that could not reach a confident decision. */
2
+ export type ReviewErrorKind = "deadline" | "timeout" | "cancelled" | "provider" | "invalid-output" | "context";
3
+
4
+ export class ReviewFailure extends Error {
5
+ constructor(readonly kind: ReviewErrorKind, message: string, options?: { cause?: unknown }) {
6
+ super(message, options);
7
+ this.name = "ReviewFailure";
8
+ }
9
+ }
10
+
11
+ /** Finds an explicit category even when a provider library wraps our failure. */
12
+ function explicitKind(error: unknown): ReviewErrorKind | undefined {
13
+ let current = error;
14
+ for (let depth = 0; depth < 4; depth++) {
15
+ if (current instanceof ReviewFailure) return current.kind;
16
+ if (typeof current !== "object" || current === null) return undefined;
17
+ const cause = (current as { cause?: unknown }).cause;
18
+ if (cause === undefined || cause === current) return undefined;
19
+ current = cause;
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ /** Maps arbitrary thrown values to a bounded category without exposing provider text. */
25
+ export function errorKindOf(error: unknown): ReviewErrorKind {
26
+ // Only ReviewFailure marks malformed reviewer output. Provider text such as
27
+ // "Invalid API key" stays a provider failure even when a wrapper reuses it.
28
+ const explicit = explicitKind(error);
29
+ if (explicit !== undefined) return explicit;
30
+ const name = error instanceof Error ? error.name : "";
31
+ const message = error instanceof Error ? error.message : "";
32
+ // AbortSignal.timeout() aborts with a TimeoutError whose message also says
33
+ // "aborted"; a provider's own timeout is not a user interruption.
34
+ if (name === "TimeoutError" ||
35
+ (typeof DOMException === "function" && error instanceof DOMException && error.code === DOMException.TIMEOUT_ERR)) return "timeout";
36
+ if (name === "AbortError" || /abort|cancel/i.test(message)) return "cancelled";
37
+ if (/timeout|timed out/i.test(message)) return "timeout";
38
+ return "provider";
39
+ }