diff --git a/docs/ai-guardrails/README.md b/docs/ai-guardrails/README.md index 7f9aafa925c..db405c17194 100644 --- a/docs/ai-guardrails/README.md +++ b/docs/ai-guardrails/README.md @@ -15,7 +15,7 @@ The migration goal is not to hide the upstream lineage. It is to make the fork l ## Operating principles -This plan inherits the key philosophy from `claude-code-skills` epic `#130`, its README, and its ADRs: +This plan inherits the key philosophy from `claude-code-skills` epic `#130`, its README, its ADRs, and Anthropic's skill construction guide: - enforce quality and safety through mechanism before prose - push checks to the fastest reliable layer first @@ -43,10 +43,10 @@ The main source set for this migration is: - `terisuke/claude-code-skills/docs/references/anthropic-skill-guide-summary.md` - `terisuke/claude-code-skills/docs/requirements/design-requirements-2026-03-24.md` - Claude Code official hooks and settings docs -- Anthropic skill guide PDF and summary +- Anthropic skill guide PDF (`The Complete Guide to Building Skills for Claude`) and summary - OpenCode rules, skills, commands, and plugins docs -At the time of writing, the source repository does not expose a separate document explicitly titled `BDF`, so the canonical reference set above is anchored to the documents that the source README, requirements, and epic `#130` actually cite. +In this migration, references to the `BDF` document should be interpreted as Anthropic's PDF `The Complete Guide to Building Skills for Claude`, which is the skill-construction guide the source repository philosophy lines up with operationally. When these sources disagree: @@ -66,6 +66,12 @@ The following rules are mandatory for guardrail work in this fork: - reuse Claude-compatible `SKILL.md` assets directly before rewriting them - keep OpenCode core close to upstream unless a missing extension point proves otherwise - do not let merge, release, or review freshness depend on agent goodwill alone +- design instructions with progressive disclosure: frontmatter/router text stays short, body text stays task-focused, and detail lives in linked references or deterministic mechanisms +- define success before implementation with triggering tests, functional tests, and baseline comparison where applicable +- prefer problem-first workflows and explicit outcomes over tool-first feature narration +- for critical validation, prefer deterministic scripts, plugins, commands, or CI over soft language-only reminders +- sync `upstream/dev` into fork `dev` before starting each issue branch unless a documented exception blocks it +- push issue branches after meaningful checkpoints so the remote repo is the recovery point for the next session ## Goal @@ -126,16 +132,16 @@ Bootstrap the first thin-distribution slice that keeps OpenCode upstream-friendl ## Tracking - Epic: [#1](https://github.com/Cor-Incorporated/opencode/issues/1) -- Current issue: [#3](https://github.com/Cor-Incorporated/opencode/issues/3) +- Current issue: [#4](https://github.com/Cor-Incorporated/opencode/issues/4) - Future slices remain separate issues so implementation can stay one issue per pull request. Issue `#2` is the merged bootstrap base. -Issue `#3` is complete only when: +Issue `#4` is complete only when: -- the inventory is committed and kept current -- repo docs explain `.claude` vs `.opencode` ownership rules -- a representative Claude-compatible skill fixture is exercised in scenario tests +- the plugin brief is committed and linked from the issue pack +- repo docs explain the plugin MVP in terms of the same canon +- scenario coverage proves the plugin loads and exercises the intended hooks - future implementation work can point back to this source canon instead of relying on memory ## Session rule @@ -143,8 +149,10 @@ Issue `#3` is complete only when: When continuing this work in future sessions: - start from the GitHub epic and the linked issue, not from memory +- sync fork `dev` with `upstream/dev` before opening the next issue branch when possible - preserve upstream compatibility unless a missing extension point proves otherwise - update docs and tests in the same change set when guardrail behavior changes +- push branch checkpoints to GitHub after meaningful milestones so the next session can resume from remote state - do not mark work complete unless runtime behavior is verified, not just implemented ## Artifact map diff --git a/docs/ai-guardrails/issues/003-guardrail-plugin-mvp.md b/docs/ai-guardrails/issues/003-guardrail-plugin-mvp.md new file mode 100644 index 00000000000..5487240acf5 --- /dev/null +++ b/docs/ai-guardrails/issues/003-guardrail-plugin-mvp.md @@ -0,0 +1,48 @@ +# Issue 003: Guardrail Plugin MVP + +## Problem + +The source harness derived much of its value from deterministic hook behavior, but OpenCode does not run Claude hooks directly. The first runtime policy slice therefore needs an OpenCode-native plugin that preserves the operating model without patching core. + +## Deliverables + +- packaged guardrail plugin under `packages/guardrails/profile/plugins/` +- packaged profile config that loads the plugin without core patches +- secret and state-file read blocking +- protection for linter/formatter config edits +- shell environment injection for policy mode and runtime state paths +- lifecycle logging for session and permission events +- compaction context stub that preserves guardrail state across handoff +- issue brief and canon updates that treat Anthropic's skill guide PDF as mandatory source input + +## Acceptance + +- the plugin loads from config, not from a core-only registration path +- `shell.env` injects guardrail mode metadata +- session lifecycle events are observed and recorded +- compaction hooks add guardrail state context +- scenario tests prove the runtime behavior without a deep core patch + +## Additional rule + +This issue must follow the source canon in `docs/ai-guardrails/README.md`, including the Anthropic skill guide PDF and the `claude-code-skills` epic `#130` philosophy: progressive disclosure, mechanism-first validation, and runtime proof over implementation claims. + +## Dependencies + +- ADR 001 +- ADR 003 +- ADR 004 +- Issue 001 +- Issue 002 + +## Sources + +- `claude-code-skills` README +- `claude-code-skills` epic `#130` +- `claude-code-skills/docs/references/harness-engineering-best-practices-2026.md` +- `claude-code-skills/docs/references/anthropic-skill-guide-summary.md` +- Anthropic `The Complete Guide to Building Skills for Claude` +- https://docs.anthropic.com/en/docs/claude-code/hooks +- https://docs.anthropic.com/en/docs/claude-code/settings +- https://opencode.ai/docs/plugins +- https://opencode.ai/docs/config diff --git a/docs/ai-guardrails/issues/README.md b/docs/ai-guardrails/issues/README.md index d289204f931..771db01c63e 100644 --- a/docs/ai-guardrails/issues/README.md +++ b/docs/ai-guardrails/issues/README.md @@ -19,4 +19,5 @@ No issue is complete unless: - linked scenario tests are green - any required ADR updates are committed in the same change set - the implementation follows the source canon fixed in `docs/ai-guardrails/README.md` +- the implementation also respects the Anthropic skill guide PDF fixed in that canon - the work can ship as a single issue-scoped pull request diff --git a/docs/ai-guardrails/migration/claude-code-skills-inventory.md b/docs/ai-guardrails/migration/claude-code-skills-inventory.md index 18ba24f3296..eea84159a2c 100644 --- a/docs/ai-guardrails/migration/claude-code-skills-inventory.md +++ b/docs/ai-guardrails/migration/claude-code-skills-inventory.md @@ -16,6 +16,7 @@ The inventory is derived from these sources: - `claude-code-skills/docs/references/harness-engineering-best-practices-2026.md` - `claude-code-skills/docs/references/anthropic-skill-guide-summary.md` - `claude-code-skills/docs/requirements/design-requirements-2026-03-24.md` +- Anthropic `The Complete Guide to Building Skills for Claude` - OpenCode official docs for rules, skills, commands, plugins, and agents - Claude Code official docs for hooks and settings @@ -26,7 +27,9 @@ The migration must preserve these non-negotiable ideas from `claude-code-skills` - deterministic quality gates via mechanism, not prompt prose - feedback speed hierarchy: fastest possible layer first - pointer-based instructions: keep always-loaded instructions short and move detail to ADRs/docs +- progressive disclosure: frontmatter/router text, instruction body, and linked references must each stay in their lane - "implemented" is not "working": deployment/runtime integrity must be verified as a system +- define concrete trigger and functional success cases before adding runtime enforcement - Codex and heavyweight automation are for bounded, mechanical, long-running work - GitHub and release gates must not rely on agent goodwill alone diff --git a/packages/guardrails/README.md b/packages/guardrails/README.md index 9e6d36f925f..c790f9201cc 100644 --- a/packages/guardrails/README.md +++ b/packages/guardrails/README.md @@ -8,7 +8,7 @@ It keeps upstream OpenCode as the runtime and adds organization policy at the ed - `bin/opencode-guardrails` sets `OPENCODE_CONFIG_DIR` to the packaged profile and then delegates to the pinned `opencode` dependency - `managed/opencode.json` is the admin-managed profile for system deployment -- `profile/` contains the packaged custom config dir defaults, starting with `AGENTS.md` and `opencode.json` +- `profile/` contains the packaged custom config dir defaults, including `AGENTS.md`, `opencode.json`, and the guardrail plugin ## Design intent @@ -20,6 +20,7 @@ This package exists to preserve the operating model imported from `claude-code-s - runtime verifiability over "the code exists, so it must work" Those principles come from `claude-code-skills` epic `#130` and are tracked in this fork under `docs/ai-guardrails/`. +They now also explicitly inherit Anthropic's `The Complete Guide to Building Skills for Claude` as the BDF-equivalent source for progressive disclosure, use-case-first design, and measurable testing discipline. ## Positioning @@ -44,7 +45,8 @@ Current contents focus on the first thin-distribution slice: - packaged wrapper entrypoint - managed enterprise defaults - packaged custom config dir profile -- scenario coverage for managed config precedence and project-local asset compatibility +- packaged plugin for runtime guardrail hooks +- scenario coverage for managed config precedence, project-local asset compatibility, and plugin behavior Planned next slices are tracked in the fork: diff --git a/packages/guardrails/profile/AGENTS.md b/packages/guardrails/profile/AGENTS.md index 11e8abd4dd8..6fa8fc788a3 100644 --- a/packages/guardrails/profile/AGENTS.md +++ b/packages/guardrails/profile/AGENTS.md @@ -4,6 +4,7 @@ - Prefer config, commands, agents, and plugins over core runtime patches. - Prefer mechanism over prose: enforce with plugins, commands, permissions, and CI before adding more instruction text. - Keep always-loaded instructions short and pointer-based; move detailed rationale into ADRs and docs. +- Keep skill-style progressive disclosure intact: brief routing text here, detailed rationale in docs, deterministic enforcement in plugins and commands. - Push checks to the fastest reliable layer first, then fall back to command workflows and CI for authoritative gates. - Keep project-local `.opencode` assets working; use them for repo-specific workflows instead of editing this profile unless the rule is organization-wide. -- Keep this first slice limited to thin-distribution defaults; add workflow-specific policy only in later issue-scoped changes. +- Treat `.opencode/guardrails/` as plugin-owned runtime state, not a manual editing surface. diff --git a/packages/guardrails/profile/opencode.json b/packages/guardrails/profile/opencode.json index b7e35fbaa57..716c582b1c2 100644 --- a/packages/guardrails/profile/opencode.json +++ b/packages/guardrails/profile/opencode.json @@ -5,6 +5,9 @@ "hostname": "127.0.0.1", "mdns": false }, + "plugin": [ + "./plugins/guardrail.ts" + ], "permission": { "edit": "ask", "task": "ask", diff --git a/packages/guardrails/profile/plugins/guardrail.ts b/packages/guardrails/profile/plugins/guardrail.ts new file mode 100644 index 00000000000..85a7b9460ec --- /dev/null +++ b/packages/guardrails/profile/plugins/guardrail.ts @@ -0,0 +1,192 @@ +import { mkdir } from "fs/promises" +import path from "path" + +const sec = [ + /(^|\/)\.env($|\.)/i, + /(^|\/).*\.pem$/i, + /(^|\/).*\.key$/i, + /(^|\/).*\.p12$/i, + /(^|\/).*\.pfx$/i, + /(^|\/).*\.crt$/i, + /(^|\/).*\.cer$/i, + /(^|\/).*\.der$/i, + /(^|\/).*id_rsa.*$/i, + /(^|\/).*id_ed25519.*$/i, + /(^|\/).*credentials.*$/i, +] + +const cfg = [ + /(^|\/)eslint\.config\.[^/]+$/i, + /(^|\/)\.eslintrc(\.[^/]+)?$/i, + /(^|\/)biome\.json(c)?$/i, + /(^|\/)prettier\.config\.[^/]+$/i, + /(^|\/)\.prettierrc(\.[^/]+)?$/i, +] + +const mut = [ + /\brm\b/i, + /\bmv\b/i, + /\bcp\b/i, + /\bchmod\b/i, + /\bchown\b/i, + /\btouch\b/i, + /\btruncate\b/i, + /\btee\b/i, + /\bsed\s+-i\b/i, + /\bperl\s+-pi\b/i, + />/, +] + +function norm(file: string) { + return path.resolve(file).replaceAll("\\", "/") +} + +function rel(root: string, file: string) { + const abs = norm(file) + const dir = norm(root) + if (!abs.startsWith(dir + "/")) return abs + return abs.slice(dir.length + 1) +} + +function has(file: string, list: RegExp[]) { + return list.some((item) => item.test(file)) +} + +function stash(file: string) { + return Bun.file(file) + .json() + .catch(() => ({} as Record)) +} + +async function save(file: string, data: Record) { + await Bun.write(file, JSON.stringify(data, null, 2) + "\n") +} + +async function line(file: string, data: Record) { + const prev = await Bun.file(file).text().catch(() => "") + await Bun.write(file, prev + JSON.stringify(data) + "\n") +} + +function text(err: string) { + return `Guardrail policy blocked this action: ${err}` +} + +function pick(args: unknown) { + if (!args || typeof args !== "object") return + if ("filePath" in args && typeof args.filePath === "string") return args.filePath +} + +function bash(cmd: string) { + return mut.some((item) => item.test(cmd)) +} + +export default async function guardrail(input: { + directory: string + worktree: string +}, opts?: Record) { + const mode = typeof opts?.mode === "string" ? opts.mode : "enforced" + const root = path.join(input.directory, ".opencode", "guardrails") + const log = path.join(root, "events.jsonl") + const state = path.join(root, "state.json") + + await mkdir(root, { recursive: true }) + + async function mark(data: Record) { + const prev = await stash(state) + await save(state, { ...prev, ...data, mode, updated_at: new Date().toISOString() }) + } + + async function seen(type: string, data: Record) { + await line(log, { type, time: new Date().toISOString(), ...data }) + } + + function note(props: Record | undefined) { + return { + sessionID: typeof props?.sessionID === "string" ? props.sessionID : undefined, + permission: typeof props?.permission === "string" ? props.permission : undefined, + patterns: Array.isArray(props?.patterns) ? props.patterns : undefined, + } + } + + function hidden(file: string) { + return rel(input.worktree, file).startsWith(".opencode/guardrails/") + } + + function deny(file: string, kind: "read" | "edit") { + const item = rel(input.worktree, file) + if (kind === "read" && has(item, sec)) return "secret material is outside the allowed read surface" + if (hidden(file)) return "guardrail runtime state is plugin-owned" + if (kind === "edit" && has(item, cfg)) return "linter or formatter configuration is policy-protected" + } + + return { + event: async ({ event }: { event: { type?: string; properties?: Record } }) => { + if (!event.type) return + if (!["session.created", "permission.asked", "session.idle", "session.compacted"].includes(event.type)) return + await seen(event.type, note(event.properties)) + if (event.type === "session.created") { + await mark({ + last_session: event.properties?.sessionID, + last_event: event.type, + }) + } + if (event.type === "permission.asked") { + await mark({ + last_permission: event.properties?.permission, + last_patterns: event.properties?.patterns, + last_event: event.type, + }) + } + if (event.type === "session.compacted") { + await mark({ + last_compacted: event.properties?.sessionID, + last_event: event.type, + }) + } + }, + "tool.execute.before": async ( + item: { tool: string; args?: unknown }, + out: { args: Record }, + ) => { + const file = pick(out.args ?? item.args) + if (file && (item.tool === "read" || item.tool === "edit" || item.tool === "write")) { + const err = deny(file, item.tool === "read" ? "read" : "edit") + if (!err) return + await mark({ last_block: item.tool, last_file: rel(input.worktree, file), last_reason: err }) + throw new Error(text(err)) + } + if (item.tool === "bash") { + const cmd = typeof out.args?.command === "string" ? out.args.command : "" + const file = cmd.replaceAll("\\", "/") + if (!cmd) return + if (has(file, sec) || file.includes(".opencode/guardrails/")) { + await mark({ last_block: "bash", last_command: cmd, last_reason: "shell access to protected files" }) + throw new Error(text("shell access to protected files")) + } + if (!bash(cmd)) return + if (!cfg.some((rule) => rule.test(file)) && !file.includes(".opencode/guardrails/")) return + await mark({ last_block: "bash", last_command: cmd, last_reason: "protected runtime or config mutation" }) + throw new Error(text("protected runtime or config mutation")) + } + }, + "shell.env": async (_item: { cwd: string }, out: { env: Record }) => { + out.env.OPENCODE_GUARDRAIL_MODE = mode + out.env.OPENCODE_GUARDRAIL_ROOT = root + out.env.OPENCODE_GUARDRAIL_STATE = state + }, + "experimental.session.compacting": async ( + _item: { sessionID: string }, + out: { context: string[]; prompt?: string }, + ) => { + const data = await stash(state) + out.context.push( + [ + `Guardrail mode: ${mode}.`, + `Preserve policy state from ${rel(input.worktree, state)} when handing work to the next agent.`, + `Last guardrail event: ${typeof data.last_event === "string" ? data.last_event : "none"}.`, + `Last guardrail block: ${typeof data.last_block === "string" ? data.last_block : "none"}.`, + ].join(" "), + ) + }, + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index fb60fa096e8..24482807b3d 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -219,11 +219,17 @@ export namespace Plugin { // Subscribe to bus events, fiber interrupted when scope closes yield* bus.subscribeAll().pipe( Stream.runForEach((input) => - Effect.sync(() => { - for (const hook of hooks) { - hook["event"]?.({ event: input as any }) - } - }), + Effect.forEach( + hooks, + (hook) => + Effect.tryPromise({ + try: () => Promise.resolve(hook["event"]?.({ event: input as any })), + catch: (err) => { + log.error("plugin event hook failed", { error: err, type: input.type }) + }, + }).pipe(Effect.ignore), + { discard: true }, + ), ), Effect.forkScoped, ) diff --git a/packages/opencode/test/scenario/guardrails.test.ts b/packages/opencode/test/scenario/guardrails.test.ts index a678e66bd78..ef53cfbae3d 100644 --- a/packages/opencode/test/scenario/guardrails.test.ts +++ b/packages/opencode/test/scenario/guardrails.test.ts @@ -4,6 +4,7 @@ import path from "path" import { Agent } from "../../src/agent/agent" import { Command } from "../../src/command" import { Config } from "../../src/config/config" +import { Plugin } from "../../src/plugin" import { Instance } from "../../src/project/instance" import { Skill } from "../../src/skill" import { Filesystem } from "../../src/util/filesystem" @@ -27,6 +28,30 @@ async function managedConfig(data: object) { await write(managed, "opencode.json", data) } +async function withProfile(fn: () => Promise) { + const prev = process.env.OPENCODE_CONFIG_DIR + process.env.OPENCODE_CONFIG_DIR = profile + try { + return await fn() + } finally { + if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = prev + } +} + +function guard(dir: string) { + const root = path.join(dir, ".opencode", "guardrails") + return { + root, + log: path.join(root, "events.jsonl"), + state: path.join(root, "state.json"), + } +} + +function wait(ms = 50) { + return new Promise((done) => setTimeout(done, ms)) +} + test("managed config overrides weaker project defaults", async () => { await using tmp = await tmpdir({ git: true, @@ -110,10 +135,7 @@ description: Internal ship gate skill. }) test("guardrail profile keeps defaults while allowing project-local commands, agents, and skills", async () => { - const prev = process.env.OPENCODE_CONFIG_DIR - process.env.OPENCODE_CONFIG_DIR = profile - - try { + await withProfile(async () => { await using tmp = await tmpdir({ git: true, init: async (dir) => { @@ -172,8 +194,105 @@ description: Project-local skill. expect(agents.some((item) => item.name === "project-review")).toBe(true) }, }) - } finally { - if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR - else process.env.OPENCODE_CONFIG_DIR = prev - } + }) +}) + +test("guardrail profile plugin injects shell env and blocks protected files", async () => { + await withProfile(async () => { + await using tmp = await tmpdir({ git: true }) + const files = guard(tmp.path) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + const env = await Plugin.trigger( + "shell.env", + { cwd: tmp.path, sessionID: "session_test", callID: "call_test" }, + { env: {} }, + ) + const vars = env.env as Record + + expect(cfg.plugin_origins?.some((item) => String(Array.isArray(item.spec) ? item.spec[0] : item.spec).includes("/plugins/guardrail.ts"))).toBe(true) + expect(vars.OPENCODE_GUARDRAIL_MODE).toBe("enforced") + expect(vars.OPENCODE_GUARDRAIL_ROOT).toBe(files.root) + expect(vars.OPENCODE_GUARDRAIL_STATE).toBe(files.state) + + await expect( + Plugin.trigger( + "tool.execute.before", + { tool: "read", sessionID: "session_test", callID: "call_test" }, + { args: { filePath: path.join(tmp.path, ".env") } }, + ), + ).rejects.toThrow("secret material") + + await expect( + Plugin.trigger( + "tool.execute.before", + { tool: "write", sessionID: "session_test", callID: "call_test" }, + { args: { filePath: path.join(tmp.path, "eslint.config.js"), content: "export default []" } }, + ), + ).rejects.toThrow("policy-protected") + }, + }) + }) +}) + +test("guardrail profile plugin records lifecycle events and compaction context", async () => { + await withProfile(async () => { + await using tmp = await tmpdir({ git: true }) + const files = guard(tmp.path) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const hook = (await Plugin.list()).find((item) => typeof item.event === "function") + expect(hook?.event).toBeDefined() + + await hook?.event?.({ + event: { + type: "session.created", + properties: { + sessionID: "session_test", + }, + }, + } as any) + await hook?.event?.({ + event: { + type: "permission.asked", + properties: { + sessionID: "session_test", + permission: "bash", + patterns: ["cat .env"], + }, + }, + } as any) + await hook?.event?.({ + event: { + type: "session.idle", + properties: { + sessionID: "session_test", + }, + }, + } as any) + await wait() + + const log = await Bun.file(files.log).text() + const state = await Bun.file(files.state).json() + const compact = await Plugin.trigger( + "experimental.session.compacting", + { sessionID: "session_test" }, + { context: [], prompt: undefined }, + ) + + expect(log).toContain("\"type\":\"session.created\"") + expect(log).toContain("\"type\":\"permission.asked\"") + expect(log).toContain("\"type\":\"session.idle\"") + expect(state.last_session).toBe("session_test") + expect(state.last_permission).toBe("bash") + expect(compact.context.join("\n")).toContain("Guardrail mode: enforced.") + expect(compact.context.join("\n")).toContain(".opencode/guardrails/state.json") + }, + }) + }) })