From ab7e4be848d10dd5c87d68dafda1b0a654732875 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 21:43:25 +0000 Subject: [PATCH] feat(toolintent): activate per-session egress allowlist (D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToolCallAssessmentInput gains sessionEgressHosts; SessionOrchestrator resolves it at the assessment build site by folding EgressHostsGrantedEvents through EgressAllowlistProjection. NetworkHostRule now enforces session-granted hosts (union with static), replacing the emptySet() TODO — the allowlist built in 027ff1f is live. Co-Authored-By: Claude Opus 4.8 --- .../orchestration/SessionOrchestrator.kt | 17 +++ .../correx/core/toolintent/ToolCallRule.kt | 4 + .../core/toolintent/rules/NetworkHostRule.kt | 15 +-- .../core/toolintent/NetworkHostRuleTest.kt | 42 ++++++++ .../toolintent/SessionEgressResolutionTest.kt | 101 ++++++++++++++++++ 5 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionEgressResolutionTest.kt diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index fdde54ec..e0960b82 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -76,6 +76,7 @@ import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.stores.EventStore +import com.correx.core.sessions.projections.EgressAllowlistProjection import com.correx.core.events.types.ApprovalDecisionId import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ArtifactId @@ -939,6 +940,10 @@ abstract class SessionOrchestrator( ): RiskSummary? { val assessor = toolCallAssessor ?: return null val policy = effectives.policy ?: return null + // Resolve the egress hosts granted to this session by folding its EgressHostsGrantedEvents + // through the projection. Unioned with the static allow-list in NetworkHostRule; empty when + // nothing has been granted, which never narrows the static allow-list. + val sessionEgressHosts = resolveSessionEgressHosts(sessionId) val assessment = assessor.assess( ToolCallAssessmentInput( request = request, @@ -947,6 +952,7 @@ abstract class SessionOrchestrator( probe = worldProbe, paramRoles = tool?.paramRoles ?: emptyMap(), writeManifest = writeManifest, + sessionEgressHosts = sessionEgressHosts, ), ) emit( @@ -965,6 +971,17 @@ abstract class SessionOrchestrator( return assessment.toRiskSummary() } + /** + * Folds this session's [com.correx.core.events.events.EgressHostsGrantedEvent]s through + * [EgressAllowlistProjection] into the running union of hosts granted to it. Replay-safe (reads + * the log, never live state); empty when no grants have been emitted. + */ + internal fun resolveSessionEgressHosts(sessionId: SessionId): Set { + val projection = EgressAllowlistProjection(sessionId) + return eventStore.read(sessionId) + .fold(projection.initial()) { acc, event -> projection.apply(acc, event) } + } + private suspend fun buildSchemaEntries( responseFormat: ResponseFormat, stageId: StageId, diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt index 9a98bf29..208f2109 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt @@ -25,6 +25,10 @@ data class ToolCallAssessmentInput( val paramRoles: Map = emptyMap(), // Workspace-relative globs the active stage may write. Empty = unrestricted. val writeManifest: List = emptyList(), + // Egress hosts granted to the active session (folded from EgressHostsGrantedEvent via + // EgressAllowlistProjection). Unioned with the static allow-list at the network gate; empty + // means "no per-session grants", which never narrows the static allow-list. + val sessionEgressHosts: Set = emptySet(), ) data class ToolCallAssessment( 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 0b01cb9b..004d65f1 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,16 +38,11 @@ class NetworkHostRule( for (host in hosts(input)) { val denyHit = denied.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) + // Per-session egress grants (folded from EgressHostsGrantedEvent via + // EgressAllowlistProjection at the build site) union with the static allow-list: a host + // granted to the session is allowed even if it is not in the static set. Empty = no + // grants, which never narrows the static allow-list. + val allowHit = EgressAllowlist.isAllowed(host, allowed, input.sessionEgressHosts) 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/NetworkHostRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/NetworkHostRuleTest.kt index d59f9796..3eaf1369 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/NetworkHostRuleTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/NetworkHostRuleTest.kt @@ -23,6 +23,7 @@ class NetworkHostRuleTest { private fun input( params: Map, capabilities: Set = setOf(ToolCapability.NETWORK_ACCESS), + sessionEgressHosts: Set = emptySet(), ) = ToolCallAssessmentInput( request = ToolRequest( ToolInvocationId("i"), @@ -34,6 +35,7 @@ class NetworkHostRuleTest { capabilities = capabilities, workspace = WorkspacePolicy(Path.of("/work")), probe = FakeProbe(), + sessionEgressHosts = sessionEgressHosts, ) @Test @@ -79,6 +81,46 @@ class NetworkHostRuleTest { assertTrue(r.issues.isEmpty()) } + @Test + fun `session-granted host outside static allow-list proceeds`() { + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess( + input(mapOf("url" to "https://granted.com/x"), sessionEgressHosts = setOf("granted.com")), + ) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `host in neither static nor session allow-list yields PROMPT_USER`() { + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess( + input(mapOf("url" to "https://other.com"), sessionEgressHosts = setOf("granted.com")), + ) + assertEquals(RiskAction.PROMPT_USER, r.disposition) + assertEquals("NETWORK_HOST_NOT_ALLOWED", r.issues.single().code) + } + + @Test + fun `session-granted host honours suffix subdomain match`() { + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess( + input(mapOf("url" to "https://api.granted.com/p"), sessionEgressHosts = setOf("granted.com")), + ) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `denied host wins over a matching session grant`() { + val rule = NetworkHostRule(emptySet(), setOf("granted.com")) + val r = rule.assess( + input(mapOf("url" to "https://granted.com/x"), sessionEgressHosts = setOf("granted.com")), + ) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("NETWORK_HOST_DENIED", r.issues.single().code) + } + @Test fun `non-URL string param is ignored`() { val rule = NetworkHostRule(setOf("good.com"), setOf("evil.com")) diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionEgressResolutionTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionEgressResolutionTest.kt new file mode 100644 index 00000000..28cc54ce --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionEgressResolutionTest.kt @@ -0,0 +1,101 @@ +package com.correx.core.toolintent + +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.events.ToolRequest +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.sessions.projections.EgressAllowlistProjection +import com.correx.core.toolintent.rules.NetworkHostRule +import com.correx.core.tools.contract.ToolCapability +import kotlinx.datetime.Instant +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Mirrors the build-site wiring in SessionOrchestrator.resolveSessionEgressHosts: a session's + * EgressHostsGrantedEvents are folded through EgressAllowlistProjection, the resulting set is placed + * on ToolCallAssessmentInput.sessionEgressHosts, and NetworkHostRule consults it. Uses an in-memory + * list of StoredEvents as the fake store, matching EgressAllowlistProjectionTest's style. + */ +class SessionEgressResolutionTest { + + private val sessionId = SessionId("s1") + + private class FakeProbe : WorldProbe { + override fun exists(path: Path) = true + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + } + + private fun stored(payload: EventPayload, eventId: String, 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, + ) + + // Replicates SessionOrchestrator.resolveSessionEgressHosts: fold the session log through the + // projection. The store is faked as a plain List. + private fun resolveSessionEgressHosts(events: List): Set { + val projection = EgressAllowlistProjection(sessionId) + return events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) } + } + + private fun input(url: String, sessionEgressHosts: Set) = ToolCallAssessmentInput( + request = ToolRequest( + ToolInvocationId("i"), + sessionId, + StageId("st"), + "http_fetch", + mapOf("url" to url), + ), + capabilities = setOf(ToolCapability.NETWORK_ACCESS), + workspace = WorkspacePolicy(Path.of("/work")), + probe = FakeProbe(), + sessionEgressHosts = sessionEgressHosts, + ) + + @Test + fun `granted egress host resolves into the assessment input and is allowed`() { + val events = listOf( + stored(InitialIntentEvent(sessionId, "do a thing"), "e1"), + stored(EgressHostsGrantedEvent(sessionId, setOf("granted.com")), "e2"), + ) + val resolved = resolveSessionEgressHosts(events) + assertEquals(setOf("granted.com"), resolved) + + // Host is not in the static allow-list but is granted to the session → PROCEED. + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess(input("https://granted.com/x", resolved)) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `no grant resolves to empty and a non-static host prompts`() { + val events = listOf(stored(InitialIntentEvent(sessionId, "do a thing"), "e1")) + val resolved = resolveSessionEgressHosts(events) + assertEquals(emptySet(), resolved) + + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess(input("https://granted.com/x", resolved)) + assertEquals(RiskAction.PROMPT_USER, r.disposition) + assertEquals("NETWORK_HOST_NOT_ALLOWED", r.issues.single().code) + } +}