feat(toolintent): dynamic per-session egress allowlist (D)

- EgressHostsGrantedEvent (registered + serialization test): additive per-session
  egress grants (e.g. an approved research source list)
- EgressAllowlist pure union helper + EgressAllowlistProjection (folds grants per
  session); NetworkHostRule delegates to the union, preserving exact/suffix semantics
- rule not yet fed the session set live (ToolCallAssessmentInput has no sessionId);
  precise TODO(wiring) left. batch fetch-approval skipped (needs approval-flow surgery)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 13:14:48 +00:00
parent 18bde7ca38
commit 027ff1f6ff
8 changed files with 238 additions and 1 deletions
@@ -0,0 +1,21 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Grants additional egress [hosts] for this session (e.g. the hosts drawn from an approved
* research source list). Additive to the static `networkAllowedHosts` allow-list: a host is
* permitted if it matches the static list OR any host granted to the session, so egress can be
* widened per-session without loosening the global default. Host matching honours the same
* exact-or-suffix semantics the static allow-list uses (a granted "example.com" covers
* "api.example.com"). [reason] is free-form operator context (e.g. which source list was approved).
*/
@Serializable
@SerialName("EgressHostsGranted")
data class EgressHostsGrantedEvent(
val sessionId: SessionId,
val hosts: Set<String>,
val reason: String = "",
) : EventPayload
@@ -21,6 +21,7 @@ import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent
@@ -141,6 +142,7 @@ val eventModule = SerializersModule {
subclass(PossibleContradictionFlaggedEvent::class)
subclass(SourceFetchedEvent::class)
subclass(LowQualityExtractionEvent::class)
subclass(EgressHostsGrantedEvent::class)
}
}
@@ -0,0 +1,29 @@
package com.correx.core.sessions.projections
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.SessionId
/**
* Folds the additional egress hosts granted to one session. State is the running union of every
* [EgressHostsGrantedEvent.hosts] emitted for [sessionId]; grants are additive (set union) and
* unrelated events — including grants for other sessions — are ignored. The resulting
* `Set<String>` is the per-session input to
* [com.correx.core.toolintent.rules.EgressAllowlist.isAllowed], unioned with the static
* `networkAllowedHosts` at the network gate.
*/
class EgressAllowlistProjection(
private val sessionId: SessionId,
) : Projection<Set<String>> {
override fun initial(): Set<String> = emptySet()
override fun apply(state: Set<String>, event: StoredEvent): Set<String> {
val payload = event.payload
return if (payload is EgressHostsGrantedEvent && payload.sessionId == sessionId) {
state + payload.hosts
} else {
state
}
}
}
@@ -0,0 +1,24 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.types.SessionId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class EgressEventSerializationTest {
@Test
fun `EgressHostsGrantedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = EgressHostsGrantedEvent(
sessionId = SessionId("s"),
hosts = setOf("example.com", "docs.rust-lang.org"),
reason = "approved research source list",
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"EgressHostsGranted\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -0,0 +1,68 @@
package com.correx.core.sessions.projections
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import kotlinx.datetime.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
class EgressAllowlistProjectionTest {
private val sessionId = SessionId("s1")
private val projection = EgressAllowlistProjection(sessionId)
private fun stored(payload: EventPayload, eventId: String = "e1", session: SessionId = sessionId) =
StoredEvent(
metadata = EventMetadata(
eventId = EventId(eventId),
sessionId = session,
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = 1L,
sessionSequence = 1L,
payload = payload,
)
private fun fold(vararg events: StoredEvent): Set<String> =
events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) }
@Test
fun `initial state is empty`() {
assertEquals(emptySet(), projection.initial())
}
@Test
fun `grants fold into a union`() {
val result = fold(
stored(EgressHostsGrantedEvent(sessionId, setOf("a.com", "b.com")), "e1"),
stored(EgressHostsGrantedEvent(sessionId, setOf("b.com", "c.com")), "e2"),
)
assertEquals(setOf("a.com", "b.com", "c.com"), result)
}
@Test
fun `grants for other sessions are ignored`() {
val result = fold(
stored(EgressHostsGrantedEvent(sessionId, setOf("mine.com")), "e1"),
stored(EgressHostsGrantedEvent(SessionId("other"), setOf("theirs.com")), "e2"),
)
assertEquals(setOf("mine.com"), result)
}
@Test
fun `unrelated events are ignored`() {
val result = fold(
stored(InitialIntentEvent(sessionId, "do a thing"), "e1"),
stored(EgressHostsGrantedEvent(sessionId, setOf("kept.com")), "e2"),
)
assertEquals(setOf("kept.com"), result)
}
}