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
62 changes: 58 additions & 4 deletions packages/core/src/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const PermissionEffect = Permission.Effect
export { PermissionEffect as Effect }
export { Rule, Ruleset } from "@opencode-ai/schema/permission"
const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
const externalFollowupLimit = 256

export const ID = Permission.ID
export type ID = typeof ID.Type
Expand Down Expand Up @@ -108,9 +109,15 @@ export interface Interface {

export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Permission") {}

interface ExternalFollowup {
readonly action: "edit" | "read"
readonly resources: ReadonlyArray<string>
}

interface Pending {
readonly request: Request
readonly agent?: AgentV2.ID
readonly externalFollowup?: ExternalFollowup
readonly deferred: Deferred.Deferred<void, DeclinedError | CorrectedError>
}

Expand All @@ -123,6 +130,7 @@ const layer = Layer.effect(
const sessions = yield* SessionStore.Service
const saved = yield* PermissionSaved.Service
const pending = new Map<ID, Pending>()
const externalFollowups = new Map<string, ExternalFollowup>()

yield* Effect.addFinalizer(() =>
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), {
Expand All @@ -131,6 +139,7 @@ const layer = Layer.effect(
Effect.ensuring(
Effect.sync(() => {
pending.clear()
externalFollowups.clear()
}),
),
),
Expand Down Expand Up @@ -178,11 +187,52 @@ const layer = Layer.effect(
}
}

const create = (request: Request, agent?: AgentV2.ID) =>
function sourceKey(input: Pick<AssertInput, "sessionID" | "source">) {
if (input.source?.type !== "tool") return
return JSON.stringify([input.sessionID, input.source.messageID, input.source.callID])
}

function externalFollowup(input: AssertInput) {
if (input.action !== "external_directory") return
const value = input.metadata?.followup
if (!value || typeof value !== "object" || Array.isArray(value)) return
const action = Reflect.get(value, "action")
const resources = Reflect.get(value, "resources")
if (action !== "edit" && action !== "read") return
if (!Array.isArray(resources) || !resources.every((resource) => typeof resource === "string")) return
return { action, resources: [...resources] } satisfies ExternalFollowup
}

function rememberExternalFollowup(input: Pending) {
if (!input.externalFollowup) return
const key = sourceKey(input.request)
if (!key) return
externalFollowups.set(key, input.externalFollowup)
if (externalFollowups.size > externalFollowupLimit) {
const oldest = externalFollowups.keys().next()
if (!oldest.done) externalFollowups.delete(oldest.value)
}
return key
}

function consumeExternalFollowup(input: AssertInput) {
const key = sourceKey(input)
if (!key) return false
const followup = externalFollowups.get(key)
if (!followup) return false
externalFollowups.delete(key)
return (
followup.action === input.action &&
followup.resources.length === input.resources.length &&
followup.resources.every((resource, index) => resource === input.resources[index])
)
}

const create = (request: Request, agent?: AgentV2.ID, followup?: ExternalFollowup) =>
Effect.uninterruptible(
Effect.gen(function* () {
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
const item = { request, agent, deferred }
const item = { request, agent, externalFollowup: followup, deferred }
if (pending.has(request.id))
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`))
pending.set(request.id, item)
Expand All @@ -204,15 +254,17 @@ const layer = Layer.effect(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const result = yield* evaluateInput(input)
const approved = consumeExternalFollowup(input)
if (result.effect === "deny") {
return yield* new BlockedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
})
}
if (approved) return
if (result.effect === "allow") return
const item = yield* create(request(input), input.agent)
const item = yield* create(request(input), input.agent, externalFollowup(input))
return yield* restore(Deferred.await(item.deferred)).pipe(
Effect.catchTag("PermissionV2.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Expand Down Expand Up @@ -262,7 +314,9 @@ const layer = Layer.effect(
resources: existing.request.save,
})
}
yield* Deferred.succeed(existing.deferred, undefined)
const followup = rememberExternalFollowup(existing)
const resumed = yield* Deferred.succeed(existing.deferred, undefined)
if (!resumed && followup) externalFollowups.delete(followup)
pending.delete(input.requestID)
if (input.reply !== "always" || !existing.request.save?.length) return

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export const Plugin = {
yield* unableToEdit(
permission.assert({
...LocationMutation.externalDirectoryPermission(external),
metadata: { followup: { action: "edit", resources: [target.resource] } },
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/tool/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,19 @@ export const Plugin = {
const external = target.externalDirectory
if (external) externalDirectories.set(external.resource, external)
}
const resources = [...new Set(targets.map(({ target }) => target.resource))]
for (const external of externalDirectories.values()) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
metadata: { followup: { action: "edit", resources } },
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map(({ target }) => target.resource))],
resources,
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export const Plugin = {
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
metadata: { followup: { action: "read", resources: [target.resource] } },
sessionID: context.sessionID,
agent: context.agent,
source,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/tool/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const Plugin = {
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
metadata: { followup: { action: "edit", resources: [target.resource] } },
sessionID: context.sessionID,
agent: context.agent,
source,
Expand Down
86 changes: 84 additions & 2 deletions packages/core/test/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function assertion(input: Partial<PermissionV2.AssertInput> = {}) {
} satisfies PermissionV2.AssertInput
}

function waitForRequest() {
function waitForRequest(input = assertion()) {
return Effect.gen(function* () {
const service = yield* PermissionV2.Service
const events = yield* EventV2.Service
Expand All @@ -97,7 +97,7 @@ function waitForRequest() {
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped)
const fiber = yield* service.assert(input).pipe(Effect.forkScoped)
const request = yield* Deferred.await(asked)
return { service, fiber, request }
})
Expand Down Expand Up @@ -266,6 +266,88 @@ describe("PermissionV2", () => {
}),
)

it.effect("uses external approval only for the exact blocking follow-up", () =>
Effect.gen(function* () {
yield* setup()
const source = { type: "tool" as const, messageID: "msg_1", callID: "call_1" }
const followup = { action: "edit" as const, resources: ["/tmp/work/file.ts"] }
const external = yield* waitForRequest(
assertion({
id: PermissionV2.ID.create("per_external"),
action: "external_directory",
resources: ["/tmp/work/*"],
metadata: { followup },
source,
}),
)
yield* external.service.reply({ requestID: external.request.id, reply: "once" })
yield* Fiber.join(external.fiber)

yield* external.service.assert(assertion({ ...followup, source }))
expect(yield* external.service.list()).toEqual([])

const repeated = yield* waitForRequest(
assertion({ id: PermissionV2.ID.create("per_repeated"), ...followup, source }),
)
yield* repeated.service.reply({ requestID: repeated.request.id, reply: "reject" })
yield* Fiber.await(repeated.fiber)

const next = yield* waitForRequest(
assertion({
id: PermissionV2.ID.create("per_external_2"),
action: "external_directory",
resources: ["/tmp/work/*"],
metadata: { followup },
source,
}),
)
yield* next.service.reply({ requestID: next.request.id, reply: "once" })
yield* Fiber.join(next.fiber)

const mismatch = yield* waitForRequest(
assertion({
id: PermissionV2.ID.create("per_mismatch"),
action: "edit",
resources: ["/tmp/work/other.ts"],
source,
}),
)
yield* mismatch.service.reply({ requestID: mismatch.request.id, reply: "reject" })
yield* Fiber.await(mismatch.fiber)
}),
)

it.effect("does not retain an external follow-up after a configured denial", () =>
Effect.gen(function* () {
yield* setup()
const source = { type: "tool" as const, messageID: "msg_2", callID: "call_2" }
const followup = { action: "edit" as const, resources: ["/tmp/work/file.ts"] }
const external = yield* waitForRequest(
assertion({
id: PermissionV2.ID.create("per_external_deny"),
action: "external_directory",
resources: ["/tmp/work/*"],
metadata: { followup },
source,
}),
)
yield* external.service.reply({ requestID: external.request.id, reply: "once" })
yield* Fiber.join(external.fiber)

yield* setRules([{ action: "edit", resource: "*", effect: "deny" }])
expect(yield* external.service.assert(assertion({ ...followup, source })).pipe(Effect.flip)).toBeInstanceOf(
PermissionV2.BlockedError,
)

yield* setRules([])
const afterDeny = yield* waitForRequest(
assertion({ id: PermissionV2.ID.create("per_after_deny"), ...followup, source }),
)
yield* afterDeny.service.reply({ requestID: afterDeny.request.id, reply: "reject" })
yield* Fiber.await(afterDeny.fiber)
}),
)

it.effect("defects when an asked permission is declined", () =>
Effect.gen(function* () {
yield* setup()
Expand Down
19 changes: 13 additions & 6 deletions packages/docs/permissions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,17 @@ transitions. `doom_loop` and `lsp` are not current V2 Core permission actions.

## External directories

An external path requires a separate `external_directory` decision before the
tool's own `read` or `edit` decision. This applies to external paths used by
`read`, `edit`, `write`, and `patch`, and to an external `shell` working
directory.
An external path checks `external_directory` before the tool's own `read`,
`edit`, or `shell` action. This applies to external paths used by `read`,
`edit`, `write`, and `patch`, and to an external `shell` working directory.

For the built-in file tools, approving an `external_directory` prompt also
approves the exact `read` or `edit` operation described by that prompt. This
avoids asking once for the directory boundary and again for the same operation.
A configured `deny` for that exact action and resource still blocks it. An
external `shell` working directory does not approve the command itself, and
when a directory boundary is already allowed by configuration, the tool's own
action is evaluated normally.

```jsonc
{
Expand Down Expand Up @@ -201,8 +208,8 @@ Saved approvals are durable and project-scoped. They are additional `allow`
rules, but they can never override a configured `deny`. The proposed saved
pattern may be broader than the displayed resource: several tools propose `*`,
shell proposes the exact command text, and skills and subagents propose their
IDs. Review the confirmation carefully and remove saved approvals that are no
longer needed.
IDs. Review the displayed scope carefully and remove saved approvals that are
no longer needed.

For non-interactive runs, `opencode2 run --auto` replies `once` to permission
requests. It does not save approvals, and explicit `deny` rules remain enforced.
Expand Down
9 changes: 8 additions & 1 deletion packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,14 @@ export function Session() {
<Switch>
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
<Match when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
<Show when={permissions()[0]?.id} keyed>
{(_) => {
const request = permissions()[0]
return request ? (
<PermissionPrompt request={request} directory={session()?.location.directory} />
) : null
}}
</Show>
</Match>
<Match when={forms().length > 0}>
<Show when={forms()[0]?.id} keyed>
Expand Down
Loading
Loading