@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/README.md CHANGED
@@ -1,162 +1,206 @@
1
1
  # OpenCode Auto Mode
2
2
 
3
- ## What this solves
3
+ Reviews OpenCode permission prompts automatically. OpenCode decides what needs
4
+ approval; the plugin reviews only `ask` actions.
4
5
 
5
- OpenCode's permission prompts can interrupt routine work. This plugin reviews actions automatically and returns **allow**, **deny**, or **ask**, leaving uncertain decisions to you. Combine built-in checks, your own CEL rules, decision providers, and an LLM reviewer to choose how approvals work.
6
+ ## Install and configure
6
7
 
7
- This is an independently maintained project for OpenCode v2 (`@opencode/plugin` 2.0). **Review applies to all agents by default**, including Build and Plan. An Auto agent is optional; adding one does not limit review unless you also configure `options.agents`.
8
+ Add this to `~/.config/opencode/opencode.jsonc`. It asks for permission on every
9
+ action and reviews those requests using built-in checks. No model or API key is
10
+ needed:
8
11
 
9
- ## Install from npm
10
-
11
- The public npm package is `@mortz.prk/opencode-auto-mode`.
12
+ ```jsonc
13
+ {
14
+ "$schema": "https://opencode.ai/config.json",
15
+ "permissions": [
16
+ { "action": "*", "resource": "*", "effect": "ask" }
17
+ ],
18
+ "plugins": [
19
+ {
20
+ "package": "@mortz.prk/opencode-auto-mode",
21
+ "options": {
22
+ "classifiers": [
23
+ { "type": "static" }
24
+ ]
25
+ }
26
+ }
27
+ ]
28
+ }
29
+ ```
12
30
 
13
- Add the plugin to your global `~/.config/opencode/opencode.jsonc` using an example below. OpenCode installs the package from npm; no login, registry configuration, or separate project dependency is needed. Restart the service after changing plugins:
31
+ Restart OpenCode after saving:
14
32
 
15
33
  ```sh
16
34
  opencode service restart
17
35
  ```
18
36
 
19
- ## Configuration examples
37
+ There is no default review pipeline, and empty options are invalid. Add at least
38
+ one review entry. Built-in checks deny dangerous shell commands, allow common
39
+ read-only commands, and send other parsed commands to the configured reviewers.
40
+ Dynamic syntax such as command substitution, nested shells, and stateful builtins
41
+ still requires manual approval.
20
42
 
21
- ### Built-in checks followed by an LLM
43
+ The snippets below change parts of that configuration; keep your `classifiers`
44
+ list when adding other options.
22
45
 
23
- This reviews **all agents** because `agents` is omitted. Built-in checks allow supported read-only commands such as `pwd` and block known dangerous shell operations. Other supported actions go to the LLM using OpenCode's default model; uncertain decisions require manual approval.
46
+ ## Add a typed decision model
24
47
 
25
- ```json
48
+ ```jsonc
26
49
  {
27
- "plugins": [
28
- {
29
- "package": "@mortz.prk/opencode-auto-mode",
30
- "options": {
31
- "review": "all"
32
- }
33
- }
34
- ]
50
+ "options": {
51
+ "classifiers": [
52
+ { "type": "static" },
53
+ { "type": "decision", "model": "typesafe-ai/jev-latest" }
54
+ ]
55
+ }
35
56
  }
