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
@@ -5,6 +5,7 @@ import com.correx.core.approvals.Tier
import com.correx.core.events.events.ClarificationAnswer
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.ClarificationRequestId
import com.correx.core.events.types.GrantId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.router.ChatMode
@@ -21,6 +22,11 @@ enum class ApprovalDecision {
enum class GrantScopeDto {
SESSION,
STAGE,
// Cross-session scopes (BACKLOG §grants). PROJECT auto-approves the bound tool for any session
// on the same workspace; GLOBAL for every session on this machine. Both are tool-bound and live
// in the cross-session grant ledger rather than a single session's stream.
PROJECT,
GLOBAL,
}
@Serializable
@@ -100,10 +106,20 @@ sealed class ClientMessage {
val permittedTiers: List<Tier>,
val reason: String,
val expiresAt: Instant? = null,
// Required for SESSION scope: binds this grant to a specific tool name.
// Required for SESSION/PROJECT/GLOBAL scope: binds this grant to a specific tool name.
// The projectId for a PROJECT grant is derived server-side from the session's bound
// workspace, never trusted from the client.
val toolName: String? = null,
) : ClientMessage()
/** Operator request to revoke a standing (PROJECT/GLOBAL) grant by id; reply is a fresh GrantList. */
@Serializable
data class RevokeGrant(val grantId: GrantId) : ClientMessage()
/** Operator request for the active cross-session grants (replied to with a GrantList). */
@Serializable
data object ListGrants : ClientMessage()
@Serializable
data class Ping(val timestamp: Long) : ClientMessage()
@@ -121,6 +121,23 @@ data class IdeaDto(
val capturedAtMs: Long,
)
/**
* One standing cross-session grant for the TUI grants viewer. [scope] is "PROJECT" or "GLOBAL";
* [tiers] are the tier names this grant auto-approves; [projectId] is the workspace path a PROJECT
* grant is bound to (null for GLOBAL); [expiresAtMs] is the epoch-ms expiry, or null if it never
* expires.
*/
@Serializable
data class GrantDto(
val grantId: String,
val scope: String,
val toolName: String?,
val projectId: String?,
val tiers: List<String>,
val reason: String,
val expiresAtMs: Long?,
)
internal fun RiskSummary.toDto(): RiskSummaryDto = RiskSummaryDto(
level = level.name,
factors = signals.map { signal ->
@@ -489,6 +489,18 @@ sealed interface ServerMessage {
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/**
* The active cross-session (PROJECT/GLOBAL) grants (reply to ListGrants / CreateGrant /
* RevokeGrant). Not event-derived (a snapshot folded from the grant ledger), so cursors are null.
*/
@Serializable
@SerialName("grant.list")
data class GrantList(
val grants: List<GrantDto>,
override val sequence: Long? = null,
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/**
* A session's derived metrics, returned in response to a GetSessionStats request. Not
* event-derived (it is a projection-replay snapshot), so cursors are null. [stats] is the same
@@ -13,9 +13,10 @@ import com.correx.apps.server.protocol.WorkflowDto
import com.correx.apps.server.protocol.StageToolDecl
import com.correx.apps.server.protocol.ToolDecl
import com.correx.apps.server.workspace.WorkspaceResolution
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
import com.correx.core.approvals.GrantScope
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.ApprovalGrantExpiredEvent
import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.IdeaDiscardedEvent
@@ -246,6 +247,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg)
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
is ClientMessage.RevokeGrant -> handleRevokeGrant(msg, sendFrame)
is ClientMessage.ListGrants -> sendFrame(queries.listGrants())
is ClientMessage.ChatInput -> {
// The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput
// and reach the client as chat.turn frames via streamGlobal — no direct
@@ -402,18 +405,20 @@ class GlobalStreamHandler(private val module: ServerModule) {
msg: ClientMessage.CreateGrant,
sendFrame: suspend (ServerMessage) -> Unit,
) {
// Validate: tiers must be non-empty and bounded to T2 (server-side ceiling).
// T3/T4 are destructive/escalated tiers that must never be auto-approved via grants.
// No tier ceiling: the operator explicitly opted out of the prior T2 cap, so a grant may
// authorize any tier (including the destructive T3/T4). Every operator-creatable scope is
// still tool-bound (below) so a grant never becomes a blanket "approve everything".
if (msg.permittedTiers.isEmpty()) {
sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty"))
return
}
val maxGrantableTier = Tier.T2
val overBroad = msg.permittedTiers.filter { it.level > maxGrantableTier.level }
if (overBroad.isNotEmpty()) {
sendFrame(errorResponse("CreateGrant: tiers ${overBroad.map { it.name }} exceed maximum grantable tier ${maxGrantableTier.name}"))
return
}
suspend fun requireToolName(scopeLabel: String): String? =
msg.toolName?.takeIf { it.isNotBlank() }
?: run {
sendFrame(errorResponse("CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval"))
null
}
val scope = when (msg.scope) {
GrantScopeDto.SESSION -> {
@@ -421,12 +426,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId"))
return
}
// Require toolName — blanket session grants (no operation scope) are rejected.
val toolName = msg.toolName?.takeIf { it.isNotBlank() } ?: run {
sendFrame(errorResponse("CreateGrant: SESSION scope requires toolName to prevent blanket approval"))
return
}
GrantScope.SESSION(toolName)
GrantScope.SESSION(requireToolName("SESSION") ?: return)
}
GrantScopeDto.STAGE -> {
val sid = msg.stageId ?: run {
@@ -435,11 +435,25 @@ class GlobalStreamHandler(private val module: ServerModule) {
}
GrantScope.STAGE(sid)
}
GrantScopeDto.PROJECT -> {
// projectId is derived from the session's bound workspace, never trusted from the client.
val projectId = queries.projectIdForSession(msg.sessionId) ?: run {
sendFrame(errorResponse("CreateGrant: PROJECT scope requires a session with a bound workspace"))
return
}
GrantScope.PROJECT(projectId, requireToolName("PROJECT") ?: return)
}
GrantScopeDto.GLOBAL -> GrantScope.GLOBAL(requireToolName("GLOBAL") ?: return)
}
// SESSION/STAGE grants live in the originating session's stream (they die with it); PROJECT
// and GLOBAL grants outlive any session, so they go to the shared cross-session grant ledger.
val ledgered = scope is GrantScope.PROJECT || scope is GrantScope.GLOBAL
val streamId = if (ledgered) GRANT_LEDGER_SESSION_ID else msg.sessionId
val event = NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = msg.sessionId,
sessionId = streamId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
@@ -453,11 +467,44 @@ class GlobalStreamHandler(private val module: ServerModule) {
expiresAt = msg.expiresAt,
sessionId = msg.sessionId,
stageId = (scope as? GrantScope.STAGE)?.stageId,
projectId = null,
toolName = (scope as? GrantScope.SESSION)?.toolName,
projectId = (scope as? GrantScope.PROJECT)?.projectId,
toolName = scope.toolNameOrNull(),
),
)
module.eventStore.append(event)
// Echo the refreshed standing-grant list so the TUI can confirm a PROJECT/GLOBAL grant landed.
if (ledgered) sendFrame(queries.listGrants())
}
/**
* Revokes a standing (PROJECT/GLOBAL) grant by appending an [ApprovalGrantExpiredEvent] to the
* grant ledger — the reducer drops it, so the gate stops honouring it across all sessions while
* the create/revoke pair stays in the audit log. Replies with the refreshed grant list.
*/
private suspend fun handleRevokeGrant(
msg: ClientMessage.RevokeGrant,
sendFrame: suspend (ServerMessage) -> Unit,
) {
val event = NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = GRANT_LEDGER_SESSION_ID,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = ApprovalGrantExpiredEvent(grantId = msg.grantId),
)
module.eventStore.append(event)
sendFrame(queries.listGrants())
}
private fun GrantScope.toolNameOrNull(): String? = when (this) {
is GrantScope.SESSION -> toolName
is GrantScope.PROJECT -> toolName
is GrantScope.GLOBAL -> toolName
is GrantScope.STAGE -> null
}
private suspend fun handleStartChatSession(
@@ -5,9 +5,14 @@ import com.correx.apps.server.config.ConfigUpdateResult
import com.correx.apps.server.metrics.MetricsInspectionService
import com.correx.apps.server.protocol.ArtifactSummaryDto
import com.correx.apps.server.protocol.ConfigFieldDto
import com.correx.apps.server.protocol.GrantDto
import com.correx.apps.server.protocol.IdeaDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.workspace.WorkspaceFiles
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
import com.correx.core.approvals.GrantScope
import com.correx.core.approvals.ProjectIdentity
import com.correx.core.events.types.ProjectId
import com.correx.core.router.IdeaReader
import com.correx.core.config.EditableConfig
import com.correx.core.events.events.ArtifactContentStoredEvent
@@ -21,6 +26,7 @@ import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import java.nio.file.Paths
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
@@ -70,6 +76,51 @@ class StreamQueries(private val module: ServerModule) {
.lastOrNull()
?.workspaceRoot
/**
* The stable [ProjectId] a session's bound workspace resolves to (the same derivation the
* approval gate uses), or null when the session has no bound workspace. Used to scope a PROJECT
* grant to the right repository without trusting a client-supplied id.
*/
fun projectIdForSession(sessionId: SessionId): ProjectId? =
workspaceRootOf(sessionId)?.let { ProjectIdentity.of(it) }
/**
* The active cross-session grants as a snapshot frame, folded from the grant ledger via the
* approval reducer. Expired grants are dropped so the viewer only lists ones still in force.
* Pure read — no events appended (replay-neutral).
*/
fun listGrants(): ServerMessage.GrantList {
val now = Clock.System.now()
val grants = module.approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values
.filter { grant -> grant.expiresAt.let { it == null || it > now } }
.map { grant ->
GrantDto(
grantId = grant.id.value,
scope = grant.scope.kindName(),
toolName = grant.scope.toolNameOrNull(),
projectId = (grant.scope as? GrantScope.PROJECT)?.projectId?.value,
tiers = grant.permittedTiers.map { it.name }.sorted(),
reason = grant.reason,
expiresAtMs = grant.expiresAt?.toEpochMilliseconds(),
)
}
return ServerMessage.GrantList(grants = grants)
}
private fun GrantScope.kindName(): String = when (this) {
is GrantScope.SESSION -> "SESSION"
is GrantScope.STAGE -> "STAGE"
is GrantScope.PROJECT -> "PROJECT"
is GrantScope.GLOBAL -> "GLOBAL"
}
private fun GrantScope.toolNameOrNull(): String? = when (this) {
is GrantScope.SESSION -> toolName
is GrantScope.PROJECT -> toolName
is GrantScope.GLOBAL -> toolName
is GrantScope.STAGE -> null
}
/**
* Reads a session's events to assemble its artifact listing (creation order preserved),
* resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read —