diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
index 720a05ff7e1..30787646947 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
@@ -157,41 +157,21 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
return (
-
-
-
-
-
-
- This will allow the following patterns until OpenCode is restarted
-
-
- {(pattern) => (
-
- {"- "}
- {pattern}
-
- )}
-
-
-
-
-
- }
- options={{ confirm: "Confirm", cancel: "Cancel" }}
- escapeKey="cancel"
- onSelect={(option) => {
+ {
setStore("stage", "permission")
- if (option === "cancel") return
void sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
workspace: project.workspace.current(),
+ patterns: patterns.length > 0 ? patterns : undefined,
})
}}
+ onCancel={() => {
+ setStore("stage", "permission")
+ }}
/>
@@ -459,6 +439,97 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
)
}
+function AlwaysEditPrompt(props: {
+ initial: string[]
+ permission: string
+ onConfirm: (patterns: string[]) => void
+ onCancel: () => void
+}) {
+ let input: TextareaRenderable
+ const { theme } = useTheme()
+ const keybind = useKeybind()
+ const textareaKeybindings = useTextareaKeybindings()
+ const dimensions = useTerminalDimensions()
+ const narrow = createMemo(() => dimensions().width < 80)
+ const dialog = useDialog()
+ const initialText = props.initial.join("\n")
+
+ useKeyboard((evt) => {
+ if (dialog.stack.length > 0) return
+
+ if (evt.name === "escape" || keybind.match("app_exit", evt)) {
+ evt.preventDefault()
+ props.onCancel()
+ return
+ }
+ if (evt.name === "return" && !evt.meta && !evt.shift) {
+ evt.preventDefault()
+ const patterns = input.plainText
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ props.onConfirm(patterns)
+ }
+ })
+
+ return (
+
+
+
+ {"△"}
+ Always allow
+
+
+
+ {"Edit pattern(s) to allow for " + props.permission + " until OpenCode is restarted."}
+
+
+
+ One pattern per line. Alt+Enter inserts a newline.
+
+
+
+
+
+ )
+}
+
function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
let input: TextareaRenderable
const { theme } = useTheme()
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index 3fedd41d2ce..b1676b6aa7b 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -60,6 +60,7 @@ export type Reply = Schema.Schema.Type
const reply = {
reply: Reply,
message: Schema.optional(Schema.String),
+ patterns: Schema.optional(Schema.Array(Schema.String)),
}
export const ReplyBody = Schema.Struct(reply)
@@ -248,7 +249,10 @@ export const layer = Layer.effect(
yield* Deferred.succeed(existing.deferred, undefined)
if (input.reply === "once") return
- for (const pattern of existing.info.always) {
+ const patternsToAllow =
+ input.patterns && input.patterns.length > 0 ? input.patterns : existing.info.always
+
+ for (const pattern of patternsToAllow) {
approved.push({
permission: existing.info.permission,
pattern,
diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts
index 22c4d6f6d32..987a58250a0 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts
@@ -11,6 +11,7 @@ const root = "/permission"
const ReplyPayload = Schema.Struct({
reply: Permission.Reply,
message: Schema.optional(Schema.String),
+ patterns: Schema.optional(Schema.Array(Schema.String)),
})
export const PermissionApi = HttpApi.make("permission")
diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts
index 2a7b6195dfd..3f35a1fe4c8 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts
@@ -20,6 +20,7 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss
requestID: ctx.params.requestID,
reply: ctx.payload.reply,
message: ctx.payload.message,
+ patterns: ctx.payload.patterns,
})
return true
})
diff --git a/packages/opencode/src/server/routes/instance/permission.ts b/packages/opencode/src/server/routes/instance/permission.ts
index c18f4734b4a..906fdab5bff 100644
--- a/packages/opencode/src/server/routes/instance/permission.ts
+++ b/packages/opencode/src/server/routes/instance/permission.ts
@@ -33,7 +33,14 @@ export const PermissionRoutes = lazy(() =>
requestID: PermissionID.zod,
}),
),
- validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })),
+ validator(
+ "json",
+ z.object({
+ reply: Permission.Reply.zod,
+ message: z.string().optional(),
+ patterns: z.array(z.string()).optional(),
+ }),
+ ),
async (c) =>
jsonRequest("PermissionRoutes.reply", c, function* () {
const params = c.req.valid("param")
@@ -43,6 +50,7 @@ export const PermissionRoutes = lazy(() =>
requestID: params.requestID,
reply: json.reply,
message: json.message,
+ patterns: json.patterns,
})
return true
}),
diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts
index 0064185f462..52618f6db0d 100644
--- a/packages/opencode/test/permission/next.test.ts
+++ b/packages/opencode/test/permission/next.test.ts
@@ -1122,3 +1122,85 @@ it.live("ask - abort should clear pending request", () =>
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
}),
)
+
+it.live("reply - always with custom patterns overrides server-suggested patterns", () =>
+ Effect.gen(function* () {
+ const dir = yield* tmpdirScoped({ git: true })
+ const run = withProvided(dir)
+ const fiber = yield* ask({
+ id: PermissionID.make("per_custom1"),
+ sessionID: SessionID.make("session_custom"),
+ permission: "bash",
+ patterns: ["docker exec -it app_foo cat /etc/hosts"],
+ metadata: {},
+ always: ["docker *"],
+ ruleset: [],
+ }).pipe(run, Effect.forkScoped)
+
+ yield* waitForPending(1).pipe(run)
+ yield* reply({
+ requestID: PermissionID.make("per_custom1"),
+ reply: "always",
+ patterns: ["docker exec -it app_* cat *"],
+ }).pipe(run)
+ yield* Fiber.join(fiber)
+
+ const ok = yield* ask({
+ sessionID: SessionID.make("session_custom2"),
+ permission: "bash",
+ patterns: ["docker exec -it app_bar cat /etc/passwd"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ }).pipe(run)
+ expect(ok).toBeUndefined()
+
+ const fiber2 = yield* ask({
+ id: PermissionID.make("per_custom2"),
+ sessionID: SessionID.make("session_custom3"),
+ permission: "bash",
+ patterns: ["docker ps"],
+ metadata: {},
+ always: ["docker *"],
+ ruleset: [],
+ }).pipe(run, Effect.forkScoped)
+
+ yield* waitForPending(1).pipe(run)
+ yield* reply({ requestID: PermissionID.make("per_custom2"), reply: "reject" }).pipe(run)
+ yield* Fiber.await(fiber2)
+ }),
+)
+
+it.live("reply - always with empty custom patterns falls back to server-suggested", () =>
+ Effect.gen(function* () {
+ const dir = yield* tmpdirScoped({ git: true })
+ const run = withProvided(dir)
+ const fiber = yield* ask({
+ id: PermissionID.make("per_custom3"),
+ sessionID: SessionID.make("session_fallback"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: ["ls"],
+ ruleset: [],
+ }).pipe(run, Effect.forkScoped)
+
+ yield* waitForPending(1).pipe(run)
+ yield* reply({
+ requestID: PermissionID.make("per_custom3"),
+ reply: "always",
+ patterns: [],
+ }).pipe(run)
+ yield* Fiber.join(fiber)
+
+ const ok = yield* ask({
+ sessionID: SessionID.make("session_fallback2"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ }).pipe(run)
+ expect(ok).toBeUndefined()
+ }),
+)
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 2da7c865d77..0fd31a74906 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -2722,6 +2722,7 @@ export class Permission extends HeyApiClient {
workspace?: string
reply?: "once" | "always" | "reject"
message?: string
+ patterns?: Array
},
options?: Options,
) {
@@ -2735,6 +2736,7 @@ export class Permission extends HeyApiClient {
{ in: "query", key: "workspace" },
{ in: "body", key: "reply" },
{ in: "body", key: "message" },
+ { in: "body", key: "patterns" },
],
},
],
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index d98d5c6fe18..f5882b09d8d 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -4290,6 +4290,7 @@ export type PermissionReplyData = {
body?: {
reply: "once" | "always" | "reject"
message?: string
+ patterns?: Array
}
path: {
requestID: string