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:
+3
-2
@@ -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(
|
||||
|
||||
+10
-4
@@ -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() }
|
||||
|
||||
+10
-4
@@ -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
|
||||
|
||||
+12
@@ -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()
|
||||
}
|
||||
+11
-4
@@ -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> =
|
||||
input.request.parameters.values
|
||||
.filterIsInstance<String>()
|
||||
.filter { it.looksLikePath() }
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user