-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add guardrail plugin mvp #11
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 |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown>)) | ||
| } | ||
|
|
||
| async function save(file: string, data: Record<string, unknown>) { | ||
| await Bun.write(file, JSON.stringify(data, null, 2) + "\n") | ||
| } | ||
|
|
||
| async function line(file: string, data: Record<string, unknown>) { | ||
| const prev = await Bun.file(file).text().catch(() => "") | ||
| await Bun.write(file, prev + JSON.stringify(data) + "\n") | ||
| } | ||
|
Comment on lines
+65
to
+68
|
||
|
|
||
| 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<string, unknown>) { | ||
| 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<string, unknown>) { | ||
| const prev = await stash(state) | ||
| await save(state, { ...prev, ...data, mode, updated_at: new Date().toISOString() }) | ||
| } | ||
|
|
||
| async function seen(type: string, data: Record<string, unknown>) { | ||
| await line(log, { type, time: new Date().toISOString(), ...data }) | ||
| } | ||
|
|
||
| function note(props: Record<string, unknown> | 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" | ||
| } | ||
|
Comment on lines
+88
to
+120
|
||
|
|
||
| return { | ||
| event: async ({ event }: { event: { type?: string; properties?: Record<string, unknown> } }) => { | ||
| 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<string, unknown> }, | ||
| ) => { | ||
| 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")) | ||
|
Comment on lines
+158
to
+169
|
||
| } | ||
| }, | ||
| "shell.env": async (_item: { cwd: string }, out: { env: Record<string, string> }) => { | ||
| 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(" "), | ||
| ) | ||
| }, | ||
| } | ||
| } | ||
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.
The local brief is titled “Issue 003”, but the PR description and
docs/ai-guardrails/README.mdtracking refer to GitHub issue#4as the current issue. To avoid confusion for readers, consider adding an explicit cross-reference in the header (e.g., “(GitHub #4)”) or aligning the numbering scheme in the title.