Skip to content
107 changes: 107 additions & 0 deletions .opencode/opencode.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines +11 to +18
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

read denies .env.* / **/.env.*, which will also deny .env.example. In other configs/defaults in this repo (e.g., Agent defaults use *.env.example: allow), .env.example is treated as safe to read. If the intent is to mirror that behavior, add an explicit allow for .env.example (and **/.env.example if you want to cover nested cases) ahead of the broader .env.* deny rules.

Copilot uses AI. Check for mistakes.
},
// 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": {
Expand Down
19 changes: 5 additions & 14 deletions packages/guardrails/profile/opencode.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,15 @@
{
"$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",
"mdns": false
},
"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": [
Expand Down Expand Up @@ -74,9 +65,9 @@
}
},
"permission": {
"edit": "ask",
"task": "ask",
"webfetch": "ask",
"edit": "allow",
"task": "allow",
"webfetch": "allow",
"external_directory": "ask",
"bash": {
"*": "ask",
Expand Down
37 changes: 18 additions & 19 deletions packages/guardrails/profile/plugins/guardrail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,17 @@ function ext(file: string) {
function stash(file: string) {
return Bun.file(file)
.json()
.catch(() => ({} as Record<string, unknown>))
.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(() => "")
const prev = await Bun.file(file)
.text()
.catch(() => "")
await Bun.write(file, prev + JSON.stringify(data) + "\n")
}

Expand Down Expand Up @@ -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
Expand All @@ -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<string, unknown>) {
export default async function guardrail(
input: {
directory: string
worktree: string
},
opts?: Record<string, unknown>,
) {
const mode = typeof opts?.mode === "string" ? opts.mode : "enforced"
const evals = new Set<string>([])
const evalAgent = "provider-eval"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -368,9 +372,7 @@ export default async function guardrail(input: {
}

return {
config: async (cfg: {
provider?: Record<string, { whitelist?: string[] }>
}) => {
config: async (cfg: { provider?: Record<string, { whitelist?: string[] }> }) => {
for (const key of Object.keys(allow)) delete allow[key]
for (const [key, val] of Object.entries(cfg.provider ?? {})) {
const ids = list(val.whitelist)
Expand Down Expand Up @@ -415,10 +417,7 @@ export default async function guardrail(input: {
})
}
},
"tool.execute.before": async (
item: { tool: string; args?: unknown },
out: { args: Record<string, unknown> },
) => {
"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")
Expand All @@ -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))
}
Expand Down
59 changes: 38 additions & 21 deletions packages/guardrails/profile/plugins/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Comment on lines +475 to +477
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

Allowing a single read-only team task here can unintentionally satisfy the parallel-implementation gate for a “big()” request: team.execute sets need.set(ctx.sessionID, { done: true, ... }) after the run completes, and the tool.execute.before hook only checks gate.done. That means a user/agent could run team with 1 read-only task and then proceed with direct edit/write/mutating bash, bypassing the intended “2+ tasks before mutation” policy.

Consider tracking gate satisfaction separately (e.g., mark the gate as satisfied-for-mutation only when the team run includes >=2 tasks and/or at least one write-capable task), and keep blocking mutations until that stronger condition is met.

Suggested change
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")
if (args.tasks.length < 2) {
throw new Error("team requires at least two tasks")

Copilot uses AI. Check for mistakes.
}
ctx.metadata({
title: "team run",
metadata: {
Expand Down Expand Up @@ -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" }))
Expand Down Expand Up @@ -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")
},
Expand Down Expand Up @@ -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.",
})
Comment on lines 741 to 745
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

This injected guidance still says to “fan out at least two worker tasks” unconditionally, but team.execute now allows a single task when all tasks are read-only. To avoid confusing users/agents (especially investigation flows that are allowed to proceed with 1 read-only task), update this message to reflect the new rule (e.g., 1 task allowed for read-only investigations; 2+ required when any task/tool call can mutate).

Copilot uses AI. Check for mistakes.
},
"tool.execute.before": async (
Expand All @@ -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
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

The parallel gate check for background currently calls write(prompt, out.args.write === true). This coerces write to a boolean and treats undefined as false, which disables the heuristic detection in write() and lets write-capable background runs slip through the gate whenever the caller omits write: true.

To keep enforcement consistent with the background tool itself (which infers mutability when write is omitted), pass through out.args.write only when it’s actually a boolean; otherwise let write() infer from the prompt.

Suggested change
if (!write(String(out.args.prompt ?? ""), out.args.write === true)) return
if (
!write(
String(out.args.prompt ?? ""),
typeof out.args.write === "boolean" ? out.args.write : undefined,
)
)
return

Copilot uses AI. Check for mistakes.
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(
Expand Down
Loading
Loading