diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index e69de29b..ba9577db 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -0,0 +1,50 @@ +name: Duplicate Issue Detection + +on: + issues: + types: [opened] + +jobs: + check-duplicates: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install opencode + run: curl -fsSL https://opencode.ai/install | bash + + - name: Check for duplicate issues + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + opencode run --agent github -m anthropic/claude-sonnet-4-20250514 "A new issue has been created: '${{ github.event.issue.title }}' + + Issue body: + ${{ github.event.issue.body }} + + Please search through existing issues in this repository to find any potential duplicates of this new issue. Consider: + 1. Similar titles or descriptions + 2. Same error messages or symptoms + 3. Related functionality or components + 4. Similar feature requests + + If you find any potential duplicates, please comment on the new issue with: + - A brief explanation of why it might be a duplicate + - Links to the potentially duplicate issues + - A suggestion to check those issues first + + Use this format for the comment: + '👋 This issue might be a duplicate of existing issues. Please check: + - #[issue_number]: [brief description of similarity] + + If none of these address your specific case, please let us know how this issue differs.' + + If no clear duplicates are found, do not comment." diff --git a/.github/workflows/guidelines-check.yml b/.github/workflows/guidelines-check.yml index e69de29b..9f4915f5 100644 --- a/.github/workflows/guidelines-check.yml +++ b/.github/workflows/guidelines-check.yml @@ -0,0 +1,49 @@ +name: Guidelines Check + +on: + pull_request: + types: [opened, synchronize] + +jobs: + check-guidelines: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install opencode + run: curl -fsSL https://opencode.ai/install | bash + + - name: Check PR guidelines compliance + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + opencode run --agent github -m anthropic/claude-sonnet-4-20250514 "A new pull request has been created: '${{ github.event.pull_request.title }}' + + PR description: + ${{ github.event.pull_request.body }} + + Please check all the code changes in this pull request against the guidelines in AGENTS.md file in this repository. + + For each violation you find, create a file comment using the gh CLI. Use this exact format for each violation: + + \`\`\`bash + gh pr review ${{ github.event.pull_request.number }} --comment-body 'This violates the AGENTS.md guideline: [specific rule]. Consider: [suggestion]' --file 'path/to/file.ts' --line [line_number] + \`\`\` + + When possible, also submit code change suggestions using: + + \`\`\`bash + gh pr review ${{ github.event.pull_request.number }} --comment-body 'Suggested fix for AGENTS.md guideline violation:' --file 'path/to/file.ts' --line [line_number] --body '```suggestion + [corrected code here] + ```' + \`\`\` + + Only create comments for actual violations. If the code follows all guidelines, don't run any gh commands." diff --git a/.opencode/agent/github.md b/.opencode/agent/github.md new file mode 100644 index 00000000..da3aa451 --- /dev/null +++ b/.opencode/agent/github.md @@ -0,0 +1,13 @@ +--- +permission: + bash: + "*": "deny" + "gh*": "allow" +mode: subagent +--- + +You are running in github actions, typically to evaluate a PR. Do not do +anything that is outside the scope of that. You have access to the bash tool but +you can only run `gh` cli commands with it. + +Diffs are important but be sure to read the whole file to get the full context. diff --git a/opencode.json b/opencode.json index 003253ee..59f14ac7 100644 --- a/opencode.json +++ b/opencode.json @@ -1,8 +1,5 @@ { "$schema": "https://opencode.ai/config.json", - "agent": { - "build": {} - }, "mcp": { "context7": { "type": "remote", diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b363e0e9..aa9eeec8 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -5,6 +5,7 @@ import { Provider } from "../provider/provider" import { generateObject, type ModelMessage } from "ai" import PROMPT_GENERATE from "./generate.txt" import { SystemPrompt } from "../session/system" +import { mergeDeep } from "remeda" export namespace Agent { export const Info = z @@ -14,6 +15,11 @@ export namespace Agent { mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]), topP: z.number().optional(), temperature: z.number().optional(), + permission: z.object({ + edit: Config.Permission, + bash: z.record(z.string(), Config.Permission), + webfetch: Config.Permission.optional(), + }), model: z .object({ modelID: z.string(), @@ -31,6 +37,13 @@ export namespace Agent { const state = App.state("agent", async () => { const cfg = await Config.get() + const defaultPermission: Info["permission"] = { + edit: "allow", + bash: { + "*": "allow", + }, + webfetch: "allow", + } const result: Record = { general: { name: "general", @@ -41,17 +54,20 @@ export namespace Agent { todowrite: false, }, options: {}, + permission: defaultPermission, mode: "subagent", }, build: { name: "build", tools: {}, options: {}, + permission: defaultPermission, mode: "primary", }, plan: { name: "plan", options: {}, + permission: defaultPermission, tools: { write: false, edit: false, @@ -70,25 +86,48 @@ export namespace Agent { item = result[key] = { name: key, mode: "all", + permission: defaultPermission, options: {}, tools: {}, } - const { model, prompt, tools, description, temperature, top_p, mode, ...extra } = value + const { model, prompt, tools, description, temperature, top_p, mode, permission, ...extra } = value item.options = { ...item.options, ...extra, } - if (value.model) item.model = Provider.parseModel(value.model) - if (value.prompt) item.prompt = value.prompt - if (value.tools) + if (model) item.model = Provider.parseModel(model) + if (prompt) item.prompt = prompt + if (tools) item.tools = { ...item.tools, - ...value.tools, + ...tools, } - if (value.description) item.description = value.description - if (value.temperature != undefined) item.temperature = value.temperature - if (value.top_p != undefined) item.topP = value.top_p - if (value.mode) item.mode = value.mode + if (description) item.description = description + if (temperature != undefined) item.temperature = temperature + if (top_p != undefined) item.topP = top_p + if (mode) item.mode = mode + + if (permission ?? cfg.permission) { + const merged = mergeDeep(cfg.permission ?? {}, permission ?? {}) + if (merged.edit) item.permission.edit = merged.edit + if (merged.webfetch) item.permission.webfetch = merged.webfetch + if (merged.bash) { + if (typeof merged.bash === "string") { + item.permission.bash = { + "*": merged.bash, + } + } + // if granular permissions are provided, default to "ask" + if (typeof merged.bash === "object") { + item.permission.bash = mergeDeep( + { + "*": "ask", + }, + merged.bash, + ) + } + } + } } return result }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 40ec22b5..a8d84d8b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -164,6 +164,9 @@ export namespace Config { export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote]) export type Mcp = z.infer + export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")]) + export type Permission = z.infer + export const Agent = z .object({ model: z.string().optional(), @@ -174,6 +177,13 @@ export namespace Config { disable: z.boolean().optional(), description: z.string().optional().describe("Description of when to use the agent"), mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]).optional(), + permission: z + .object({ + edit: Permission.optional(), + bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), + webfetch: Permission.optional(), + }) + .optional(), }) .catchall(z.any()) .openapi({ @@ -243,9 +253,6 @@ export namespace Config { }) export type Layout = z.infer - export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")]) - export type Permission = z.infer - export const Info = z .object({ $schema: z.string().optional().describe("JSON schema reference for configuration validation"), diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 007de650..868c96ba 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -21,6 +21,9 @@ import { GithubCommand } from "./cli/cmd/github" const cancel = new AbortController() +try { +} catch (e) {} + process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { e: e instanceof Error ? e.message : e, diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 53c49696..d724e993 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -155,7 +155,7 @@ export namespace Permission { public readonly permissionID: string, public readonly toolCallID?: string, ) { - super(`The user rejected permission to use this functionality`) + super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`) } } } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 6d2b9486..b881161a 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -523,6 +523,7 @@ export namespace Session { t.execute(args, { sessionID: input.sessionID, abort: new AbortController().signal, + agent: agent.name, messageID: userMsg.id, metadata: async () => {}, }), @@ -765,7 +766,7 @@ export namespace Session { const enabledTools = pipe( agent.tools, - mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID)), + mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID, agent)), mergeDeep(input.tools ?? {}), ) for (const item of await ToolRegistry.tools(input.providerID, input.modelID)) { @@ -791,6 +792,7 @@ export namespace Session { abort: options.abortSignal!, messageID: assistantMsg.id, callID: options.toolCallId, + agent: agent.name, metadata: async (val) => { const match = processor.partFromToolCall(options.toolCallId) if (match && match.state.status === "running") { diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index f6368ea1..b6266dc2 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -5,12 +5,12 @@ import { Tool } from "./tool" import DESCRIPTION from "./bash.txt" import { App } from "../app/app" import { Permission } from "../permission" -import { Config } from "../config/config" import { Filesystem } from "../util/filesystem" import { lazy } from "../util/lazy" import { Log } from "../util/log" import { Wildcard } from "../util/wildcard" import { $ } from "bun" +import { Agent } from "../agent/agent" const MAX_OUTPUT_LENGTH = 30000 const DEFAULT_TIMEOUT = 1 * 60 * 1000 @@ -40,20 +40,8 @@ export const BashTool = Tool.define("bash", { async execute(params, ctx) { const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT) const app = App.info() - const cfg = await Config.get() const tree = await parser().then((p) => p.parse(params.command)) - const permissions = (() => { - const value = cfg.permission?.bash - if (!value) - return { - "*": "allow", - } - if (typeof value === "string") - return { - "*": value, - } - return value - })() + const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash) let needsAsk = false for (const node of tree.rootNode.descendantsOfType("command")) { @@ -93,17 +81,10 @@ export const BashTool = Tool.define("bash", { // always allow cd if it passes above check if (!needsAsk && command[0] !== "cd") { - const action = (() => { - for (const [pattern, value] of Object.entries(permissions)) { - const match = Wildcard.match(node.text, pattern) - log.info("checking", { text: node.text.trim(), pattern, match }) - if (match) return value - } - return "ask" - })() + const action = Wildcard.all(node.text, permissions) if (action === "deny") { throw new Error( - "The user has specifically restricted access to this command, you are not allowed to execute it.", + `The user has specifically restricted access to this command, you are not allowed to execute it. Here is the configuration: ${JSON.stringify(permissions)}`, ) } if (action === "ask") needsAsk = true diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 1b31b0aa..8c3bdc63 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -14,8 +14,8 @@ import { App } from "../app/app" import { File } from "../file" import { Bus } from "../bus" import { FileTime } from "../file/time" -import { Config } from "../config/config" import { Filesystem } from "../util/filesystem" +import { Agent } from "../agent/agent" export const EditTool = Tool.define("edit", { description: DESCRIPTION, @@ -40,7 +40,7 @@ export const EditTool = Tool.define("edit", { throw new Error(`File ${filePath} is not in the current working directory`) } - const cfg = await Config.get() + const agent = await Agent.get(ctx.agent) let diff = "" let contentOld = "" let contentNew = "" @@ -48,7 +48,7 @@ export const EditTool = Tool.define("edit", { if (params.oldString === "") { contentNew = params.newString diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) - if (cfg.permission?.edit === "ask") { + if (agent.permission.edit === "ask") { await Permission.ask({ type: "edit", sessionID: ctx.sessionID, @@ -77,7 +77,7 @@ export const EditTool = Tool.define("edit", { contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) - if (cfg.permission?.edit === "ask") { + if (agent.permission.edit === "ask") { await Permission.ask({ type: "edit", sessionID: ctx.sessionID, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c2fe5943..e7938057 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -11,7 +11,7 @@ import { TodoWriteTool, TodoReadTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" -import { Config } from "../config/config" +import type { Agent } from "../agent/agent" export namespace ToolRegistry { const ALL = [ @@ -66,20 +66,23 @@ export namespace ToolRegistry { return result } - export async function enabled(_providerID: string, _modelID: string): Promise> { - const cfg = await Config.get() + export async function enabled( + _providerID: string, + _modelID: string, + agent: Agent.Info, + ): Promise> { const result: Record = {} result["patch"] = false - if (cfg.permission?.edit === "deny") { + if (agent.permission.edit === "deny") { result["edit"] = false result["patch"] = false result["write"] = false } - if (cfg?.permission?.bash === "deny") { + if (agent.permission.bash["*"] === "deny" && Object.keys(agent.permission.bash).length === 1) { result["bash"] = false } - if (cfg?.permission?.webfetch === "deny") { + if (agent.permission.webfetch === "deny") { result["webfetch"] = false } diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 1c71b9a7..8be3d0cd 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -7,6 +7,7 @@ export namespace Tool { export type Context = { sessionID: string messageID: string + agent: string callID?: string abort: AbortSignal metadata(input: { title?: string; metadata?: M }): void diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 5b0028f8..951ad730 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -8,8 +8,8 @@ import { App } from "../app/app" import { Bus } from "../bus" import { File } from "../file" import { FileTime } from "../file/time" -import { Config } from "../config/config" import { Filesystem } from "../util/filesystem" +import { Agent } from "../agent/agent" export const WriteTool = Tool.define("write", { description: DESCRIPTION, @@ -28,8 +28,8 @@ export const WriteTool = Tool.define("write", { const exists = await file.exists() if (exists) await FileTime.assert(ctx.sessionID, filepath) - const cfg = await Config.get() - if (cfg.permission?.edit === "ask") + const agent = await Agent.get(ctx.agent) + if (agent.permission.edit === "ask") await Permission.ask({ type: "write", sessionID: ctx.sessionID, diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index e19949d6..43277dfa 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -8,6 +8,7 @@ const ctx = { sessionID: "test", messageID: "", toolCallID: "", + agent: "build", abort: AbortSignal.any([]), metadata: () => {}, } @@ -33,7 +34,7 @@ describe("tool.bash", () => { test("cd ../ should fail outside of project root", async () => { await App.provide({ cwd: projectRoot }, async () => { - await expect( + expect( bash.execute( { command: "cd ../", diff --git a/packages/opencode/test/tool/tool.test.ts b/packages/opencode/test/tool/tool.test.ts index a0f7ce90..313aa424 100644 --- a/packages/opencode/test/tool/tool.test.ts +++ b/packages/opencode/test/tool/tool.test.ts @@ -8,6 +8,7 @@ const ctx = { sessionID: "test", messageID: "", toolCallID: "", + agent: "build", abort: AbortSignal.any([]), metadata: () => {}, } diff --git a/packages/web/src/content/docs/docs/agents.mdx b/packages/web/src/content/docs/docs/agents.mdx index bcb0eca2..beb1b29a 100644 --- a/packages/web/src/content/docs/docs/agents.mdx +++ b/packages/web/src/content/docs/docs/agents.mdx @@ -358,6 +358,147 @@ Here are all the tools can be controlled through the agent config. --- +### Permissions + +Permissions control what actions an agent can take. + +- edit, bash, webfetch + +Each permission can be set to allow, ask, or deny. + +- allow, ask, deny + +Configure permissions globally in opencode.json. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "ask", + "bash": "allow", + "webfetch": "deny" + } +} +``` + +You can override permissions per agent in JSON. + +```json title="opencode.json" {7-18} +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "permission": { + "edit": "allow", + "bash": { + "*": "allow", + "git push": "ask", + "terraform *": "deny" + }, + "webfetch": "ask" + } + } + } +} +``` + +You can also set permissions in Markdown agents. + +```markdown title="~/.config/opencode/agent/review.md" +--- +description: Code review without edits +mode: subagent +permission: + edit: deny + bash: ask + webfetch: deny +--- + +Only analyze code and suggest changes. +``` + +Bash permissions support granular patterns for fine-grained control. + +```json title="Allow most, ask for risky, deny terraform" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": { + "*": "allow", + "git push": "ask", + "terraform *": "deny" + } + } +} +``` + +If you provide a granular bash map, the default becomes ask unless you set \* explicitly. + +```json title="Granular defaults to ask" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": { + "git status": "allow" + } + } +} +``` + +Agent-level permissions merge over global settings. + +- Global sets defaults; agent overrides when specified + +Specific bash rules can override a global default. + +```json title="Global ask, agent allows safe commands" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { "bash": "ask" }, + "agent": { + "build": { + "permission": { + "bash": { "git status": "allow", "*": "ask" } + } + } + } +} +``` + +Permissions affect tool availability and prompts differently. + +- deny hides tools (edit also hides write/patch); ask prompts; allow runs + +For quick reference, here are common setups. + +```json title="Read-only reviewer" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "review": { + "permission": { "edit": "deny", "bash": "deny", "webfetch": "allow" } + } + } +} +``` + +```json title="Planning agent that can browse but cannot change code" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "plan": { + "permission": { "edit": "deny", "bash": "deny", "webfetch": "ask" } + } + } +} +``` + +See the full permissions guide for more patterns. + +- /docs/permissions + +--- + ### Mode Control the agent's mode with the `mode` config. The `mode` option is used to determine how the agent can be used. diff --git a/packages/web/src/content/docs/docs/permissions.mdx b/packages/web/src/content/docs/docs/permissions.mdx index 2ac7b58a..44dbc92e 100644 --- a/packages/web/src/content/docs/docs/permissions.mdx +++ b/packages/web/src/content/docs/docs/permissions.mdx @@ -21,6 +21,8 @@ Permissions are configured in your `opencode.json` file under the `permission` k | `bash` | Control bash command execution | | `webfetch` | Control web content fetching | +They can also be configured per agent, see [Agent Configuration](/docs/agents#agent-configuration) for more details. + --- ### edit