@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 +143 -10
- package/dist/gui-experimental.cjs +1 -0
- package/package.json +23 -13
- package/src/audit.ts +24 -7
- package/src/classifier.ts +19 -3
- package/src/config.ts +10 -1
- package/src/decisions.ts +38 -15
- package/src/errors.ts +39 -0
- package/src/gui-state.ts +160 -0
- package/src/gui.tsx +129 -0
- package/src/index.ts +377 -98
- package/src/otlp.ts +18 -15
- package/src/reviewer.ts +11 -1
- package/src/rpc.ts +117 -0
- package/src/shell-policy.ts +1337 -0
- package/src/shell.ts +379 -18
- package/src/state.ts +75 -0
- package/src/status.ts +72 -0
- package/src/tiers.ts +2 -20
- package/src/timeout.ts +30 -0
- package/src/traces.ts +13 -7
- package/src/transcript.ts +145 -29
- package/src/tui-state.ts +7 -1
- package/src/tui.tsx +20 -5
package/README.md
CHANGED
|
@@ -28,19 +28,38 @@ needed:
|
|
|
28
28
|
}
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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.
|
|
105
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
41
|
-
"@opencode/schema": "
|
|
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.
|
|
47
|
-
"@opentui/solid": ">=0.
|
|
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": "
|
|
53
|
-
"@opentui/core": "0.
|
|
60
|
+
"@opencode/theme": "2.2.0",
|
|
61
|
+
"@opentui/core": "0.7.0",
|
|
54
62
|
"@opentui/keymap": "^0.6.6",
|
|
55
|
-
"@opentui/solid": "0.
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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") {
|
|
29
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
85
|
+
if (size > MAX_BYTES) throw new ReviewFailure("invalid-output", "Decision response too large");
|
|
72
86
|
chunks.push(value);
|
|
73
87
|
}
|
|
74
|
-
return
|
|
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")
|
|
93
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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))
|
|
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 {
|
|
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
|
+
}
|