feat: plane-2 rules read declared param roles instead of guessing args

Add ParamRole and a defaulted Tool.paramRoles to the tool contract; built-in
tools declare which params carry effects: file_read/write/edit -> path=PATH,
shell -> argv=EXEC_COMMAND. PathContainmentRule / ExecInterpreterRule /
NetworkHostRule now inspect only the declared params when roles are present, and
fall back to the looksLikePath / leading-token / extractHost heuristic only for
un-enriched (e.g. custom) tools - so a custom FILE_WRITE tool is still
containment-checked. Shared extractParamStrings flattens String and List<*>
(argv) values. SessionOrchestrator.runPlane2Assessment resolves the tool once
and passes tool.paramRoles into the assessment input.

This retires the arg-guessing from the first-party path: a slashy edit 'content'
arg is no longer mis-flagged as a path.
This commit is contained in:
2026-05-31 16:15:22 +04:00
parent 97d03eb7bd
commit ff166ed311
14 changed files with 125 additions and 14 deletions
@@ -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(
@@ -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<ToolCapability>,
val workspace: WorkspacePolicy,
val probe: WorldProbe,
val paramRoles: Map<String, ParamRole> = emptyMap(),
)
data class ToolCallAssessment(
@@ -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<String>) : ToolCallRule {
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 executables(input: ToolCallAssessmentInput): List<String> {
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<String>()
}
return raws.mapNotNull { leadingToken(it) }
}
private fun leadingToken(raw: String): String? =
raw.split(TOKEN_DELIMITERS).firstOrNull { it.isNotBlank() }
@@ -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<String> =
input.request.parameters.values
.filterIsInstance<String>()
.mapNotNull { extractHost(it) }
private fun hosts(input: ToolCallAssessmentInput): List<String> {
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<String>()
}
return raws.mapNotNull { extractHost(it) }
}
private fun extractHost(raw: String): String? {
if (!raw.contains("://")) return null
@@ -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<String> = when (value) {
is String -> listOf(value)
is List<*> -> value.mapNotNull { it?.toString() }
else -> emptyList()
}
@@ -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<String> =
private fun candidatePaths(input: ToolCallAssessmentInput): List<String> {
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<String>()
.filter { it.looksLikePath() }
}
}
private fun String.looksLikePath(): Boolean =
isNotBlank() && (startsWith("/") || startsWith("~") || contains("/") || contains(".."))
@@ -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())
}
}
@@ -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)
}
}
@@ -0,0 +1,3 @@
package com.correx.core.tools.contract
enum class ParamRole { PATH, EXEC_COMMAND, NETWORK_TARGET }
@@ -10,5 +10,6 @@ interface Tool {
val parametersSchema: JsonObject
val tier: Tier
val requiredCapabilities: Set<ToolCapability>
val paramRoles: Map<String, ParamRole> get() = emptyMap()
fun validateRequest(request: ToolRequest): ValidationResult
}
@@ -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<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
override fun affectedPaths(request: ToolRequest): Set<Path> {
val pathString = request.parameters["path"] as? String ?: return emptySet()
@@ -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<ToolCapability> = setOf(ToolCapability.FILE_READ)
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
override fun validateRequest(request: ToolRequest): ValidationResult {
val pathString = request.parameters["path"] as? String
@@ -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<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
override fun affectedPaths(request: ToolRequest): Set<Path> {
val pathString = request.parameters["path"] as? String ?: return emptySet()
@@ -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<ToolCapability> =
setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN)
override val paramRoles: Map<String, ParamRole> = mapOf("argv" to ParamRole.EXEC_COMMAND)
override fun validateRequest(request: ToolRequest): ValidationResult {
val argv = when (val raw = request.parameters["argv"]) {