Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 99 additions & 28 deletions packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,41 +157,21 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
return (
<Switch>
<Match when={store.stage === "always"}>
<Prompt
title="Always allow"
body={
<Switch>
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
<TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
</Match>
<Match when={true}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
<box>
<For each={props.request.always}>
{(pattern) => (
<text fg={theme.text}>
{"- "}
{pattern}
</text>
)}
</For>
</box>
</box>
</Match>
</Switch>
}
options={{ confirm: "Confirm", cancel: "Cancel" }}
escapeKey="cancel"
onSelect={(option) => {
<AlwaysEditPrompt
initial={Array.from(props.request.always)}
permission={props.request.permission}
onConfirm={(patterns) => {
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")
}}
/>
</Match>
<Match when={store.stage === "reject"}>
Expand Down Expand Up @@ -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 (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>Always allow</text>
</box>
<box paddingLeft={1}>
<text fg={theme.textMuted}>
{"Edit pattern(s) to allow for " + props.permission + " until OpenCode is restarted."}
</text>
</box>
<box paddingLeft={1}>
<text fg={theme.textMuted}>One pattern per line. Alt+Enter inserts a newline.</text>
</box>
</box>
<box
flexDirection={narrow() ? "column" : "row"}
flexShrink={0}
paddingTop={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
gap={1}
>
<textarea
ref={(val: TextareaRenderable) => {
input = val
val.traits = { status: "ALWAYS" }
}}
initialValue={initialText}
focused
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
keyBindings={textareaKeybindings()}
/>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>confirm</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>cancel</span>
</text>
</box>
</box>
</box>
)
}

function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
let input: TextareaRenderable
const { theme } = useTheme()
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type Reply = Schema.Schema.Type<typeof Reply>
const reply = {
reply: Reply,
message: Schema.optional(Schema.String),
patterns: Schema.optional(Schema.Array(Schema.String)),
}

export const ReplyBody = Schema.Struct(reply)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down
10 changes: 9 additions & 1 deletion packages/opencode/src/server/routes/instance/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -43,6 +50,7 @@ export const PermissionRoutes = lazy(() =>
requestID: params.requestID,
reply: json.reply,
message: json.message,
patterns: json.patterns,
})
return true
}),
Expand Down
82 changes: 82 additions & 0 deletions packages/opencode/test/permission/next.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}),
)
2 changes: 2 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2722,6 +2722,7 @@ export class Permission extends HeyApiClient {
workspace?: string
reply?: "once" | "always" | "reject"
message?: string
patterns?: Array<string>
},
options?: Options<never, ThrowOnError>,
) {
Expand All @@ -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" },
],
},
],
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4290,6 +4290,7 @@ export type PermissionReplyData = {
body?: {
reply: "once" | "always" | "reject"
message?: string
patterns?: Array<string>
}
path: {
requestID: string
Expand Down
Loading