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:
2026-06-24 18:27:08 +00:00
parent 693e38ffac
commit 146f96a6fd
3 changed files with 126 additions and 0 deletions
@@ -62,6 +62,7 @@ import com.correx.core.toolintent.rules.ManifestContainmentRule
import com.correx.core.toolintent.rules.NetworkHostRule import com.correx.core.toolintent.rules.NetworkHostRule
import com.correx.core.toolintent.rules.PathContainmentRule import com.correx.core.toolintent.rules.PathContainmentRule
import com.correx.core.toolintent.rules.ReadBeforeWriteRule 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.freestyle.FreestyleDriver
import com.correx.apps.server.inference.summarizeWithInference import com.correx.apps.server.inference.summarizeWithInference
import com.correx.core.kernel.orchestration.JournalCompactionService import com.correx.core.kernel.orchestration.JournalCompactionService
@@ -263,6 +264,7 @@ fun main() {
rules = listOf( rules = listOf(
PathContainmentRule(), PathContainmentRule(),
ReadBeforeWriteRule(), ReadBeforeWriteRule(),
ReferenceExistsRule(),
ManifestContainmentRule(), ManifestContainmentRule(),
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()), ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
NetworkHostRule( NetworkHostRule(
@@ -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"])
}
}