Repository navigation
feat: support opencode 2 plugin API #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -78,22 +78,36 @@ shape: | |
| The gate reads the file once at session start, so restart the harness after | ||
| editing. Only the first 2000 characters are sent to the model, so keep the | ||
| file short and put the MUST NOT rules first. If the file is longer, the gate | ||
| warns you at startup and in every block message, and the rules after the cut | ||
| are not applied. | ||
| warns you in every block message and, except on opencode 2, also at startup. | ||
| The rules after the cut are not applied. | ||
| See [`guardrails.example.md`](guardrails.example.md) — copy it to `GUARDRAILS.md`. | ||
|
|
||
| **2. Install the gate in your harness.** | ||
|
|
||
| ### opencode | ||
|
|
||
| Add the plugin to `opencode.json` (global or per project): | ||
| Add the plugin to `opencode.json` (global or per project). opencode 2 reads | ||
| the `plugins` key: | ||
|
|
||
| ```json | ||
| { | ||
| "plugins": ["@bergetai/guardrails-md"] | ||
| } | ||
| ``` | ||
|
|
||
| opencode 1.x reads `plugin` instead: | ||
|
|
||
| ```json | ||
| { | ||
| "plugin": ["@bergetai/guardrails-md"] | ||
| } | ||
| ``` | ||
|
|
||
| The package supports opencode 1.18.29 and newer, including 2.x. Older 1.x | ||
| releases cannot load it. opencode 2 does not report a plugin that fails to load, | ||
| so after an upgrade check that `opencode plugin list` shows | ||
| `@bergetai/guardrails-md`. | ||
|
|
||
| The package was called `@bergetai/opencode-guardrails-md` up to 0.5.1. That | ||
| name is deprecated; replace it with `@bergetai/guardrails-md` in your config. | ||
|
|
||
|
|
@@ -137,8 +151,11 @@ export BERGET_API_KEY=… | |
|
|
||
| Keys come from [berget.ai](https://berget.ai). The free tier includes €5 of | ||
| credit, and a gate call is small enough that it lasts a long time. If you | ||
| are logged in to Berget in your harness (`@bergetai/opencode-auth` in opencode, | ||
| `/login` in pi), skip the key: the gate picks up your seat token. If you run your own | ||
| are logged in to Berget in your harness (`@bergetai/opencode-auth` in opencode | ||
| 1.x, `/login` in pi), skip the key: the gate picks up your seat token. opencode | ||
| 2 does not refresh that token. One left behind by 1.x is still used until it | ||
| expires, and after that the gate goes inactive without a warning. Set the key | ||
| on opencode 2. If you run your own | ||
| System One-compatible endpoint, point `BERGET_BASE_URL` at it instead. | ||
|
|
||
| From now on, every bash command your agent runs has to pass your guardrails | ||
|
|
@@ -421,7 +438,10 @@ Manual install for opencode: copy [`core.ts`](core.ts) and | |
| `~/.config/opencode/plugins/guardrails-md/` (global) or | ||
| `.opencode/plugins/guardrails-md/` (per project), keeping the `adapters/` | ||
| folder, and register | ||
| `"plugin": ["./plugins/guardrails-md/adapters/opencode.ts"]`. Manual installs | ||
| `"plugin": ["./plugins/guardrails-md/adapters/opencode.ts"]`. opencode 2 | ||
| loads `.opencode/plugins/` on its own but only from a root `index.ts`, so add | ||
| one containing `export { default } from "./adapters/opencode.ts"` instead of | ||
| registering the path. Manual installs | ||
| need `@typesafe-ai/sdk` resolvable (`npm install -g @typesafe-ai/sdk`); the | ||
| npm package brings it as a dependency. | ||
|
|
||
|
|
@@ -436,14 +456,14 @@ either. What differs is where the gate looks. | |
|
|
||
| | | opencode | pi | Claude Code | | ||
| |---|---|---|---| | ||
| | Hook | `tool.execute.before`: bash judged; `edit`, `write` and `apply_patch` checked against the protected paths | `tool_call`, including calls a codemode script makes: bash judged; `edit` and `write` checked against the protected paths | hooks module: `tool.call` on `Bash` and on `Monitor` when it runs a `command`; `Edit`, `Write` and `NotebookEdit` checked against the protected paths; `session.start` freezes the policy, the root and the environment | | ||
| | Hook | 1.x `tool.execute.before`: bash judged; `edit`, `write` and `apply_patch` checked against the protected paths. 2.x `tool.hook("execute.before")`: the same for `shell`, `edit`, `write` and `patch` | `tool_call`, including calls a codemode script makes: bash judged; `edit` and `write` checked against the protected paths | hooks module: `tool.call` on `Bash` and on `Monitor` when it runs a `command`; `Edit`, `Write` and `NotebookEdit` checked against the protected paths; `session.start` freezes the policy, the root and the environment | | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit — 1.x judges the "shell" tool (no "bash" built-in exists in current 1.x); wording only — guard() gates both names. |
||
| | On block | throws; the agent reads the message | returns `{ block, reason }` to the agent and shows a warning to you (on stderr in `pi -p`) | answers `{ deny }`; Claude reads the reason | | ||
| | Without a credential | inactive; one log line with `SYSTEMONE_LOG=1` | inactive; warns you once per session (on stderr in `pi -p`) | inactive; a toast warns you at session start | | ||
| | Seat token | `$XDG_DATA_HOME/opencode/auth.json` (default `~/.local/share`) | pi's Berget login, OAuth or API key, resolved by pi itself; then the OAuth entry in `$PI_CODING_AGENT_DIR/auth.json` (default `~/.pi/agent`) | none — `BERGET_API_KEY` (or `TYPESAFE_API_KEY`) only | | ||
| | Seat token | 1.x: `$XDG_DATA_HOME/opencode/auth.json` (default `~/.local/share`); 2.x: the same file, which 2.x never refreshes; set `BERGET_API_KEY` | pi's Berget login, OAuth or API key, resolved by pi itself; then the OAuth entry in `$PI_CODING_AGENT_DIR/auth.json` (default `~/.pi/agent`) | none — `BERGET_API_KEY` (or `TYPESAFE_API_KEY`) only | | ||
| | Policy file | `GUARDRAILS.md`, then `.opencode/GUARDRAILS.md` (lowercase legacy names after) | `GUARDRAILS.md`, then `.pi/GUARDRAILS.md` (lowercase legacy names after) | `GUARDRAILS.md`, then `.claude/GUARDRAILS.md` (lowercase legacy names after) | | ||
| | Policy read from | the project directory opencode passes the plugin | the directory pi was started in | the session's directory, frozen at `session.start` | | ||
| | Audit log | `~/.cache/opencode/systemone-gate.log` | `~/.cache/pi/systemone-gate.log` | not supported (`$.fs` cannot append) | | ||
| | Protected files | `edit`, `write`, `apply_patch` refuse protected paths; a block throws | `edit` and `write` refuse protected paths; a block returns `{ block, reason }` and warns you | `Edit`, `Write` and `NotebookEdit` refuse protected paths; the module answers `{ deny }`. Symlinks are resolved in all three | | ||
| | Protected files | `edit`, `write`, `apply_patch` (2.x: `patch`) refuse protected paths; a block throws | `edit` and `write` refuse protected paths; a block returns `{ block, reason }` and warns you | `Edit`, `Write` and `NotebookEdit` refuse protected paths; the module answers `{ deny }`. Symlinks are resolved in all three | | ||
|
|
||
| In pi, `/reload` counts as a restart: it re-reads `GUARDRAILS.md` and resets | ||
| the cooldown. pi's `powershell` tool is not gated; if a repo enables it in | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,28 @@ | ||
| /** | ||
| * guardrails-md for opencode — gates every bash command through core.ts | ||
| * guardrails-md for opencode — gates every shell command through core.ts | ||
| * before it runs, and deterministically refuses edits and writes to protected | ||
| * paths (edit/write/apply_patch): no model call, no threshold, no cooldown. | ||
| * A block throws, and opencode hands the message to the agent. Config warnings | ||
| * (bad threshold, truncated GUARDRAILS.md) are shown as a toast at load. | ||
| * paths: no model call, no threshold, no cooldown. A block throws, and | ||
| * opencode hands the message to the agent. | ||
| * | ||
| * Seat token: opencode's auth storage, maintained by @bergetai/opencode-auth. | ||
| * One default export serves both plugin APIs: opencode 1.x calls `server()`, | ||
| * opencode 2.x calls `setup()`. The two name their tools and arguments | ||
| * differently (bash/shell, apply_patch/patch, filePath/path). | ||
| * | ||
| * Config warnings (bad threshold, truncated GUARDRAILS.md) are a toast at load | ||
| * on 1.x; 2.x has no toast API, so there they only reach the block reasons. | ||
| * | ||
| * Seat token: opencode 1.x auth storage, maintained by @bergetai/opencode-auth. | ||
| * Guardrails: GUARDRAILS.md or .opencode/GUARDRAILS.md (legacy lowercase names | ||
| * are still read), frozen at plugin load. | ||
| * Log: ~/.cache/opencode/systemone-gate.log (SYSTEMONE_LOG=1). | ||
| */ | ||
| import type { Plugin } from "@opencode-ai/plugin" | ||
| import type { Plugin as V1Plugin } from "@opencode-ai/plugin" | ||
| import type { Plugin as V2 } from "@opencode/plugin" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit — @opencode/plugin is imported for types but not declared in package.json; only fails in local tsc/IDE runs since CI never typechecks and import type is erased. |
||
| import { createGate, type Harness } from "../core.ts" | ||
|
|
||
| type Gate = ReturnType<typeof createGate> | ||
| type Args = { command?: unknown; filePath?: unknown; path?: unknown; patchText?: unknown } | undefined | ||
|
|
||
| function opencode(): Harness { | ||
| const home = process.env.HOME | ||
| return { | ||
|
|
@@ -33,33 +43,46 @@ function patchPaths(patchText: string): string[] { | |
| return paths | ||
| } | ||
|
|
||
| export const SystemOneGate = (async ({ directory, client }) => { | ||
| async function guard(gate: Gate, tool: string, args: Args): Promise<void> { | ||
| if (tool === "bash" || tool === "shell") { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅ good — Matching both bash and shell also fixes 0.7.0 silently gating nothing on opencode 1.19.x, where the Bash tool was renamed to shell. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅ good — Checking both "bash" and "shell" is what fixes gating on opencode 1.19.x — current 1.x has no "bash" built-in, only "shell". |
||
| const block = await gate.check(String(args?.command ?? "").trim()) | ||
| if (block) throw new Error(block.reason) | ||
| return | ||
| } | ||
| // Both spellings are checked: a call carrying both must not pass on the one | ||
| // the tool ignores. | ||
| if (tool === "edit" || tool === "write") { | ||
| for (const path of [args?.filePath, args?.path]) { | ||
| const block = gate.checkPath(String(path ?? "")) | ||
| if (block) throw new Error(block.reason) | ||
| } | ||
| return | ||
| } | ||
| if (tool === "apply_patch" || tool === "patch") { | ||
| for (const path of patchPaths(String(args?.patchText ?? ""))) { | ||
| const block = gate.checkPath(path) | ||
| if (block) throw new Error(block.reason) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const server = (async ({ directory, client }) => { | ||
| const gate = createGate(opencode(), directory) | ||
| // Best effort: the TUI may not be up yet, and every block reason repeats | ||
| // these warnings anyway. | ||
| for (const message of gate.warnings) { | ||
| Promise.resolve(client?.tui?.showToast?.({ body: { message, variant: "warning" } })).catch(() => {}) | ||
| } | ||
| return { | ||
| "tool.execute.before": async (input, output) => { | ||
| if (input.tool === "bash") { | ||
| const block = await gate.check(String(output.args?.command ?? "").trim()) | ||
| if (block) throw new Error(block.reason) | ||
| return | ||
| } | ||
| if (input.tool === "edit" || input.tool === "write") { | ||
| const block = gate.checkPath(String(output.args?.filePath ?? "")) | ||
| if (block) throw new Error(block.reason) | ||
| return | ||
| } | ||
| if (input.tool === "apply_patch") { | ||
| for (const path of patchPaths(String(output.args?.patchText ?? ""))) { | ||
| const block = gate.checkPath(path) | ||
| if (block) throw new Error(block.reason) | ||
| } | ||
| } | ||
| }, | ||
| "tool.execute.before": (input, output) => guard(gate, input.tool, output.args), | ||
| } | ||
| }) satisfies Plugin | ||
| }) satisfies V1Plugin | ||
|
|
||
| export default SystemOneGate | ||
| export default { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅ good — The dual id/setup()/server() default export matches the officially documented V1+V2 support shape (V1 object entrypoints supported since 1.18.29). |
||
| id: "@bergetai/guardrails-md", | ||
| async setup(ctx) { | ||
| const gate = createGate(opencode(), ctx.location.directory) | ||
| await ctx.tool.hook("execute.before", (event) => guard(gate, event.tool, event.input as Args)) | ||
| }, | ||
| server, | ||
| } satisfies V2.Plugin & { server: V1Plugin } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 nit — The 1.x row says only "bash" is judged; the code now also matches
shellon the 1.x path (needed since 1.19.x renamed the tool — 0.7.0 gated nothing there).