From ea80597cabc7d7275b1a24ae9bd861d8c31f2b61 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 31 May 2026 07:35:18 +0400 Subject: [PATCH] feat: add ExecInterpreterRule and NetworkHostRule plane-2 rules --- .../kotlin/com/correx/apps/server/Main.kt | 13 ++- .../com/correx/core/config/ConfigLoader.kt | 4 + .../com/correx/core/config/CorrexConfig.kt | 7 ++ .../toolintent/rules/ExecInterpreterRule.kt | 64 +++++++++++++ .../core/toolintent/rules/NetworkHostRule.kt | 80 +++++++++++++++++ .../toolintent/ExecInterpreterRuleTest.kt | 89 ++++++++++++++++++ .../core/toolintent/NetworkHostRuleTest.kt | 90 +++++++++++++++++++ 7 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ExecInterpreterRule.kt create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/ExecInterpreterRuleTest.kt create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/NetworkHostRuleTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 184b5928..a075159a 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -40,6 +40,8 @@ import com.correx.core.validation.semantic.rules.CycleExitRule import com.correx.core.validation.semantic.rules.MissingToolRule import com.correx.core.toolintent.ToolCallAssessor import com.correx.core.toolintent.WorkspacePolicy +import com.correx.core.toolintent.rules.ExecInterpreterRule +import com.correx.core.toolintent.rules.NetworkHostRule import com.correx.core.toolintent.rules.PathContainmentRule import com.correx.infrastructure.InfrastructureModule import com.correx.infrastructure.artifactscas.DefaultMaterializingArtifactWriter @@ -113,7 +115,16 @@ fun main() { ?: workingDir val privilegedLocations = toolsConfig.privilegedLocations.map { Path.of(it) } val workspacePolicy = WorkspacePolicy(workspaceRoot, privilegedLocations) - val toolCallAssessor = ToolCallAssessor(rules = listOf(PathContainmentRule())) + val toolCallAssessor = ToolCallAssessor( + rules = listOf( + PathContainmentRule(), + ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()), + NetworkHostRule( + allowedHosts = toolsConfig.networkAllowedHosts.toSet(), + deniedHosts = toolsConfig.networkDeniedHosts.toSet(), + ), + ), + ) val inferenceRouter = DefaultInferenceRouter(infraRegistry, FirstAvailableRoutingStrategy()) val engines = OrchestratorEngines( diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt index e1aaa272..129024b4 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt @@ -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 -> diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt index 89b3d031..045eb0ad 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt @@ -41,12 +41,19 @@ data class ToolsConfig( val fileEditEnabled: Boolean = true, val workspaceRoot: String = "", val privilegedLocations: List = DEFAULT_PRIVILEGED_LOCATIONS, + val interpreterExecutables: List = DEFAULT_INTERPRETERS, + val networkAllowedHosts: List = emptyList(), + val networkDeniedHosts: List = emptyList(), ) { companion object { val DEFAULT_PRIVILEGED_LOCATIONS: List = listOf( "/etc", "/usr", "/bin", "/sbin", "/boot", "/lib", "/lib64", "/sys", "/proc", "/dev", "/root", ) + val DEFAULT_INTERPRETERS: List = listOf( + "python", "python2", "python3", "bash", "sh", "zsh", "fish", + "node", "deno", "ruby", "perl", "php", "Rscript", "lua", "groovy", + ) } } diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ExecInterpreterRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ExecInterpreterRule.kt new file mode 100644 index 00000000..3864d8aa --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ExecInterpreterRule.kt @@ -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) : ToolCallRule { + + private val interpreters: Set = interpreters.map { it.lowercase() }.toSet() + + override fun appliesTo(capabilities: Set): Boolean = + ToolCapability.SHELL_EXEC in capabilities || ToolCapability.PROCESS_SPAWN in capabilities + + override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { + val issues = mutableListOf() + val observations = mutableListOf() + 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 = + input.request.parameters.values + .filterIsInstance() + .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,\\[\\]\"']+") + } +} 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 new file mode 100644 index 00000000..cac238f8 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt @@ -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, + deniedHosts: Set, +) : ToolCallRule { + + private val allowed: Set = allowedHosts.map { it.lowercase() }.toSet() + private val denied: Set = deniedHosts.map { it.lowercase() }.toSet() + + override fun appliesTo(capabilities: Set): Boolean = + ToolCapability.NETWORK_ACCESS in capabilities + + override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { + val issues = mutableListOf() + val observations = mutableListOf() + 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 = + input.request.parameters.values + .filterIsInstance() + .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" + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ExecInterpreterRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ExecInterpreterRuleTest.kt new file mode 100644 index 00000000..518f440f --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ExecInterpreterRuleTest.kt @@ -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, + capabilities: Set = 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) + } +} 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 new file mode 100644 index 00000000..d59f9796 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/NetworkHostRuleTest.kt @@ -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, + capabilities: Set = 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()) + } +}