diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt index b7d2fdc6..86edfb49 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt @@ -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, 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() diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt index bbd16985..1b3871de 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt @@ -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, + val reason: String, + val expiresAtMs: Long?, +) + internal fun RiskSummary.toDto(): RiskSummaryDto = RiskSummaryDto( level = level.name, factors = signals.map { signal -> diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index e840e213..e08d30a2 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -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, + 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 diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index b937dd06..4a618419 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -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( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt index 82c7c94f..3d4e2b01 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt @@ -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 — diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt index d62e1124..79de330f 100644 --- a/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt @@ -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 } diff --git a/core/approvals/src/test/kotlin/com/correx/core/approvals/domain/GrantScopeMatchingTest.kt b/core/approvals/src/test/kotlin/com/correx/core/approvals/domain/GrantScopeMatchingTest.kt new file mode 100644 index 00000000..7d91a973 --- /dev/null +++ b/core/approvals/src/test/kotlin/com/correx/core/approvals/domain/GrantScopeMatchingTest.kt @@ -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 = 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" } + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/GrantLedger.kt b/core/events/src/main/kotlin/com/correx/core/approvals/GrantLedger.kt new file mode 100644 index 00000000..4f93db21 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/approvals/GrantLedger.kt @@ -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()) +} diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt b/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt index 93430a18..b0c63de7 100644 --- a/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt +++ b/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt @@ -8,8 +8,15 @@ import kotlinx.serialization.Serializable sealed interface GrantScope { // 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 - // 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 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 } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 7be575a9..87731aab 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -2,6 +2,8 @@ package com.correx.core.kernel.orchestration import com.correx.core.approvals.ApprovalOutcome 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.DefaultApprovalRepository import com.correx.core.approvals.domain.ApprovalEngine @@ -727,10 +729,15 @@ abstract class SessionOrchestrator( if (tier.isAtMost(Tier.T1) && !plane2Prompts) { // no approval needed } else { - val approvalState = approvalRepository.getApprovalState(sessionId) - val activeGrants = approvalState.grants.values.toList() + // Grants in effect = this session's own (SESSION/STAGE) unioned with the + // 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( - identity = ApprovalScopeIdentity(sessionId, stageId, projectId = null), + identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId), mode = ApprovalMode.PROMPT, ) val requestId = ApprovalRequestId(UUID.randomUUID().toString())