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:
@@ -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.SessionWorkspaceBoundEvent
|
||||||
import com.correx.core.events.events.ContextTruncatedEvent
|
import com.correx.core.events.events.ContextTruncatedEvent
|
||||||
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
|
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.EventPayload
|
||||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||||
@@ -141,6 +142,7 @@ val eventModule = SerializersModule {
|
|||||||
subclass(PossibleContradictionFlaggedEvent::class)
|
subclass(PossibleContradictionFlaggedEvent::class)
|
||||||
subclass(SourceFetchedEvent::class)
|
subclass(SourceFetchedEvent::class)
|
||||||
subclass(LowQualityExtractionEvent::class)
|
subclass(LowQualityExtractionEvent::class)
|
||||||
|
subclass(EgressHostsGrantedEvent::class)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>, sessionHosts: Set<String>): 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
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -38,7 +38,16 @@ class NetworkHostRule(
|
|||||||
|
|
||||||
for (host in hosts(input)) {
|
for (host in hosts(input)) {
|
||||||
val denyHit = denied.any { host == it || host.endsWith(".$it") }
|
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<String>; (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<String> = emptySet()
|
||||||
|
val allowHit = EgressAllowlist.isAllowed(host, allowed, sessionHosts)
|
||||||
observations += ToolCallObservation(
|
observations += ToolCallObservation(
|
||||||
ruleCode = RULE_CODE,
|
ruleCode = RULE_CODE,
|
||||||
facts = mapOf("host" to host, "denied" to denyHit.toString(), "allowed" to allowHit.toString()),
|
facts = mapOf("host" to host, "denied" to denyHit.toString(), "allowed" to allowHit.toString()),
|
||||||
|
|||||||
@@ -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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user