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:
+17
@@ -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(
|
||||
|
||||
+5
-10
@@ -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"))
|
||||
|
||||
+101
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user