feat(toolintent): write-scope adherence gate (declarable)

When a session has claimed a task with declared affected_paths, an in-workspace
write outside that scope is BLOCKED — but declarably: the message tells the agent
to widen the task's affected_paths via task_update (which re-records the scope)
and retry. Legitimate, unforeseen changes (a dependency, a caller) are never
trapped, only forced to become a recorded, auditable scope change. Reads the
session's activeTask scope (recorded at claim) — no cross-stream lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 21:31:08 +00:00
parent 6b5a758081
commit 4159361690
2 changed files with 127 additions and 0 deletions
@@ -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<ToolCapability>): 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<ValidationIssue>()
val observations = mutableListOf<ToolCallObservation>()
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"
}
}
@@ -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"))
}
}