diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 36683aa2..93621ec2 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -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( diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt new file mode 100644 index 00000000..56d9133f --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt @@ -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): 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() + val observations = mutableListOf() + 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" + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt new file mode 100644 index 00000000..13ff5d96 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt @@ -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 = 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"]) + } +}