feat(approvals): cross-session grant scopes (PROJECT/GLOBAL) + revoke

Make the grant system's wider scopes real and add a revoke producer.

Before: grants were loaded per-session (the gate folds only the running
session's own stream), so GrantScope.PROJECT — though declared and matched
in the engine — was dead in practice, and there was no GLOBAL scope or any
way to revoke a grant (ApprovalGrantExpiredEvent had no producer).

- GrantScope: add GLOBAL(toolName); make PROJECT tool-bound. Every
  operator-creatable scope is now tool-bound so a grant can never be a
  blanket "approve everything" (that's YOLO mode).
- DefaultApprovalEngine.scopeMatches: GLOBAL matches the bound tool in any
  context; PROJECT matches when the session's projectId AND tool match.
- Cross-session ledger: PROJECT/GLOBAL grants are appended to a reserved
  GRANT_LEDGER_SESSION_ID stream instead of a session's. The approval gate
  now unions the ledger's grants with the session's, and derives projectId
  from the bound workspace root (ProjectIdentity.of) so PROJECT grants match
  later sessions on the same repo. SESSION/STAGE grants still live in (and
  die with) their session.
- Revoke: ClientMessage.RevokeGrant appends ApprovalGrantExpiredEvent to the
  ledger (reducer already drops it); ListGrants/GrantList expose the active
  standing grants for the TUI viewer.
- Drop the server-side T2 grant ceiling per operator request: a grant may
  now authorize any tier (incl. destructive T3/T4). Tool-binding is retained
  as the remaining guard.

Backend only; the TUI scope picker + grants viewer follow. core:events,
core:approvals, core:kernel, apps:server compile; approvals/events/kernel/
server suites green; new GrantScopeMatchingTest (5) covers GLOBAL/PROJECT
match, project isolation, and the no-cap T4 path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 16:07:33 +00:00
parent 54f94a549f
commit c36d41b9d5
10 changed files with 323 additions and 31 deletions
@@ -87,13 +87,18 @@ class DefaultApprovalEngine : ApprovalEngine {
private fun scopeMatches(scope: GrantScope, context: ApprovalContext, requestToolName: String?): Boolean =
when (scope) {
// SESSION grants are scoped to a specific tool name.
// A null toolName on either side means the binding is absent; treat as no-match
// to prevent a legacy/malformed grant from becoming a blanket approval.
is GrantScope.SESSION -> scope.toolName != null
&& requestToolName != null
&& scope.toolName == requestToolName
// SESSION/PROJECT/GLOBAL grants are bound to a specific tool name. A null toolName on
// either side means the binding is absent; treat as no-match so a legacy/malformed grant
// can never become a blanket approval. PROJECT additionally requires the active session's
// workspace to resolve to the same projectId; GLOBAL applies to every session.
is GrantScope.SESSION -> toolBound(scope.toolName, requestToolName)
is GrantScope.STAGE -> context.identity.stageId == scope.stageId
is GrantScope.PROJECT -> context.identity.projectId == scope.projectId
is GrantScope.PROJECT -> toolBound(scope.toolName, requestToolName)
&& context.identity.projectId != null
&& context.identity.projectId == scope.projectId
is GrantScope.GLOBAL -> toolBound(scope.toolName, requestToolName)
}
private fun toolBound(grantToolName: String?, requestToolName: String?): Boolean =
grantToolName != null && requestToolName != null && grantToolName == requestToolName
}
@@ -0,0 +1,99 @@
package com.correx.core.approvals.domain
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.GrantScope
import com.correx.core.approvals.Tier
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalGrant
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.GrantId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.ValidationReportId
import com.correx.core.sessions.ApprovalMode
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* Scope matching for the cross-session grant scopes (PROJECT, GLOBAL). A matching grant turns a
* request that would otherwise PROMPT into an AUTO_APPROVED COMPLETED decision; a non-match leaves
* it PENDING (PROMPT mode, tier above threshold).
*/
class GrantScopeMatchingTest {
private val engine = DefaultApprovalEngine()
private val now = Instant.parse("2026-06-21T00:00:00Z")
private val projectA = ProjectId("/repo/a")
private val projectB = ProjectId("/repo/b")
private fun request(tool: String, tier: Tier = Tier.T3) = DomainApprovalRequest(
id = ApprovalRequestId("req-1"),
tier = tier,
validationReportId = ValidationReportId("vr-1"),
riskSummaryId = null,
timestamp = now,
toolName = tool,
)
private fun context(projectId: ProjectId?) = ApprovalContext(
identity = ApprovalScopeIdentity(SessionId("s-1"), stageId = null, projectId = projectId),
mode = ApprovalMode.PROMPT,
)
private fun grant(scope: GrantScope, tiers: Set<Tier> = setOf(Tier.T4)) = ApprovalGrant(
id = GrantId("g-1"),
scope = scope,
permittedTiers = tiers,
reason = "test",
timestamp = now,
)
private fun isAutoApproved(d: com.correx.core.approvals.model.ApprovalDecision) =
d.state == ApprovalStatus.COMPLETED && d.isApproved
@Test
fun `GLOBAL grant auto-approves the bound tool in any project`() {
val g = grant(GrantScope.GLOBAL("write_file"))
val decision = engine.evaluate(request("write_file"), context(projectA), listOf(g), now)
assert(isAutoApproved(decision)) { "GLOBAL grant should auto-approve its tool anywhere" }
assertEquals("grant:g-1", decision.reason)
}
@Test
fun `GLOBAL grant does not approve a different tool`() {
val g = grant(GrantScope.GLOBAL("write_file"))
val decision = engine.evaluate(request("shell"), context(projectA), listOf(g), now)
assertEquals(ApprovalStatus.PENDING, decision.state)
assertNull(decision.outcome)
}
@Test
fun `PROJECT grant approves only the matching project`() {
val g = grant(GrantScope.PROJECT(projectA, "shell"))
val match = engine.evaluate(request("shell"), context(projectA), listOf(g), now)
assert(isAutoApproved(match)) { "PROJECT grant should approve in its own project" }
val otherProject = engine.evaluate(request("shell"), context(projectB), listOf(g), now)
assertEquals(ApprovalStatus.PENDING, otherProject.state)
}
@Test
fun `PROJECT grant does not approve when the session has no project identity`() {
val g = grant(GrantScope.PROJECT(projectA, "shell"))
val decision = engine.evaluate(request("shell"), context(projectId = null), listOf(g), now)
assertEquals(ApprovalStatus.PENDING, decision.state)
}
@Test
fun `a wide grant with no tier ceiling auto-approves a T4 request`() {
// The operator opted out of the prior T2 cap: a grant whose permittedTiers reach T4
// authorizes up to T4 (ceiling semantics), so even a destructive call auto-clears.
val g = grant(GrantScope.GLOBAL("delete"), tiers = setOf(Tier.T4))
val decision = engine.evaluate(request("delete", tier = Tier.T4), context(projectA), listOf(g), now)
assert(isAutoApproved(decision)) { "T4 request should auto-approve under a T4-permitting grant" }
}
}