diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 8380f7f719ef..3ee6df8491e1 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -6,9 +6,116 @@ }, }, "permission": { + // Read operations: allow by default, deny sensitive files + // Mirrors Claude Code's Read allow + .env/.secrets deny + "read": { + "*": "allow", + ".env": "deny", + ".env.*": "deny", + "**/.env": "deny", + "**/.env.*": "deny", + "secrets/**": "deny", + "**/secrets/**": "deny", + }, + // Edit operations: allow by default (Claude Code's acceptEdits mode) + // Deny migration files and sensitive configs "edit": { + "*": "allow", "packages/opencode/migration/*": "deny", + ".env": "deny", + ".env.*": "deny", + "**/.env": "deny", + "**/.env.*": "deny", + }, + // File search: always allow (Claude Code allows Glob/Grep unconditionally) + "glob": "allow", + "grep": "allow", + "list": "allow", + // Bash: pattern-based control mirroring Claude Code's Bash whitelist + "bash": { + "*": "ask", + // JS/TS toolchain + "node *": "allow", + "npm *": "allow", + "npx *": "allow", + "pnpm *": "allow", + "bun *": "allow", + "bunx *": "allow", + "yarn *": "allow", + "turbo *": "allow", + "tsc *": "allow", + // Python toolchain + "python *": "allow", + "python3 *": "allow", + "pip *": "allow", + "pip3 install *": "allow", + "uv *": "allow", + // Linters/formatters + "eslint *": "allow", + "prettier *": "allow", + "biome *": "allow", + // Test runners + "jest *": "allow", + "vitest *": "allow", + "playwright *": "allow", + // Git operations + "git *": "allow", + "gh *": "allow", + // System utilities + "ls *": "allow", + "wc *": "allow", + "lsof *": "allow", + "test *": "allow", + "set *": "allow", + "dig *": "allow", + "nslookup *": "allow", + "cat *": "allow", + "head *": "allow", + "tail *": "allow", + "mkdir *": "allow", + "cp *": "allow", + "mv *": "allow", + "touch *": "allow", + "chmod *": "allow", + "which *": "allow", + "echo *": "allow", + "pwd": "allow", + "env *": "allow", + "sort *": "allow", + "uniq *": "allow", + "diff *": "allow", + "grep *": "allow", + "find *": "allow", + "sed *": "allow", + "awk *": "allow", + "xargs *": "allow", + // Network + "curl *": "allow", + "openssl *": "allow", + // Container/Cloud + "docker *": "allow", + "vercel *": "allow", + "supabase *": "allow", + // Dangerous operations: deny + "rm -rf *": "deny", + "sudo *": "deny", + "git push --force*": "deny", + "git push -f *": "deny", + "curl * | sh*": "deny", + "curl * | bash*": "deny", }, + // Low-risk research tools should stay automatic to reduce unnecessary prompts. + "websearch": "allow", + "webfetch": "allow", + "codesearch": "allow", + // Tool integrations: allow + "lsp": "allow", + "task": "allow", + "skill": "allow", + "question": "allow", + "todowrite": "allow", + // External directory access: ask (security boundary) + "external_directory": "ask", }, "mcp": {}, "tools": { diff --git a/packages/guardrails/profile/opencode.json b/packages/guardrails/profile/opencode.json index 6cf165d34607..82eac254e56f 100644 --- a/packages/guardrails/profile/opencode.json +++ b/packages/guardrails/profile/opencode.json @@ -1,12 +1,7 @@ { "$schema": "https://opencode.ai/config.json", "default_agent": "implement", - "enabled_providers": [ - "zai", - "zai-coding-plan", - "openai", - "openrouter" - ], + "enabled_providers": ["zai", "zai-coding-plan", "openai", "openrouter"], "share": "disabled", "server": { "hostname": "127.0.0.1", @@ -14,11 +9,7 @@ }, "provider": { "zai": { - "whitelist": [ - "glm-5", - "glm-4.5", - "glm-4.5-air" - ] + "whitelist": ["glm-5", "glm-4.5", "glm-4.5-air"] }, "zai-coding-plan": { "whitelist": [ @@ -74,9 +65,9 @@ } }, "permission": { - "edit": "ask", - "task": "ask", - "webfetch": "ask", + "edit": "allow", + "task": "allow", + "webfetch": "allow", "external_directory": "ask", "bash": { "*": "ask", diff --git a/packages/guardrails/profile/plugins/guardrail.ts b/packages/guardrails/profile/plugins/guardrail.ts index 2b4fbd6c49b1..b8b79fb90a67 100644 --- a/packages/guardrails/profile/plugins/guardrail.ts +++ b/packages/guardrails/profile/plugins/guardrail.ts @@ -109,7 +109,7 @@ function ext(file: string) { function stash(file: string) { return Bun.file(file) .json() - .catch(() => ({} as Record)) + .catch(() => ({}) as Record) } async function save(file: string, data: Record) { @@ -117,7 +117,9 @@ async function save(file: string, data: Record) { } async function line(file: string, data: Record) { - const prev = await Bun.file(file).text().catch(() => "") + const prev = await Bun.file(file) + .text() + .catch(() => "") await Bun.write(file, prev + JSON.stringify(data) + "\n") } @@ -168,10 +170,7 @@ function free(data: { return !(ids && ids.has(str(data.id))) } -function preview(data: { - id?: unknown - status?: unknown -}) { +function preview(data: { id?: unknown; status?: unknown }) { const id = str(data.id) const status = str(data.status) if (status && status !== "active") return true @@ -197,10 +196,13 @@ function cmp(left: string, right: string) { return a[2] - b[2] } -export default async function guardrail(input: { - directory: string - worktree: string -}, opts?: Record) { +export default async function guardrail( + input: { + directory: string + worktree: string + }, + opts?: Record, +) { const mode = typeof opts?.mode === "string" ? opts.mode : "enforced" const evals = new Set([]) const evalAgent = "provider-eval" @@ -322,7 +324,9 @@ export default async function guardrail(input: { return baseline(args.oldString, args.newString) } if (typeof args.content !== "string") return - const prev = await Bun.file(file).text().catch(() => "") + const prev = await Bun.file(file) + .text() + .catch(() => "") if (!prev) return return baseline(prev, args.content) } @@ -368,9 +372,7 @@ export default async function guardrail(input: { } return { - config: async (cfg: { - provider?: Record - }) => { + config: async (cfg: { provider?: Record }) => { for (const key of Object.keys(allow)) delete allow[key] for (const [key, val] of Object.entries(cfg.provider ?? {})) { const ids = list(val.whitelist) @@ -415,10 +417,7 @@ export default async function guardrail(input: { }) } }, - "tool.execute.before": async ( - item: { tool: string; args?: unknown }, - out: { args: Record }, - ) => { + "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") @@ -437,7 +436,7 @@ export default async function guardrail(input: { if ((item.tool === "edit" || item.tool === "write") && file && code(file)) { const count = await budget() if (count >= 4) { - const err = `context budget exceeded after ${count} source reads; narrow scope or delegate before editing` + const err = `context budget exceeded after ${count} source reads; narrow scope or delegate with the team tool before editing` await mark({ last_block: item.tool, last_file: rel(input.worktree, file), last_reason: err }) throw new Error(text(err)) } diff --git a/packages/guardrails/profile/plugins/team.ts b/packages/guardrails/profile/plugins/team.ts index 9638772cdf97..846d8594e620 100644 --- a/packages/guardrails/profile/plugins/team.ts +++ b/packages/guardrails/profile/plugins/team.ts @@ -42,7 +42,10 @@ type Stat = { type Client = { session: { - create(input: { body: { parentID: string; title: string }; query: { directory: string } }): Promise<{ data: { id: string } }> + create(input: { + body: { parentID: string; title: string } + query: { directory: string } + }): Promise<{ data: { id: string } }> promptAsync(input: { path: { id: string } query: { directory: string } @@ -158,9 +161,10 @@ function big(text: string) { const data = text.trim() if (!data) return false const plan = (data.match(/^\s*([-*]|\d+\.)\s+/gm) ?? []).length - const impl = /(implement|implementation|build|create|add|fix|refactor|rewrite|patch|parallel|subagent|team|background|worker|修正|実装|追加|改修|並列|サブエージェント|チーム)/i.test( - data, - ) + const impl = + /(implement|implementation|build|create|add|fix|refactor|rewrite|patch|parallel|subagent|team|background|worker|修正|実装|追加|改修|並列|サブエージェント|チーム)/i.test( + data, + ) const wide = data.length >= 500 || plan >= 3 || @@ -237,19 +241,24 @@ async function save(dir: string, run: Run) { } async function load(dir: string, id: string) { - const data = await Bun.file(file(dir, id)).json().catch(() => undefined) + const data = await Bun.file(file(dir, id)) + .json() + .catch(() => undefined) return isRun(data) ? data : undefined } async function scan(dir: string) { await mkdir(root(dir), { recursive: true }) const list = await readdir(root(dir)).catch(() => []) - return Promise.all(list.filter((item) => item.endsWith(".json")).map((item) => Bun.file(path.join(root(dir), item)).json().catch(() => undefined))).then( - (list) => - list - .filter(isRun) - .toSorted((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))), - ) + return Promise.all( + list + .filter((item) => item.endsWith(".json")) + .map((item) => + Bun.file(path.join(root(dir), item)) + .json() + .catch(() => undefined), + ), + ).then((list) => list.filter(isRun).toSorted((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)))) } async function yardadd(dir: string, id: string) { @@ -365,11 +374,7 @@ async function stop(client: Client, run: Run) { ).catch(() => undefined) } -export default async function team(input: { - client: Client - worktree: string - directory: string -}) { +export default async function team(input: { client: Client; worktree: string; directory: string }) { const job = async (ctx: Ctx, run: Run, item: Step) => { const push = write(item.prompt, item.write) const box = push && item.worktree ? await yardadd(ctx.worktree, `${run.id}-${item.id}`) : ctx.directory @@ -467,7 +472,10 @@ export default async function team(input: { }, async execute(args, ctx) { defs(args.tasks) - if (args.tasks.length < 2) throw new Error("team requires at least two tasks") + if (!args.tasks.length) throw new Error("team requires at least one task") + if (args.tasks.length < 2 && args.tasks.some((item) => write(item.prompt, item.write))) { + throw new Error("team requires at least two tasks when any task can mutate files") + } ctx.metadata({ title: "team run", metadata: { @@ -529,7 +537,9 @@ export default async function team(input: { try { for (;;) { - const ready = list.filter((item) => item.state === "pending" && item.depends.every((dep) => done.has(dep)) && !active.has(item.id)) + const ready = list.filter( + (item) => item.state === "pending" && item.depends.every((dep) => done.has(dep)) && !active.has(item.id), + ) if (args.strategy === "wave" && ready.length) { ready.forEach((item) => todo(run, item.id, { state: "queued" })) @@ -676,7 +686,9 @@ export default async function team(input: { run_id: z.string().optional(), }, async execute(args, ctx) { - const list = args.run_id ? [live.get(args.run_id) ?? (await load(ctx.worktree, args.run_id))].filter(isRun) : await scan(ctx.worktree) + const list = args.run_id + ? [live.get(args.run_id) ?? (await load(ctx.worktree, args.run_id))].filter(isRun) + : await scan(ctx.worktree) if (!list.length) return "No team runs found." return list.map((item) => note(item)).join("\n\n") }, @@ -729,8 +741,7 @@ export default async function team(input: { sessionID: out.message.sessionID, messageID: out.message.id, type: "text", - text: - "Parallel implementation policy is active for this request. Before any edit, write, apply_patch, or mutating bash call, you MUST call the `team` tool and fan out at least two worker tasks. Mark tasks that should edit code with `write: true`; those tasks will be isolated in git worktrees and merged back when possible. Use `background` only for side work that should keep running after this turn.", + text: "Parallel implementation policy is active for this request. Before any edit, write, apply_patch, or mutating bash call, you MUST call the `team` tool and fan out at least two worker tasks. Mark tasks that should edit code with `write: true`; those tasks will be isolated in git worktrees and merged back when possible. Use `background` only for side work that should keep running after this turn.", }) }, "tool.execute.before": async ( @@ -745,6 +756,12 @@ export default async function team(input: { const gate = need.get(item.sessionID) if (!gate || gate.done) return if (item.tool === "team") return + if (item.tool === "background") { + if (!write(String(out.args.prompt ?? ""), out.args.write === true)) return + throw new Error( + `Parallel implementation is enforced for this turn. Use the team tool with at least two tasks before starting write-capable background workers. Reason: ${gate.reason}`, + ) + } if (item.tool !== "edit" && item.tool !== "write" && item.tool !== "apply_patch" && item.tool !== "bash") return if (item.tool === "bash" && !mut(String(out.args.command ?? ""))) return throw new Error( diff --git a/packages/opencode/test/scenario/guardrails.test.ts b/packages/opencode/test/scenario/guardrails.test.ts index f2ecb3459801..7d9c5558763e 100644 --- a/packages/opencode/test/scenario/guardrails.test.ts +++ b/packages/opencode/test/scenario/guardrails.test.ts @@ -17,6 +17,7 @@ import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" import { assertReplay, it, run } from "./harness" import { replays } from "./replay" +import TeamPlugin from "../../../guardrails/profile/plugins/team" const managed = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const profile = path.resolve(import.meta.dir, "../../../guardrails/profile") @@ -245,7 +246,8 @@ Use the project-local command. expect(review?.mode).toBe("subagent") expect(perm(impl, "question")).toBe("allow") expect(perm(impl, "plan_enter")).toBe("allow") - expect(perm(impl, "edit")).toBe("ask") + expect(perm(impl, "webfetch", "https://example.com")).toBe("allow") + expect(perm(impl, "edit")).toBe("allow") expect(perm(impl, "bash", "git reset --hard HEAD")).toBe("deny") expect(perm(impl, "bash", "git push origin --force-with-lease")).toBe("deny") expect(perm(review, "edit")).toBe("deny") @@ -408,14 +410,14 @@ test("guardrail profile enforces provider admission lanes", async () => { agent: "provider-eval", model: { ...evalModel, - id: "google/gemini-3-pro-preview" as typeof evalModel.id, - cost: { - ...evalModel.cost, - input: 0.1, - output: 0.2, - cache: { read: 0, write: 0 }, - }, + id: "google/gemini-3-pro-preview" as typeof evalModel.id, + cost: { + ...evalModel.cost, + input: 0.1, + output: 0.2, + cache: { read: 0, write: 0 }, }, + }, }, { temperature: undefined, topP: undefined, topK: undefined, options: {} }, ), @@ -453,7 +455,11 @@ test("guardrail profile plugin injects shell env and blocks protected files", as ) 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( + 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) @@ -619,12 +625,161 @@ test("guardrail profile plugin enforces version baselines and context budget", a }, }, ), - ).rejects.toThrow("context budget exceeded") + ).rejects.toThrow("delegate with the team tool") }, }) }) }) +test("guardrail profile blocks write-capable background workers until team runs", async () => { + await withProfile(async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const parts: { id?: string; sessionID?: string; messageID?: string; type?: string; text?: string }[] = [ + { + type: "text", + text: + "Implement the following multi-file refactor across packages/a and packages/b.\n" + + "1. Add a shared helper.\n" + + "2. Update both packages.\n" + + "3. Fix downstream imports.\n" + + "This is a broad multi-file implementation.", + }, + ] + + await Plugin.trigger( + "chat.message", + { + sessionID: "session_team_gate", + agent: "implement", + }, + { + message: { + id: "msg_team_gate", + sessionID: "session_team_gate", + role: "user", + }, + parts, + }, + ) + + await expect( + Plugin.trigger( + "tool.execute.before", + { tool: "background", sessionID: "session_team_gate", callID: "call_bg_write" }, + { + args: { + prompt: "Edit src/a.ts to add the new helper", + write: true, + }, + }, + ), + ).rejects.toThrow("Use the team tool with at least two tasks") + + await expect( + Plugin.trigger( + "tool.execute.before", + { tool: "background", sessionID: "session_team_gate", callID: "call_bg_read" }, + { + args: { + prompt: "Inspect src/a.ts and summarize it", + write: false, + }, + }, + ), + ).resolves.toEqual({ + args: { + prompt: "Inspect src/a.ts and summarize it", + write: false, + }, + }) + }, + }) + }) +}) + +test("project-specific bash allows beat profile wildcard asks", async () => { + await withProfile(async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await write(dir, "opencode.json", { + $schema: "https://opencode.ai/config.json", + permission: { + bash: { + "*": "ask", + "supabase *": "allow", + }, + }, + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.get("build") + expect(perm(agent, "bash", "supabase db query")).toBe("allow") + expect(perm(agent, "bash", "unknown cmd")).toBe("ask") + }, + }) + }) +}) + +test("team tool allows a single read-only task", async () => { + await withProfile(async () => { + await using tmp = await tmpdir({ git: true, config: { share: "auto" } }) + + const plugin = await TeamPlugin({ + client: { + session: { + create: async () => ({ data: { id: "child" } }), + promptAsync: async () => ({}), + prompt: async () => ({}), + status: async () => ({ data: { child: { type: "idle" } } }), + messages: async () => ({ + data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "ok" }] }], + }), + abort: async () => ({}), + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + const result = await plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "inspect", + description: "inspect only", + prompt: "Inspect src/index.ts and report findings without editing files.", + write: false, + worktree: false, + }, + ], + }, + { + sessionID: "session_team_single", + messageID: "", + agent: "build", + directory: tmp.path, + worktree: tmp.path, + abort: AbortSignal.any([]), + metadata: () => {}, + ask: async () => {}, + }, + ) + + expect(result).toContain("run_id:") + }) +}) + test("guardrail profile plugin records factcheck and review freshness state", async () => { await withProfile(async () => { await using tmp = await tmpdir({ @@ -766,9 +921,9 @@ test("guardrail profile plugin records lifecycle events and compaction context", { context: [], prompt: undefined }, ) - expect(log).toContain("\"type\":\"session.created\"") - expect(log).toContain("\"type\":\"permission.asked\"") - expect(log).toContain("\"type\":\"session.idle\"") + 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.read_count).toBe(0) expect(state.factchecked).toBe(false) @@ -833,7 +988,10 @@ test("team plugin skips parallel enforcement on HEAD-less repos", async () => { expect(injected!.text).toContain("Parallel implementation policy is suspended") const parallelInjected = parts.find( - (item) => item.type === "text" && typeof item.text === "string" && item.text.includes("Parallel implementation policy is active"), + (item) => + item.type === "text" && + typeof item.text === "string" && + item.text.includes("Parallel implementation policy is active"), ) expect(parallelInjected).toBeUndefined()