feat: add ExecInterpreterRule and NetworkHostRule plane-2 rules
This commit is contained in:
@@ -40,6 +40,8 @@ import com.correx.core.validation.semantic.rules.CycleExitRule
|
|||||||
import com.correx.core.validation.semantic.rules.MissingToolRule
|
import com.correx.core.validation.semantic.rules.MissingToolRule
|
||||||
import com.correx.core.toolintent.ToolCallAssessor
|
import com.correx.core.toolintent.ToolCallAssessor
|
||||||
import com.correx.core.toolintent.WorkspacePolicy
|
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.core.toolintent.rules.PathContainmentRule
|
||||||
import com.correx.infrastructure.InfrastructureModule
|
import com.correx.infrastructure.InfrastructureModule
|
||||||
import com.correx.infrastructure.artifactscas.DefaultMaterializingArtifactWriter
|
import com.correx.infrastructure.artifactscas.DefaultMaterializingArtifactWriter
|
||||||
@@ -113,7 +115,16 @@ fun main() {
|
|||||||
?: workingDir
|
?: workingDir
|
||||||
val privilegedLocations = toolsConfig.privilegedLocations.map { Path.of(it) }
|
val privilegedLocations = toolsConfig.privilegedLocations.map { Path.of(it) }
|
||||||
val workspacePolicy = WorkspacePolicy(workspaceRoot, privilegedLocations)
|
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 inferenceRouter = DefaultInferenceRouter(infraRegistry, FirstAvailableRoutingStrategy())
|
||||||
val engines = OrchestratorEngines(
|
val engines = OrchestratorEngines(
|
||||||
|
|||||||
@@ -302,6 +302,10 @@ object ConfigLoader {
|
|||||||
workspaceRoot = asString(toolsSection["workspace_root"], ""),
|
workspaceRoot = asString(toolsSection["workspace_root"], ""),
|
||||||
privilegedLocations = asStringList(toolsSection["privileged_locations"])
|
privilegedLocations = asStringList(toolsSection["privileged_locations"])
|
||||||
.ifEmpty { ToolsConfig.DEFAULT_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 ->
|
val providers = providersList.mapNotNull { providerMap ->
|
||||||
|
|||||||
@@ -41,12 +41,19 @@ data class ToolsConfig(
|
|||||||
val fileEditEnabled: Boolean = true,
|
val fileEditEnabled: Boolean = true,
|
||||||
val workspaceRoot: String = "",
|
val workspaceRoot: String = "",
|
||||||
val privilegedLocations: List<String> = DEFAULT_PRIVILEGED_LOCATIONS,
|
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 {
|
companion object {
|
||||||
val DEFAULT_PRIVILEGED_LOCATIONS: List<String> = listOf(
|
val DEFAULT_PRIVILEGED_LOCATIONS: List<String> = listOf(
|
||||||
"/etc", "/usr", "/bin", "/sbin", "/boot", "/lib", "/lib64",
|
"/etc", "/usr", "/bin", "/sbin", "/boot", "/lib", "/lib64",
|
||||||
"/sys", "/proc", "/dev", "/root",
|
"/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",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+64
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user