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
@@ -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(