Skip to content

Commit 10735f9

Browse files
authored
Add agent-level permissions with whitelist/blacklist support (anomalyco#1862)
1 parent ccaebdc commit 10735f9

File tree

18 files changed

+344
-54
lines changed

18 files changed

+344
-54
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Duplicate Issue Detection
2+
3+
on:
4+
issues:
5+
types: [opened]
6+
7+
jobs:
8+
check-duplicates:
9+
runs-on: ubuntu-latest
10+
permissions:
11+
contents: read
12+
issues: write
13+
id-token: write
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
with:
18+
fetch-depth: 1
19+
20+
- name: Install opencode
21+
run: curl -fsSL https://opencode.ai/install | bash
22+
23+
- name: Check for duplicate issues
24+
env:
25+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
26+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27+
run: |
28+
opencode run --agent github -m anthropic/claude-sonnet-4-20250514 "A new issue has been created: '${{ github.event.issue.title }}'
29+
30+
Issue body:
31+
${{ github.event.issue.body }}
32+
33+
Please search through existing issues in this repository to find any potential duplicates of this new issue. Consider:
34+
1. Similar titles or descriptions
35+
2. Same error messages or symptoms
36+
3. Related functionality or components
37+
4. Similar feature requests
38+
39+
If you find any potential duplicates, please comment on the new issue with:
40+
- A brief explanation of why it might be a duplicate
41+
- Links to the potentially duplicate issues
42+
- A suggestion to check those issues first
43+
44+
Use this format for the comment:
45+
'👋 This issue might be a duplicate of existing issues. Please check:
46+
- #[issue_number]: [brief description of similarity]
47+
48+
If none of these address your specific case, please let us know how this issue differs.'
49+
50+
If no clear duplicates are found, do not comment."
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Guidelines Check
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize]
6+
7+
jobs:
8+
check-guidelines:
9+
runs-on: ubuntu-latest
10+
permissions:
11+
contents: read
12+
pull-requests: write
13+
id-token: write
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
with:
18+
fetch-depth: 1
19+
20+
- name: Install opencode
21+
run: curl -fsSL https://opencode.ai/install | bash
22+
23+
- name: Check PR guidelines compliance
24+
env:
25+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
26+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27+
run: |
28+
opencode run --agent github -m anthropic/claude-sonnet-4-20250514 "A new pull request has been created: '${{ github.event.pull_request.title }}'
29+
30+
PR description:
31+
${{ github.event.pull_request.body }}
32+
33+
Please check all the code changes in this pull request against the guidelines in AGENTS.md file in this repository.
34+
35+
For each violation you find, create a file comment using the gh CLI. Use this exact format for each violation:
36+
37+
\`\`\`bash
38+
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]
39+
\`\`\`
40+
41+
When possible, also submit code change suggestions using:
42+
43+
\`\`\`bash
44+
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
45+
[corrected code here]
46+
```'
47+
\`\`\`
48+
49+
Only create comments for actual violations. If the code follows all guidelines, don't run any gh commands."

.opencode/agent/github.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
permission:
3+
bash:
4+
"*": "deny"
5+
"gh*": "allow"
6+
mode: subagent
7+
---
8+
9+
You are running in github actions, typically to evaluate a PR. Do not do
10+
anything that is outside the scope of that. You have access to the bash tool but
11+
you can only run `gh` cli commands with it.
12+
13+
Diffs are important but be sure to read the whole file to get the full context.

opencode.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
{
22
"$schema": "https://opencode.ai/config.json",
3-
"agent": {
4-
"build": {}
5-
},
63
"mcp": {
74
"context7": {
85
"type": "remote",

packages/opencode/src/agent/agent.ts

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Provider } from "../provider/provider"
55
import { generateObject, type ModelMessage } from "ai"
66
import PROMPT_GENERATE from "./generate.txt"
77
import { SystemPrompt } from "../session/system"
8+
import { mergeDeep } from "remeda"
89

910
export namespace Agent {
1011
export const Info = z
@@ -14,6 +15,11 @@ export namespace Agent {
1415
mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]),
1516
topP: z.number().optional(),
1617
temperature: z.number().optional(),
18+
permission: z.object({
19+
edit: Config.Permission,
20+
bash: z.record(z.string(), Config.Permission),
21+
webfetch: Config.Permission.optional(),
22+
}),
1723
model: z
1824
.object({
1925
modelID: z.string(),
@@ -31,6 +37,13 @@ export namespace Agent {
3137

3238
const state = App.state("agent", async () => {
3339
const cfg = await Config.get()
40+
const defaultPermission: Info["permission"] = {
41+
edit: "allow",
42+
bash: {
43+
"*": "allow",
44+
},
45+
webfetch: "allow",
46+
}
3447
const result: Record<string, Info> = {
3548
general: {
3649
name: "general",
@@ -41,17 +54,20 @@ export namespace Agent {
4154
todowrite: false,
4255
},
4356
options: {},
57+
permission: defaultPermission,
4458
mode: "subagent",
4559
},
4660
build: {
4761
name: "build",
4862
tools: {},
4963
options: {},
64+
permission: defaultPermission,
5065
mode: "primary",
5166
},
5267
plan: {
5368
name: "plan",
5469
options: {},
70+
permission: defaultPermission,
5571
tools: {
5672
write: false,
5773
edit: false,
@@ -70,25 +86,48 @@ export namespace Agent {
7086
item = result[key] = {
7187
name: key,
7288
mode: "all",
89+
permission: defaultPermission,
7390
options: {},
7491
tools: {},
7592
}
76-
const { model, prompt, tools, description, temperature, top_p, mode, ...extra } = value
93+
const { model, prompt, tools, description, temperature, top_p, mode, permission, ...extra } = value
7794
item.options = {
7895
...item.options,
7996
...extra,
8097
}
81-
if (value.model) item.model = Provider.parseModel(value.model)
82-
if (value.prompt) item.prompt = value.prompt
83-
if (value.tools)
98+
if (model) item.model = Provider.parseModel(model)
99+
if (prompt) item.prompt = prompt
100+
if (tools)
84101
item.tools = {
85102
...item.tools,
86-
...value.tools,
103+
...tools,
87104
}
88-
if (value.description) item.description = value.description
89-
if (value.temperature != undefined) item.temperature = value.temperature
90-
if (value.top_p != undefined) item.topP = value.top_p
91-
if (value.mode) item.mode = value.mode
105+
if (description) item.description = description
106+
if (temperature != undefined) item.temperature = temperature
107+
if (top_p != undefined) item.topP = top_p
108+
if (mode) item.mode = mode
109+
110+
if (permission ?? cfg.permission) {
111+
const merged = mergeDeep(cfg.permission ?? {}, permission ?? {})
112+
if (merged.edit) item.permission.edit = merged.edit
113+
if (merged.webfetch) item.permission.webfetch = merged.webfetch
114+
if (merged.bash) {
115+
if (typeof merged.bash === "string") {
116+
item.permission.bash = {
117+
"*": merged.bash,
118+
}
119+
}
120+
// if granular permissions are provided, default to "ask"
121+
if (typeof merged.bash === "object") {
122+
item.permission.bash = mergeDeep(
123+
{
124+
"*": "ask",
125+
},
126+
merged.bash,
127+
)
128+
}
129+
}
130+
}
92131
}
93132
return result
94133
})

packages/opencode/src/config/config.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ export namespace Config {
164164
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
165165
export type Mcp = z.infer<typeof Mcp>
166166

167+
export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
168+
export type Permission = z.infer<typeof Permission>
169+
167170
export const Agent = z
168171
.object({
169172
model: z.string().optional(),
@@ -174,6 +177,13 @@ export namespace Config {
174177
disable: z.boolean().optional(),
175178
description: z.string().optional().describe("Description of when to use the agent"),
176179
mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]).optional(),
180+
permission: z
181+
.object({
182+
edit: Permission.optional(),
183+
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
184+
webfetch: Permission.optional(),
185+
})
186+
.optional(),
177187
})
178188
.catchall(z.any())
179189
.openapi({
@@ -243,9 +253,6 @@ export namespace Config {
243253
})
244254
export type Layout = z.infer<typeof Layout>
245255

246-
export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
247-
export type Permission = z.infer<typeof Permission>
248-
249256
export const Info = z
250257
.object({
251258
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),

packages/opencode/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import { GithubCommand } from "./cli/cmd/github"
2121

2222
const cancel = new AbortController()
2323

24+
try {
25+
} catch (e) {}
26+
2427
process.on("unhandledRejection", (e) => {
2528
Log.Default.error("rejection", {
2629
e: e instanceof Error ? e.message : e,

packages/opencode/src/permission/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export namespace Permission {
155155
public readonly permissionID: string,
156156
public readonly toolCallID?: string,
157157
) {
158-
super(`The user rejected permission to use this functionality`)
158+
super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`)
159159
}
160160
}
161161
}

