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:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user