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:
+12
-7
@@ -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
|
||||
}
|
||||
|
||||
+99
@@ -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" }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user