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
@@ -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 {
// 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
}