feat: add ExecInterpreterRule and NetworkHostRule plane-2 rules

This commit is contained in:
2026-05-31 07:35:18 +04:00
parent 545068d222
commit ea80597cab
7 changed files with 346 additions and 1 deletions
@@ -302,6 +302,10 @@ object ConfigLoader {
workspaceRoot = asString(toolsSection["workspace_root"], ""),
privilegedLocations = asStringList(toolsSection["privileged_locations"])
.ifEmpty { ToolsConfig.DEFAULT_PRIVILEGED_LOCATIONS },
interpreterExecutables = asStringList(toolsSection["interpreter_executables"])
.ifEmpty { ToolsConfig.DEFAULT_INTERPRETERS },
networkAllowedHosts = asStringList(toolsSection["network_allowed_hosts"]),
networkDeniedHosts = asStringList(toolsSection["network_denied_hosts"]),
)
val providers = providersList.mapNotNull { providerMap ->
@@ -41,12 +41,19 @@ data class ToolsConfig(
val fileEditEnabled: Boolean = true,
val workspaceRoot: String = "",
val privilegedLocations: List<String> = DEFAULT_PRIVILEGED_LOCATIONS,
val interpreterExecutables: List<String> = DEFAULT_INTERPRETERS,
val networkAllowedHosts: List<String> = emptyList(),
val networkDeniedHosts: List<String> = emptyList(),
) {
companion object {
val DEFAULT_PRIVILEGED_LOCATIONS: List<String> = listOf(
"/etc", "/usr", "/bin", "/sbin", "/boot", "/lib", "/lib64",
"/sys", "/proc", "/dev", "/root",
)
val DEFAULT_INTERPRETERS: List<String> = listOf(
"python", "python2", "python3", "bash", "sh", "zsh", "fish",
"node", "deno", "ruby", "perl", "php", "Rscript", "lua", "groovy",
)
}
}
@@ -0,0 +1,64 @@
package com.correx.core.toolintent.rules
import com.correx.core.events.events.ToolCallObservation
import com.correx.core.events.risk.RiskAction
import com.correx.core.toolintent.ToolCallAssessment
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallRule
import com.correx.core.toolintent.maxAction
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity
/**
* Classifies shell/process executions by the invoked executable. Dispatches on
* SHELL_EXEC / PROCESS_SPAWN. An interpreter invocation (python, bash, node, …)
* runs arbitrary code and is elevated to PROMPT_USER; a plain command proceeds.
* Executable detection is generic: it tokenizes every string parameter (covers
* both an `argv` JSON-array string and a raw command line) and inspects the
* leading token's basename — no tool-name or parameter-name hardcoding.
*/
class ExecInterpreterRule(interpreters: Set<String>) : ToolCallRule {
private val interpreters: Set<String> = interpreters.map { it.lowercase() }.toSet()
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
ToolCapability.SHELL_EXEC in capabilities || ToolCapability.PROCESS_SPAWN in capabilities
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
val issues = mutableListOf<ValidationIssue>()
val observations = mutableListOf<ToolCallObservation>()
var disposition = RiskAction.PROCEED
for (executable in executables(input)) {
val basename = executable.substringAfterLast('/').lowercase()
val isInterpreter = basename in interpreters
observations += ToolCallObservation(
ruleCode = RULE_CODE,
facts = mapOf("executable" to executable, "isInterpreter" to isInterpreter.toString()),
)
if (isInterpreter) {
issues += ValidationIssue(
code = "INTERPRETER_EXECUTION",
message = "Tool '${input.request.toolName}' invokes interpreter '$basename'",
severity = ValidationSeverity.WARNING,
)
disposition = maxAction(disposition, RiskAction.PROMPT_USER)
}
}
return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
}
private fun executables(input: ToolCallAssessmentInput): List<String> =
input.request.parameters.values
.filterIsInstance<String>()
.mapNotNull { leadingToken(it) }
private fun leadingToken(raw: String): String? =
raw.split(TOKEN_DELIMITERS).firstOrNull { it.isNotBlank() }
private companion object {
const val RULE_CODE = "EXEC_INTERPRETER"
val TOKEN_DELIMITERS = Regex("[\\s,\\[\\]\"']+")
}
}
@@ -0,0 +1,80 @@
package com.correx.core.toolintent.rules
import com.correx.core.events.events.ToolCallObservation
import com.correx.core.events.risk.RiskAction
import com.correx.core.toolintent.ToolCallAssessment
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallRule
import com.correx.core.toolintent.maxAction
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity
import java.net.URI
/**
* Host allow/deny for network-capable tools. Dispatches on NETWORK_ACCESS.
* Extracts hosts from URL-like string parameters (generic; no param-name
* hardcoding). A denied host → BLOCK (terminal); with a non-empty allow-list,
* any host not on it → PROMPT_USER; otherwise PROCEED. Suffix matches treat a
* configured "example.com" as covering "api.example.com".
*/
@Suppress("ReturnCount")
class NetworkHostRule(
allowedHosts: Set<String>,
deniedHosts: Set<String>,
) : ToolCallRule {
private val allowed: Set<String> = allowedHosts.map { it.lowercase() }.toSet()
private val denied: Set<String> = deniedHosts.map { it.lowercase() }.toSet()
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
ToolCapability.NETWORK_ACCESS in capabilities
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
val issues = mutableListOf<ValidationIssue>()
val observations = mutableListOf<ToolCallObservation>()
var disposition = RiskAction.PROCEED
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") }
observations += ToolCallObservation(
ruleCode = RULE_CODE,
facts = mapOf("host" to host, "denied" to denyHit.toString(), "allowed" to allowHit.toString()),
)
when {
denyHit -> {
issues += ValidationIssue(
code = "NETWORK_HOST_DENIED",
message = "Tool '${input.request.toolName}' targets denied host: $host",
severity = ValidationSeverity.ERROR,
)
disposition = maxAction(disposition, RiskAction.BLOCK)
}
!allowHit -> {
issues += ValidationIssue(
code = "NETWORK_HOST_NOT_ALLOWED",
message = "Tool '${input.request.toolName}' targets host outside allow-list: $host",
severity = ValidationSeverity.WARNING,
)
disposition = maxAction(disposition, RiskAction.PROMPT_USER)
}
}
}
return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
}
private fun hosts(input: ToolCallAssessmentInput): List<String> =
input.request.parameters.values
.filterIsInstance<String>()
.mapNotNull { extractHost(it) }
private fun extractHost(raw: String): String? {
if (!raw.contains("://")) return null
return runCatching { URI(raw).host?.lowercase() }.getOrNull()
}
private companion object {
const val RULE_CODE = "NETWORK_HOST"
}
}
@@ -0,0 +1,89 @@
package com.correx.core.toolintent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.risk.RiskAction
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.toolintent.rules.ExecInterpreterRule
import com.correx.core.tools.contract.ToolCapability
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ExecInterpreterRuleTest {
private val rule = ExecInterpreterRule(setOf("bash", "python", "python3"))
private class FakeProbe : WorldProbe {
override fun exists(path: Path) = true
override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize()
}
private fun input(
params: Map<String, Any>,
capabilities: Set<ToolCapability> = setOf(ToolCapability.SHELL_EXEC),
) = ToolCallAssessmentInput(
request = ToolRequest(
ToolInvocationId("i"),
SessionId("s"),
StageId("st"),
"shell_exec",
params,
),
capabilities = capabilities,
workspace = WorkspacePolicy(Path.of("/work")),
probe = FakeProbe(),
)
@Test
fun `appliesTo true for SHELL_EXEC`() {
assertTrue(rule.appliesTo(setOf(ToolCapability.SHELL_EXEC)))
}
@Test
fun `appliesTo true for PROCESS_SPAWN`() {
assertTrue(rule.appliesTo(setOf(ToolCapability.PROCESS_SPAWN)))
}
@Test
fun `appliesTo false for FILE_READ`() {
assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_READ)))
}
@Test
fun `appliesTo false for empty set`() {
assertFalse(rule.appliesTo(emptySet()))
}
@Test
fun `argv-style bash invocation yields PROMPT_USER and INTERPRETER_EXECUTION`() {
val r = rule.assess(input(mapOf("argv" to """["bash","script.sh"]""")))
assertEquals(RiskAction.PROMPT_USER, r.disposition)
assertEquals(1, r.issues.size)
assertEquals("INTERPRETER_EXECUTION", r.issues.single().code)
}
@Test
fun `argv-style non-interpreter ls proceeds with no issues`() {
val r = rule.assess(input(mapOf("argv" to """["ls","-la"]""")))
assertEquals(RiskAction.PROCEED, r.disposition)
assertTrue(r.issues.isEmpty())
}
@Test
fun `raw command line python yields PROMPT_USER`() {
val r = rule.assess(input(mapOf("cmd" to "python foo.py")))
assertEquals(RiskAction.PROMPT_USER, r.disposition)
assertEquals("INTERPRETER_EXECUTION", r.issues.single().code)
}
@Test
fun `absolute path interpreter python3 yields PROMPT_USER via basename detection`() {
val r = rule.assess(input(mapOf("cmd" to "/usr/bin/python3 x.py")))
assertEquals(RiskAction.PROMPT_USER, r.disposition)
assertEquals("INTERPRETER_EXECUTION", r.issues.single().code)
}
}
@@ -0,0 +1,90 @@
package com.correx.core.toolintent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.risk.RiskAction
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.toolintent.rules.NetworkHostRule
import com.correx.core.tools.contract.ToolCapability
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NetworkHostRuleTest {
private class FakeProbe : WorldProbe {
override fun exists(path: Path) = true
override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize()
}
private fun input(
params: Map<String, Any>,
capabilities: Set<ToolCapability> = setOf(ToolCapability.NETWORK_ACCESS),
) = ToolCallAssessmentInput(
request = ToolRequest(
ToolInvocationId("i"),
SessionId("s"),
StageId("st"),
"http_fetch",
params,
),
capabilities = capabilities,
workspace = WorkspacePolicy(Path.of("/work")),
probe = FakeProbe(),
)
@Test
fun `appliesTo true only for NETWORK_ACCESS`() {
val rule = NetworkHostRule(emptySet(), emptySet())
assertTrue(rule.appliesTo(setOf(ToolCapability.NETWORK_ACCESS)))
assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_READ)))
assertFalse(rule.appliesTo(setOf(ToolCapability.SHELL_EXEC)))
assertFalse(rule.appliesTo(emptySet()))
}
@Test
fun `denied host yields BLOCK with NETWORK_HOST_DENIED`() {
val rule = NetworkHostRule(emptySet(), setOf("evil.com"))
val r = rule.assess(input(mapOf("url" to "https://evil.com/x")))
assertEquals(RiskAction.BLOCK, r.disposition)
assertEquals(1, r.issues.size)
assertEquals("NETWORK_HOST_DENIED", r.issues.single().code)
}
@Test
fun `host outside allow-list yields PROMPT_USER with NETWORK_HOST_NOT_ALLOWED`() {
val rule = NetworkHostRule(setOf("good.com"), emptySet())
val r = rule.assess(input(mapOf("url" to "https://other.com")))
assertEquals(RiskAction.PROMPT_USER, r.disposition)
assertEquals(1, r.issues.size)
assertEquals("NETWORK_HOST_NOT_ALLOWED", r.issues.single().code)
}
@Test
fun `allow-list suffix match proceeds`() {
val rule = NetworkHostRule(setOf("good.com"), emptySet())
val r = rule.assess(input(mapOf("url" to "https://api.good.com/p")))
assertEquals(RiskAction.PROCEED, r.disposition)
assertTrue(r.issues.isEmpty())
}
@Test
fun `no lists configured any URL proceeds`() {
val rule = NetworkHostRule(emptySet(), emptySet())
val r = rule.assess(input(mapOf("url" to "https://anywhere.example.com/path")))
assertEquals(RiskAction.PROCEED, r.disposition)
assertTrue(r.issues.isEmpty())
}
@Test
fun `non-URL string param is ignored`() {
val rule = NetworkHostRule(setOf("good.com"), setOf("evil.com"))
val r = rule.assess(input(mapOf("text" to "just text")))
assertEquals(RiskAction.PROCEED, r.disposition)
assertTrue(r.issues.isEmpty())
assertTrue(r.observations.isEmpty())
}
}