fix(toolintent): manifest gate defers to task affected_paths; scaffold-glob guidance
Two failed web-ui freestyle runs dead-ended on the write manifest. The scaffold_frontend stage declared writes=[frontend/package.json, frontend/vite.config.ts], so every other file the scaffold produced (tsconfig, src/main.tsx, index.html, App.tsx) was BLOCKED as PATH_OUTSIDE_MANIFEST with no agent-facing escape hatch — unlike WRITE_SCOPE, which advertises task_update. Retry exhausted -> WorkflowFailed. - ManifestContainmentRule: a write already inside the active task's affected_paths is allowed even when the stage manifest is narrower. The task scope is the agent-widenable, recorded authority (see WriteScopeRule); the stage manifest is a planner hint that defers to it. Block message now names the remedy (widen affected_paths via task_update). - architect_freestyle prompt: a scaffold/generator stage must declare its `writes` as a covering directory glob (frontend/**), not enumerate files, and use ** not * — frontend/* does not cover frontend/src/main.tsx. core:toolintent green (78 tests), detekt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+25
-5
@@ -19,8 +19,11 @@ import java.nio.file.Path
|
|||||||
* workspace but outside the declared set is scope creep — the dominant local-implementer
|
* 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.
|
* failure that tests do not catch — and is raised as PATH_OUTSIDE_MANIFEST → BLOCK.
|
||||||
*
|
*
|
||||||
* Empty manifest = unrestricted (workspace containment still applies via
|
* A write already inside the active task's affected_paths is allowed even if the manifest is
|
||||||
* [PathContainmentRule]). Out-of-workspace targets are that rule's concern, not this one.
|
* narrower: those paths are the agent-widenable, recorded authority (see [WriteScopeRule]), and
|
||||||
|
* the stage manifest is a planner hint that defers to them. 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
|
* 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).
|
* observations, so replay reads them back rather than re-globbing (invariant #9).
|
||||||
*/
|
*/
|
||||||
@@ -36,6 +39,14 @@ class ManifestContainmentRule : ToolCallRule {
|
|||||||
val matchers = input.writeManifest.map {
|
val matchers = input.writeManifest.map {
|
||||||
FileSystems.getDefault().getPathMatcher("glob:$it")
|
FileSystems.getDefault().getPathMatcher("glob:$it")
|
||||||
}
|
}
|
||||||
|
// The active task's affected_paths are the agent-widenable, recorded authority (see
|
||||||
|
// WriteScopeRule). A write already inside that scope is not manifest scope-creep — the
|
||||||
|
// stage manifest is a narrower planner hint that must defer to it, otherwise a too-tight
|
||||||
|
// manifest becomes an unrecoverable dead-end (the escape hatch is task_update affected_paths).
|
||||||
|
val taskScope = input.session.activeTask?.scope.orEmpty()
|
||||||
|
val taskMatchers = taskScope.map {
|
||||||
|
FileSystems.getDefault().getPathMatcher("glob:${it.removePrefix("./")}")
|
||||||
|
}
|
||||||
|
|
||||||
val issues = mutableListOf<ValidationIssue>()
|
val issues = mutableListOf<ValidationIssue>()
|
||||||
val observations = mutableListOf<ToolCallObservation>()
|
val observations = mutableListOf<ToolCallObservation>()
|
||||||
@@ -48,7 +59,9 @@ class ManifestContainmentRule : ToolCallRule {
|
|||||||
val resolvedReal = input.probe.resolveReal(resolvedInput)
|
val resolvedReal = input.probe.resolveReal(resolvedInput)
|
||||||
val inWorkspace = resolvedReal.startsWith(workspaceReal)
|
val inWorkspace = resolvedReal.startsWith(workspaceReal)
|
||||||
val relative = if (inWorkspace) workspaceReal.relativize(resolvedReal).toString() else raw
|
val relative = if (inWorkspace) workspaceReal.relativize(resolvedReal).toString() else raw
|
||||||
val inManifest = inWorkspace && matchers.any { it.matches(Path.of(relative)) }
|
val relPath = Path.of(relative)
|
||||||
|
val inManifest = inWorkspace && matchers.any { it.matches(relPath) }
|
||||||
|
val inTaskScope = inWorkspace && taskMatchers.any { it.matches(relPath) }
|
||||||
|
|
||||||
observations += ToolCallObservation(
|
observations += ToolCallObservation(
|
||||||
ruleCode = RULE_CODE,
|
ruleCode = RULE_CODE,
|
||||||
@@ -57,14 +70,21 @@ class ManifestContainmentRule : ToolCallRule {
|
|||||||
"relative" to relative,
|
"relative" to relative,
|
||||||
"inWorkspace" to inWorkspace.toString(),
|
"inWorkspace" to inWorkspace.toString(),
|
||||||
"inManifest" to inManifest.toString(),
|
"inManifest" to inManifest.toString(),
|
||||||
|
"inTaskScope" to inTaskScope.toString(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (inWorkspace && !inManifest) {
|
if (inWorkspace && !inManifest && !inTaskScope) {
|
||||||
|
val remedy = if (taskScope.isEmpty()) {
|
||||||
|
" Claim a task whose affected_paths cover this path (task_update), then retry."
|
||||||
|
} else {
|
||||||
|
" If this path is genuinely part of the task, widen its affected_paths via " +
|
||||||
|
"task_update (note why), then retry."
|
||||||
|
}
|
||||||
issues += ValidationIssue(
|
issues += ValidationIssue(
|
||||||
code = "PATH_OUTSIDE_MANIFEST",
|
code = "PATH_OUTSIDE_MANIFEST",
|
||||||
message = "Tool '${input.request.toolName}' writes '$raw' outside the stage's " +
|
message = "Tool '${input.request.toolName}' writes '$raw' outside the stage's " +
|
||||||
"declared manifest ${input.writeManifest}",
|
"declared manifest ${input.writeManifest}.$remedy",
|
||||||
severity = ValidationSeverity.ERROR,
|
severity = ValidationSeverity.ERROR,
|
||||||
)
|
)
|
||||||
disposition = maxAction(disposition, RiskAction.BLOCK)
|
disposition = maxAction(disposition, RiskAction.BLOCK)
|
||||||
|
|||||||
+42
-11
@@ -24,17 +24,20 @@ class ManifestContainmentRuleTest {
|
|||||||
override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize()
|
override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun input(pathArg: String, manifest: List<String>) = ToolCallAssessmentInput(
|
private fun input(pathArg: String, manifest: List<String>, taskScope: List<String> = emptyList()) =
|
||||||
request = ToolRequest(
|
ToolCallAssessmentInput(
|
||||||
ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write",
|
request = ToolRequest(
|
||||||
mapOf("path" to pathArg, "mode" to "0644"),
|
ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write",
|
||||||
),
|
mapOf("path" to pathArg, "mode" to "0644"),
|
||||||
capabilities = setOf(ToolCapability.FILE_WRITE),
|
),
|
||||||
workspace = WorkspacePolicy(workspace),
|
capabilities = setOf(ToolCapability.FILE_WRITE),
|
||||||
probe = FakeProbe(),
|
workspace = WorkspacePolicy(workspace),
|
||||||
paramRoles = mapOf("path" to ParamRole.PATH),
|
probe = FakeProbe(),
|
||||||
writeManifest = manifest,
|
paramRoles = mapOf("path" to ParamRole.PATH),
|
||||||
)
|
writeManifest = manifest,
|
||||||
|
session = if (taskScope.isEmpty()) SessionContext()
|
||||||
|
else SessionContext(activeTask = ActiveTask("t1", taskScope)),
|
||||||
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `applies only to write capability`() {
|
fun `applies only to write capability`() {
|
||||||
@@ -67,6 +70,34 @@ class ManifestContainmentRuleTest {
|
|||||||
assertEquals("false", r.observations.single().facts["inManifest"])
|
assertEquals("false", r.observations.single().facts["inManifest"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `write outside manifest but inside task scope is allowed`() {
|
||||||
|
// The scaffold dead-end: manifest lists only two files, but the claimed task's
|
||||||
|
// affected_paths cover the whole subtree. The recorded task scope wins.
|
||||||
|
val r = rule.assess(
|
||||||
|
input(
|
||||||
|
"/work/project/frontend/src/main.tsx",
|
||||||
|
manifest = listOf("frontend/package.json", "frontend/vite.config.ts"),
|
||||||
|
taskScope = listOf("frontend/**"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||||
|
assertTrue(r.issues.isEmpty())
|
||||||
|
val facts = r.observations.single().facts
|
||||||
|
assertEquals("false", facts["inManifest"])
|
||||||
|
assertEquals("true", facts["inTaskScope"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `write outside both manifest and task scope is blocked with widen remedy`() {
|
||||||
|
val r = rule.assess(
|
||||||
|
input("/work/project/apps/x.kt", manifest = listOf("core/**"), taskScope = listOf("frontend/**")),
|
||||||
|
)
|
||||||
|
assertEquals(RiskAction.BLOCK, r.disposition)
|
||||||
|
assertEquals("PATH_OUTSIDE_MANIFEST", r.issues.single().code)
|
||||||
|
assertTrue(r.issues.single().message.contains("task_update"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `relative path arg is resolved against the workspace before matching`() {
|
fun `relative path arg is resolved against the workspace before matching`() {
|
||||||
val r = rule.assess(input("core/a/B.kt", listOf("core/**")))
|
val r = rule.assess(input("core/a/B.kt", listOf("core/**")))
|
||||||
|
|||||||
@@ -103,7 +103,12 @@ Emit a JSON object that validates against the `execution_plan` schema:
|
|||||||
always pass the template/preset arg the generator requires (`-- --template react-ts`) since
|
always pass the template/preset arg the generator requires (`-- --template react-ts`) since
|
||||||
there's no prompt to fall back on. `file_write` is the fallback when no generator fits the
|
there's no prompt to fall back on. `file_write` is the fallback when no generator fits the
|
||||||
stack. Only bare remote runners (`npx`, `bunx`, `pnpx`) stay blocked — they execute arbitrary
|
stack. Only bare remote runners (`npx`, `bunx`, `pnpx`) stay blocked — they execute arbitrary
|
||||||
remote code; reach them via `npm create` instead.
|
remote code; reach them via `npm create` instead. A scaffold/generator stage emits an
|
||||||
|
open-ended file set (a whole `frontend/src/**` tree, not two named files), so declare its
|
||||||
|
`writes` as the covering directory glob — `["frontend/**"]`, **not** an enumerated
|
||||||
|
`["frontend/package.json", "frontend/vite.config.ts"]`. A too-narrow `writes` becomes the
|
||||||
|
stage's write manifest and blocks every file the generator legitimately produces. Use `**`
|
||||||
|
(recursive), not `*` (one level) — `frontend/*` does not cover `frontend/src/main.tsx`.
|
||||||
- **Every stage prompt must describe its exact JSON output structure when using a
|
- **Every stage prompt must describe its exact JSON output structure when using a
|
||||||
structured kind.** The model that runs the stage never sees the raw JSON schema — it only sees
|
structured kind.** The model that runs the stage never sees the raw JSON schema — it only sees
|
||||||
the kind's name (e.g. `analysis`, `research_report`, `review_report`) and the prompt you write
|
the kind's name (e.g. `analysis`, `research_report`, `review_report`) and the prompt you write
|
||||||
|
|||||||
Reference in New Issue
Block a user