From 746b426db560275bea35894049b0d8e5fb8d5377 Mon Sep 17 00:00:00 2001 From: Terada Kousuke Date: Fri, 3 Apr 2026 11:18:45 +0900 Subject: [PATCH] feat: bootstrap thin distribution --- bun.lock | 12 ++ docs/ai-guardrails/README.md | 100 +++++++++++++ .../001-thin-distribution-over-deep-fork.md | 38 +++++ ...04-scenario-tests-before-productization.md | 38 +++++ .../issues/001-bootstrap-thin-distribution.md | 29 ++++ packages/guardrails/README.md | 75 ++++++++++ packages/guardrails/bin/opencode-guardrails | 30 ++++ packages/guardrails/managed/opencode.json | 38 +++++ packages/guardrails/package.json | 20 +++ packages/guardrails/profile/AGENTS.md | 9 ++ packages/guardrails/profile/opencode.json | 38 +++++ .../opencode/test/scenario/guardrails.test.ts | 139 ++++++++++++++++++ 12 files changed, 566 insertions(+) create mode 100644 docs/ai-guardrails/README.md create mode 100644 docs/ai-guardrails/adr/001-thin-distribution-over-deep-fork.md create mode 100644 docs/ai-guardrails/adr/004-scenario-tests-before-productization.md create mode 100644 docs/ai-guardrails/issues/001-bootstrap-thin-distribution.md create mode 100644 packages/guardrails/README.md create mode 100755 packages/guardrails/bin/opencode-guardrails create mode 100644 packages/guardrails/managed/opencode.json create mode 100644 packages/guardrails/package.json create mode 100644 packages/guardrails/profile/AGENTS.md create mode 100644 packages/guardrails/profile/opencode.json create mode 100644 packages/opencode/test/scenario/guardrails.test.ts diff --git a/bun.lock b/bun.lock index 1a16a37698a4..cc48585918bc 100644 --- a/bun.lock +++ b/bun.lock @@ -297,6 +297,16 @@ "typescript": "catalog:", }, }, + "packages/guardrails": { + "name": "@opencode-ai/guardrails", + "version": "1.3.13", + "bin": { + "opencode-guardrails": "./bin/opencode-guardrails", + }, + "dependencies": { + "opencode": "1.3.13", + }, + }, "packages/opencode": { "name": "opencode", "version": "1.3.13", @@ -1478,6 +1488,8 @@ "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], + "@opencode-ai/guardrails": ["@opencode-ai/guardrails@workspace:packages/guardrails"], + "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], diff --git a/docs/ai-guardrails/README.md b/docs/ai-guardrails/README.md new file mode 100644 index 000000000000..3331c6795843 --- /dev/null +++ b/docs/ai-guardrails/README.md @@ -0,0 +1,100 @@ +# OpenCode Internal Guardrails Plan + +This document defines the production plan for building an internal AI coding environment on top of OpenCode without turning the repo into a long-lived fork. + +## Product framing + +The target product should be described consistently across README, issues, and future docs as: + +- a Cor-Incorporated fork of OpenCode +- intentionally upstream-compatible where practical +- extended through a thin internal distribution layer +- not an official upstream OpenCode release channel + +The migration goal is not to hide the upstream lineage. It is to make the fork legible while keeping core drift low. + +## Operating principles + +This plan inherits the key philosophy from `claude-code-skills` epic `#130`, its README, and its ADRs: + +- enforce quality and safety through mechanism before prose +- push checks to the fastest reliable layer first +- keep always-loaded instructions pointer-based and short +- treat deployment/runtime verification as a separate requirement from code review +- prefer explicit workflow gates for review, CI, and release-sensitive operations + +That means the migration target is not "copy Claude hooks as-is." It is "preserve the operating model using OpenCode-native config, plugins, commands, permissions, and CI." + +## Goal + +Bootstrap the first thin-distribution slice that keeps OpenCode upstream-friendly while adding: + +- managed configuration for enterprise control +- localhost-only server defaults +- disabled sharing by default +- a pinned wrapper entrypoint +- scenario tests that pin config precedence and project-local compatibility + +## Non-goals + +- a deep rewrite of OpenCode core +- default use of preview or free third-party models for confidential code +- direct AI-driven push, merge, or release without explicit workflow gates +- always-on remote config, remote instructions, or unbounded MCP expansion + +## Baseline facts + +- OpenCode already supports `AGENTS.md`, managed config, commands, agents, skills, and permissions. +- Managed config and custom config directories are enough to ship a first internal distribution without a deep core fork. +- Scenario tests in `packages/opencode` can validate precedence and compatibility without special product code paths. + +## Product requirements + +### Architecture + +- Keep OpenCode as the upstream engine. +- Add a wrapper distribution, not a deep fork. +- Store organization defaults in managed config and project config, not in scattered local scripts. +- Default server exposure to localhost-only and default sharing to disabled. + +### First slice + +- Keep OpenCode as the upstream engine. +- Add a wrapper distribution, not a deep fork. +- Store organization defaults in managed config and packaged profile files, not in scattered scripts. +- Default server exposure to localhost-only and default sharing to disabled. +- Prove config precedence and project-local compatibility with scenario tests. + +## Delivery phases + +1. Freeze architecture decisions in ADRs. +2. Land the thin-distribution bootstrap as the first issue-sized slice. +3. Keep scenario coverage in the same change set as runtime behavior. +4. Stack later issues for plugin policy, safe workflows, provider lanes, and replay coverage on top of this base. + +## Tracking + +- Epic: [#1](https://github.com/Cor-Incorporated/opencode/issues/1) +- Current issue: [#2](https://github.com/Cor-Incorporated/opencode/issues/2) +- Future slices remain separate issues so implementation can stay one issue per pull request. + +## Session rule + +When continuing this work in future sessions: + +- start from the GitHub epic and the linked issue, not from memory +- preserve upstream compatibility unless a missing extension point proves otherwise +- update docs and tests in the same change set when guardrail behavior changes +- do not mark work complete unless runtime behavior is verified, not just implemented + +## Artifact map + +- ADRs: `docs/ai-guardrails/adr/` +- Issue briefs: `docs/ai-guardrails/issues/` +- Scenario tests: `packages/opencode/test/scenario/` +- Thin distribution package: `packages/guardrails/` + +## Primary references + +- OpenCode config: https://opencode.ai/docs/config +- OpenCode server: https://opencode.ai/docs/server diff --git a/docs/ai-guardrails/adr/001-thin-distribution-over-deep-fork.md b/docs/ai-guardrails/adr/001-thin-distribution-over-deep-fork.md new file mode 100644 index 000000000000..7c25ffe363c1 --- /dev/null +++ b/docs/ai-guardrails/adr/001-thin-distribution-over-deep-fork.md @@ -0,0 +1,38 @@ +# ADR 001: Thin Distribution Over Deep Fork + +- Status: Accepted +- Date: 2026-04-03 + +## Context + +The internal product needs enterprise guardrails, provider policy, and Claude asset migration. OpenCode already exposes the primitives needed to do this through config layering, managed settings, plugins, commands, agents, and server APIs. + +A deep fork would create a permanent rebase tax, make upstream updates harder, and encourage product logic to drift into core files that are not unique to the internal distribution. + +## Decision + +Build the internal product as a thin distribution: + +- keep upstream OpenCode pinned and trackable +- prefer wrapper CLI, managed config, project config, `.opencode` assets, and plugins +- treat core patches as exceptions that must be justified by a missing extension point + +## Consequences + +### Positive + +- lower upstream merge cost +- easier security and version upgrades +- clearer separation between platform behavior and organization policy +- easier scenario testing because policy lives at the edges + +### Negative + +- some existing Claude hooks must be redesigned instead of copied 1:1 +- workflow control depends on configuration discipline and tests + +## Evidence + +- OpenCode config precedence and managed config support: https://opencode.ai/docs/config +- OpenCode plugins and hook surface: https://opencode.ai/docs/plugins +- OpenCode commands and agents: https://opencode.ai/docs/commands and https://opencode.ai/docs/agents diff --git a/docs/ai-guardrails/adr/004-scenario-tests-before-productization.md b/docs/ai-guardrails/adr/004-scenario-tests-before-productization.md new file mode 100644 index 000000000000..ea829760b99d --- /dev/null +++ b/docs/ai-guardrails/adr/004-scenario-tests-before-productization.md @@ -0,0 +1,38 @@ +# ADR 004: Scenario Tests Before Productization + +- Status: Accepted +- Date: 2026-04-03 + +## Context + +The internal distribution will add policy, not just features. Policy breaks quietly when config precedence, plugin hooks, or compatibility discovery shifts under upstream changes. + +Unit tests are necessary but not sufficient. The contract that matters is scenario behavior across config, discovery, and plugin wiring. + +## Decision + +Add scenario tests before product code for these contracts: + +- managed config overrides weaker config layers for enterprise restrictions +- Claude-compatible skills remain discoverable during migration +- plugin hooks can inject environment and observe session lifecycle events + +Future work should extend this suite with replay tests for release gates, provider admission, and share/server restrictions. + +## Consequences + +### Positive + +- safer upstream upgrades +- easier AI-driven implementation because expected behavior is executable +- faster detection of regressions in config precedence and plugin surfaces + +### Negative + +- test fixtures must stay aligned with evolving config semantics + +## Evidence + +- OpenCode config precedence and managed settings: https://opencode.ai/docs/config +- OpenCode plugin events: https://opencode.ai/docs/plugins +- OpenCode skills and commands: https://opencode.ai/docs/skills and https://opencode.ai/docs/commands diff --git a/docs/ai-guardrails/issues/001-bootstrap-thin-distribution.md b/docs/ai-guardrails/issues/001-bootstrap-thin-distribution.md new file mode 100644 index 000000000000..b9377bea8905 --- /dev/null +++ b/docs/ai-guardrails/issues/001-bootstrap-thin-distribution.md @@ -0,0 +1,29 @@ +# Issue 001: Bootstrap Thin Distribution + +## Problem + +The repo needs a first internal distribution layer that can enforce organization defaults without forking core behavior into unrelated files. + +## Deliverables + +- wrapper entrypoint for the internal distribution +- pinned OpenCode version strategy +- managed config profile for enterprise defaults +- localhost-only server default +- default `share: "disabled"` + +## Acceptance + +- internal launcher resolves to a pinned OpenCode build +- managed config overrides weaker config layers in tests +- project config can still add project-local commands, skills, and agents + +## Dependencies + +- ADR 001 +- ADR 004 + +## Sources + +- https://opencode.ai/docs/config +- https://opencode.ai/docs/server diff --git a/packages/guardrails/README.md b/packages/guardrails/README.md new file mode 100644 index 000000000000..9e6d36f925f4 --- /dev/null +++ b/packages/guardrails/README.md @@ -0,0 +1,75 @@ +# Guardrails Distribution + +This package is the thin internal distribution layer for the guardrails plan. + +It should be understood as the Cor-Incorporated-specific layer that sits on top of upstream-compatible OpenCode, not as a separate reimplementation of the core runtime. + +It keeps upstream OpenCode as the runtime and adds organization policy at the edges: + +- `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` + +## Design intent + +This package exists to preserve the operating model imported from `claude-code-skills` without turning OpenCode into a deep fork. + +- mechanism-first guardrails +- fast feedback before slower workflow gates +- pointer-based instructions instead of bloated always-loaded prompts +- 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/`. + +## Positioning + +When describing this package or this fork externally, use wording close to: + +- forked from OpenCode +- compatibility with upstream preserved where practical +- Cor-Incorporated-specific policy and workflow layer added in `packages/guardrails` + +Avoid wording that implies this package replaces OpenCode itself. The intended architecture is still upstream engine plus thin internal distribution. + +## Upstream strategy + +- Keep this package version aligned with `packages/opencode/package.json` +- Upgrade upstream first, then update this package only where the extension surface changed +- Prefer `managed/` and `profile/` assets over core patches + +## Current scope + +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 + +Planned next slices are tracked in the fork: + +- epic [#1](https://github.com/Cor-Incorporated/opencode/issues/1) +- plugin MVP [#4](https://github.com/Cor-Incorporated/opencode/issues/4) +- safe agents and commands [#5](https://github.com/Cor-Incorporated/opencode/issues/5) +- provider policy [#6](https://github.com/Cor-Incorporated/opencode/issues/6) +- scenario/replay harness [#7](https://github.com/Cor-Incorporated/opencode/issues/7) + +## Usage + +Run the wrapper directly: + +```sh +opencode-guardrails +``` + +It respects an existing `OPENCODE_CONFIG_DIR` so project- or environment-specific overrides can still replace the packaged profile when needed. + +## Managed deployment + +Copy [managed/opencode.json](/Users/teradakousuke/Developer/opencode/packages/guardrails/managed/opencode.json) into the system managed config directory: + +- macOS: `/Library/Application Support/opencode/opencode.json` +- Linux: `/etc/opencode/opencode.json` +- Windows: `%ProgramData%\\opencode\\opencode.json` + +For macOS MDM deployments, use the same keys in the `ai.opencode.managed` payload that OpenCode already reads through managed preferences. diff --git a/packages/guardrails/bin/opencode-guardrails b/packages/guardrails/bin/opencode-guardrails new file mode 100755 index 000000000000..9293865e887f --- /dev/null +++ b/packages/guardrails/bin/opencode-guardrails @@ -0,0 +1,30 @@ +#!/usr/bin/env node + +const child = require("child_process") +const fs = require("fs") +const path = require("path") + +function fail(msg) { + console.error(msg) + process.exit(1) +} + +function bin() { + const file = require.resolve("opencode/package.json") + const root = path.dirname(file) + const json = JSON.parse(fs.readFileSync(file, "utf8")) + const rel = typeof json.bin === "string" ? json.bin : json.bin?.opencode + if (!rel) fail("Failed to resolve opencode bin from dependency") + return path.resolve(root, rel) +} + +const dir = path.resolve(__dirname, "..", "profile") +process.env.OPENCODE_CONFIG_DIR ||= dir + +const out = child.spawnSync(bin(), process.argv.slice(2), { + stdio: "inherit", + env: process.env, +}) + +if (out.error) fail(out.error.message) +process.exit(typeof out.status === "number" ? out.status : 1) diff --git a/packages/guardrails/managed/opencode.json b/packages/guardrails/managed/opencode.json new file mode 100644 index 000000000000..b7e35fbaa570 --- /dev/null +++ b/packages/guardrails/managed/opencode.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://opencode.ai/config.json", + "share": "disabled", + "server": { + "hostname": "127.0.0.1", + "mdns": false + }, + "permission": { + "edit": "ask", + "task": "ask", + "webfetch": "ask", + "external_directory": "ask", + "bash": { + "*": "ask", + "rm -rf *": "deny", + "sudo *": "deny", + "curl * | sh*": "deny", + "wget * | sh*": "deny" + }, + "read": { + "*": "allow", + "*.env": "deny", + "*.env.*": "deny", + "*.env.example": "allow", + "*id_rsa*": "deny", + "*id_ed25519*": "deny", + "*.pem": "deny", + "*.key": "deny", + "*.p12": "deny", + "*.pfx": "deny", + "*.cer": "deny", + "*.crt": "deny", + "*.der": "deny", + "*.kdbx": "deny", + "*credentials*": "deny" + } + } +} diff --git a/packages/guardrails/package.json b/packages/guardrails/package.json new file mode 100644 index 000000000000..f1aea7828e39 --- /dev/null +++ b/packages/guardrails/package.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/guardrails", + "version": "1.3.13", + "private": true, + "type": "module", + "license": "MIT", + "bin": { + "opencode-guardrails": "./bin/opencode-guardrails" + }, + "files": [ + "bin", + "managed", + "profile", + "README.md" + ], + "dependencies": { + "opencode": "1.3.13" + } +} diff --git a/packages/guardrails/profile/AGENTS.md b/packages/guardrails/profile/AGENTS.md new file mode 100644 index 000000000000..11e8abd4dd8b --- /dev/null +++ b/packages/guardrails/profile/AGENTS.md @@ -0,0 +1,9 @@ +# Guardrail Profile + +- Treat this profile as a thin distribution over upstream OpenCode. +- 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. +- 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. diff --git a/packages/guardrails/profile/opencode.json b/packages/guardrails/profile/opencode.json new file mode 100644 index 000000000000..b7e35fbaa570 --- /dev/null +++ b/packages/guardrails/profile/opencode.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://opencode.ai/config.json", + "share": "disabled", + "server": { + "hostname": "127.0.0.1", + "mdns": false + }, + "permission": { + "edit": "ask", + "task": "ask", + "webfetch": "ask", + "external_directory": "ask", + "bash": { + "*": "ask", + "rm -rf *": "deny", + "sudo *": "deny", + "curl * | sh*": "deny", + "wget * | sh*": "deny" + }, + "read": { + "*": "allow", + "*.env": "deny", + "*.env.*": "deny", + "*.env.example": "allow", + "*id_rsa*": "deny", + "*id_ed25519*": "deny", + "*.pem": "deny", + "*.key": "deny", + "*.p12": "deny", + "*.pfx": "deny", + "*.cer": "deny", + "*.crt": "deny", + "*.der": "deny", + "*.kdbx": "deny", + "*credentials*": "deny" + } + } +} diff --git a/packages/opencode/test/scenario/guardrails.test.ts b/packages/opencode/test/scenario/guardrails.test.ts new file mode 100644 index 000000000000..e4a0539af986 --- /dev/null +++ b/packages/opencode/test/scenario/guardrails.test.ts @@ -0,0 +1,139 @@ +import { afterEach, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Agent } from "../../src/agent/agent" +import { Command } from "../../src/command" +import { Config } from "../../src/config/config" +import { Instance } from "../../src/project/instance" +import { Skill } from "../../src/skill" +import { Filesystem } from "../../src/util/filesystem" +import { tmpdir } from "../fixture/fixture" + +const managed = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! +const profile = path.resolve(import.meta.dir, "../../../guardrails/profile") + +afterEach(async () => { + await Instance.disposeAll() + await fs.rm(managed, { force: true, recursive: true }).catch(() => {}) + await Config.invalidate(true) +}) + +async function write(dir: string, file: string, data: object) { + await Filesystem.write(path.join(dir, file), JSON.stringify(data, null, 2)) +} + +async function managedConfig(data: object) { + await fs.mkdir(managed, { recursive: true }) + await write(managed, "opencode.json", data) +} + +test("managed config overrides weaker project defaults", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await write(dir, "opencode.json", { + $schema: "https://opencode.ai/config.json", + share: "auto", + server: { + hostname: "0.0.0.0", + mdns: true, + }, + }) + }, + }) + + await managedConfig({ + $schema: "https://opencode.ai/config.json", + share: "disabled", + server: { + hostname: "127.0.0.1", + mdns: false, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + expect(cfg.share).toBe("disabled") + expect(cfg.server?.hostname).toBe("127.0.0.1") + expect(cfg.server?.mdns).toBe(false) + }, + }) +}) + +test("guardrails package pins the runtime to the packaged opencode version", async () => { + const guardrails = await Bun.file(path.resolve(import.meta.dir, "../../../guardrails/package.json")).json() + const opencode = await Bun.file(path.resolve(import.meta.dir, "../../package.json")).json() + + expect(guardrails.dependencies.opencode).toBe(opencode.version) +}) + +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 using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await write(dir, "opencode.json", { + $schema: "https://opencode.ai/config.json", + share: "auto", + }) + await Bun.write( + path.join(dir, ".opencode", "commands", "project-local.md"), + `--- +description: Project-local workflow. +--- + +Use the project-local command. +`, + ) + await Bun.write( + path.join(dir, ".opencode", "agents", "project-review.md"), + `--- +description: Project-local review helper. +mode: subagent +permission: + "*": deny + read: allow +--- + +Review local project context only. +`, + ) + await Bun.write( + path.join(dir, ".opencode", "skills", "project-skill", "SKILL.md"), + `--- +name: project-skill +description: Project-local skill. +--- + +# Project Skill +`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + const cmds = await Command.list() + const skills = await Skill.all() + const agents = await Agent.list() + + expect(cfg.share).toBe("disabled") + expect(cfg.server?.hostname).toBe("127.0.0.1") + expect(cfg.server?.mdns).toBe(false) + expect(cmds.some((item) => item.name === "project-local")).toBe(true) + expect(skills.some((item) => item.name === "project-skill")).toBe(true) + 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 + } +})