diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index d12a57be..ce5f0fb6 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -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(), diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index c8d121cf..5c01aa6c 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -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, ): 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( diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt index fa77c3a8..9a98bf29 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt @@ -23,6 +23,8 @@ data class ToolCallAssessmentInput( val workspace: WorkspacePolicy, val probe: WorldProbe, val paramRoles: Map = emptyMap(), + // Workspace-relative globs the active stage may write. Empty = unrestricted. + val writeManifest: List = emptyList(), ) data class ToolCallAssessment( diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ManifestContainmentRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ManifestContainmentRule.kt new file mode 100644 index 00000000..61923c71 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ManifestContainmentRule.kt @@ -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): 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() + val observations = mutableListOf() + 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" + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ParamValueExtractor.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ParamValueExtractor.kt index 25a76ec7..c8867618 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ParamValueExtractor.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ParamValueExtractor.kt @@ -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 = 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, parameters: Map): List { + val declared = paramRoles.filterValues { it == ParamRole.PATH }.keys + return if (declared.isNotEmpty()) { + declared.flatMap { extractParamStrings(parameters[it]) } + } else { + parameters.values.filterIsInstance().filter { it.looksLikePath() } + } +} + +private fun String.looksLikePath(): Boolean = + isNotBlank() && (startsWith("/") || startsWith("~") || contains("/") || contains("..")) diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt index 7f843480..ffebfac7 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt @@ -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() 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 { - 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() - .filter { it.looksLikePath() } - } - } - - private fun String.looksLikePath(): Boolean = - isNotBlank() && (startsWith("/") || startsWith("~") || contains("/") || contains("..")) - private companion object { const val RULE_CODE = "PATH_CONTAINMENT" } diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ManifestContainmentRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ManifestContainmentRuleTest.kt new file mode 100644 index 00000000..1c3fbe8a --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ManifestContainmentRuleTest.kt @@ -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) = 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"]) + } +} diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt index 87f9b84d..a63872bc 100644 --- a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt @@ -18,5 +18,10 @@ data class StageConfig( val maxRetries: Int = 3, val produces: List = emptyList(), val needs: Set = 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 = emptyList(), val metadata: Map = emptyMap(), ) diff --git a/examples/workflows/role_pipeline.toml b/examples/workflows/role_pipeline.toml index 27fa607e..bfdb82fc 100644 --- a/examples/workflows/role_pipeline.toml +++ b/examples/workflows/role_pipeline.toml @@ -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" diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt index 6800f5c0..b11901ac 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt @@ -41,6 +41,7 @@ private data class StageSection( val produces: List = emptyList(), val needs: List = emptyList(), @param:JsonProperty("allowed_tools") val allowedTools: List = emptyList(), + val writes: List = 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 diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt index 0bd3a9f1..c3e6736e 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt @@ -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) + } }