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:
2026-06-13 16:05:39 +04:00
parent 894969d62b
commit 4e5e4e56ea
11 changed files with 233 additions and 16 deletions
@@ -57,6 +57,7 @@ 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.ManifestContainmentRule
import com.correx.core.toolintent.rules.NetworkHostRule
import com.correx.core.toolintent.rules.PathContainmentRule
import com.correx.apps.server.freestyle.FreestyleDriver
@@ -216,6 +217,7 @@ fun main() {
val toolCallAssessor = ToolCallAssessor(
rules = listOf(
PathContainmentRule(),
ManifestContainmentRule(),
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
NetworkHostRule(
allowedHosts = toolsConfig.networkAllowedHosts.toSet(),
@@ -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(
@@ -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"
}
}
@@ -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(".."))
@@ -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"
}
@@ -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(),
)
+4
View File
@@ -53,6 +53,10 @@ max_retries = 2
# 4. Implement the plan. Writes files (jailed to the workspace). The loop target —
# max_retries here caps how many review→implement refinement rounds are allowed.
# Optional: a `writes` manifest (workspace-relative globs) hard-bounds where this
# stage may write — a FILE_WRITE outside it is blocked as scope creep. Left open
# here because the targets are task-specific; a task-scoped workflow would set e.g.
# writes = ["core/sessions/**", "testing/sessions/**"]
[[stages]]
id = "implementer"
prompt = "prompts/implementer.md"
@@ -41,6 +41,7 @@ private data class StageSection(
val produces: List<ProducesEntry> = emptyList(),
val needs: List<String> = emptyList(),
@param:JsonProperty("allowed_tools") val allowedTools: List<String> = emptyList(),
val writes: List<String> = emptyList(),
@param:JsonProperty("token_budget") val tokenBudget: Int = 4096,
@param:JsonProperty("max_retries") val maxRetries: Int = 3,
@param:JsonProperty("requires_approval") val requiresApproval: Boolean = false,
@@ -102,6 +103,7 @@ class TomlWorkflowLoader(
},
needs = s.needs.map { ArtifactId(it) }.toSet(),
allowedTools = s.allowedTools.toSet(),
writeManifest = s.writes,
tokenBudget = s.tokenBudget,
// Propagate the declared token budget to the inference completion cap.
// Without this the StageConfig default (maxTokens=2048) is used, truncating
@@ -135,4 +135,24 @@ class TomlWorkflowLoaderTest {
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(validToml) }
assertEquals("", loader.loadMeta(path).description)
}
@Test
fun `writes maps to the stage write manifest`() {
val toml = validToml.replace(
"allowed_tools = [\"ShellTool\"]",
"allowed_tools = [\"ShellTool\"]\nwrites = [\"core/**\", \"docs/*.md\"]",
)
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(toml) }
val graph = loader.load(path)
val collectStage = graph.stages[graph.start]!!
assertEquals(listOf("core/**", "docs/*.md"), collectStage.writeManifest)
}
@Test
fun `writes absent means an empty manifest`() {
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(validToml) }
val graph = loader.load(path)
val collectStage = graph.stages[graph.start]!!
assertEquals(emptyList(), collectStage.writeManifest)
}
}