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:
@@ -5,6 +5,7 @@ import com.correx.core.approvals.Tier
|
|||||||
import com.correx.core.events.events.ClarificationAnswer
|
import com.correx.core.events.events.ClarificationAnswer
|
||||||
import com.correx.core.events.types.ApprovalRequestId
|
import com.correx.core.events.types.ApprovalRequestId
|
||||||
import com.correx.core.events.types.ClarificationRequestId
|
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.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.router.ChatMode
|
import com.correx.core.router.ChatMode
|
||||||
@@ -21,6 +22,11 @@ enum class ApprovalDecision {
|
|||||||
enum class GrantScopeDto {
|
enum class GrantScopeDto {
|
||||||
SESSION,
|
SESSION,
|
||||||
STAGE,
|
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
|
@Serializable
|
||||||
@@ -100,10 +106,20 @@ sealed class ClientMessage {
|
|||||||
val permittedTiers: List<Tier>,
|
val permittedTiers: List<Tier>,
|
||||||
val reason: String,
|
val reason: String,
|
||||||
val expiresAt: Instant? = null,
|
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,
|
val toolName: String? = null,
|
||||||
) : ClientMessage()
|
) : 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
|
@Serializable
|
||||||
data class Ping(val timestamp: Long) : ClientMessage()
|
data class Ping(val timestamp: Long) : ClientMessage()
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,23 @@ data class IdeaDto(
|
|||||||
val capturedAtMs: Long,
|
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(
|
internal fun RiskSummary.toDto(): RiskSummaryDto = RiskSummaryDto(
|
||||||
level = level.name,
|
level = level.name,
|
||||||
factors = signals.map { signal ->
|
factors = signals.map { signal ->
|
||||||
|
|||||||
@@ -489,6 +489,18 @@ sealed interface ServerMessage {
|
|||||||
override val sessionSequence: Long? = null,
|
override val sessionSequence: Long? = null,
|
||||||
) : ServerMessage, NonEventMessage
|
) : 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
|
* 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
|
* 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.StageToolDecl
|
||||||
import com.correx.apps.server.protocol.ToolDecl
|
import com.correx.apps.server.protocol.ToolDecl
|
||||||
import com.correx.apps.server.workspace.WorkspaceResolution
|
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.GrantScope
|
||||||
import com.correx.core.approvals.Tier
|
|
||||||
import com.correx.core.events.events.ApprovalGrantCreatedEvent
|
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.ChatSessionStartedEvent
|
||||||
import com.correx.core.events.events.EventMetadata
|
import com.correx.core.events.events.EventMetadata
|
||||||
import com.correx.core.events.events.IdeaDiscardedEvent
|
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.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
|
||||||
is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg)
|
is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg)
|
||||||
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
|
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
|
||||||
|
is ClientMessage.RevokeGrant -> handleRevokeGrant(msg, sendFrame)
|
||||||
|
is ClientMessage.ListGrants -> sendFrame(queries.listGrants())
|
||||||
is ClientMessage.ChatInput -> {
|
is ClientMessage.ChatInput -> {
|
||||||
// The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput
|
// The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput
|
||||||
// and reach the client as chat.turn frames via streamGlobal — no direct
|
// 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,
|
msg: ClientMessage.CreateGrant,
|
||||||
sendFrame: suspend (ServerMessage) -> Unit,
|
sendFrame: suspend (ServerMessage) -> Unit,
|
||||||
) {
|
) {
|
||||||
// Validate: tiers must be non-empty and bounded to T2 (server-side ceiling).
|
// No tier ceiling: the operator explicitly opted out of the prior T2 cap, so a grant may
|
||||||
// T3/T4 are destructive/escalated tiers that must never be auto-approved via grants.
|
// 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()) {
|
if (msg.permittedTiers.isEmpty()) {
|
||||||
sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty"))
|
sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val maxGrantableTier = Tier.T2
|
|
||||||
val overBroad = msg.permittedTiers.filter { it.level > maxGrantableTier.level }
|
suspend fun requireToolName(scopeLabel: String): String? =
|
||||||
if (overBroad.isNotEmpty()) {
|
msg.toolName?.takeIf { it.isNotBlank() }
|
||||||
sendFrame(errorResponse("CreateGrant: tiers ${overBroad.map { it.name }} exceed maximum grantable tier ${maxGrantableTier.name}"))
|
?: run {
|
||||||
return
|
sendFrame(errorResponse("CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval"))
|
||||||
}
|
null
|
||||||
|
}
|
||||||
|
|
||||||
val scope = when (msg.scope) {
|
val scope = when (msg.scope) {
|
||||||
GrantScopeDto.SESSION -> {
|
GrantScopeDto.SESSION -> {
|
||||||
@@ -421,12 +426,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId"))
|
sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Require toolName — blanket session grants (no operation scope) are rejected.
|
GrantScope.SESSION(requireToolName("SESSION") ?: return)
|
||||||
val toolName = msg.toolName?.takeIf { it.isNotBlank() } ?: run {
|
|
||||||
sendFrame(errorResponse("CreateGrant: SESSION scope requires toolName to prevent blanket approval"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
GrantScope.SESSION(toolName)
|
|
||||||
}
|
}
|
||||||
GrantScopeDto.STAGE -> {
|
GrantScopeDto.STAGE -> {
|
||||||
val sid = msg.stageId ?: run {
|
val sid = msg.stageId ?: run {
|
||||||
@@ -435,11 +435,25 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
}
|
}
|
||||||
GrantScope.STAGE(sid)
|
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(
|
val event = NewEvent(
|
||||||
metadata = EventMetadata(
|
metadata = EventMetadata(
|
||||||
eventId = EventId(UUID.randomUUID().toString()),
|
eventId = EventId(UUID.randomUUID().toString()),
|
||||||
sessionId = msg.sessionId,
|
sessionId = streamId,
|
||||||
timestamp = Clock.System.now(),
|
timestamp = Clock.System.now(),
|
||||||
schemaVersion = 1,
|
schemaVersion = 1,
|
||||||
causationId = null,
|
causationId = null,
|
||||||
@@ -453,11 +467,44 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
expiresAt = msg.expiresAt,
|
expiresAt = msg.expiresAt,
|
||||||
sessionId = msg.sessionId,
|
sessionId = msg.sessionId,
|
||||||
stageId = (scope as? GrantScope.STAGE)?.stageId,
|
stageId = (scope as? GrantScope.STAGE)?.stageId,
|
||||||
projectId = null,
|
projectId = (scope as? GrantScope.PROJECT)?.projectId,
|
||||||
toolName = (scope as? GrantScope.SESSION)?.toolName,
|
toolName = scope.toolNameOrNull(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
module.eventStore.append(event)
|
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(
|
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.metrics.MetricsInspectionService
|
||||||
import com.correx.apps.server.protocol.ArtifactSummaryDto
|
import com.correx.apps.server.protocol.ArtifactSummaryDto
|
||||||
import com.correx.apps.server.protocol.ConfigFieldDto
|
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.IdeaDto
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
import com.correx.apps.server.workspace.WorkspaceFiles
|
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.router.IdeaReader
|
||||||
import com.correx.core.config.EditableConfig
|
import com.correx.core.config.EditableConfig
|
||||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
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 com.correx.core.events.types.SessionId
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
@@ -70,6 +76,51 @@ class StreamQueries(private val module: ServerModule) {
|
|||||||
.lastOrNull()
|
.lastOrNull()
|
||||||
?.workspaceRoot
|
?.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),
|
* 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 —
|
* resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read —
|
||||||
|
|||||||
+12
-7
@@ -87,13 +87,18 @@ class DefaultApprovalEngine : ApprovalEngine {
|
|||||||
|
|
||||||
private fun scopeMatches(scope: GrantScope, context: ApprovalContext, requestToolName: String?): Boolean =
|
private fun scopeMatches(scope: GrantScope, context: ApprovalContext, requestToolName: String?): Boolean =
|
||||||
when (scope) {
|
when (scope) {
|
||||||
// SESSION grants are scoped to a specific tool name.
|
// SESSION/PROJECT/GLOBAL grants are bound to a specific tool name. A null toolName on
|
||||||
// A null toolName on either side means the binding is absent; treat as no-match
|
// either side means the binding is absent; treat as no-match so a legacy/malformed grant
|
||||||
// to prevent a legacy/malformed grant from becoming a blanket approval.
|
// can never become a blanket approval. PROJECT additionally requires the active session's
|
||||||
is GrantScope.SESSION -> scope.toolName != null
|
// workspace to resolve to the same projectId; GLOBAL applies to every session.
|
||||||
&& requestToolName != null
|
is GrantScope.SESSION -> toolBound(scope.toolName, requestToolName)
|
||||||
&& scope.toolName == requestToolName
|
|
||||||
is GrantScope.STAGE -> context.identity.stageId == scope.stageId
|
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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.correx.core.approvals
|
||||||
|
|
||||||
|
import com.correx.core.events.types.ProjectId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserved event-store stream that holds cross-session approval grants (PROJECT and GLOBAL scopes).
|
||||||
|
*
|
||||||
|
* Session-scoped grants live in their own session's stream and die with it; project/global grants
|
||||||
|
* must outlive any single session and be visible to every other one, so they are appended here
|
||||||
|
* instead. The approval gate folds this ledger ([com.correx.core.approvals.DefaultApprovalReducer]
|
||||||
|
* over [DefaultApprovalRepository.getApprovalState]) and unions its grants with the running
|
||||||
|
* session's own before evaluating — see `SessionOrchestrator`. Revocation appends an
|
||||||
|
* [com.correx.core.events.events.ApprovalGrantExpiredEvent] to the same stream, which the reducer
|
||||||
|
* drops, so the audit trail stays intact (invariant #9: record facts, replay reads them).
|
||||||
|
*
|
||||||
|
* The id is a sentinel, not a real session; it never carries a workflow run.
|
||||||
|
*/
|
||||||
|
val GRANT_LEDGER_SESSION_ID = SessionId("__grant_ledger__")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a stable [ProjectId] from a workspace root path so PROJECT-scoped grants created in one
|
||||||
|
* session match later sessions opened on the same repository. Both the grant-creation site (server)
|
||||||
|
* and the approval gate (orchestrator) run in the same process, so canonicalising to a normalised
|
||||||
|
* absolute path yields the same id on both sides regardless of how the root was spelled.
|
||||||
|
*/
|
||||||
|
object ProjectIdentity {
|
||||||
|
fun of(workspaceRoot: String): ProjectId =
|
||||||
|
ProjectId(Paths.get(workspaceRoot).toAbsolutePath().normalize().toString())
|
||||||
|
}
|
||||||
@@ -8,8 +8,15 @@ import kotlinx.serialization.Serializable
|
|||||||
sealed interface GrantScope {
|
sealed interface GrantScope {
|
||||||
// toolName binds this grant to a specific operation kind (e.g. "shell", "write_file").
|
// toolName binds this grant to a specific operation kind (e.g. "shell", "write_file").
|
||||||
// A null toolName is rejected at grant-creation time; it is kept nullable here only
|
// A null toolName is rejected at grant-creation time; it is kept nullable here only
|
||||||
// for backward-compatible deserialization of legacy events.
|
// for backward-compatible deserialization of legacy events. Every operator-creatable
|
||||||
|
// scope is tool-bound so a grant can never become a blanket "approve everything"
|
||||||
|
// (that is YOLO mode, configured separately).
|
||||||
@Serializable data class SESSION(val toolName: String? = null) : GrantScope
|
@Serializable data class SESSION(val toolName: String? = null) : GrantScope
|
||||||
@Serializable data class STAGE(val stageId: StageId) : GrantScope
|
@Serializable data class STAGE(val stageId: StageId) : GrantScope
|
||||||
@Serializable data class PROJECT(val projectId: ProjectId) : GrantScope
|
|
||||||
|
/** Auto-approve [toolName] for any session whose workspace resolves to [projectId]. */
|
||||||
|
@Serializable data class PROJECT(val projectId: ProjectId, val toolName: String? = null) : GrantScope
|
||||||
|
|
||||||
|
/** Auto-approve [toolName] for every session on this machine (the widest scope). */
|
||||||
|
@Serializable data class GLOBAL(val toolName: String? = null) : GrantScope
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -2,6 +2,8 @@ package com.correx.core.kernel.orchestration
|
|||||||
|
|
||||||
import com.correx.core.approvals.ApprovalOutcome
|
import com.correx.core.approvals.ApprovalOutcome
|
||||||
import com.correx.core.approvals.ApprovalStatus
|
import com.correx.core.approvals.ApprovalStatus
|
||||||
|
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
|
||||||
|
import com.correx.core.approvals.ProjectIdentity
|
||||||
import com.correx.core.approvals.Tier
|
import com.correx.core.approvals.Tier
|
||||||
import com.correx.core.approvals.DefaultApprovalRepository
|
import com.correx.core.approvals.DefaultApprovalRepository
|
||||||
import com.correx.core.approvals.domain.ApprovalEngine
|
import com.correx.core.approvals.domain.ApprovalEngine
|
||||||
@@ -727,10 +729,15 @@ abstract class SessionOrchestrator(
|
|||||||
if (tier.isAtMost(Tier.T1) && !plane2Prompts) {
|
if (tier.isAtMost(Tier.T1) && !plane2Prompts) {
|
||||||
// no approval needed
|
// no approval needed
|
||||||
} else {
|
} else {
|
||||||
val approvalState = approvalRepository.getApprovalState(sessionId)
|
// Grants in effect = this session's own (SESSION/STAGE) unioned with the
|
||||||
val activeGrants = approvalState.grants.values.toList()
|
// cross-session ledger (PROJECT/GLOBAL). projectId is derived from the bound
|
||||||
|
// workspace root so PROJECT grants match later sessions on the same repo.
|
||||||
|
val sessionGrants = approvalRepository.getApprovalState(sessionId).grants.values
|
||||||
|
val ledgerGrants = approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values
|
||||||
|
val activeGrants = (sessionGrants + ledgerGrants).toList()
|
||||||
|
val projectId = effectives.policy?.workspaceRoot?.let { ProjectIdentity.of(it.toString()) }
|
||||||
val approvalCtx = ApprovalContext(
|
val approvalCtx = ApprovalContext(
|
||||||
identity = ApprovalScopeIdentity(sessionId, stageId, projectId = null),
|
identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId),
|
||||||
mode = ApprovalMode.PROMPT,
|
mode = ApprovalMode.PROMPT,
|
||||||
)
|
)
|
||||||
val requestId = ApprovalRequestId(UUID.randomUUID().toString())
|
val requestId = ApprovalRequestId(UUID.randomUUID().toString())
|
||||||
|
|||||||
Reference in New Issue
Block a user