feat(toolintent): stage write-manifest enforcement (role-reliability §2)
Scope creep — an implementer writing files it never declared — is the dominant local-model failure that compiler/tests don't catch. A stage can now declare a `writes` manifest (workspace-relative globs); a FILE_WRITE that resolves inside the workspace but outside the declared set is raised as PATH_OUTSIDE_MANIFEST → BLOCK, so the model gets the violation as tool feedback and retries within scope (the §2 bounded-retry signal). Implemented as a plane-2 rule beside PathContainmentRule (same effect-based dispatch on FILE_WRITE, same symlink-safe WorldProbe resolution, same recorded observations so replay reads them back — invariant #9). An empty manifest is unrestricted; out-of-workspace targets stay PathContainmentRule's concern, so the two rules don't double-flag. - StageConfig.writeManifest + TomlWorkflowLoader `writes` parsing - ToolCallAssessmentInput.writeManifest, threaded from the active stage via SessionOrchestrator.runPlane2Assessment - new ManifestContainmentRule, registered in the server assessor - shared candidatePathStrings extracted so both path rules judge the same target set - role_pipeline.toml documents the option on the implementer stage The dedicated ManifestViolationEvent the spec names is not added: the violation is already recorded replayably in ToolCallAssessedEvent (issue + observations + BLOCK) and surfaced in the TUI rationale band. A first-class event is worth adding only once reconciliation consumes it.
This commit is contained in:
+3
@@ -638,6 +638,7 @@ abstract class SessionOrchestrator(
|
||||
}
|
||||
val plane2Risk: RiskSummary? = runPlane2Assessment(
|
||||
sessionId, stageId, invocationId, toolCall.function.name, request, tool, effectives,
|
||||
stageConfig.writeManifest,
|
||||
)?.let { assessment ->
|
||||
if (assessment.recommendedAction == RiskAction.BLOCK) {
|
||||
emit(
|
||||
@@ -893,6 +894,7 @@ abstract class SessionOrchestrator(
|
||||
request: ToolRequest,
|
||||
tool: Tool?,
|
||||
effectives: RunEffectives,
|
||||
writeManifest: List<String>,
|
||||
): RiskSummary? {
|
||||
val assessor = toolCallAssessor ?: return null
|
||||
val policy = effectives.policy ?: return null
|
||||
@@ -903,6 +905,7 @@ abstract class SessionOrchestrator(
|
||||
workspace = policy,
|
||||
probe = worldProbe,
|
||||
paramRoles = tool?.paramRoles ?: emptyMap(),
|
||||
writeManifest = writeManifest,
|
||||
),
|
||||
)
|
||||
emit(
|
||||
|
||||
@@ -23,6 +23,8 @@ data class ToolCallAssessmentInput(
|
||||
val workspace: WorkspacePolicy,
|
||||
val probe: WorldProbe,
|
||||
val paramRoles: Map<String, ParamRole> = emptyMap(),
|
||||
// Workspace-relative globs the active stage may write. Empty = unrestricted.
|
||||
val writeManifest: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class ToolCallAssessment(
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
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.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Stage-scoped write manifest (role-reliability §2). When the active stage declares a
|
||||
* non-empty [ToolCallAssessmentInput.writeManifest] (workspace-relative globs), every
|
||||
* FILE_WRITE target is matched against it. A write to a path that resolves inside the
|
||||
* workspace but outside the declared set is scope creep — the dominant local-implementer
|
||||
* failure that tests do not catch — and is raised as PATH_OUTSIDE_MANIFEST → BLOCK.
|
||||
*
|
||||
* Empty manifest = unrestricted (workspace containment still applies via
|
||||
* [PathContainmentRule]). Out-of-workspace targets are that rule's concern, not this one.
|
||||
* Path resolution goes through WorldProbe (symlink-safe) and the facts are recorded as
|
||||
* observations, so replay reads them back rather than re-globbing (invariant #9).
|
||||
*/
|
||||
class ManifestContainmentRule : ToolCallRule {
|
||||
|
||||
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
|
||||
ToolCapability.FILE_WRITE in capabilities
|
||||
|
||||
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
|
||||
if (input.writeManifest.isEmpty()) return ToolCallAssessment()
|
||||
|
||||
val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot)
|
||||
val matchers = input.writeManifest.map {
|
||||
FileSystems.getDefault().getPathMatcher("glob:$it")
|
||||
}
|
||||
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
val observations = mutableListOf<ToolCallObservation>()
|
||||
var disposition = RiskAction.PROCEED
|
||||
|
||||
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
|
||||
val candidate = Path.of(raw)
|
||||
val resolvedInput = if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
|
||||
val resolvedReal = input.probe.resolveReal(resolvedInput)
|
||||
val inWorkspace = resolvedReal.startsWith(workspaceReal)
|
||||
val relative = if (inWorkspace) workspaceReal.relativize(resolvedReal).toString() else raw
|
||||
val inManifest = inWorkspace && matchers.any { it.matches(Path.of(relative)) }
|
||||
|
||||
observations += ToolCallObservation(
|
||||
ruleCode = RULE_CODE,
|
||||
facts = mapOf(
|
||||
"path" to raw,
|
||||
"relative" to relative,
|
||||
"inWorkspace" to inWorkspace.toString(),
|
||||
"inManifest" to inManifest.toString(),
|
||||
),
|
||||
)
|
||||
|
||||
if (inWorkspace && !inManifest) {
|
||||
issues += ValidationIssue(
|
||||
code = "PATH_OUTSIDE_MANIFEST",
|
||||
message = "Tool '${input.request.toolName}' writes '$raw' outside the stage's " +
|
||||
"declared manifest ${input.writeManifest}",
|
||||
severity = ValidationSeverity.ERROR,
|
||||
)
|
||||
disposition = maxAction(disposition, RiskAction.BLOCK)
|
||||
}
|
||||
}
|
||||
|
||||
return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val RULE_CODE = "WRITE_MANIFEST"
|
||||
}
|
||||
}
|
||||
+20
@@ -1,5 +1,7 @@
|
||||
package com.correx.core.toolintent.rules
|
||||
|
||||
import com.correx.core.tools.contract.ParamRole
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -10,3 +12,21 @@ internal fun extractParamStrings(value: Any?): List<String> = when (value) {
|
||||
is List<*> -> value.mapNotNull { it?.toString() }
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* The path-like argument strings of a tool call: the values of params declared
|
||||
* [ParamRole.PATH], or — when none are declared — any string value that looks like a
|
||||
* path. Shared by the path-containment and write-manifest rules so both judge exactly
|
||||
* the same set of targets.
|
||||
*/
|
||||
internal fun candidatePathStrings(paramRoles: Map<String, ParamRole>, parameters: Map<String, Any>): List<String> {
|
||||
val declared = paramRoles.filterValues { it == ParamRole.PATH }.keys
|
||||
return if (declared.isNotEmpty()) {
|
||||
declared.flatMap { extractParamStrings(parameters[it]) }
|
||||
} else {
|
||||
parameters.values.filterIsInstance<String>().filter { it.looksLikePath() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.looksLikePath(): Boolean =
|
||||
isNotBlank() && (startsWith("/") || startsWith("~") || contains("/") || contains(".."))
|
||||
|
||||
+1
-16
@@ -6,7 +6,6 @@ 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
|
||||
@@ -34,7 +33,7 @@ class PathContainmentRule : ToolCallRule {
|
||||
val observations = mutableListOf<ToolCallObservation>()
|
||||
var disposition = RiskAction.PROCEED
|
||||
|
||||
for (raw in candidatePaths(input)) {
|
||||
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
|
||||
val candidate = Path.of(raw)
|
||||
val resolvedInput = if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
|
||||
val resolvedReal = input.probe.resolveReal(resolvedInput)
|
||||
@@ -76,20 +75,6 @@ class PathContainmentRule : ToolCallRule {
|
||||
return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
|
||||
}
|
||||
|
||||
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(".."))
|
||||
|
||||
private companion object {
|
||||
const val RULE_CODE = "PATH_CONTAINMENT"
|
||||
}
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
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.ManifestContainmentRule
|
||||
import com.correx.core.tools.contract.ParamRole
|
||||
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 ManifestContainmentRuleTest {
|
||||
|
||||
private val workspace = Path.of("/work/project")
|
||||
private val rule = ManifestContainmentRule()
|
||||
|
||||
private class FakeProbe : WorldProbe {
|
||||
override fun exists(path: Path) = true
|
||||
override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize()
|
||||
}
|
||||
|
||||
private fun input(pathArg: String, manifest: List<String>) = ToolCallAssessmentInput(
|
||||
request = ToolRequest(
|
||||
ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write",
|
||||
mapOf("path" to pathArg, "mode" to "0644"),
|
||||
),
|
||||
capabilities = setOf(ToolCapability.FILE_WRITE),
|
||||
workspace = WorkspacePolicy(workspace),
|
||||
probe = FakeProbe(),
|
||||
paramRoles = mapOf("path" to ParamRole.PATH),
|
||||
writeManifest = manifest,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `applies only to write capability`() {
|
||||
assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_WRITE)))
|
||||
assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_READ)))
|
||||
assertFalse(rule.appliesTo(emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty manifest is unrestricted`() {
|
||||
val r = rule.assess(input("/work/project/anywhere/x.kt", emptyList()))
|
||||
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||
assertTrue(r.issues.isEmpty())
|
||||
assertTrue(r.observations.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write inside the manifest proceeds`() {
|
||||
val r = rule.assess(input("/work/project/core/a/B.kt", listOf("core/**", "docs/*.md")))
|
||||
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||
assertTrue(r.issues.isEmpty())
|
||||
assertEquals("true", r.observations.single().facts["inManifest"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write outside the manifest is blocked`() {
|
||||
val r = rule.assess(input("/work/project/apps/server/Main.kt", listOf("core/**")))
|
||||
assertEquals(RiskAction.BLOCK, r.disposition)
|
||||
assertEquals("PATH_OUTSIDE_MANIFEST", r.issues.single().code)
|
||||
assertEquals("false", r.observations.single().facts["inManifest"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relative path arg is resolved against the workspace before matching`() {
|
||||
val r = rule.assess(input("core/a/B.kt", listOf("core/**")))
|
||||
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||
assertTrue(r.issues.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single-star glob does not cross directories`() {
|
||||
// "docs/*.md" matches docs/x.md but not docs/sub/y.md
|
||||
val ok = rule.assess(input("/work/project/docs/x.md", listOf("docs/*.md")))
|
||||
assertEquals(RiskAction.PROCEED, ok.disposition)
|
||||
val nested = rule.assess(input("/work/project/docs/sub/y.md", listOf("docs/*.md")))
|
||||
assertEquals(RiskAction.BLOCK, nested.disposition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out-of-workspace target is left to the containment rule`() {
|
||||
// ManifestContainmentRule only judges in-workspace writes; an outside path is
|
||||
// PathContainmentRule's job, so this rule must not also block it.
|
||||
val r = rule.assess(input("/tmp/scratch.txt", listOf("core/**")))
|
||||
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||
assertTrue(r.issues.isEmpty())
|
||||
assertEquals("false", r.observations.single().facts["inWorkspace"])
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,10 @@ data class StageConfig(
|
||||
val maxRetries: Int = 3,
|
||||
val produces: List<TypedArtifactSlot> = emptyList(),
|
||||
val needs: Set<ArtifactId> = emptySet(),
|
||||
// Workspace-relative globs the stage is permitted to write (role-reliability §2
|
||||
// diff manifest). Empty = unrestricted within the workspace. A FILE_WRITE outside
|
||||
// the declared set is scope creep — the dominant local-implementer failure — and is
|
||||
// blocked by the plane-2 ManifestContainmentRule.
|
||||
val writeManifest: List<String> = emptyList(),
|
||||
val metadata: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user