36
57
  ```
37
58
 
38
- Set `options.model` to `{ "id": "your-model", "providerID": "your-provider" }` to select the reviewer model. `review: "all"` reviews eligible actions even when OpenCode would allow them; `review: "prompts"` reviews only actions already requiring approval. OpenCode's explicit denies remain authoritative.
59
+ | Provider | Setup |
60
+ | --- | --- |
61
+ | `typesafe-ai` | Set `TYPESAFE_API_KEY`. |
62
+ | `openai` | Set `OPENAI_API_KEY`. |
63
+ | `laya` | Run a loopback `/v1/systemone` service. `LAYA_API_KEY` is optional. |
64
+
65
+ Decision entries accept `minConfidence` (default `0.9`), `timeoutMs`, and
66
+ `apiKeyEnv`. `laya` also accepts `endpoint`.
39
67
 
40
- ### Static checks and your own rules, without a model
68
+ ## Add an LLM
41
69
 
42
- This also reviews **all agents**. `pwd` passes the static checks, `git status --short` matches the exact CEL allow rule, `curl` matches the deny rule, and edits or writes require your approval. Unmatched actions require manual approval because there is no model stage.
70
+ Add an `llm` entry after the built-in checks:
43
71
 
44
- ```json
72
+ ```jsonc
45
73
  {
46
- "plugins": [
47
- {
48
- "package": "@mortz.prk/opencode-auto-mode",
49
- "options": {
50
- "review": "all",
51
- "classifier": {
52
- "stages": [
53
- { "type": "static" },
54
- {
55
- "type": "rules",
56
- "rules": [
57
- {
58
- "expression": "action == 'shell' && command == 'git' && args == ['status', '--short']",
59
- "decision": "allow"
60
- },
61
- {
62
- "expression": "action == 'shell' && command == 'curl'",
63
- "decision": "deny"
64
- },
65
- {
66
- "expression": "action in ['edit', 'write']",
67
- "decision": "ask"
68
- }
69
- ]
70
- }
71
- ]
72
- }
73
- }
74
- }
75
- ]
74
+ "options": {
75
+ "classifiers": [
76
+ { "type": "static" },
77
+ { "type": "llm" }
78
+ ]
79
+ }
76
80
  }
77
81
  ```
78
82
 
