From 027ff1f6ff33604bef365163a0d319e8cd507f59 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 13:14:48 +0000 Subject: [PATCH] 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 --- .../correx/core/events/events/EgressEvents.kt | 21 ++++++ .../events/serialization/Serialization.kt | 2 + .../projections/EgressAllowlistProjection.kt | 29 ++++++++ .../EgressEventSerializationTest.kt | 24 +++++++ .../EgressAllowlistProjectionTest.kt | 68 +++++++++++++++++++ .../core/toolintent/rules/EgressAllowlist.kt | 32 +++++++++ .../core/toolintent/rules/NetworkHostRule.kt | 11 ++- .../core/toolintent/EgressAllowlistTest.kt | 52 ++++++++++++++ 8 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/EgressEvents.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjection.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/EgressEventSerializationTest.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjectionTest.kt create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/EgressAllowlist.kt create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/EgressAllowlistTest.kt diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/EgressEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/EgressEvents.kt new file mode 100644 index 00000000..bd389e1c --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/EgressEvents.kt @@ -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, + val reason: String = "", +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 0c215e13..d9b17031 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -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) } } diff --git a/core/events/src/main/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjection.kt b/core/events/src/main/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjection.kt new file mode 100644 index 00000000..61710af3 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjection.kt @@ -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` 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> { + + override fun initial(): Set = emptySet() + + override fun apply(state: Set, event: StoredEvent): Set { + val payload = event.payload + return if (payload is EgressHostsGrantedEvent && payload.sessionId == sessionId) { + state + payload.hosts + } else { + state + } + } +} diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/EgressEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/EgressEventSerializationTest.kt new file mode 100644 index 00000000..3ecbac76 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/EgressEventSerializationTest.kt @@ -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) + } +} diff --git a/core/events/src/test/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjectionTest.kt b/core/events/src/test/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjectionTest.kt new file mode 100644 index 00000000..7783fb62 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjectionTest.kt @@ -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 = + 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) + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/EgressAllowlist.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/EgressAllowlist.kt new file mode 100644 index 00000000..5a00c415 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/EgressAllowlist.kt @@ -0,0 +1,32 @@ +package com.correx.core.toolintent.rules + +/** + * Pure, stateless host allow-list matching for network egress. The effective allow-list is the + * union of the static `networkAllowedHosts` and any hosts granted to the active session (see + * [com.correx.core.events.events.EgressHostsGrantedEvent]): a host is allowed if it matches the + * static set OR the session set. Matching honours the exact-or-suffix semantics + * [com.correx.core.toolintent.rules.NetworkHostRule] uses — a configured "example.com" covers + * "api.example.com" — and never narrows it; the session set can only widen what is allowed. + * + * An empty union (no static and no session hosts) means "no allow-list configured", which the + * caller treats as allow-all, mirroring the rule's existing behaviour. + */ +object EgressAllowlist { + + /** + * True if [host] is permitted by the union of [staticHosts] and [sessionHosts]. Both sets are + * normalised to lower-case; [host] is matched case-insensitively. An empty union allows any host. + */ + fun isAllowed(host: String, staticHosts: Set, sessionHosts: Set): Boolean { + val target = host.lowercase() + val union = staticHosts.asSequence() + sessionHosts.asSequence() + var sawAny = false + for (allowed in union) { + sawAny = true + val a = allowed.lowercase() + if (target == a || target.endsWith(".$a")) return true + } + // No allow-list configured at all → allow-all (matches NetworkHostRule's empty-list behaviour). + return !sawAny + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt index 5674c9fb..0b01cb9b 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt @@ -38,7 +38,16 @@ class NetworkHostRule( for (host in hosts(input)) { val denyHit = denied.any { host == it || host.endsWith(".$it") } - val allowHit = allowed.isEmpty() || allowed.any { host == it || host.endsWith(".$it") } + // TODO(wiring): pass the active session's granted egress hosts here instead of emptySet(). + // ToolCallAssessmentInput carries no sessionId / session-state today, and this rule is + // constructed once at boot from static config (apps/server Main.kt), so it cannot resolve + // per-session grants. To go live: (1) add `sessionId: SessionId` (and/or the effective + // session egress set) to ToolCallAssessmentInput where it is built per call; (2) fold + // EgressHostsGrantedEvent for that session via EgressAllowlistProjection into a + // Set; (3) substitute that set for `emptySet()` below. The union helper and the + // projection are already in place — only the input plumbing is missing. + val sessionHosts: Set = emptySet() + val allowHit = EgressAllowlist.isAllowed(host, allowed, sessionHosts) observations += ToolCallObservation( ruleCode = RULE_CODE, facts = mapOf("host" to host, "denied" to denyHit.toString(), "allowed" to allowHit.toString()), diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/EgressAllowlistTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/EgressAllowlistTest.kt new file mode 100644 index 00000000..dd2515c2 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/EgressAllowlistTest.kt @@ -0,0 +1,52 @@ +package com.correx.core.toolintent + +import com.correx.core.toolintent.rules.EgressAllowlist +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EgressAllowlistTest { + + @Test + fun `host in static set only is allowed`() { + assertTrue(EgressAllowlist.isAllowed("good.com", setOf("good.com"), emptySet())) + } + + @Test + fun `host in session set only is allowed`() { + assertTrue(EgressAllowlist.isAllowed("granted.com", emptySet(), setOf("granted.com"))) + } + + @Test + fun `host in neither set is denied when an allow-list exists`() { + assertFalse(EgressAllowlist.isAllowed("other.com", setOf("good.com"), setOf("granted.com"))) + } + + @Test + fun `empty union allows any host`() { + assertTrue(EgressAllowlist.isAllowed("anywhere.example.com", emptySet(), emptySet())) + } + + @Test + fun `suffix match is honoured for static hosts`() { + assertTrue(EgressAllowlist.isAllowed("api.good.com", setOf("good.com"), emptySet())) + } + + @Test + fun `suffix match is honoured for session hosts`() { + assertTrue(EgressAllowlist.isAllowed("docs.granted.com", emptySet(), setOf("granted.com"))) + } + + @Test + fun `suffix match does not leak across unrelated domains`() { + // "evilgood.com" must NOT match a configured "good.com" — only true subdomains. + assertFalse(EgressAllowlist.isAllowed("evilgood.com", setOf("good.com"), emptySet())) + } + + @Test + fun `matching is case-insensitive`() { + assertTrue(EgressAllowlist.isAllowed("API.Good.COM", setOf("good.com"), emptySet())) + assertTrue(EgressAllowlist.isAllowed("api.good.com", setOf("GOOD.COM"), emptySet())) + assertTrue(EgressAllowlist.isAllowed("api.granted.com", emptySet(), setOf("Granted.Com"))) + } +}