packages/opencode/src/session/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,7 @@ export namespace Session {
523523
t.execute(args, {
524524
sessionID: input.sessionID,
525525
abort: new AbortController().signal,
526+
agent: agent.name,
526527
messageID: userMsg.id,
527528
metadata: async () => {},
528529
}),
@@ -765,7 +766,7 @@ export namespace Session {
765766

766767
const enabledTools = pipe(
767768
agent.tools,
768-
mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID)),
769+
mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID, agent)),
769770
mergeDeep(input.tools ?? {}),
770771
)
771772
for (const item of await ToolRegistry.tools(input.providerID, input.modelID)) {
@@ -791,6 +792,7 @@ export namespace Session {
791792
abort: options.abortSignal!,
792793
messageID: assistantMsg.id,
793794
callID: options.toolCallId,
795+
agent: agent.name,
794796
metadata: async (val) => {
795797
const match = processor.partFromToolCall(options.toolCallId)
796798
if (match && match.state.status === "running") {

packages/opencode/src/tool/bash.ts

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import { Tool } from "./tool"
55
import DESCRIPTION from "./bash.txt"
66
import { App } from "../app/app"
77
import { Permission } from "../permission"
8-
import { Config } from "../config/config"
98
import { Filesystem } from "../util/filesystem"
109
import { lazy } from "../util/lazy"
1110
import { Log } from "../util/log"
1211
import { Wildcard } from "../util/wildcard"
1312
import { $ } from "bun"
13+
import { Agent } from "../agent/agent"
1414

1515
const MAX_OUTPUT_LENGTH = 30000
1616
const DEFAULT_TIMEOUT = 1 * 60 * 1000
@@ -40,20 +40,8 @@ export const BashTool = Tool.define("bash", {
4040
async execute(params, ctx) {
4141
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
4242
const app = App.info()
43-
const cfg = await Config.get()
4443
const tree = await parser().then((p) => p.parse(params.command))
45-
const permissions = (() => {
46-
const value = cfg.permission?.bash
47-
if (!value)
48-
return {
49-
"*": "allow",
50-
}
51-
if (typeof value === "string")
52-
return {
53-
"*": value,
54-
}
55-
return value
56-
})()
44+
const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash)
5745

5846
let needsAsk = false
5947
for (const node of tree.rootNode.descendantsOfType("command")) {
@@ -93,17 +81,10 @@ export const BashTool = Tool.define("bash", {
9381

9482
// always allow cd if it passes above check
9583
if (!needsAsk && command[0] !== "cd") {
96-
const action = (() => {
97-
for (const [pattern, value] of Object.entries(permissions)) {
98-
const match = Wildcard.match(node.text, pattern)
99-
log.info("checking", { text: node.text.trim(), pattern, match })
100-
if (match) return value
101-
}
102-
return "ask"
103-
})()
84+
const action = Wildcard.all(node.text, permissions)
10485
if (action === "deny") {
10586
throw new Error(
106-
"The user has specifically restricted access to this command, you are not allowed to execute it.",
87+
`The user has specifically restricted access to this command, you are not allowed to execute it. Here is the configuration: ${JSON.stringify(permissions)}`,
10788
)
10889
}
10990
if (action === "ask") needsAsk = true

0 commit comments

Comments
 (0)