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
24 changes: 16 additions & 8 deletions docs/ai-guardrails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

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

Expand Down Expand Up @@ -126,25 +132,27 @@ 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

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
Expand Down
48 changes: 48 additions & 0 deletions docs/ai-guardrails/issues/003-guardrail-plugin-mvp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Issue 003: Guardrail Plugin MVP
Copy link

Copilot AI Apr 3, 2026

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.md tracking refer to GitHub issue #4 as 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.

Suggested change
# Issue 003: Guardrail Plugin MVP
# Issue 003 (GitHub #4): Guardrail Plugin MVP

Copilot uses AI. Check for mistakes.

## 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
1 change: 1 addition & 0 deletions docs/ai-guardrails/issues/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions docs/ai-guardrails/migration/claude-code-skills-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand Down
6 changes: 4 additions & 2 deletions packages/guardrails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

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

Expand Down
3 changes: 2 additions & 1 deletion packages/guardrails/profile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions packages/guardrails/profile/opencode.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"hostname": "127.0.0.1",
"mdns": false
},
"plugin": [
"./plugins/guardrail.ts"
],
"permission": {
"edit": "ask",
"task": "ask",
Expand Down
192 changes: 192 additions & 0 deletions packages/guardrails/profile/plugins/guardrail.ts
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
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line() appends by reading the entire existing log file into memory and rewriting it (prev + ...). This is O(n) per event and can become very slow as events.jsonl grows; it also risks lost updates if writes ever overlap. Prefer an actual append (e.g., fs.appendFile / Bun.write(..., { append: true })) so each event write is constant-time and atomic at the file level.

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hidden()/deny() compute relative paths from input.worktree, but guardrail state is written under path.join(input.directory, ".opencode/guardrails"). If directory is a subfolder of worktree (common when launching from a nested path), .opencode/guardrails/* will not start with .opencode/guardrails/ relative to the worktree, so protected state files may not be blocked by the read/write checks. Consider basing the hidden/state checks on the actual root path (e.g., compare normalized absolute paths to root) or use input.directory consistently for rel()/hidden()/deny().

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bash protected-file check runs has(file, sec) where file is the full shell command string (e.g. cat .env). The sec regexes are written for path-like strings ((^|/)\.env..., .*\.pem$, etc.), so common commands containing whitespace/flags won’t match and will bypass the intended protection. Consider parsing the command/args (or at least scanning for protected path tokens with boundaries that include whitespace/quotes) before applying the secret/config patterns.

Copilot uses AI. Check for mistakes.
}
},
"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(" "),
)
},
}
}
Loading