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.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(
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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