diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/WriteScopeRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/WriteScopeRule.kt new file mode 100644 index 00000000..85dd7438 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/WriteScopeRule.kt @@ -0,0 +1,72 @@ +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 + +/** + * Write-scope adherence. When the session has claimed a task that declared affected_paths, an + * in-workspace write outside that scope is BLOCKED — but **declarably**: the message tells the + * agent that if the change is genuinely needed (a dependency, a caller the plan didn't foresee) it + * should widen the task's affected_paths via task_update (which re-records the scope), then retry. + * So legitimate work is never trapped — only forced to be explicit, turning silent scope-creep into + * a recorded, auditable scope change. No active task or no declared scope ⇒ nothing to enforce; + * out-of-workspace targets are PathContainmentRule's concern. + */ +class WriteScopeRule : ToolCallRule { + + override fun appliesTo(capabilities: Set): Boolean = + ToolCapability.FILE_WRITE in capabilities + + override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { + val active = input.session.activeTask + if (active == null || active.scope.isEmpty()) return ToolCallAssessment() + + val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot) + val matchers = active.scope.map { FileSystems.getDefault().getPathMatcher("glob:${it.removePrefix("./")}") } + + 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 resolvedReal = input.probe.resolveReal( + if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate), + ) + if (!resolvedReal.startsWith(workspaceReal)) continue // out of workspace: not this gate + val rel = workspaceReal.relativize(resolvedReal) + val inScope = matchers.any { it.matches(rel) } + + observations += ToolCallObservation( + ruleCode = RULE_CODE, + facts = mapOf("path" to raw, "inScope" to inScope.toString(), "task" to active.taskId), + ) + + if (!inScope) { + issues += ValidationIssue( + code = RULE_CODE, + message = "Tool '${input.request.toolName}' writes '$raw', outside claimed task " + + "${active.taskId}'s affected_paths (${active.scope}). If this change is needed, " + + "add its path via task_update affected_paths (note why), then retry.", + severity = ValidationSeverity.ERROR, + ) + disposition = maxAction(disposition, RiskAction.BLOCK) + } + } + + return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition) + } + + private companion object { + const val RULE_CODE = "WRITE_SCOPE" + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt new file mode 100644 index 00000000..63389229 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt @@ -0,0 +1,55 @@ +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.WriteScopeRule +import com.correx.core.tools.contract.ToolCapability +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WriteScopeRuleTest { + + private val workspace = Path.of("/work/project") + private val rule = WriteScopeRule() + + private class FakeProbe : WorldProbe { + override fun exists(path: Path) = true + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + } + + private fun input(pathArg: String, active: ActiveTask?) = ToolCallAssessmentInput( + request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write", mapOf("path" to pathArg)), + capabilities = setOf(ToolCapability.FILE_WRITE), + workspace = WorkspacePolicy(workspace, emptyList()), + probe = FakeProbe(), + session = SessionContext(activeTask = active), + ) + + private val scoped = ActiveTask("auth-2", listOf("core/auth/**")) + + @Test + fun `no active task means nothing to enforce`() { + assertEquals(RiskAction.PROCEED, rule.assess(input("/work/project/core/billing/X.kt", active = null)).disposition) + } + + @Test + fun `a write inside the claimed task scope proceeds`() { + val r = rule.assess(input("/work/project/core/auth/Login.kt", scoped)) + assertEquals(RiskAction.PROCEED, r.disposition) + assertEquals("true", r.observations.single().facts["inScope"]) + } + + @Test + fun `a write outside the claimed task scope is blocked and names the task`() { + val r = rule.assess(input("/work/project/core/billing/Charge.kt", scoped)) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("WRITE_SCOPE", r.issues.single().code) + assertTrue(r.issues.single().message.contains("auth-2")) + assertTrue(r.issues.single().message.contains("task_update affected_paths")) + } +}