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:
+2
@@ -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
|
||||
|
||||
+20
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user