@mortz.prk/opencode-auto-mode 0.1.2 → 1.0.0

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,167 +1,203 @@
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 leave everything else as `ask`.
20
40
 
21
- ### Built-in checks followed by an LLM
41
+ The snippets below change parts of that configuration; keep your `classifiers`
42
+ list when adding other options.
22
43
 
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.
44
+ ## Add a typed decision model
24
45
 
25
- ```json
46
+ ```jsonc
26
47
  {
27
- "plugins": [
28
- {
29
- "package": "@mortz.prk/opencode-auto-mode",
30
- "options": {
31
- "review": "all"
32
- }
33
- }
34
- ]
48
+ "options": {
49
+ "classifiers": [
50
+ { "type": "static" },
51
+ { "type": "decision", "model": "typesafe-ai/jev-latest" }
52
+ ]
53
+ }
35
54
  }
36
55
  ```
37
56
 
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.
57
+ | Provider | Setup |
58
+ | --- | --- |
59
+ | `typesafe-ai` | Set `TYPESAFE_API_KEY`. |
60
+ | `openai` | Set `OPENAI_API_KEY`. |
61
+ | `laya` | Run a loopback `/v1/systemone` service. `LAYA_API_KEY` is optional. |
62
+
63
+ Decision entries accept `minConfidence` (default `0.9`), `timeoutMs`, and
64
+ `apiKeyEnv`. `laya` also accepts `endpoint`.
39
65
 
40
- ### Static checks and your own rules, without a model
66
+ ## Add an LLM
41
67
 
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.
68
+ Add an `llm` entry after the built-in checks:
43
69
 
44
- ```json
70
+ ```jsonc
45
71
  {
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
- ]
72
+ "options": {
73
+ "classifiers": [
74
+ { "type": "static" },
75
+ { "type": "llm" }
76
+ ]
77
+ }
76
78
  }
77
79
  ```
78
80
 
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.
81
+ When an `llm` entry has no `model`, the **session model** is used. To choose a
82
+ different model:
80
83
 
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.
84
+ ```jsonc
85
+ { "type": "llm", "model": "anthropic/claude-sonnet-4-5" }
86
+ ```
82
87
 
83
- ### Decision providers with an LLM fallback
88
+ ## Combine reviewers
84
89
 
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.
90
+ Entries run in order. Add as many as you need:
86
91
 
87
- ```json
92
+ ```jsonc
88
93
  {
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
- ]
94
+ "options": {
95
+ "classifiers": [
96
+ { "type": "static" },
97
+ { "type": "decision", "model": "typesafe-ai/jev-latest" },
98
+ { "type": "llm" }
99
+ ]
100
+ }
106
101
  }
107
102
  ```
108
103
 
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`.
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`.
117
106
 
118
- ## Tips: an optional Auto agent
107
+ ## Use it for specific agents
119
108
 
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.
109
+ OpenCode allows everything by default. To review only the `build` agent, set its
110
+ permissions and leave global permissions unchanged:
121
111
 
122
- ```json
112
+ ```jsonc
123
113
  {
124
114
  "agents": {
125
- "auto": {
126
- "mode": "primary",
127
- "description": "Build with automatic permission review"
115
+ "build": {
116
+ "permissions": [
117
+ { "action": "*", "resource": "*", "effect": "ask" }
118
+ ]
128
119
  }
129
- },
130
- "plugins": [
131
- {
132
- "package": "@mortz.prk/opencode-auto-mode",
133
- "options": {
134
- "review": "all",
135
- "agents": ["auto"]
120
+ }
121
+ }
122
+ ```
123
+
124
+ ## TUI indicator
125
+
126
+ A passive indicator shows when the plugin is reviewing the current session.
127
+ It is enabled by default. Set `tui: false` in plugin options to hide it.
128
+ OpenCode's own permission dialog handles manual approvals.
129
+
130
+ ## Review options
131
+
132
+ Each LLM or typed decision reviewer can have its own policy:
133
+
134
+ ```jsonc
135
+ {
136
+ "options": {
137
+ "classifiers": [
138
+ {
139
+ "type": "llm",
140
+ "policy": {
141
+ "trusted": ["the repository and its CI configuration"],
142
+ "blocks": ["publish packages", "change production infrastructure"],
143
+ "exceptions": ["run the repository's test and lint scripts"]
144
+ }
136
145
  }
137
- }
138
- ]
146
+ ]
147
+ }
139
148
  }
140
149
  ```
141
150
 
142
- Select Auto alongside Build and Plan. To review all agents while keeping Auto available, omit `options.agents`.
151
+ `trusted` describes the trusted boundary, `blocks` describes actions to deny, and
152
+ `exceptions` describes narrow acceptable cases. Omitted lists use the built-in
153
+ policy; an empty list clears that list. Policies are independent between
154
+ reviewers and do not apply to static checks or replace OpenCode permissions.
143
155
 
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.
156
+ ## Auditing
145
157
 
146
- ## Publishing releases
158
+ Enable the audit log in plugin options:
147
159
 
148
- The package is public on npm. GitHub releases trigger `.github/workflows/publish.yml`, which uses npm trusted publishing through GitHub Actions OIDC. No npm token is needed in repository secrets.
160
+ ```jsonc
161
+ {
162
+ "options": {
163
+ "auditLog": true
164
+ }
165
+ }
166
+ ```
149
167
 
150
- For the first publication, enable two-factor authentication on the `mortz.prk` npm account. Merge the migration at version `0.1.1` to `main`, check out that commit, and run the project checks before publishing:
168
+ Decisions are appended as JSON lines to
169
+ `$XDG_STATE_HOME/opencode-auto-mode/decisions.jsonl`. Replay recorded tool calls
170
+ without executing anything:
151
171
 
