feat(toolintent): reference-must-exist gate (anti-hallucination)
A file_read of a path that is inside the workspace but does not exist is now BLOCKED with "no such path" instead of silently returning empty — so an agent that hallucinated a filename fails loud and self-corrects (list the directory, fix the name) rather than proceeding on an absent reference. Out-of-workspace targets stay PathContainmentRule's concern, so the gates don't double-handle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,7 @@ import com.correx.core.toolintent.rules.ManifestContainmentRule
|
||||
import com.correx.core.toolintent.rules.NetworkHostRule
|
||||
import com.correx.core.toolintent.rules.PathContainmentRule
|
||||
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
|
||||
import com.correx.core.toolintent.rules.ReferenceExistsRule
|
||||
import com.correx.apps.server.freestyle.FreestyleDriver
|
||||
import com.correx.apps.server.inference.summarizeWithInference
|
||||
import com.correx.core.kernel.orchestration.JournalCompactionService
|
||||
@@ -263,6 +264,7 @@ fun main() {
|
||||
rules = listOf(
|
||||
PathContainmentRule(),
|
||||
ReadBeforeWriteRule(),
|
||||
ReferenceExistsRule(),
|
||||
ManifestContainmentRule(),
|
||||
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
|
||||
NetworkHostRule(
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
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.Path
|
||||
|
||||
/**
|
||||
* Reference-must-exist gate (anti-hallucination). Dispatches on FILE_READ: a read of a path that is
|
||||
* inside the workspace but does not exist is BLOCKED with "no such path", so an agent that
|
||||
* hallucinated a filename fails loud and self-corrects (list the directory, fix the name) instead of
|
||||
* proceeding on an empty/absent read. Out-of-workspace targets are [PathContainmentRule]'s concern
|
||||
* (it prompts), so this gate stays silent on them to avoid double-handling.
|
||||
*/
|
||||
class ReferenceExistsRule : ToolCallRule {
|
||||
|
||||
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
|
||||
ToolCapability.FILE_READ in capabilities
|
||||
|
||||
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
|
||||
val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot)
|
||||
val root = input.workspace.workspaceRoot
|
||||
|
||||
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 resolvedInput = if (candidate.isAbsolute) candidate else root.resolve(candidate)
|
||||
val exists = input.probe.exists(resolvedInput)
|
||||
val inWorkspace = input.probe.resolveReal(resolvedInput).startsWith(workspaceReal)
|
||||
|
||||
observations += ToolCallObservation(
|
||||
ruleCode = RULE_CODE,
|
||||
facts = mapOf("path" to raw, "exists" to exists.toString(), "inWorkspace" to inWorkspace.toString()),
|
||||
)
|
||||
|
||||
if (inWorkspace && !exists) {
|
||||
issues += ValidationIssue(
|
||||
code = RULE_CODE,
|
||||
message = "Tool '${input.request.toolName}' references '$raw', which does not " +
|
||||
"exist — list the directory or fix the path; do not assume its contents.",
|
||||
severity = ValidationSeverity.ERROR,
|
||||
)
|
||||
disposition = maxAction(disposition, RiskAction.BLOCK)
|
||||
}
|
||||
}
|
||||
|
||||
return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val RULE_CODE = "REFERENCE_EXISTS"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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.ReferenceExistsRule
|
||||
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 ReferenceExistsRuleTest {
|
||||
|
||||
private val workspace = Path.of("/work/project")
|
||||
private val rule = ReferenceExistsRule()
|
||||
|
||||
private class FakeProbe(private val existing: Set<Path> = emptySet()) : WorldProbe {
|
||||
override fun exists(path: Path): Boolean = path.toAbsolutePath().normalize() in existing
|
||||
override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize()
|
||||
}
|
||||
|
||||
private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize()
|
||||
|
||||
private fun input(pathArg: String, probe: WorldProbe) = ToolCallAssessmentInput(
|
||||
request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_read", mapOf("path" to pathArg)),
|
||||
capabilities = setOf(ToolCapability.FILE_READ),
|
||||
workspace = WorkspacePolicy(workspace, emptyList()),
|
||||
probe = probe,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `applies only to FILE_READ`() {
|
||||
assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_READ)))
|
||||
assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_WRITE)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading a non-existent in-workspace path is blocked`() {
|
||||
val r = rule.assess(input("/work/project/src/Ghost.kt", FakeProbe(existing = emptySet())))
|
||||
assertEquals(RiskAction.BLOCK, r.disposition)
|
||||
assertEquals("REFERENCE_EXISTS", r.issues.single().code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading an existing path proceeds`() {
|
||||
val target = "/work/project/src/A.kt"
|
||||
val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target)))))
|
||||
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||
assertTrue(r.issues.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-existent path outside the workspace is left to other gates`() {
|
||||
val r = rule.assess(input("/tmp/elsewhere.txt", FakeProbe(existing = emptySet())))
|
||||
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||
assertEquals("false", r.observations.single().facts["inWorkspace"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user