Просмотр исходного кода

Add agent-level permissions with whitelist/blacklist support (#1862)

Dax 6 месяцев назад
Родитель
Сommit
10735f93ca

+ 50 - 0
.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."

+ 49 - 0
.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."

+ 13 - 0
.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.

+ 0 - 3
opencode.json

@@ -1,8 +1,5 @@
 {
   "$schema": "https://opencode.ai/config.json",
-  "agent": {
-    "build": {}
-  },
   "mcp": {
     "context7": {
       "type": "remote",

+ 48 - 9
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<string, Info> = {
       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
   })

+ 10 - 3
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<typeof Mcp>
 
+  export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
+  export type Permission = z.infer<typeof Permission>
+
   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<typeof Layout>
 
-  export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
-  export type Permission = z.infer<typeof Permission>
-
   export const Info = z
     .object({
       $schema: z.string().optional().describe("JSON schema reference for configuration validation"),

+ 3 - 0
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,

+ 1 - 1
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.`)
     }
   }
 }

+ 3 - 1
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") {

+ 4 - 23
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

+ 4 - 4
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,

+ 9 - 6
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<Record<string, boolean>> {
-    const cfg = await Config.get()
+  export async function enabled(
+    _providerID: string,
+    _modelID: string,
+    agent: Agent.Info,
+  ): Promise<Record<string, boolean>> {
     const result: Record<string, boolean> = {}
     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
     }
 

+ 1 - 0
packages/opencode/src/tool/tool.ts

@@ -7,6 +7,7 @@ export namespace Tool {
   export type Context<M extends Metadata = Metadata> = {
     sessionID: string
     messageID: string
+    agent: string
     callID?: string
     abort: AbortSignal
     metadata(input: { title?: string; metadata?: M }): void

+ 3 - 3
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,

+ 2 - 1
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 ../",

+ 1 - 0
packages/opencode/test/tool/tool.test.ts

@@ -8,6 +8,7 @@ const ctx = {
   sessionID: "test",
   messageID: "",
   toolCallID: "",
+  agent: "build",
   abort: AbortSignal.any([]),
   metadata: () => {},
 }

+ 141 - 0
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.

+ 2 - 0
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