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:
@@ -29,7 +29,7 @@ class ApprovalEngineEdgeCasesTest {
|
||||
fun `grants from different scopes apply only to matching identity`() {
|
||||
val sessionGrant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION,
|
||||
scope = GrantScope.SESSION(),
|
||||
permittedTiers = setOf(Tier.T3),
|
||||
reason = "",
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
@@ -59,7 +59,7 @@ class ApprovalEngineEdgeCasesTest {
|
||||
val past = Instant.parse("2025-12-31T23:59:59Z")
|
||||
val expiredGrant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION,
|
||||
scope = GrantScope.SESSION(),
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "",
|
||||
timestamp = past,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.GrantScope
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
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.events.types.GrantId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.testing.fixtures.request
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class GrantSecurityTest {
|
||||
|
||||
private val engine = DefaultApprovalEngine()
|
||||
|
||||
private val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
private val grantTime = Instant.parse("2026-01-01T00:00:00Z")
|
||||
|
||||
private fun denyCtx() = ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s1"), null, null),
|
||||
ApprovalMode.DENY
|
||||
)
|
||||
|
||||
// ─── 1. Operation isolation ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `SESSION grant for shell does not auto-approve write_file request`() {
|
||||
val req = request("r1", Tier.T2).copy(toolName = "write_file")
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION(toolName = "shell"),
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "test",
|
||||
timestamp = grantTime
|
||||
)
|
||||
|
||||
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||
|
||||
assertEquals(ApprovalStatus.PENDING, decision.state)
|
||||
assertNull(decision.outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SESSION grant for write_file does not auto-approve shell request`() {
|
||||
val req = request("r1", Tier.T2).copy(toolName = "shell")
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION(toolName = "write_file"),
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "test",
|
||||
timestamp = grantTime
|
||||
)
|
||||
|
||||
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||
|
||||
assertEquals(ApprovalStatus.PENDING, decision.state)
|
||||
assertNull(decision.outcome)
|
||||
}
|
||||
|
||||
// ─── 2. Tier ceiling ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `grant with max tier T2 does not auto-approve T3 request even with matching toolName`() {
|
||||
val req = request("r1", Tier.T3).copy(toolName = "shell")
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION(toolName = "shell"),
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "test",
|
||||
timestamp = grantTime
|
||||
)
|
||||
|
||||
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||
|
||||
assertEquals(ApprovalStatus.PENDING, decision.state)
|
||||
assertNull(decision.outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grant with max tier T2 auto-approves T2 request with matching toolName (positive control)`() {
|
||||
val req = request("r1", Tier.T2).copy(toolName = "shell")
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION(toolName = "shell"),
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "test",
|
||||
timestamp = grantTime
|
||||
)
|
||||
|
||||
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||
|
||||
assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome)
|
||||
assertEquals(ApprovalStatus.COMPLETED, decision.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grant with max tier T2 auto-approves T1 request with matching toolName (ceiling covers lower tiers)`() {
|
||||
val req = request("r1", Tier.T1).copy(toolName = "shell")
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION(toolName = "shell"),
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "test",
|
||||
timestamp = grantTime
|
||||
)
|
||||
|
||||
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||
|
||||
assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome)
|
||||
assertEquals(ApprovalStatus.COMPLETED, decision.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grant with max tier T2 auto-approves T0 request with matching toolName (ceiling covers lowest tier)`() {
|
||||
val req = request("r1", Tier.T0).copy(toolName = "shell")
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION(toolName = "shell"),
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "test",
|
||||
timestamp = grantTime
|
||||
)
|
||||
|
||||
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||
|
||||
assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome)
|
||||
assertEquals(ApprovalStatus.COMPLETED, decision.state)
|
||||
}
|
||||
|
||||
// ─── 3. No blanket grant ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `SESSION grant with null toolName does not match any request (default-deny preserved)`() {
|
||||
val req = request("r1", Tier.T2).copy(toolName = "shell")
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION(toolName = null),
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "test",
|
||||
timestamp = grantTime
|
||||
)
|
||||
|
||||
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||
|
||||
assertEquals(ApprovalStatus.PENDING, decision.state)
|
||||
assertNull(decision.outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SESSION grant with null toolName does not match request with null toolName`() {
|
||||
val req = request("r1", Tier.T2).copy(toolName = null)
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION(toolName = null),
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "test",
|
||||
timestamp = grantTime
|
||||
)
|
||||
|
||||
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||
|
||||
assertEquals(ApprovalStatus.PENDING, decision.state)
|
||||
assertNull(decision.outcome)
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,10 @@ class GrantSemanticsTest {
|
||||
|
||||
@Test
|
||||
fun `grant permits exact tier - auto_approved`() {
|
||||
val req = request("r1", Tier.T2)
|
||||
val req = request("r1", Tier.T2).copy(toolName = "shell")
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION,
|
||||
scope = GrantScope.SESSION(toolName = "shell"),
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "test",
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
@@ -45,7 +45,7 @@ class GrantSemanticsTest {
|
||||
val req = request("r1", Tier.T3)
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION,
|
||||
scope = GrantScope.SESSION(),
|
||||
permittedTiers = setOf(Tier.T2),
|
||||
reason = "test",
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
@@ -63,18 +63,18 @@ class GrantSemanticsTest {
|
||||
|
||||
@Test
|
||||
fun `multiple grants - any match is enough`() {
|
||||
val req = request("r1", Tier.T4)
|
||||
val req = request("r1", Tier.T4).copy(toolName = "write_file")
|
||||
val grants = listOf(
|
||||
ApprovalGrant(
|
||||
GrantId("g1"),
|
||||
GrantScope.SESSION,
|
||||
GrantScope.SESSION(toolName = "shell"),
|
||||
setOf(Tier.T3),
|
||||
"",
|
||||
Instant.parse("2026-01-01T00:00:00Z")
|
||||
),
|
||||
ApprovalGrant(
|
||||
GrantId("g2"),
|
||||
GrantScope.SESSION,
|
||||
GrantScope.SESSION(toolName = "write_file"),
|
||||
setOf(Tier.T4),
|
||||
"",
|
||||
Instant.parse("2026-01-01T00:00:00Z")
|
||||
|
||||
@@ -21,7 +21,7 @@ class NoExecutionCouplingTest {
|
||||
val grants = mutableListOf(
|
||||
ApprovalGrant(
|
||||
GrantId("g1"),
|
||||
GrantScope.SESSION,
|
||||
GrantScope.SESSION(),
|
||||
setOf(Tier.T2),
|
||||
"",
|
||||
Instant.parse("2026-01-01T00:00:00Z")
|
||||
|
||||
@@ -38,7 +38,7 @@ class TierImmutabilityTest {
|
||||
val req = request("r1", tier)
|
||||
val grant = ApprovalGrant(
|
||||
id = GrantId("g1"),
|
||||
scope = GrantScope.SESSION,
|
||||
scope = GrantScope.SESSION(),
|
||||
permittedTiers = setOf(tier),
|
||||
reason = "test",
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
|
||||
Reference in New Issue
Block a user