Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton bdb6fa54b9 fix(core): limit inherited permissions to denies 2026-08-12 19:55:44 -04:00
Kit Langton 168e8b603a fix(core): subagent sessions inherit ancestor permission rulesets 2026-08-12 19:22:23 -04:00
3 changed files with 194 additions and 23 deletions
+65 -21
View File
@@ -115,6 +115,11 @@ interface Pending {
readonly deferred: Deferred.Deferred<void, DeclinedError | CorrectedError>
}
interface ConfiguredPermissions {
readonly own: Permission.Ruleset
readonly ancestors: ReadonlyArray<Permission.Ruleset>
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -147,15 +152,54 @@ const layer = Layer.effect(
)
})
const configured = Effect.fn("Permission.configured")(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions
})
const configured = Effect.fn("Permission.configured")(configuredPermissions)
function denied(input: AssertInput, rules: Permission.Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
function configuredPermissions(
sessionID: SessionSchema.ID,
agentID?: Agent.ID,
): Effect.Effect<ConfiguredPermissions, SessionErrors.NotFoundError> {
return Effect.gen(function* () {
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
const own = agent?.permissions ?? missingAgentPermissions
if (!session.parentID) return { own, ancestors: [] }
return { own, ancestors: yield* ancestorPermissions(session.parentID) }
})
}
function ancestorPermissions(
sessionID: SessionSchema.ID,
): Effect.Effect<ReadonlyArray<Permission.Ruleset>, SessionErrors.NotFoundError> {
return Effect.gen(function* () {
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(session.agent)
const own = agent?.permissions ?? []
if (!session.parentID) return [own]
return [...(yield* ancestorPermissions(session.parentID)), own]
})
}
function evaluateResource(
input: AssertInput,
resource: string,
rules: ConfiguredPermissions,
remembered: Permission.Ruleset,
) {
if (rules.ancestors.some((ruleset) => evaluate(input.action, resource, ruleset).effect === "deny")) return "deny"
if (evaluate(input.action, resource, rules.own).effect === "deny") return "deny"
return evaluate(input.action, resource, rules.own, remembered).effect
}
function evaluateRules(
input: AssertInput,
rules: ConfiguredPermissions,
remembered: Permission.Ruleset,
) {
const effects = input.resources.map((resource) => evaluateResource(input, resource, rules, remembered))
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
return effect
}
function relevant(input: AssertInput, rules: Permission.Ruleset) {
@@ -164,11 +208,18 @@ const layer = Layer.effect(
const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) {
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 effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
return { effect, rules: all }
const remembered = yield* savedRules()
const effect = evaluateRules(input, rules, remembered)
const denied = input.resources
.filter((resource) => evaluateResource(input, resource, rules, remembered) === "deny")
.map((resource): Permission.Rule => ({ action: input.action, resource, effect: "deny" }))
return {
effect,
rules:
effect === "deny"
? [...rules.ancestors.flat(), ...rules.own, ...denied]
: [...rules.own, ...remembered],
}
})
function request(input: AssertInput): Request {
@@ -283,14 +334,7 @@ const layer = Layer.effect(
Effect.catchTag("Session.NotFoundError", () => Effect.succeed(undefined)),
)
if (!rules) continue
if (denied(input, rules)) continue
const effective = [...rules, ...rememberedRules]
if (
!item.request.resources.every(
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
)
)
continue
if (evaluateRules(input, rules, rememberedRules) !== "allow") continue
yield* bus.publish(Permission.Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
@@ -171,8 +171,6 @@ export const Plugin = {
title: input.description,
agent: Agent.ID.make(input.agent),
model,
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
})
.pipe(
Effect.mapError(
+129
View File
@@ -135,6 +135,135 @@ describe("Permission", () => {
}),
)
it.effect("does not inherit ancestor asks", () =>
Effect.gen(function* () {
yield* setup([])
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: Session.ID.make("ses_child"),
project_id: Project.ID.global,
parent_id: Session.ID.make("ses_test"),
slug: "child",
directory: "/project",
title: "child",
version: "test",
agent: "child",
})
.run()
.pipe(Effect.orDie)
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("child"), (agent) => {
agent.permissions = [{ action: "read", resource: "*", effect: "allow" }]
}),
)
const service = yield* Permission.Service
const child = assertion({ sessionID: Session.ID.make("ses_child") })
expect(yield* service.ask(child)).toMatchObject({ effect: "allow" })
expect(yield* service.list()).toEqual([])
}),
)
it.effect("inherits denies from every ancestor and denies override remembered allows", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "deny" }])
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values([
{
id: Session.ID.make("ses_child"),
project_id: Project.ID.global,
parent_id: Session.ID.make("ses_test"),
slug: "child",
directory: "/project",
title: "child",
version: "test",
agent: "child",
},
{
id: Session.ID.make("ses_grandchild"),
project_id: Project.ID.global,
parent_id: Session.ID.make("ses_child"),
slug: "grandchild",
directory: "/project",
title: "grandchild",
version: "test",
agent: "grandchild",
},
])
.run()
.pipe(Effect.orDie)
const agents = yield* Agent.Service
yield* agents.transform((editor) => {
editor.update(Agent.ID.make("child"), (agent) => {
agent.permissions = [{ action: "read", resource: "*", effect: "allow" }]
})
editor.update(Agent.ID.make("grandchild"), (agent) => {
agent.permissions = [{ action: "read", resource: "*", effect: "allow" }]
})
})
const service = yield* Permission.Service
const grandchild = assertion({ sessionID: Session.ID.make("ses_grandchild") })
expect(yield* service.ask(grandchild)).toMatchObject({ effect: "deny" })
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "read", resources: ["src/index.ts"] })
expect(yield* service.ask(grandchild)).toMatchObject({ effect: "deny" })
const denied = yield* service.assert(grandchild).pipe(Effect.flip)
expect(denied).toBeInstanceOf(Permission.BlockedError)
if (denied instanceof Permission.BlockedError)
expect(Permission.evaluate("read", "src/index.ts", denied.rules).effect).toBe("deny")
}),
)
it.effect("ignores a missing ancestor agent", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "deny" }])
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: Session.ID.make("ses_child"),
project_id: Project.ID.global,
parent_id: Session.ID.make("ses_test"),
slug: "child",
directory: "/project",
title: "child",
version: "test",
agent: "child",
})
.run()
.pipe(Effect.orDie)
const agents = yield* Agent.Service
yield* agents.transform((editor) => {
editor.remove(Agent.ID.make("test"))
editor.update(Agent.ID.make("child"), (agent) => {
agent.permissions = [{ action: "read", resource: "*", effect: "allow" }]
})
})
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ sessionID: Session.ID.make("ses_child") }))).toMatchObject({
effect: "allow",
})
}),
)
it.effect("denies all when the current session agent is missing", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const agents = yield* Agent.Service
yield* agents.transform((editor) => editor.remove(Agent.ID.make("test")))
const service = yield* Permission.Service
expect(yield* service.ask(assertion())).toMatchObject({ effect: "deny" })
}),
)
it.effect("allows and denies from explicit rules without asking", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])