fix: harden event store, serialization, and grant engine; wire router chat
Event log integrity (sole source of truth): - SqliteEventStore: serialize append/appendAll under a Mutex, enable WAL + busy_timeout + synchronous=NORMAL, roll back on any Throwable. Replaces the app-computed `SELECT MAX(seq)+1` read-modify-write that dropped events under concurrent appends (race was invisible to CI: only the in-memory store and single-threaded :memory: tests were ever exercised). - Serialization: encodeDefaults + ignoreUnknownKeys, explicit @SerialName on all 46 EventPayload subclasses and event-referenced enum constants, @Serializable on RiskLevel/RiskAction. Guards the append-only log against silent corruption from future class renames, field removals, or default-value changes. Approval/grant security (Invariant: approvals cannot widen authority): - Tier authorization is now a ceiling (request.tier.level <= max granted) instead of set membership; SESSION grants bind to a specific toolName instead of matching everything; handleCreateGrant rejects empty/over-tier/scopeless grants. Router chat wiring (Phase 0-2): ChatInput round-trip, StartChatSession from IDLE, router-panel layout, workflows behind Ctrl+W. Tests: SqliteEventStore concurrency, serialization round-trip + discriminator stability + unknown-key tolerance, adversarial grant scope/tier; updated existing approval and reducer tests for the new grant semantics and StartChatSession effect.
This commit is contained in:
+15
-7
@@ -26,8 +26,10 @@ class DefaultApprovalEngine : ApprovalEngine {
|
||||
|
||||
val matchingGrant = grants.firstOrNull { grant ->
|
||||
!isExpired(grant, now)
|
||||
&& scopeMatches(grant.scope, context)
|
||||
&& request.tier in grant.permittedTiers
|
||||
&& scopeMatches(grant.scope, context, request.toolName)
|
||||
// Ceiling semantics: grant authorizes up to its highest tier, not an exact set.
|
||||
&& grant.permittedTiers.isNotEmpty()
|
||||
&& request.tier.level <= grant.permittedTiers.maxOf { it.level }
|
||||
}
|
||||
|
||||
if (matchingGrant != null) {
|
||||
@@ -83,9 +85,15 @@ class DefaultApprovalEngine : ApprovalEngine {
|
||||
private fun isExpired(grant: ApprovalGrant, now: Instant): Boolean =
|
||||
grant.expiresAt != null && grant.expiresAt <= now
|
||||
|
||||
private fun scopeMatches(scope: GrantScope, context: ApprovalContext): Boolean = when (scope) {
|
||||
is GrantScope.SESSION -> true
|
||||
is GrantScope.STAGE -> context.identity.stageId == scope.stageId
|
||||
is GrantScope.PROJECT -> context.identity.projectId == scope.projectId
|
||||
}
|
||||
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
|
||||
is GrantScope.STAGE -> context.identity.stageId == scope.stageId
|
||||
is GrantScope.PROJECT -> context.identity.projectId == scope.projectId
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -68,7 +68,7 @@ class DefaultApprovalReducerTest {
|
||||
val grantId = GrantId("grant-1")
|
||||
val payload = ApprovalGrantCreatedEvent(
|
||||
grantId = grantId,
|
||||
scope = GrantScope.SESSION,
|
||||
scope = GrantScope.SESSION(),
|
||||
permittedTiers = setOf(Tier.T1, Tier.T2),
|
||||
reason = "user approved",
|
||||
expiresAt = null,
|
||||
@@ -81,7 +81,7 @@ class DefaultApprovalReducerTest {
|
||||
|
||||
assertTrue(state.grants.containsKey(grantId))
|
||||
assertEquals(grantId, state.grants[grantId]?.id)
|
||||
assertEquals(GrantScope.SESSION, state.grants[grantId]?.scope)
|
||||
assertEquals(GrantScope.SESSION(), state.grants[grantId]?.scope)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -90,7 +90,7 @@ class DefaultApprovalReducerTest {
|
||||
|
||||
val addPayload = ApprovalGrantCreatedEvent(
|
||||
grantId = grantId,
|
||||
scope = GrantScope.SESSION,
|
||||
scope = GrantScope.SESSION(),
|
||||
permittedTiers = setOf(Tier.T1),
|
||||
reason = "granted",
|
||||
expiresAt = null,
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ class DefaultApprovalRepositoryTest {
|
||||
metadata = metadata(sessionId, "event-1"),
|
||||
payload = ApprovalGrantCreatedEvent(
|
||||
grantId = grantId,
|
||||
scope = GrantScope.SESSION,
|
||||
scope = GrantScope.SESSION(),
|
||||
permittedTiers = setOf(Tier.T1, Tier.T2),
|
||||
reason = "approved",
|
||||
expiresAt = null,
|
||||
|
||||
Reference in New Issue
Block a user