Files
correx/testing/approvals/src/test/kotlin/GrantSecurityTest.kt
T
kami cdee5f2245 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.
2026-05-28 22:39:15 +04:00

172 lines
6.4 KiB
Kotlin

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)
}
}