79
- Rules use [Buf's CEL evaluator](https://github.com/bufbuild/cel-es). Available variables are `action`, `resource`, `command`, `args`, `agent`, `projectDir`, and `isDelegation`. For shell resources, `command` is the literal executable and `args` contains its decoded arguments. Every resource must be covered by allow rules within a rules stage. Matching deny rules take precedence across stages, followed by ask rules, before any allow decision. Collection macros are disabled; invalid expressions or evaluation limits require manual approval.
83
+ When an `llm` entry has no `model`, the **session model** is used. To choose a
84
+ different model:
80
85
 
81
- Shell review uses OpenCode's subcommand parsing and a literal-command guard. A permitted `pwd` does not authorize the rest of `pwd ; sudo rm -rf`; the dangerous operation is denied. Unsupported syntax, such as redirects or substitutions, and Windows shells require manual approval.
86
+ ```jsonc
87
+ { "type": "llm", "model": "anthropic/claude-sonnet-4-5" }
88
+ ```
82
89
 
83
- ### Decision providers with an LLM fallback
90
+ ## Combine reviewers
84
91
 
85
- This reviews **all agents** too. Static checks run first, followed by Jev, OpenAI Decisions, local Laya, and an LLM. Remove or reorder provider stages to suit your setup; you do not need all of them.
92
+ Entries run in order. Add as many as you need:
86
93
 
87
- ```json
94
+ ```jsonc
88
95
  {
89
- "plugins": [
90
- {
91
- "package": "@mortz.prk/opencode-auto-mode",
92
- "options": {
93
- "review": "all",
94
- "classifier": {
95
- "stages": [
96
- { "type": "static" },
97
- { "type": "jev", "minConfidence": 0.9 },
98
- { "type": "openai-decision", "minConfidence": 0.9 },
99
- { "type": "laya", "endpoint": "http://127.0.0.1:8000/v1/systemone" },
100
- { "type": "llm" }
101
- ]
102
- }
103
- }
104
- }
105
- ]
96
+ "options": {
97
+ "classifiers": [
98
+ { "type": "static" },
99
+ { "type": "decision", "model": "typesafe-ai/jev-latest" },
100
+ { "type": "llm" }
101
+ ]
102
+ }
106
103
  }
107
104
  ```
108
105
 
109
- | Stage | Setup |
110
- | --- | --- |
111
- | `jev` | Set `TYPESAFE_API_KEY`; default model is `jev-latest`. |
112
- | `openai-decision` | Set `OPENAI_API_KEY`; default model is `gpt-6-luna`. |
113
- | `laya` | Run a compatible local `/v1/systemone` service; optionally set `LAYA_API_KEY`. Only loopback endpoints are accepted. |
114
- | `llm` | Uses OpenCode's default model, or the configured reviewer model. |
115
-
116
- A valid provider allow/deny result below `minConfidence` continues to the next stage. An explicit ask, provider error, timeout, or invalid response requires manual approval and stops the pipeline. Export API keys in the environment of the OpenCode service. Provider stages also accept `model`, `apiKeyEnv`, and `timeoutMs`.
106
+ An entry can allow, deny, or ask, which stops the pipeline. If it abstains or
107
+ fails, the next entry runs. If nothing decides, the action remains `ask`.
117
108
 
118
- ## Tips: an optional Auto agent
109
+ ## Use it for specific agents
119
110
 
120
- Add an Auto agent if you want a separate agent for automatic review, then restrict the plugin with `options.agents: ["auto"]`. **Only this example limits review to Auto**; Build, Plan, and other agents retain their normal OpenCode permission behavior.
111
+ OpenCode allows everything by default. To review only the `build` agent, set its
112
+ permissions and leave global permissions unchanged:
121
113
 
122
- ```json
114
+ ```jsonc
123
115
  {
124
116
  "agents": {
125
- "auto": {
126
- "mode": "primary",
127
- "description": "Build with automatic permission review"
117
+ "build": {
118
+ "permissions": [
119
+ { "action": "*", "resource": "*", "effect": "ask" }
120
+ ]
128
121
  }
129
- },
130
- "plugins": [
131
- {
132
- "package": "@mortz.prk/opencode-auto-mode",
133
- "options": {
134
- "review": "all",
135
- "agents": ["auto"]
122
+ }
123
+ }
124
+ ```
125
+
126
+ ## TUI indicator
127
+
128
+ A passive indicator shows when the plugin is reviewing the current session.
129
+ It is enabled by default. Set `tui: false` in plugin options to hide it.
130
+ Invalid options show `auto-mode: invalid config` in the same place and an error
131
+ toast at startup. OpenCode's own permission dialog handles manual approvals.
132
+
133
+ ## Review options
134
+
135
+ Each LLM or typed decision reviewer can have its own policy:
136
+
137
+ ```jsonc
138
+ {
139
+ "options": {
140
+ "classifiers": [
141
+ {
142
+ "type": "llm",
143
+ "policy": {
144
+ "trusted": ["the repository and its CI configuration"],
145
+ "blocks": ["publish packages", "change production infrastructure"],
146
+ "exceptions": ["run the repository's test and lint scripts"]
147
+ }
136
148
  }
137
- }
138
- ]
149
+ ]
150
+ }
139
151
  }
140
152
  ```
141
153
 
142
- Select Auto alongside Build and Plan. To review all agents while keeping Auto available, omit `options.agents`.
154
+ `trusted` describes the trusted boundary, `blocks` describes actions to deny, and
155
+ `exceptions` describes narrow acceptable cases. Omitted lists use the built-in
156
+ policy; an empty list clears that list. Policies are independent between
157
+ reviewers and do not apply to static checks or replace OpenCode permissions.
143
158
 
144
- Keep ordinary OpenCode permission rules requiring approval for sensitive actions so prompts remain available when the plugin is disabled. `trusted`, `blocks`, and `exceptions` replace the corresponding model-review policy lists. Configure these globally. The TUI integration shows review status and provides manual allow/deny commands.
159
+ ## Auditing
145
160
 
146
- ## Publishing releases
161
+ Enable the audit log in plugin options:
147
162
 
148
- Open **Actions → Release npm package → Run workflow**, select `main`, choose `patch`, `minor`, or `major` (default: `patch`), and run it. This manual workflow runs tests, lint, and typecheck, increments `package.json`, checks the package, commits and tags the version, publishes to npm, and creates an immutable GitHub release with generated notes.
163
+ ```jsonc
164
+ {
165
+ "options": {
166
+ "auditLog": true
167
+ }
168
+ }
169
+ ```
149
170
 
150
- The same release can be started with one command:
171
+ Decisions are appended as JSON lines to
172
+ `$XDG_STATE_HOME/opencode-auto-mode/decisions.jsonl`. Replay recorded tool calls
173
+ without executing anything:
151
174
 
152
175
  ```sh
153
- gh workflow run publish.yml --repo mortezaPRK/opencode-auto-mode --ref main -f bump=patch
176
+ bun scripts/replay.ts [--days 14] [--session ID] [--agent build|any] [--json]
177
+ ```
178
+
179
+ To send decisions through OpenCode's OTLP telemetry, configure OTLP for OpenCode
180
+ with its standard environment variables, then enable the plugin flag. This sends
181
+ correlated decision logs and review traces, including model reviewer attempts:
182
+
183
+ ```jsonc
184
+ {
185
+ "options": {
186
+ "otlp": true
187
+ }
188
+ }
154
189
  ```
155
190
 
156
- Publishing uses the existing [npm trusted publisher](https://docs.npmjs.com/trusted-publishers/) for GitHub owner `mortezaPRK`, repository `opencode-auto-mode`, and workflow filename `publish.yml`. No npm token or additional personal access token is needed in repository secrets.
191
+ ## Development
157
192
 
158
- GitHub release immutability is enabled in the repository settings. Releases and tags do not trigger publication; only running this workflow does.
193
+ ```sh
194
+ bun install
195
+ bun test
196
+ bun run lint
197
+ bun run typecheck
198
+ bun run test:integration
199
+ ```
159
200
 
160
201
  ## Credit
161
202
 
162
- Created originally by [Andrew Mellen (mellena1)](https://github.com/mellena1) in [mellena1/opencode-auto-mode](https://github.com/mellena1/opencode-auto-mode), also credited as the author in [package.json](package.json). This independent project retains the original [MIT license](LICENSE).
203
+ Created originally by [Andrew Mellen (mellena1)](https://github.com/mellena1) in
204
+ [mellena1/opencode-auto-mode](https://github.com/mellena1/opencode-auto-mode),
205
+ also credited as the author in [package.json](package.json). This independent
206
+ project retains the original [MIT license](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mortz.prk/opencode-auto-mode",
3
- "version": "0.1.3",
3
+ "version": "1.0.1",
4
4
  "description": "Configurable automatic permission review for OpenCode v2.",
5
5
  "author": {
6
6
  "name": "Andrew Mellen",
@@ -36,9 +36,10 @@
36
36
  "test:integration": "bun run integration/opencode.ts"
37
37
  },
38
38
  "dependencies": {
39
- "@bufbuild/cel": "0.6.1",
40
- "@bufbuild/protobuf": "2.16.0",
39
+ "@opencode/ai": "2.0.30",
41
40
  "@opencode/plugin": "^2.0.14",
41
+ "@opencode/schema": "^2.0.30",
42
+ "effect": "4.0.1",
42
43
  "shell-quote": "1.12.0"
43
44
  },
44
45
  "peerDependencies": {
@@ -49,9 +50,9 @@
49
50
  "devDependencies": {
50
51
  "@eslint/js": "^10.0.1",
51
52
  "@opencode/theme": "^2.0.14",
52
- "@opentui/core": "0.5.17",
53
- "@opentui/keymap": "^0.5.10",
54
- "@opentui/solid": "0.5.17",
53
+ "@opentui/core": "0.6.6",
54
+ "@opentui/keymap": "^0.6.6",
55
+ "@opentui/solid": "0.6.6",
55
56
  "@types/node": "^26.6.4",
56
57
  "@types/shell-quote": "1.7.5",
57
58
  "@typescript/native": "npm:typescript@^7.0.2",
package/src/audit.ts ADDED
@@ -0,0 +1,61 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ export interface AuditRecord {
6
+ ts: string;
7
+ reviewID: string;
8
+ sessionID: string;
9
+ agent?: string;
10
+ action: string;
11
+ resources: string[];
12
+ hostEffect: string;
13
+ decision: string;
14
+ stage: string;
15
+ reason: string;
16
+ durationMs: number;
17
+ error?: boolean;
18
+ errorDetail?: string;
19
+ traceID?: string;
20
+ spanID?: string;
21
+ }
22
+
23
+ export interface AuditLog { write(record: AuditRecord): void }
24
+
25
+ const MAX_BYTES = 10 * 1024 * 1024;
26
+ const MAX_RESOURCES = 16;
27
+ const MAX_RESOURCE_CHARS = 512;
28
+ const MAX_REASON_CHARS = 512;
29
+
30
+ export function defaultAuditPath(): string {
31
+ const base = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
32
+ return path.join(base, "opencode-auto-mode", "decisions.jsonl");
33
+ }
34
+
35
+ function clip(value: string, max: number): string {
36
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
37
+ }
38
+
39
+ export function createAuditLog(setting: boolean | string | undefined): AuditLog | undefined {
40
+ if (setting === undefined || setting === false) return undefined;
41
+ const file = typeof setting === "string" ? setting : defaultAuditPath();
42
+ return {
43
+ write(record) {
44
+ let fd: number | undefined;
45
+ try {
46
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
47
+ try { if (fs.statSync(file).size > MAX_BYTES) fs.renameSync(file, `${file}.1`); } catch { /* no file yet */ }
48
+ 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`);
56
+ } catch {
57
+ // Auditing is best effort and must not interrupt permission review.
58
+ } finally { if (fd !== undefined) fs.closeSync(fd); }
59
+ },
60
+ };
61
+ }
package/src/classifier.ts CHANGED
@@ -1,53 +1,42 @@
1
1
  import { classify } from "./tiers.js";
2
2
  import type { ClassifierStage } from "./config.js";
3
- import type { PermissionRequest, ReviewResult } from "./reviewer.js";
4
- import { evaluateRules, type RuleMatch } from "./rules.js";
3
+ import type { PermissionRequest, ReviewResult, StageResult } from "./reviewer.js";
5
4
  import { guardShell } from "./shell.js";
6
5
 
7
- function ruleDecision(matches: RuleMatch[]): ReviewResult | undefined {
8
- for (const decision of ["deny", "ask"] as const) {
9
- if (matches.some((match) => match.rule.decision === decision && match.any))
10
- return { decision, reason: `user rule: ${decision}` };
11
- }
12
- if (matches[0]?.coverage.length && matches[0].coverage.every((_, index) =>
13
- matches.some((match) => match.rule.decision === "allow" && match.coverage[index])))
14
- return { decision: "allow", reason: "all resources match CEL allow rules" };
15
- return undefined;
16
- }
17
-
18
6
  export async function compositeDecision(
19
7
  stages: ClassifierStage[],
20
8
  request: PermissionRequest,
21
- review: (stage: Exclude<ClassifierStage, { type: "static" } | { type: "rules" }>) => Promise<ReviewResult | undefined>,
9
+ review: (stage: Exclude<ClassifierStage, { type: "static" }>) => Promise<StageResult>,
22
10
  ): Promise<ReviewResult> {
23
11
  const shellResult = request.action === "shell" ? guardShell(request.resources) : undefined;
24
- if (shellResult?.decision === "deny") return shellResult;
12
+ if (shellResult?.decision === "deny") return { ...shellResult, stage: "shell-guard" };
25
13
  for (const stage of stages) {
26
14
  if (stage.type !== "static") continue;
27
15
  const result = classify(request.action, request.resources);
28
- if (result.tier === "auto-deny") return { decision: "deny", reason: result.reason };
16
+ if (result.tier === "auto-deny") return { decision: "deny", reason: result.reason, stage: "static" };
29
17
  }
30
- let matches: RuleMatch[];
31
- try { matches = await evaluateRules(stages.flatMap((stage) => stage.type === "rules" ? stage.rules : []), request); }
32
- catch { return { decision: "ask", reason: "CEL rule evaluation failed; please decide yourself" }; }
33
- for (const decision of ["deny", "ask"] as const) {
34
- const result = ruleDecision(matches.filter((match) => match.rule.decision === decision));
35
- if (result) return result;
18
+ if (shellResult) {
19
+ // Parsed read-only results feed the static stage, but never bypass a model-only pipeline.
20
+ const hasStatic = stages.some((stage) => stage.type === "static");
21
+ if (shellResult.decision !== "allow" || hasStatic)
22
+ return { ...shellResult, stage: shellResult.decision === "allow" ? "static" : "shell-guard" };
36
23
  }
37
- if (shellResult) return shellResult;
24
+ let abstained: string | undefined;
38
25
  for (const stage of stages) {
39
26
  if (stage.type === "static") {
40
27
  const result = classify(request.action, request.resources, {
41
28
  projectDir: request.projectDir, allowInProjectEdits: stage.allowInProjectEdits,
42
29
  });
43
- if (result.tier === "auto-allow") return { decision: "allow", reason: result.reason };
44
- } else if (stage.type === "rules") {
45
- const result = ruleDecision(matches.filter((match) => stage.rules.includes(match.rule)));
46
- if (result) return result;
30
+ if (result.tier === "auto-allow") return { decision: "allow", reason: result.reason, stage: "static" };
47
31
  } else {
48
32
  const result = await review(stage);
49
- if (result) return result;
33
+ if (result.decision === "abstain") { abstained = result.reason; continue; }
34
+ return { decision: result.decision, reason: result.reason, stage: result.stage ?? stage.type };
50
35
  }
51
36
  }
52
- return { decision: "ask", reason: "No classifier made a decision" };
37
+ return {
38
+ decision: "ask",
39
+ reason: abstained ? `No classifier made a decision (${abstained})` : "No classifier made a decision",
40
+ stage: "none",
41
+ };
53
42
  }