152
172
  ```sh
153
- bun install --frozen-lockfile --ignore-scripts
154
- bun test
155
- bun run typecheck
156
- npm pack --dry-run
157
- npm login --registry=https://registry.npmjs.org
158
- npm publish --access public --ignore-scripts --registry=https://registry.npmjs.org
173
+ bun scripts/replay.ts [--days 14] [--session ID] [--agent build|any] [--json]
159
174
  ```
160
175
 
161
- After the package exists, configure its [npm trusted publisher](https://docs.npmjs.com/trusted-publishers/) with GitHub owner `mortezaPRK`, repository `opencode-auto-mode`, and workflow filename `publish.yml`. Leave the environment field empty and explicitly allow direct `npm publish`.
176
+ To send decisions through OpenCode's OTLP telemetry, configure OTLP for OpenCode
177
+ with its standard environment variables, then enable the plugin flag. This sends
178
+ correlated decision logs and review traces, including model reviewer attempts:
162
179
 
163
- For the first automated publication, bump the manifest to `0.1.2`, refresh the lockfile, merge to `main`, and publish GitHub release `v0.1.2` within two days of creating the trusted publisher. For later releases, update the version, merge to `main`, and publish a GitHub release with the matching `v<version>` tag. The workflow requires the tag's version to match `package.json` and its commit to be on `main`; it runs tests and a typecheck before publishing.
180
+ ```jsonc
181
+ {
182
+ "options": {
183
+ "otlp": true
184
+ }
185
+ }
186
+ ```
187
+
188
+ ## Development
189
+
190
+ ```sh
191
+ bun install
192
+ bun test
193
+ bun run lint
194
+ bun run typecheck
195
+ bun run test:integration
196
+ ```
164
197
 
165
198
  ## Credit
166
199
 
167
- 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).
200
+ Created originally by [Andrew Mellen (mellena1)](https://github.com/mellena1) in
201
+ [mellena1/opencode-auto-mode](https://github.com/mellena1/opencode-auto-mode),
202
+ also credited as the author in [package.json](package.json). This independent
203
+ 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.2",
3
+ "version": "1.0.0",
4
4
  "description": "Configurable automatic permission review for OpenCode v2.",
5
5
  "author": {
6
6
  "name": "Andrew Mellen",
@@ -16,7 +16,9 @@
16
16
  },
17
17
  "type": "module",
18
18
  "license": "MIT",
19
- "files": ["src"],
19
+ "files": [
20
+ "src"
21
+ ],
20
22
  "exports": {
21
23
  ".": {
22
24
  "import": "./src/index.ts",
@@ -28,14 +30,16 @@
28
30
  }
29
31
  },
30
32
  "scripts": {
33
+ "lint": "eslint . --max-warnings=0",
31
34
  "typecheck": "tsc --noEmit",
32
35
  "test": "bun test",
33
36
  "test:integration": "bun run integration/opencode.ts"
34
37
  },
35
38
  "dependencies": {
36
- "@bufbuild/cel": "0.6.1",
37
- "@bufbuild/protobuf": "2.16.0",
39
+ "@opencode/ai": "2.0.30",
38
40
  "@opencode/plugin": "^2.0.14",
41
+ "@opencode/schema": "^2.0.30",
42
+ "effect": "4.0.1",
39
43
  "shell-quote": "1.12.0"
40
44
  },
41
45
  "peerDependencies": {
@@ -44,13 +48,17 @@
44
48
  "solid-js": ">=1.9.0"
45
49
  },
46
50
  "devDependencies": {
51
+ "@eslint/js": "^10.0.1",
47
52
  "@opencode/theme": "^2.0.14",
48
- "@opentui/core": "0.5.10",
49
- "@opentui/keymap": "^0.5.10",
50
- "@opentui/solid": "0.5.10",
51
- "@types/node": "^24.0.0",
53
+ "@opentui/core": "0.6.6",
54
+ "@opentui/keymap": "^0.6.6",
55
+ "@opentui/solid": "0.6.6",
56
+ "@types/node": "^26.6.4",
52
57
  "@types/shell-quote": "1.7.5",
53
- "solid-js": "1.9.14",
54
- "typescript": "^5.8.2"
58
+ "@typescript/native": "npm:typescript@^7.0.2",
59
+ "eslint": "^10.12.0",
60
+ "solid-js": "1.9.17",
61
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
62
+ "typescript-eslint": "^8.71.1"
55
63
  }
56
64
  }
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,37 @@
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 };
29
- }
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;
16
+ if (result.tier === "auto-deny") return { decision: "deny", reason: result.reason, stage: "static" };
36
17
  }
37
- if (shellResult) return shellResult;
18
+ if (shellResult) return { ...shellResult, stage: "shell-guard" };
19
+ let abstained: string | undefined;
38
20
  for (const stage of stages) {
39
21
  if (stage.type === "static") {
40
22
  const result = classify(request.action, request.resources, {
41
23
  projectDir: request.projectDir, allowInProjectEdits: stage.allowInProjectEdits,
42
24
  });
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;
25
+ if (result.tier === "auto-allow") return { decision: "allow", reason: result.reason, stage: "static" };
47
26
  } else {
48
27
  const result = await review(stage);
49
- if (result) return result;
28
+ if (result.decision === "abstain") { abstained = result.reason; continue; }
29
+ return { decision: result.decision, reason: result.reason, stage: result.stage ?? stage.type };
50
30
  }
51
31
  }
52
- return { decision: "ask", reason: "No classifier made a decision" };
32
+ return {
33
+ decision: "ask",
34
+ reason: abstained ? `No classifier made a decision (${abstained})` : "No classifier made a decision",
35
+ stage: "none",
36
+ };
53
37
  }