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 849a9866..36683aa2 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 @@ -61,6 +61,7 @@ import com.correx.core.toolintent.rules.ExecInterpreterRule 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.apps.server.freestyle.FreestyleDriver import com.correx.apps.server.inference.summarizeWithInference import com.correx.core.kernel.orchestration.JournalCompactionService @@ -261,6 +262,7 @@ fun main() { val toolCallAssessor = ToolCallAssessor( rules = listOf( PathContainmentRule(), + ReadBeforeWriteRule(), ManifestContainmentRule(), ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()), NetworkHostRule( diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 34e938ed..624829e2 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -69,6 +69,7 @@ import com.correx.core.events.events.ToolReceipt import com.correx.core.events.events.ToolRequest import com.correx.core.events.risk.RiskAction import com.correx.core.events.risk.RiskSummary +import com.correx.core.toolintent.ReadFilesProjection import com.correx.core.toolintent.ToolCallAssessmentInput import com.correx.core.toolintent.ToolCallAssessor import com.correx.core.toolintent.WorkspacePolicy @@ -697,7 +698,10 @@ abstract class SessionOrchestrator( sessionId = sessionId, toolName = toolCall.function.name, tier = tier, - reason = "blocked by tool-call policy", + // Record the specific rule rationale, not a generic string, so the audit + // trail (and task history) shows why a call was blocked. + reason = assessment.rationale.joinToString("; ") + .ifBlank { "blocked by tool-call policy" }, ), ) val sourceId = toolCall.id ?: invocationId.value @@ -965,6 +969,7 @@ abstract class SessionOrchestrator( paramRoles = tool?.paramRoles ?: emptyMap(), writeManifest = writeManifest, sessionEgressHosts = sessionEgressHosts, + sessionReadPaths = resolveSessionReadPaths(sessionId), ), ) emit( @@ -994,6 +999,18 @@ abstract class SessionOrchestrator( .fold(projection.initial()) { acc, event -> projection.apply(acc, event) } } + /** + * Folds this session's file_read tool events through [ReadFilesProjection] into the set of paths + * it has successfully read — the input to [com.correx.core.toolintent.rules.ReadBeforeWriteRule]. + * Replay-safe (reads the log, never live state); empty before any read completes. + */ + internal fun resolveSessionReadPaths(sessionId: SessionId): Set { + val projection = ReadFilesProjection(sessionId) + return eventStore.read(sessionId) + .fold(projection.initial()) { acc, event -> projection.apply(acc, event) } + .readPaths + } + private suspend fun buildSchemaEntries( responseFormat: ResponseFormat, stageId: StageId, diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt new file mode 100644 index 00000000..5c3af946 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt @@ -0,0 +1,54 @@ +package com.correx.core.toolintent + +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolExecutionCompletedEvent +import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.Projection +import com.correx.core.toolintent.rules.candidatePathStrings + +/** Reads still awaiting their completion event, plus the paths confirmed read so far. */ +data class ReadFilesState( + val pending: Map> = emptyMap(), + val readPaths: Set = emptySet(), +) + +/** + * Folds one session's tool events into the set of file paths it has successfully read. A `file_read` + * counts only once its [ToolExecutionCompletedEvent] lands — a read that failed (file not found) + * never enters the set, so it can't satisfy [com.correx.core.toolintent.rules.ReadBeforeWriteRule]. + * Paths are kept as the raw arguments; the rule resolves both sides through the probe, so + * `./Foo.kt` read and `Foo.kt` written still match. The same projection precedent as + * [com.correx.core.sessions.projections.EgressAllowlistProjection]. + */ +class ReadFilesProjection(private val sessionId: SessionId) : Projection { + + override fun initial(): ReadFilesState = ReadFilesState() + + override fun apply(state: ReadFilesState, event: StoredEvent): ReadFilesState = + when (val payload = event.payload) { + is ToolInvocationRequestedEvent -> recordPending(state, payload) + is ToolExecutionCompletedEvent -> confirmRead(state, payload) + else -> state + } + + private fun recordPending(state: ReadFilesState, payload: ToolInvocationRequestedEvent): ReadFilesState { + if (payload.sessionId != sessionId || payload.toolName != FILE_READ_TOOL) return state + val paths = candidatePathStrings(emptyMap(), payload.request.parameters) + if (paths.isEmpty()) return state + return state.copy(pending = state.pending + (payload.invocationId.value to paths)) + } + + private fun confirmRead(state: ReadFilesState, payload: ToolExecutionCompletedEvent): ReadFilesState { + val paths = state.pending[payload.invocationId.value] + if (payload.sessionId != sessionId || paths == null) return state + return state.copy( + pending = state.pending - payload.invocationId.value, + readPaths = state.readPaths + paths, + ) + } + + private companion object { + const val FILE_READ_TOOL = "file_read" + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt index 208f2109..719e32e0 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt @@ -29,6 +29,10 @@ data class ToolCallAssessmentInput( // EgressAllowlistProjection). Unioned with the static allow-list at the network gate; empty // means "no per-session grants", which never narrows the static allow-list. val sessionEgressHosts: Set = emptySet(), + // Paths this session has successfully file_read (folded via ReadFilesProjection). The input to + // ReadBeforeWriteRule; empty means nothing has been read yet (so any write to an existing file + // is unread). Raw argument strings — the rule resolves both sides through the probe. + val sessionReadPaths: Set = emptySet(), ) data class ToolCallAssessment( diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt new file mode 100644 index 00000000..57492985 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt @@ -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.Path + +/** + * Read-before-write gate (anti-hallucination). Dispatches on FILE_WRITE, covering both file_write + * and file_edit. A write to a path that exists on disk but was not file_read earlier this session + * is BLOCKED — the classic "edited a file whose contents it never saw" failure. A brand-new file + * (not on disk) passes: you cannot read what does not exist, and creating one is legitimate. + * + * The block message names the path; the orchestrator feeds the rationale back as a tool result, so + * the agent reads the file and retries. Read paths and the write target are both resolved through + * the probe (toRealPath), so `./Foo.kt` read then `Foo.kt` written match, and symlinks can't dodge it. + */ +class ReadBeforeWriteRule : ToolCallRule { + + override fun appliesTo(capabilities: Set): Boolean = + ToolCapability.FILE_WRITE in capabilities + + override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { + val root = input.workspace.workspaceRoot + val readReal = input.sessionReadPaths.map { realOf(input, root, it) }.toSet() + + val issues = mutableListOf() + val observations = mutableListOf() + var disposition = RiskAction.PROCEED + + for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { + val resolvedInput = resolveInput(root, raw) + val exists = input.probe.exists(resolvedInput) + val read = input.probe.resolveReal(resolvedInput) in readReal + + observations += ToolCallObservation( + ruleCode = RULE_CODE, + facts = mapOf("path" to raw, "exists" to exists.toString(), "read" to read.toString()), + ) + + if (exists && !read) { + issues += ValidationIssue( + code = RULE_CODE, + message = "Tool '${input.request.toolName}' targets '$raw', which you have not " + + "read this session — call file_read on it before writing or editing.", + severity = ValidationSeverity.ERROR, + ) + disposition = maxAction(disposition, RiskAction.BLOCK) + } + } + + return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition) + } + + private fun resolveInput(root: Path, raw: String): Path { + val candidate = Path.of(raw) + return if (candidate.isAbsolute) candidate else root.resolve(candidate) + } + + private fun realOf(input: ToolCallAssessmentInput, root: Path, raw: String): Path = + input.probe.resolveReal(resolveInput(root, raw)) + + private companion object { + const val RULE_CODE = "READ_BEFORE_WRITE" + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt new file mode 100644 index 00000000..fe84835f --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt @@ -0,0 +1,80 @@ +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.ReadBeforeWriteRule +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 ReadBeforeWriteRuleTest { + + private val workspace = Path.of("/work/project") + private val rule = ReadBeforeWriteRule() + + /** A probe where existence is configurable; resolveReal just normalizes (no symlinks). */ + 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, + read: Set = emptySet(), + tool: String = "file_edit", + ) = ToolCallAssessmentInput( + request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), tool, mapOf("path" to pathArg)), + capabilities = setOf(ToolCapability.FILE_WRITE), + workspace = WorkspacePolicy(workspace, emptyList()), + probe = probe, + sessionReadPaths = read, + ) + + @Test + fun `applies only to FILE_WRITE`() { + assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_WRITE))) + assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_READ))) + assertFalse(rule.appliesTo(emptySet())) + } + + @Test + fun `editing an existing file that was not read is blocked`() { + val target = "/work/project/src/A.kt" + val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target))))) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("READ_BEFORE_WRITE", r.issues.single().code) + assertEquals("false", r.observations.single().facts["read"]) + } + + @Test + fun `editing an existing file that was read proceeds`() { + val target = "/work/project/src/A.kt" + val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target))), read = setOf(target))) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `creating a brand-new file proceeds without a prior read`() { + val target = "/work/project/src/New.kt" // not in existing → new file + val r = rule.assess(input(target, FakeProbe(existing = emptySet()), tool = "file_write")) + assertEquals(RiskAction.PROCEED, r.disposition) + } + + @Test + fun `a relative read of the same file satisfies an absolute write`() { + val target = "/work/project/src/A.kt" + // read recorded as a workspace-relative path; resolves to the same real path as the write. + val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target))), read = setOf("src/A.kt"))) + assertEquals(RiskAction.PROCEED, r.disposition) + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadFilesProjectionTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadFilesProjectionTest.kt new file mode 100644 index 00000000..45e59829 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadFilesProjectionTest.kt @@ -0,0 +1,83 @@ +package com.correx.core.toolintent + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolExecutionCompletedEvent +import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.events.ToolReceipt +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import kotlinx.datetime.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ReadFilesProjectionTest { + + private val session = SessionId("s") + private val projection = ReadFilesProjection(session) + + private var seq = 0L + private fun stored(payload: EventPayload) = StoredEvent( + metadata = EventMetadata(EventId("e-${seq}"), session, Instant.parse("2026-01-01T00:00:00Z"), 1, null, null), + sequence = ++seq, + sessionSequence = seq, + payload = payload, + ) + + private fun readRequested(invId: String, path: String) = stored( + ToolInvocationRequestedEvent( + invocationId = ToolInvocationId(invId), + sessionId = session, + stageId = StageId("st"), + toolName = "file_read", + tier = Tier.T1, + request = ToolRequest(ToolInvocationId(invId), session, StageId("st"), "file_read", mapOf("path" to path)), + ), + ) + + private fun completed(invId: String, tool: String = "file_read") = stored( + ToolExecutionCompletedEvent( + invocationId = ToolInvocationId(invId), + sessionId = session, + toolName = tool, + receipt = ToolReceipt( + invocationId = ToolInvocationId(invId), + toolName = tool, + exitCode = 0, + outputSummary = "ok", + durationMs = 1, + tier = Tier.T1, + timestamp = Instant.parse("2026-01-01T00:00:00Z"), + ), + ), + ) + + private fun fold(events: List) = + events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) } + + @Test + fun `a completed file_read adds the path to the read set`() { + val state = fold(listOf(readRequested("r1", "src/A.kt"), completed("r1"))) + assertEquals(setOf("src/A.kt"), state.readPaths) + } + + @Test + fun `a read still pending its completion does not count`() { + val state = fold(listOf(readRequested("r1", "src/A.kt"))) + assertTrue(state.readPaths.isEmpty()) + assertTrue(state.pending.containsKey("r1")) + } + + @Test + fun `a non-read tool completion is ignored`() { + // file_write request never enters pending, so its completion adds nothing. + val state = fold(listOf(completed("w1", tool = "file_write"))) + assertTrue(state.readPaths.isEmpty()) + } +}