From cc699a1402179d50383358b2f2202081d0deb314 Mon Sep 17 00:00:00 2001 From: Terada Kousuke Date: Mon, 6 Apr 2026 23:43:10 +0900 Subject: [PATCH 1/2] fix(guardrails): add plugin field to profile config for runtime loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without `"plugin": [...]` in opencode.json, the guardrail.ts and team.ts plugins are never loaded at runtime. The scenario tests pass because they call Plugin.trigger() directly, bypassing config-based loading. Verified via: `OPENCODE_CONFIG_DIR=./packages/guardrails/profile opencode debug config --print-logs` → "loading plugin" log entries for both guardrail.ts and team.ts confirmed. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/guardrails/profile/opencode.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/guardrails/profile/opencode.json b/packages/guardrails/profile/opencode.json index 59fafa811659..73f1e5e31cb2 100644 --- a/packages/guardrails/profile/opencode.json +++ b/packages/guardrails/profile/opencode.json @@ -2,6 +2,7 @@ "$schema": "https://opencode.ai/config.json", "instructions": ["AGENTS.md"], "default_agent": "implement", + "plugin": ["./plugins/guardrail.ts", "./plugins/team.ts"], "enabled_providers": [ "zai", "zai-coding-plan", From 77f89c0a92df96cf5a0da4a473a1f1f1d7fd02d7 Mon Sep 17 00:00:00 2001 From: Terada Kousuke Date: Mon, 6 Apr 2026 23:44:30 +0900 Subject: [PATCH 2/2] test(guardrails): add plugin config load + firing integration test Verifies the complete plugin lifecycle from config to runtime: 1. Plugin is discovered from profile opencode.json plugin field 2. Config.get() includes plugin references 3. session.created fires and initializes state.json with all fields 4. events.jsonl records session.created 5. Secret file read triggers hard block 6. Test execution tracking (tests_executed flag) 7. shell.env exposes OPENCODE_GUARDRAIL_MODE/ROOT/STATE 20 tests / 208 assertions ALL PASS. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/test/scenario/guardrails.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/packages/opencode/test/scenario/guardrails.test.ts b/packages/opencode/test/scenario/guardrails.test.ts index dbfe1ee6752c..c0fdf4736ea6 100644 --- a/packages/opencode/test/scenario/guardrails.test.ts +++ b/packages/opencode/test/scenario/guardrails.test.ts @@ -1046,6 +1046,79 @@ test("guardrail delegation gates and quality hooks fire correctly", async () => }) }, 15000) +test("guardrail plugin loads from profile config and fires session.created", async () => { + await withProfile(async () => { + await using tmp = await tmpdir({ git: true }) + const files = guard(tmp.path) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // 1. Verify plugin is loaded from config + const plugins = await Plugin.list() + const guardrailPlugin = plugins.find( + (item) => typeof item.event === "function" && typeof item["tool.execute.before"] === "function", + ) + expect(guardrailPlugin).toBeDefined() + + // 2. Verify config includes plugin field + const cfg = await Config.get() + expect(cfg.plugin).toBeDefined() + expect(cfg.plugin!.some((p: unknown) => typeof p === "string" && String(p).includes("guardrail"))).toBe(true) + + // 3. Fire session.created and verify state file is written + await guardrailPlugin!.event!({ + event: { + type: "session.created", + properties: { sessionID: "session_config_load_test" }, + }, + } as any) + await wait() + + const state = await Bun.file(files.state).json() + expect(state.mode).toBe("enforced") + expect(state.active_tasks).toEqual({}) + expect(state.active_task_count).toBe(0) + expect(state.llm_call_count).toBe(0) + expect(state.llm_calls_by_provider).toEqual({}) + expect(state.session_providers).toEqual([]) + expect(state.consecutive_failures).toBe(0) + expect(state.issue_verification_done).toBe(false) + + // 4. Verify events.jsonl was written + const logText = await Bun.file(files.log).text() + expect(logText).toContain("session.created") + expect(logText).toContain("session_config_load_test") + + // 5. Fire tool.execute.before for secret file — verify hard block + await expect( + Plugin.trigger( + "tool.execute.before", + { tool: "read", sessionID: "session_config_load_test", callID: "call_secret" }, + { args: { filePath: path.join(tmp.path, ".env.production") } }, + ), + ).rejects.toThrow("secret material") + + // 6. Fire tool.execute.after for bash — verify state tracking + await Plugin.trigger( + "tool.execute.after", + { tool: "bash", sessionID: "session_config_load_test", callID: "call_test", args: { command: "bun test" } }, + { title: "bash", output: "19 pass", metadata: { exitCode: 0 } }, + ) + const updatedState = await Bun.file(files.state).json() + expect(updatedState.tests_executed).toBe(true) + + // 7. Verify shell.env hook exposes guardrail env vars + const envOut = { env: {} as Record } + await guardrailPlugin!["shell.env"]!({ cwd: tmp.path } as any, envOut as any) + expect(envOut.env.OPENCODE_GUARDRAIL_MODE).toBe("enforced") + expect(envOut.env.OPENCODE_GUARDRAIL_ROOT).toBeDefined() + expect(envOut.env.OPENCODE_GUARDRAIL_STATE).toBeDefined() + }, + }) + }) +}, 15000) + for (const replay of Object.values(replays)) { it.live(`guardrail replay keeps ${replay.command} executable`, () => run(replay).pipe(