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
6 changes: 6 additions & 0 deletions .changeset/strict-shell-analysis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@opencode-ai/core": patch
---

Harden portable shell permission analysis so unsupported syntax, hidden shell
side effects, and unknown directory changes cannot inherit narrower approvals.
37 changes: 26 additions & 11 deletions packages/core/src/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const AssertInput = Schema.Struct({
id: ID.pipe(Schema.optional),
...RequestFields,
agent: Agent.ID.pipe(Schema.optional),
resourceMode: Schema.Literals(["wildcard", "exact"]).pipe(Schema.optional),
}).annotate({ identifier: "Permission.AssertInput" })
export type AssertInput = typeof AssertInput.Type

Expand Down Expand Up @@ -117,6 +118,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pe
interface Pending {
readonly request: Request
readonly agent?: Agent.ID
readonly resourceMode?: AssertInput["resourceMode"]
readonly deferred: Deferred.Deferred<void, DeclinedError | CorrectedError>
}

Expand Down Expand Up @@ -177,8 +179,22 @@ const layer = Layer.effect(
return false
})

function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
function evaluated(
input: Pick<AssertInput, "action" | "resourceMode">,
resource: string,
rules: Permission.Ruleset,
) {
if (input.resourceMode !== "exact") return evaluate(input.action, resource, rules)
for (let index = rules.length - 1; index >= 0; index--) {
const rule = rules[index]
if (!Wildcard.match(input.action, rule.action)) continue
if (rule.resource === resource || rule.resource === "*" || rule.effect !== "allow") return rule
}
return { action: input.action, resource: "*", effect: "ask" as const }
}

function denied(input: Pick<AssertInput, "action" | "resources" | "resourceMode">, rules: Permission.Ruleset) {
return input.resources.some((resource) => evaluated(input, resource, rules).effect === "deny")
}

function relevant(input: AssertInput, rules: Permission.Ruleset) {
Expand All @@ -189,7 +205,7 @@ const layer = Layer.effect(
const rules = yield* configured(input.sessionID, input.agent)
if (denied(input, rules)) return { effect: "deny" as const, rules }
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effects = input.resources.map((resource) => evaluated(input, resource, all).effect)
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
return { effect, rules: all }
})
Expand All @@ -206,11 +222,11 @@ const layer = Layer.effect(
}
}

const create = (request: Request, agent?: Agent.ID) =>
const create = (request: Request, agent?: Agent.ID, resourceMode?: AssertInput["resourceMode"]) =>
Effect.uninterruptible(
Effect.gen(function* () {
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
const item = { request, agent, deferred }
const item = { request, agent, resourceMode, 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 @@ -224,7 +240,7 @@ const layer = Layer.effect(
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
const value = request(input)
if (result.effect === "ask") yield* create(value, input.agent)
if (result.effect === "ask") yield* create(value, input.agent, input.resourceMode)
return { id: value.id, effect: result.effect }
})

Expand All @@ -240,7 +256,7 @@ const layer = Layer.effect(
})
}
if (result.effect === "allow") return
const item = yield* create(request(input), input.agent)
const item = yield* create(request(input), input.agent, input.resourceMode)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
Expand Down Expand Up @@ -305,12 +321,11 @@ const layer = Layer.effect(
Effect.catchTag("Session.NotFoundError", () => Effect.undefined),
)
if (!rules) continue
if (denied(item.request, rules)) continue
const asserted = { ...item.request, resourceMode: item.resourceMode }
if (denied(asserted, rules)) continue
const effective = [...rules, ...rememberedRules]
if (
!item.request.resources.every(
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
)
!item.request.resources.every((resource) => evaluated(asserted, resource, effective).effect === "allow")
)
continue
yield* bus.publish(Permission.Event.Replied, {
Expand Down
Loading
Loading