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

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 21:43:25 +00:00
parent ef20557c9c
commit ab7e4be848
5 changed files with 169 additions and 10 deletions
@@ -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<String> {
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,
@@ -25,6 +25,10 @@ data class ToolCallAssessmentInput(
val paramRoles: Map<String, ParamRole> = emptyMap(),
// Workspace-relative globs the active stage may write. Empty = unrestricted.
val writeManifest: List<String> = 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<String> = emptySet(),
)
data class ToolCallAssessment(
@@ -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<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)
// 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()),
@@ -23,6 +23,7 @@ class NetworkHostRuleTest {
private fun input(
params: Map<String, Any>,
capabilities: Set<ToolCapability> = setOf(ToolCapability.NETWORK_ACCESS),
sessionEgressHosts: Set<String> = 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"))
@@ -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<StoredEvent>.
private fun resolveSessionEgressHosts(events: List<StoredEvent>): Set<String> {
val projection = EgressAllowlistProjection(sessionId)
return events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) }
}
private fun input(url: String, sessionEgressHosts: Set<String>) = 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)
}
}