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,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)) {
|
||||
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(
|
||||
ruleCode = RULE_CODE,
|
||||
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