Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
38 changes: 29 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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 |

Copy link
Copy Markdown

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 shell on the 1.x path (needed since 1.19.x renamed the tool — 0.7.0 gated nothing there).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Expand Down
3 changes: 3 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 91 additions & 3 deletions adapters/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
type V2Hook = (event: { tool: string; input: { command?: string; path?: string; filePath?: string; patchText?: string } }) => Promise<void>

const allow = { answers: { destructive: { noul: 0.01 }, credentials: { noul: 0 } } }
const blockDestructive = { answers: { destructive: { noul: 0.98 }, credentials: { noul: 0 } } }
Expand All @@ -19,10 +20,22 @@ const showToast = vi.fn()

async function loadHook(): Promise<Hook> {
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<V2Hook> {
const hook = await setupV2()
return hook.mock.calls[0][1] as V2Hook
}

beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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()
})
})
77 changes: 50 additions & 27 deletions adapters/opencode.ts
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 {
Expand All @@ -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") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 }
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Loading