diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 7eba7bfa..eb0bbcae 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -599,13 +599,14 @@ abstract class SessionOrchestrator( ): RiskSummary? { val assessor = toolCallAssessor ?: return null val policy = workspacePolicy ?: return null - val capabilities = toolRegistry?.resolve(toolName)?.requiredCapabilities ?: emptySet() + val tool = toolRegistry?.resolve(toolName) val assessment = assessor.assess( ToolCallAssessmentInput( request = request, - capabilities = capabilities, + capabilities = tool?.requiredCapabilities ?: emptySet(), workspace = policy, probe = worldProbe, + paramRoles = tool?.paramRoles ?: emptyMap(), ), ) emit( diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt index 8944abd0..fa77c3a8 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt @@ -3,6 +3,7 @@ package com.correx.core.toolintent import com.correx.core.events.events.ToolCallObservation import com.correx.core.events.events.ToolRequest import com.correx.core.events.risk.RiskAction +import com.correx.core.tools.contract.ParamRole import com.correx.core.tools.contract.ToolCapability import com.correx.core.validation.model.ValidationIssue @@ -21,6 +22,7 @@ data class ToolCallAssessmentInput( val capabilities: Set, val workspace: WorkspacePolicy, val probe: WorldProbe, + val paramRoles: Map = emptyMap(), ) data class ToolCallAssessment( 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 index 3864d8aa..a945167d 100644 --- 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 @@ -6,6 +6,7 @@ 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.ParamRole import com.correx.core.tools.contract.ToolCapability import com.correx.core.validation.model.ValidationIssue import com.correx.core.validation.model.ValidationSeverity @@ -49,10 +50,15 @@ class ExecInterpreterRule(interpreters: Set) : ToolCallRule { return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition) } - private fun executables(input: ToolCallAssessmentInput): List = - input.request.parameters.values - .filterIsInstance() - .mapNotNull { leadingToken(it) } + private fun executables(input: ToolCallAssessmentInput): List { + val declared = input.paramRoles.filterValues { it == ParamRole.EXEC_COMMAND }.keys + val raws = if (declared.isNotEmpty()) { + declared.flatMap { extractParamStrings(input.request.parameters[it]) } + } else { + input.request.parameters.values.filterIsInstance() + } + return raws.mapNotNull { leadingToken(it) } + } private fun leadingToken(raw: String): String? = raw.split(TOKEN_DELIMITERS).firstOrNull { it.isNotBlank() } 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 index cac238f8..5674c9fb 100644 --- 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 @@ -6,6 +6,7 @@ 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.ParamRole import com.correx.core.tools.contract.ToolCapability import com.correx.core.validation.model.ValidationIssue import com.correx.core.validation.model.ValidationSeverity @@ -64,10 +65,15 @@ class NetworkHostRule( return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition) } - private fun hosts(input: ToolCallAssessmentInput): List = - input.request.parameters.values - .filterIsInstance() - .mapNotNull { extractHost(it) } + private fun hosts(input: ToolCallAssessmentInput): List { + val declared = input.paramRoles.filterValues { it == ParamRole.NETWORK_TARGET }.keys + val raws = if (declared.isNotEmpty()) { + declared.flatMap { extractParamStrings(input.request.parameters[it]) } + } else { + input.request.parameters.values.filterIsInstance() + } + return raws.mapNotNull { extractHost(it) } + } private fun extractHost(raw: String): String? { if (!raw.contains("://")) return null diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ParamValueExtractor.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ParamValueExtractor.kt new file mode 100644 index 00000000..25a76ec7 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ParamValueExtractor.kt @@ -0,0 +1,12 @@ +package com.correx.core.toolintent.rules + +/** + * Flattens a raw tool-call parameter value into candidate strings for rule + * inspection. A declared param may arrive as a plain String (e.g. a path) or a + * List<*> (e.g. shell `argv`); both are reduced to strings. + */ +internal fun extractParamStrings(value: Any?): List = when (value) { + is String -> listOf(value) + is List<*> -> value.mapNotNull { it?.toString() } + else -> emptyList() +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt index e6e70507..7f843480 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt @@ -6,6 +6,7 @@ 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.ParamRole import com.correx.core.tools.contract.ToolCapability import com.correx.core.validation.model.ValidationIssue import com.correx.core.validation.model.ValidationSeverity @@ -75,10 +76,16 @@ class PathContainmentRule : ToolCallRule { return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition) } - private fun candidatePaths(input: ToolCallAssessmentInput): List = - input.request.parameters.values - .filterIsInstance() - .filter { it.looksLikePath() } + private fun candidatePaths(input: ToolCallAssessmentInput): List { + val declared = input.paramRoles.filterValues { it == ParamRole.PATH }.keys + return if (declared.isNotEmpty()) { + declared.flatMap { extractParamStrings(input.request.parameters[it]) } + } else { + input.request.parameters.values + .filterIsInstance() + .filter { it.looksLikePath() } + } + } private fun String.looksLikePath(): Boolean = isNotBlank() && (startsWith("/") || startsWith("~") || contains("/") || contains("..")) 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 index 518f440f..55ce0a2a 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ExecInterpreterRuleTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ExecInterpreterRuleTest.kt @@ -6,6 +6,7 @@ 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.ParamRole import com.correx.core.tools.contract.ToolCapability import java.nio.file.Path import kotlin.test.Test @@ -86,4 +87,24 @@ class ExecInterpreterRuleTest { assertEquals(RiskAction.PROMPT_USER, r.disposition) assertEquals("INTERPRETER_EXECUTION", r.issues.single().code) } + + @Test + fun `declared EXEC_COMMAND role flags an argv list invoking python`() { + val r = rule.assess( + input(mapOf("argv" to listOf("python", "foo.py"))) + .copy(paramRoles = mapOf("argv" to ParamRole.EXEC_COMMAND)), + ) + assertEquals(RiskAction.PROMPT_USER, r.disposition) + assertEquals("INTERPRETER_EXECUTION", r.issues.single().code) + } + + @Test + fun `declared EXEC_COMMAND role ignores non-declared params`() { + val r = rule.assess( + input(mapOf("argv" to listOf("ls", "-la"), "note" to "python is great")) + .copy(paramRoles = mapOf("argv" to ParamRole.EXEC_COMMAND)), + ) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } } diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/PathContainmentRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/PathContainmentRuleTest.kt index 5e6c8bf3..4335f438 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/PathContainmentRuleTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/PathContainmentRuleTest.kt @@ -6,6 +6,7 @@ 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.PathContainmentRule +import com.correx.core.tools.contract.ParamRole import com.correx.core.tools.contract.ToolCapability import java.nio.file.Path import kotlin.test.Test @@ -85,4 +86,47 @@ class PathContainmentRuleTest { assertEquals(RiskAction.PROCEED, r.disposition) assertTrue(r.observations.isEmpty()) } + + @Test + fun `declared PATH role flags outside-workspace path arg`() { + val r = rule.assess(input("/tmp/scratch.txt", FakeProbe()).copy(paramRoles = mapOf("path" to ParamRole.PATH))) + assertEquals(RiskAction.PROMPT_USER, r.disposition) + assertEquals("PATH_OUTSIDE_WORKSPACE", r.issues.single().code) + } + + @Test + fun `declared PATH role ignores a slashy non-path param`() { + val r = rule.assess( + ToolCallAssessmentInput( + request = ToolRequest( + ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write", + mapOf("path" to "/work/project/A.kt", "content" to "import a/b/c"), + ), + capabilities = setOf(ToolCapability.FILE_WRITE), + workspace = WorkspacePolicy(workspace, listOf(Path.of("/etc"))), + probe = FakeProbe(), + paramRoles = mapOf("path" to ParamRole.PATH), + ), + ) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + assertEquals(1, r.observations.size) // only the declared path param, not the slashy content + } + + @Test + fun `no declared roles still flags a slashy content param via fallback`() { + val r = rule.assess( + ToolCallAssessmentInput( + request = ToolRequest( + ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write", + mapOf("content" to "/tmp/evil.txt"), + ), + capabilities = setOf(ToolCapability.FILE_WRITE), + workspace = WorkspacePolicy(workspace, listOf(Path.of("/etc"))), + probe = FakeProbe(), + ), + ) + assertEquals(RiskAction.PROMPT_USER, r.disposition) + assertEquals("PATH_OUTSIDE_WORKSPACE", r.issues.single().code) + } } diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/contract/ParamRole.kt b/core/tools/src/main/kotlin/com/correx/core/tools/contract/ParamRole.kt new file mode 100644 index 00000000..ca997487 --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/contract/ParamRole.kt @@ -0,0 +1,3 @@ +package com.correx.core.tools.contract + +enum class ParamRole { PATH, EXEC_COMMAND, NETWORK_TARGET } diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt b/core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt index f8d7621d..be6a3fe3 100644 --- a/core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt +++ b/core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt @@ -10,5 +10,6 @@ interface Tool { val parametersSchema: JsonObject val tier: Tier val requiredCapabilities: Set + val paramRoles: Map get() = emptyMap() fun validateRequest(request: ToolRequest): ValidationResult } diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt index 1cad3684..3e726cbf 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt @@ -4,6 +4,7 @@ import com.correx.core.approvals.Tier import com.correx.core.events.events.ToolRequest import com.correx.core.events.types.ToolInvocationId import com.correx.core.tools.contract.FileAffectingTool +import com.correx.core.tools.contract.ParamRole import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolExecutor @@ -74,6 +75,7 @@ class FileEditTool( } override val tier: Tier = Tier.T3 override val requiredCapabilities: Set = setOf(ToolCapability.FILE_WRITE) + override val paramRoles: Map = mapOf("path" to ParamRole.PATH) override fun affectedPaths(request: ToolRequest): Set { val pathString = request.parameters["path"] as? String ?: return emptySet() diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt index 2a956702..38382c5a 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt @@ -2,6 +2,7 @@ package com.correx.infrastructure.tools.filesystem import com.correx.core.approvals.Tier import com.correx.core.events.events.ToolRequest +import com.correx.core.tools.contract.ParamRole import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolExecutor @@ -47,6 +48,7 @@ class FileReadTool( } override val tier: Tier = Tier.T1 override val requiredCapabilities: Set = setOf(ToolCapability.FILE_READ) + override val paramRoles: Map = mapOf("path" to ParamRole.PATH) override fun validateRequest(request: ToolRequest): ValidationResult { val pathString = request.parameters["path"] as? String diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt index 1a6b68a8..21566bf8 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt @@ -4,6 +4,7 @@ import com.correx.core.approvals.Tier import com.correx.core.events.events.ToolRequest import com.correx.core.events.types.ToolInvocationId import com.correx.core.tools.contract.FileAffectingTool +import com.correx.core.tools.contract.ParamRole import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolExecutor @@ -60,6 +61,7 @@ class FileWriteTool( } override val tier: Tier = Tier.T2 override val requiredCapabilities: Set = setOf(ToolCapability.FILE_WRITE) + override val paramRoles: Map = mapOf("path" to ParamRole.PATH) override fun affectedPaths(request: ToolRequest): Set { val pathString = request.parameters["path"] as? String ?: return emptySet() diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt index 045ff06b..f2906e37 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt @@ -2,6 +2,7 @@ package com.correx.infrastructure.tools.shell import com.correx.core.approvals.Tier import com.correx.core.events.events.ToolRequest +import com.correx.core.tools.contract.ParamRole import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolExecutor @@ -42,6 +43,7 @@ class ShellTool( override val tier: Tier = Tier.T2 override val requiredCapabilities: Set = setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN) + override val paramRoles: Map = mapOf("argv" to ParamRole.EXEC_COMMAND) override fun validateRequest(request: ToolRequest): ValidationResult { val argv = when (val raw = request.parameters["argv"]) {