From da467b3261e7b42a5628d83e135b8a6d7a12677a Mon Sep 17 00:00:00 2001 From: mevech Date: Wed, 21 Oct 2026 15:40:15 +0200 Subject: [PATCH 1/3] feat: support opencode 2 plugin API opencode 2 rejects the V1 function export with only a log warning, so the gate silently stopped running. The default export now carries both the V2 id/setup and the V1 server(), and the guard covers the V2 tool names (shell, patch) and the edit/write `path` field. --- README.md | 34 +++++++++++--- SECURITY.md | 3 ++ adapters/opencode.test.ts | 94 +++++++++++++++++++++++++++++++++++++-- adapters/opencode.ts | 77 +++++++++++++++++++++----------- 4 files changed, 171 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index de3897b..0f7f7ad 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,16 @@ See [`guardrails.example.md`](guardrails.example.md) — copy it to `GUARDRAILS. ### 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 { @@ -94,6 +103,11 @@ Add the plugin to `opencode.json` (global or per project): } ``` +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 | | 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 diff --git a/SECURITY.md b/SECURITY.md index 8ba91a4..08b3345 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,6 +33,9 @@ find a way to break the stated guarantees, we want to hear about it: - Claude Code hooks modules are an early-access API. The module fails closed through its `.catch` handler; an engine change that bypassed module hooks entirely would leave Bash ungated. +- opencode 2 skips a plugin that fails to load and only writes a warning to + its log, so bash runs ungated. Releases without opencode 2 support fail + this way. `opencode plugin list` shows whether the gate is loaded. - `GUARDRAILS.md` is read once at session start but lives in the repo — the edit tools refuse to touch it and the other protected paths, and the running session judges under the frozen copy, but a bash write can still diff --git a/adapters/opencode.test.ts b/adapters/opencode.test.ts index b6dea31..a989631 100644 --- a/adapters/opencode.test.ts +++ b/adapters/opencode.test.ts @@ -8,6 +8,7 @@ vi.mock("@typesafe-ai/sdk", () => sdk.module) const { systemOne } = sdk type Hook = (input: { tool: string }, output: { args: { command?: string; filePath?: string; patchText?: string } }) => Promise +type V2Hook = (event: { tool: string; input: { command?: string; path?: string; filePath?: string; patchText?: string } }) => Promise const allow = { answers: { destructive: { noul: 0.01 }, credentials: { noul: 0 } } } const blockDestructive = { answers: { destructive: { noul: 0.98 }, credentials: { noul: 0 } } } @@ -19,10 +20,22 @@ const showToast = vi.fn() async function loadHook(): Promise { const mod = await import("./opencode.ts") - const hooks = await mod.default({ directory: dir, client: { tui: { showToast } } } as never) + const hooks = await mod.default.server({ directory: dir, client: { tui: { showToast } } } as never) return hooks["tool.execute.before"] as Hook } +async function setupV2() { + const mod = await import("./opencode.ts") + const hook = vi.fn().mockResolvedValue({ dispose: async () => {} }) + await mod.default.setup({ location: { directory: dir }, tool: { hook } } as never) + return hook +} + +async function loadV2Hook(): Promise { + const hook = await setupV2() + return hook.mock.calls[0][1] as V2Hook +} + beforeEach(() => { vi.resetModules() vi.clearAllMocks() @@ -45,9 +58,11 @@ afterEach(() => { }) describe("opencode adapter", () => { - it("Given opencode's loader calls every export, When the module loads, Then every export is a function", async () => { + it("Given the opencode 1.x and 2.x loaders, When the module loads, Then the default export carries a V1 server and a V2 id and setup", async () => { const mod = await import("./opencode.ts") - for (const value of Object.values(mod)) expect(value).toBeTypeOf("function") + expect(mod.default.id).toBe("@bergetai/guardrails-md") + expect(mod.default.server).toBeTypeOf("function") + expect(mod.default.setup).toBeTypeOf("function") }) it("Given a non-bash tool, When it is called, Then the model is not asked", async () => { @@ -159,3 +174,76 @@ describe("opencode adapter: protected paths", () => { await expect(hook({ tool: "edit" }, { args: { filePath: join(dir, "guardrails.md") } })).resolves.toBeUndefined() }) }) + +describe("opencode 2 adapter", () => { + it("Given opencode 2, When setup runs, Then one execute.before tool hook is registered", async () => { + const hook = await setupV2() + expect(hook).toHaveBeenCalledTimes(1) + expect(hook).toHaveBeenCalledWith("execute.before", expect.any(Function)) + }) + + it("Given a safe verdict, When shell runs, Then the hook resolves", async () => { + const hook = await loadV2Hook() + await expect(hook({ tool: "shell", input: { command: "ls -la" } })).resolves.toBeUndefined() + expect(systemOne.mock.calls[0][0].state.text).toContain("ls -la") + }) + + it("Given a destructive verdict, When shell runs, Then the hook throws with opencode wording", async () => { + systemOne.mockResolvedValue(blockDestructive) + const hook = await loadV2Hook() + await expect(hook({ tool: "shell", input: { command: "rm -rf /data" } })).rejects.toThrow( + /destructive=0\.98[\s\S]*restart opencode/, + ) + }) + + it("Given a read tool, When it is called, Then the model is not asked", async () => { + const hook = await loadV2Hook() + await hook({ tool: "read", input: { path: join(dir, "guardrails.md") } }) + expect(systemOne).not.toHaveBeenCalled() + }) + + it("Given GUARDRAILS.md in the plugin location, When shell runs, Then the policy is judged", async () => { + writeFileSync(join(dir, "GUARDRAILS.md"), "# v2 rules") + const hook = await loadV2Hook() + await hook({ tool: "shell", input: { command: "ls" } }) + expect(systemOne.mock.calls[0][0].state.text).toContain("# v2 rules") + }) + + it("Given an edit on GUARDRAILS.md, When the hook runs, Then it throws and the model is not asked", async () => { + const hook = await loadV2Hook() + await expect(hook({ tool: "edit", input: { path: join(dir, "GUARDRAILS.md") } })).rejects.toThrow( + /protected file — GUARDRAILS\.md[\s\S]*restart opencode/, + ) + expect(systemOne).not.toHaveBeenCalled() + }) + + it("Given a write to a relative opencode.json, When the hook runs, Then it throws", async () => { + const hook = await loadV2Hook() + await expect(hook({ tool: "write", input: { path: "opencode.json" } })).rejects.toThrow(/protected file — opencode\.json/) + }) + + it("Given a patch touching .opencode/plugins, When the hook runs, Then it throws and the model is not asked", async () => { + const hook = await loadV2Hook() + const patchText = `*** Begin Patch\n*** Add File: .opencode/plugins/x.ts\n+export {}\n*** End Patch` + await expect(hook({ tool: "patch", input: { patchText } })).rejects.toThrow(/protected file — \.opencode\//) + expect(systemOne).not.toHaveBeenCalled() + }) + + it("Given an edit whose filePath is harmless and whose path is GUARDRAILS.md, When the hook runs, Then it throws", async () => { + const hook = await loadV2Hook() + await expect( + hook({ tool: "edit", input: { filePath: join(dir, "src", "a.ts"), path: join(dir, "GUARDRAILS.md") } }), + ).rejects.toThrow(/protected file — GUARDRAILS\.md/) + }) + + it("Given a patch that moves a file onto opencode.json, When the hook runs, Then it throws", async () => { + const hook = await loadV2Hook() + const patchText = `*** Begin Patch\n*** Update File: src/a.ts\n*** Move to: opencode.json\n@@\n*** End Patch` + await expect(hook({ tool: "patch", input: { patchText } })).rejects.toThrow(/protected file — opencode\.json/) + }) + + it("Given an edit on a plain source file, When the hook runs, Then it passes", async () => { + const hook = await loadV2Hook() + await expect(hook({ tool: "edit", input: { path: join(dir, "src", "a.ts") } })).resolves.toBeUndefined() + }) +}) diff --git a/adapters/opencode.ts b/adapters/opencode.ts index 990f4e4..2a09c9c 100644 --- a/adapters/opencode.ts +++ b/adapters/opencode.ts @@ -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" import { createGate, type Harness } from "../core.ts" +type Gate = ReturnType +type Args = { command?: unknown; filePath?: unknown; path?: unknown; patchText?: unknown } | undefined + function opencode(): Harness { const home = process.env.HOME return { @@ -33,7 +43,30 @@ function patchPaths(patchText: string): string[] { return paths } -export const SystemOneGate = (async ({ directory, client }) => { +async function guard(gate: Gate, tool: string, args: Args): Promise { + if (tool === "bash" || tool === "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. @@ -41,25 +74,15 @@ export const SystemOneGate = (async ({ directory, client }) => { 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 { + 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 } From 7bc4926a7ca55a0da230ae3e288f80be819b8fd5 Mon Sep 17 00:00:00 2001 From: mevech Date: Wed, 21 Oct 2026 15:45:04 +0200 Subject: [PATCH 2/3] chore: bump version to 0.8.0 --- .claude-plugin/plugin.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 0fb83b6..45afa69 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "guardrails-md", - "version": "0.7.0", + "version": "0.8.0", "description": "GUARDRAILS.md gate: a System One decision model judges every Bash command before it runs — destructive / credentials / GUARDRAILS.md policy", "author": { "name": "Berget AI" diff --git a/package-lock.json b/package-lock.json index df2959d..8b85f73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bergetai/guardrails-md", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bergetai/guardrails-md", - "version": "0.7.0", + "version": "0.8.0", "license": "MIT", "dependencies": { "@typesafe-ai/sdk": "^0.6.0" diff --git a/package.json b/package.json index b7c0b97..abae31f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bergetai/guardrails-md", - "version": "0.7.0", + "version": "0.8.0", "description": "GUARDRAILS.md gate for opencode, pi and Claude Code (hooks module): a System One decision model (TypeSafe Jev) judges every bash command before it runs — destructive / credentials / GUARDRAILS.md policy", "type": "module", "exports": { From 3f4977a688d0002bf65a743c7e8ef52bc746567b Mon Sep 17 00:00:00 2001 From: mevech Date: Thu, 22 Oct 2026 06:55:50 +0200 Subject: [PATCH 3/3] docs: no startup warning on opencode 2 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0f7f7ad..90fb0b7 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,8 @@ 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.**