fix(events,tools,toolintent): failure attribution, one path normalization rule, file_copy (#713)

Three generic harness fixes from the web-ui postmortem dataset. Nothing here keys
on a language, framework, build tool or task type.

1. Failure attribution. WorkflowFailedEvent carries one primary FailureAttribution
   (AGENT | HARNESS | WORKFLOW | ENVIRONMENT | PROVIDER | OPERATOR | UNKNOWN),
   defaulted to UNKNOWN so pre-field events replay unchanged. FailureAttributor is
   the deterministic reason->layer mapping, used both at emission and when
   classifying history, so the baseline and the live metric are one measurement.
   Emission sites set it: failWorkflow derives from the reason unless the caller
   knows the layer, cancellation is OPERATOR, the server catch-all falls back to
   HARNESS, a grounding-rejected plan is AGENT. Multi-cause chains stay on
   FailureTicketOpened — no second causal structure.

   GET /metrics/failure-attribution (FailureAttributionInspectionService, mirroring
   ToolReliabilityInspectionService) reports counts, share, UNKNOWN share, the
   preserved reasons and the ticket categories from the same sessions. Read-only:
   historical events are classified at READ time and reported as `inferred`, never
   written back over an append-only log.

   Baseline over the local log, 122 terminal failures: AGENT 51 (41.8%),
   OPERATOR 28 (23.0%), WORKFLOW 19 (15.6%), PROVIDER 15 (12.3%), HARNESS 6 (4.9%),
   ENVIRONMENT 3 (2.5%), UNKNOWN 0.

2. The `~` guard bug. ToolPath is now the ONE canonical normalization rule
   (expand a leading `~`/`~/`, keep absolutes, anchor relatives on the session
   working dir). Every filesystem tool, all six plane-2 path rules and the approval
   preview resolve through it, so policy and existence checks inspect the path the
   tool will operate on. `~/.gradle/init.d/offline.gradle` used to resolve to
   `<workspace>/~/.gradle/...`: reported non-existent AND in-workspace, so the
   reference gate called a real file a hallucination and the out-of-workspace prompt
   never fired. Containment and external-read approval behaviour are unchanged —
   the expanded path is simply outside the workspace, where it always belonged.

3. file_copy (#713). A first-class tool with the writer's jail, tier, receipt,
   replay and CAS pre/post images; static and binary assets no longer move through
   the model's token stream. Needed one generic split: ParamRole.SOURCE_PATH marks a
   path a call reads FROM, so containment gates judge both params while write-target
   gates (read-before-write, stale-write, write scope, write manifest) judge the
   mutated one. ReadBeforeWriteRule exempts any call declaring a SOURCE_PATH: its
   content comes from disk, not from memory, and requiring a read of a binary is
   unsatisfiable. Existing tools declare no SOURCE_PATH, so their behaviour is
   byte-identical.

Tests: ToolPathTest (9), FailureAttributionTest (10), PathNormalizationRuleTest (6),
FileCopyToolTest (10), plus a home-relative FileReadTool read. ./gradlew check green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 11:57:49 +04:00
parent 519290368f
commit 6a8a7b31c1
36 changed files with 1232 additions and 79 deletions
+3 -1
View File
@@ -20,6 +20,7 @@ CORREX kernel team. This is the most cross-cutting module in the codebase — ch
- `JsonEventSerializer` / `EventSerializer` — serialize/deserialize `StoredEvent` to JSON.
- `EventDispatcher` — broadcasts events to in-process listeners.
- Domain event files: `ApprovalEvents`, `ArtifactEvents`, `ContextEvents`, `InferenceEvents`, `OrchestrationEvents`, `RouterEvents`, `SessionEvents`, `TaskEvents`, `ToolEvents`, `IntentEvents`, `RiskAssessedEvent`, `JournalCompactedEvent`, and many more — all payload definitions live here.
- `FailureAttribution` / `FailureAttributor` — the terminal-failure taxonomy (`AGENT`, `HARNESS`, `WORKFLOW`, `ENVIRONMENT`, `PROVIDER`, `OPERATOR`, `UNKNOWN`) carried as `WorkflowFailedEvent.attribution`, plus the deterministic reason→layer mapping used both at emission and when classifying historical events. One primary attribution per terminal event; a multi-cause chain is the session's `FailureTicketOpenedEvent`s, not a second structure.
- `LspDiagnosticsCompletedEvent` records pulled language-server diagnostics or a graceful skip reason; replay consumes this observation and never contacts the server.
- Shared vocabulary: `IdentityTypes` (SessionId, TaskId, etc.), `Tier`, `TokenUsage`, `ToolReceipt`, `ToolRequest`, `RiskLevel`, `RetryPolicy`, `GrantScope`, `GrantLedger`.
@@ -30,7 +31,8 @@ CORREX kernel team. This is the most cross-cutting module in the codebase — ch
- Event classes are `@Serializable data class` with no mutable state. No methods beyond data accessors.
- `RunBranchPushedEvent` records an optional server Git transport push only after it succeeds; its branch/base/head SHAs are observations, not values replay recalculates.
- `RepoMapEntry.descriptor` is a bounded source-purpose observation recorded with the repo map and used when constructing semantic L3 embeddings.
- Do not add domain logic to events. They are records, not actors.
- Do not add domain logic to events. They are records, not actors. `FailureAttributor` is the one exception by design: a pure reason→layer function that must be identical for live emission and for historical classification, so it lives beside the enum it returns.
- `WorkflowFailedEvent.attribution` defaults to `UNKNOWN` so pre-field events replay unchanged. Classify those at READ time (see `FailureAttributionInspectionService`); never rewrite history to backfill them.
- `EgressAllowlistProjection` — special projection kept in this module because it is used by both `core:toolintent` and `core:events` consumers; it is a shared cross-cutting projection.
## Verification
@@ -0,0 +1,142 @@
package com.correx.core.events.events
import kotlinx.serialization.Serializable
/**
* WHOSE failure a terminal [WorkflowFailedEvent] was: the primary layer that has to change for the
* run to succeed. One value per terminal event. When several causes contributed, the causal chain is
* the session's [FailureTicketOpenedEvent]s — this enum does not model chains.
*
* The point is measurement: "correx failed 99 runs" is not actionable, "61 of them were harness
* defects" is. Read the layer, not the symptom.
*/
@Serializable
enum class FailureAttribution {
/** The model produced invalid work while the harness operated correctly. */
AGENT,
/** Correx's own runtime: a linkage error, a bug in a reducer/tool layer, a false observation
* handed to the agent, or an expectation correx could not evaluate for lack of instrumentation. */
HARNESS,
/** The workflow/graph definition: no transition matched, a condition referenced a field that
* cannot exist, a declared prompt or stage was never authored. */
WORKFLOW,
/** The machine the run executes on: a missing executable, permissions, disk, ports. */
ENVIRONMENT,
/** The inference provider: unavailable, timed out, or answered with a body correx cannot read. */
PROVIDER,
/** A human ended the run: cancellation, or a denied/rejected approval. */
OPERATOR,
/** Not classifiable from the recorded reason. A metric, not a bucket: a rising UNKNOWN share
* means the taxonomy or the reason text needs work, and every UNKNOWN is a defect to triage. */
UNKNOWN,
}
/**
* Deterministic mapping from a terminal failure reason to its [FailureAttribution].
*
* Same function for live emission and for classifying historical events recorded before the field
* existed, so a backfilled baseline and a live metric are the same measurement. It is a pure
* function of the reason string: no clock, no I/O, no session lookup — safe to re-run over the whole
* event log any number of times.
*
* Markers are matched in layer order — OPERATOR, PROVIDER, ENVIRONMENT, WORKFLOW, HARNESS, AGENT —
* because the outermost cause wins: a provider timeout that surfaces as an artifact-validation
* failure is still a provider failure. Match on what the layer says about ITSELF (a provider being
* unavailable, a program that cannot be run), never on the domain of the run: nothing here may key
* on a language, framework, build tool or task type.
*/
object FailureAttributor {
/**
* Classifies [reason]. [fallback] is returned when no marker matches — a call site that knows
* the layer from its position (e.g. a top-level catch-all in the correx runtime) supplies its
* own instead of leaving the failure [FailureAttribution.UNKNOWN].
*/
fun classify(reason: String, fallback: FailureAttribution = FailureAttribution.UNKNOWN): FailureAttribution {
val text = reason.lowercase()
// A reason that is a bare JVM binary name with no prose is a linkage/classload error
// (NoClassDefFoundError.getMessage()), i.e. a correx runtime defect.
if (text.isNotBlank() && !text.contains(' ') && text.contains('/') && !text.contains('.')) {
return FailureAttribution.HARNESS
}
return MARKERS.firstOrNull { (_, markers) -> markers.any { it in text } }?.first ?: fallback
}
private val MARKERS: List<Pair<FailureAttribution, List<String>>> = listOf(
FailureAttribution.OPERATOR to listOf(
"cancelled",
"canceled",
"approval denied",
"approval rejected",
"rejected by operator",
),
FailureAttribution.PROVIDER to listOf(
"is unavailable",
"health check failed",
"connection refused",
"request timeout has expired",
"no provider satisfies",
"returned 400",
"returned 401",
"returned 403",
"returned 404",
"returned 5",
"chatcompletionresponse",
"no completion returned",
"context window exceeded",
),
FailureAttribution.ENVIRONMENT to listOf(
"cannot run program",
"exec failed",
"command not found",
"permission denied",
"no space left",
"address already in use",
),
FailureAttribution.WORKFLOW to listOf(
"no transition condition matched",
"no matching transition",
"condition evaluation failed",
// A stage's declaration disagrees with reality: the prerequisite it names is unresolved
// or sits outside the scope it declared. Both are authoring defects in the definition.
"build prerequisite",
"declared prompt",
"unknown stage",
"no such stage",
),
FailureAttribution.HARNESS to listOf(
"noclassdeffounderror",
"nosuchmethod",
"classnotfound",
"not supported in map",
"hex string must have even length",
"could not be evaluated",
"no instrumentation",
"unexpected orchestrator failure",
),
FailureAttribution.AGENT to listOf(
"did not produce declared artifacts",
"did not satisfy its file contract",
"did not pass",
"failed semantic review",
"declared no artifacts",
"review loop exhausted",
// A call plane-2 denied: the harness evaluated policy correctly, the agent proposed it.
"blocked by tool-call policy",
"validation failed",
"artifact repair failed",
"repair ladder exhausted",
"recovery route budget exhausted",
"refinement loop",
"execution plan rejected",
"is stuck",
"failed to decode",
),
)
}
@@ -34,6 +34,12 @@ data class WorkflowFailedEvent(
val stageId: StageId,
val reason: String,
val retryExhausted: Boolean,
// WHOSE failure this was — the layer that must change for the run to succeed (see
// [FailureAttribution]). Set at emission by the site that knows the cause, or derived from
// [reason] by [FailureAttributor]; the original [reason] is always preserved alongside it.
// Defaulted to UNKNOWN so events recorded before this field replay unchanged: a classification
// for those is INFERRED at read time, never written back over history.
val attribution: FailureAttribution = FailureAttribution.UNKNOWN,
) : EventPayload
/**
@@ -0,0 +1,140 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The taxonomy's contract. Cases are drawn from real `WorkflowFailed.reason` texts in the local
* event log so the mapping is checked against failures that actually happened, and every case keys
* on what a LAYER says about itself — never on a language, framework or build tool.
*/
class FailureAttributionTest {
private fun assertLayer(expected: FailureAttribution, reason: String) =
assertEquals(expected, FailureAttributor.classify(reason), reason)
@Test
fun `operator-ended runs`() {
assertLayer(FailureAttribution.OPERATOR, "CANCELLED")
assertLayer(FailureAttribution.OPERATOR, "approval denied")
assertLayer(FailureAttribution.OPERATOR, "approval rejected for stage architect")
}
@Test
fun `provider failures`() {
assertLayer(
FailureAttribution.PROVIDER,
"Provider 'llama-cpp:default' is unavailable: Health check failed: Connection refused",
)
assertLayer(
FailureAttribution.PROVIDER,
"Request timeout has expired [url=http://127.0.0.1:10000/v1/chat/completions, " +
"request_timeout=600000 ms]",
)
assertLayer(FailureAttribution.PROVIDER, "No provider satisfies capabilities [] for stage 'routing'")
// A provider body correx cannot decode is a provider-communication failure, not a bad artifact.
assertLayer(
FailureAttribution.PROVIDER,
"Illegal input: Fields [id, choices, usage] are required for type with serial name " +
"'com.correx.infrastructure.inference.llama.cpp.ChatCompletionResponse', but they were missing",
)
assertLayer(FailureAttribution.PROVIDER, "llama-server returned 400 Bad Request: {\"error\":{}}")
}
@Test
fun `environment failures`() {
assertLayer(
FailureAttribution.ENVIRONMENT,
"Cannot run program \"cd\" (in directory \"/w\"): Exec failed, error: 2 (No such file or directory)",
)
}
@Test
fun `workflow-definition failures`() {
assertLayer(FailureAttribution.WORKFLOW, "no transition condition matched from stage analyst")
assertLayer(
FailureAttribution.WORKFLOW,
"condition evaluation failed on 'verify_completion->done': Field 'verdict' not found",
)
assertLayer(FailureAttribution.WORKFLOW, "[SessionOrchestrator] stage=analyst: declared prompt 'x' missing")
assertLayer(FailureAttribution.WORKFLOW, "no matching transition from stage A")
assertLayer(FailureAttribution.WORKFLOW, "build prerequisite 'x' unresolved after bootstrap: missing")
}
@Test
fun `harness failures`() {
// A bare JVM binary name with no prose is a linkage error inside correx itself.
assertLayer(
FailureAttribution.HARNESS,
"com/correx/core/kernel/orchestration/SessionOrchestrator\$failWorkflow\$1",
)
assertLayer(FailureAttribution.HARNESS, "com/correx/core/approvals/GrantLedgerKt")
assertLayer(FailureAttribution.HARNESS, "null values are not supported in Map<String, Any>")
// An expectation correx could not evaluate for lack of instrumentation is ours, not the agent's.
assertLayer(FailureAttribution.HARNESS, "expected_result could not be evaluated: no instrumentation")
}
@Test
fun `agent failures`() {
assertLayer(FailureAttribution.AGENT, "stage implementer did not produce declared artifacts: patch")
assertLayer(FailureAttribution.AGENT, "validation failed")
assertLayer(FailureAttribution.AGENT, "artifact repair failed (FORMATTING): could not extract a JSON object")
assertLayer(FailureAttribution.AGENT, "refinement loop 'implementer->reviewer' exceeded 2 iterations")
assertLayer(FailureAttribution.AGENT, "recovery route budget exhausted for stage ui_review (gate=execution)")
assertLayer(FailureAttribution.AGENT, "repair ladder exhausted for stage x (gate=stage_loop_break)")
assertLayer(FailureAttribution.AGENT, "execution plan rejected (grounding): plan failed grounding")
assertLayer(FailureAttribution.AGENT, "stage x did not satisfy its file contract. Fix these before review:")
assertLayer(FailureAttribution.AGENT, "stage x did not pass its PROJECT build gate")
assertLayer(FailureAttribution.AGENT, "stage x did not pass static analysis. Fix these before review:")
assertLayer(FailureAttribution.AGENT, "stage x failed semantic review — fix these correctness issues:")
assertLayer(FailureAttribution.AGENT, "stage x declared no artifacts and ran no tools")
assertLayer(FailureAttribution.AGENT, "review loop exhausted after exactly 3 cycles.")
assertLayer(FailureAttribution.AGENT, "blocked by tool-call policy")
}
@Test
fun `an unmatched reason is UNKNOWN, and a call site may supply its own fallback`() {
assertLayer(FailureAttribution.UNKNOWN, "something nobody has seen before")
assertEquals(
FailureAttribution.HARNESS,
FailureAttributor.classify("something nobody has seen before", FailureAttribution.HARNESS),
)
// The reason text still wins over a call site's fallback when it names an outer layer.
assertEquals(
FailureAttribution.OPERATOR,
FailureAttributor.classify("CANCELLED", FailureAttribution.HARNESS),
)
}
@Test
fun `classification is a pure function of the reason`() {
val reason = "no transition condition matched from stage analyst"
assertEquals(FailureAttributor.classify(reason), FailureAttributor.classify(reason))
}
@Test
fun `an event recorded before the field replays as UNKNOWN with its reason preserved`() {
val stored = """{"sessionId":"s","stageId":"st","reason":"CANCELLED","retryExhausted":false}"""
val event = Json.decodeFromString<WorkflowFailedEvent>(stored)
assertEquals(FailureAttribution.UNKNOWN, event.attribution)
assertEquals("CANCELLED", event.reason)
// …and the historical baseline classifies it at read time, without rewriting history.
assertEquals(FailureAttribution.OPERATOR, FailureAttributor.classify(event.reason))
}
@Test
fun `a live event carries its attribution through a round-trip`() {
val event = WorkflowFailedEvent(
sessionId = SessionId("s"),
stageId = StageId("st"),
reason = "no transition condition matched from stage analyst",
retryExhausted = true,
attribution = FailureAttribution.WORKFLOW,
)
val json = Json.encodeToString(WorkflowFailedEvent.serializer(), event)
assertEquals(event, Json.decodeFromString(WorkflowFailedEvent.serializer(), json))
}
}
@@ -1,4 +1,5 @@
package com.correx.core.kernel.orchestration
import com.correx.core.tools.contract.ToolPath
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -46,8 +47,7 @@ internal suspend fun readFileIfExists(path: String, workspaceRoot: java.nio.file
// Resolve relative paths against the session's workspace root, same as the tools do —
// resolving against the daemon CWD showed the operator the wrong file (or nothing) when
// server CWD ≠ workspace_root.
val raw = java.nio.file.Paths.get(path)
val filePath = if (raw.isAbsolute || workspaceRoot == null) raw else workspaceRoot.resolve(raw)
val filePath = ToolPath.resolve(path, workspaceRoot)
if (java.nio.file.Files.exists(filePath)) {
java.nio.file.Files.readString(filePath)
} else null
@@ -5,6 +5,8 @@ import com.correx.core.events.events.CritiqueFindingsRecordedEvent
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.FailureAttribution
import com.correx.core.events.events.FailureAttributor
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
@@ -108,6 +110,9 @@ internal suspend fun SessionOrchestrator.failWorkflow(
stageId: StageId,
reason: String,
retryExhausted: Boolean,
// Null ⇒ derive the attribution from [reason] (FailureAttributor). A caller that knows the layer
// from its own position passes it explicitly instead of relying on the reason text.
attribution: FailureAttribution? = null,
): WorkflowResult.Failed {
log.warn(
"[Orchestrator] FAILED session={} stage={} reason={} retryExhausted={}",
@@ -134,7 +139,16 @@ internal suspend fun SessionOrchestrator.failWorkflow(
// (e.g. a serialization edge), we cannot record the failure at all — log it loudly with the
// full throwable so it is never silent, then still return a clean Failed result.
runCatching {
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted))
emit(
sessionId,
WorkflowFailedEvent(
sessionId,
stageId,
reason,
retryExhausted,
attribution ?: FailureAttributor.classify(reason),
),
)
}.onFailure { e ->
log.error(
"[Orchestrator] failWorkflow: FAILED to record terminal WorkflowFailedEvent — event " +
@@ -1,5 +1,6 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.FailureAttribution
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.OutsidePathAccessGrantedEvent
@@ -212,7 +213,16 @@ internal suspend fun SessionOrchestrator.handleCancellation(
stageId: StageId,
): WorkflowResult.Cancelled {
log.warn("[Orchestrator] CANCELLED session={} stage={}", sessionId.value, stageId.value)
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, "CANCELLED", retryExhausted = false))
emit(
sessionId,
WorkflowFailedEvent(
sessionId,
stageId,
"CANCELLED",
retryExhausted = false,
attribution = FailureAttribution.OPERATOR,
),
)
cancellations.remove(sessionId)
return WorkflowResult.Cancelled(sessionId)
}
+3 -1
View File
@@ -24,7 +24,7 @@ CORREX kernel team. This module enforces Hard Invariant #9 for the tool-call pat
- `WorkspacePolicy` — aggregates rules and configuration for a workspace.
- `WorldProbe` — performs environment checks (filesystem, network) and records the observations as events immediately (Hard Invariant #9). Never call `WorldProbe` during replay.
- `EgressAllowlist` — current egress allowlist; rebuilt from `EgressAllowlistProjection` (in `core:events`).
- `ParamValueExtractor` — extracts typed parameter values from tool call arguments.
- `ParamValueExtractor` — extracts typed parameter values from tool call arguments. `candidatePathStrings` = every path-like argument (`ParamRole.PATH` + `ParamRole.SOURCE_PATH`), used by the containment/existence gates; `writeTargetPathStrings` = only the paths a call MUTATES (`ParamRole.PATH`), used by the write-target gates. A tool declaring none of those roles falls back to sniffing path-like strings, so `shell` is unaffected.
- `RiskMapping` — maps rule violations to risk levels for `core:risk`.
- `SessionContext` — session-scoped context passed to rules during evaluation.
@@ -33,6 +33,8 @@ CORREX kernel team. This module enforces Hard Invariant #9 for the tool-call pat
- Hard Invariant #9: all `WorldProbe` calls record observations as events. Replay reads those recorded events — it must not call `WorldProbe` again.
- Hard Invariant #5: every tool call must be assessed before execution. Assessment result is recorded as `ToolCallAssessmentEvents` in `core:events`.
- New rules implement `ToolCallRule` and are registered in `WorkspacePolicy`. Do not add rule logic directly to `ToolCallAssessor`.
- Resolve every model-supplied path through `ToolPath.resolve` (`core:tools`) — the one canonical normalization rule, shared with the filesystem tools. A rule that resolves paths itself will judge a different path than the tool operates on (the `~` bug: `~/x` resolved to `<workspace>/~/x`, so a real home-directory file was reported as a non-existent in-workspace file and the out-of-workspace prompt never fired).
- A call that declares a `SOURCE_PATH` takes its content from disk, not from the model, so `ReadBeforeWriteRule` exempts it: requiring a read of a copied file's bytes is unsatisfiable for binaries and defeats the purpose of the tool.
## Verification
@@ -7,6 +7,7 @@ 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.tools.contract.ToolPath
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.FileSystems
@@ -52,10 +53,8 @@ class ManifestContainmentRule : ToolCallRule {
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 input.workspace.workspaceRoot.resolve(candidate)
for (raw in writeTargetPathStrings(input.paramRoles, input.request.parameters)) {
val resolvedInput = ToolPath.resolve(raw, input.workspace.workspaceRoot)
val resolvedReal = input.probe.resolveReal(resolvedInput)
val inWorkspace = resolvedReal.startsWith(workspaceReal)
val relative = if (inWorkspace) workspaceReal.relativize(resolvedReal).toString() else raw
@@ -14,13 +14,31 @@ internal fun extractParamStrings(value: Any?): List<String> = when (value) {
}
/**
* The path-like argument strings of a tool call: the values of params declared
* [ParamRole.PATH], or — when none are declared — any string value that looks like a
* path. Shared by the path-containment and write-manifest rules so both judge exactly
* the same set of targets.
* Every path-like argument of a tool call the call's own targets ([ParamRole.PATH]) and any path
* it merely reads from ([ParamRole.SOURCE_PATH]). Used by the containment/existence gates, which
* must judge both: a source outside the workspace is as much an escape as a destination outside it.
*/
internal fun candidatePathStrings(paramRoles: Map<String, ParamRole>, parameters: Map<String, Any>): List<String> {
val declared = paramRoles.filterValues { it == ParamRole.PATH }.keys
internal fun candidatePathStrings(paramRoles: Map<String, ParamRole>, parameters: Map<String, Any>): List<String> =
pathStringsForRoles(paramRoles, parameters, setOf(ParamRole.PATH, ParamRole.SOURCE_PATH))
/**
* Only the paths a tool call MUTATES ([ParamRole.PATH]). Used by the write-target gates
* (read-before-write, stale-write, write scope, write manifest): blocking a copy because its
* SOURCE was never read, or charging a source against the task's write scope, would be wrong.
*/
internal fun writeTargetPathStrings(paramRoles: Map<String, ParamRole>, parameters: Map<String, Any>): List<String> =
pathStringsForRoles(paramRoles, parameters, setOf(ParamRole.PATH))
/**
* Values of the params declared with one of [roles] or — when the tool declares none of them (e.g.
* `shell`, whose param is an [ParamRole.EXEC_COMMAND]) — any string value that looks like a path.
*/
private fun pathStringsForRoles(
paramRoles: Map<String, ParamRole>,
parameters: Map<String, Any>,
roles: Set<ParamRole>,
): List<String> {
val declared = paramRoles.filterValues { it in roles }.keys
return if (declared.isNotEmpty()) {
declared.flatMap { extractParamStrings(parameters[it]) }
} else {
@@ -7,9 +7,9 @@ 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.tools.contract.ToolPath
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.Path
/**
* Effect-based path containment. Dispatches on FILE_READ / FILE_WRITE. For every
@@ -34,9 +34,7 @@ class PathContainmentRule : ToolCallRule {
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 input.workspace.workspaceRoot.resolve(candidate)
val resolvedInput = ToolPath.resolve(raw, input.workspace.workspaceRoot)
val resolvedReal = input.probe.resolveReal(resolvedInput)
val exists = input.probe.exists(resolvedInput)
val privileged = privilegedReal.any { resolvedReal.startsWith(it) }
@@ -6,7 +6,9 @@ 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.ParamRole
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolPath
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.Path
@@ -27,6 +29,12 @@ class ReadBeforeWriteRule : ToolCallRule {
ToolCapability.FILE_WRITE in capabilities
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
// A call that declares a SOURCE_PATH takes its content from a file on disk, not from the
// model's memory, so there is nothing for this gate to protect: demanding a file_read of the
// source (or of the target being replaced by it) would force the very bytes-through-context
// round-trip that such tools exist to avoid — and is unsatisfiable for a binary file.
if (input.paramRoles.containsValue(ParamRole.SOURCE_PATH)) return ToolCallAssessment()
val root = input.workspace.workspaceRoot
val readReal = input.session.reads.map { realOf(input, root, it) }.toSet()
@@ -34,7 +42,7 @@ class ReadBeforeWriteRule : ToolCallRule {
val observations = mutableListOf<ToolCallObservation>()
var disposition = RiskAction.PROCEED
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
for (raw in writeTargetPathStrings(input.paramRoles, input.request.parameters)) {
val resolvedInput = resolveInput(root, raw)
val exists = input.probe.exists(resolvedInput)
val read = input.probe.resolveReal(resolvedInput) in readReal
@@ -58,10 +66,7 @@ class ReadBeforeWriteRule : ToolCallRule {
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 resolveInput(root: Path, raw: String): Path = ToolPath.resolve(raw, root)
private fun realOf(input: ToolCallAssessmentInput, root: Path, raw: String): Path =
input.probe.resolveReal(resolveInput(root, raw))
@@ -7,9 +7,9 @@ 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.tools.contract.ToolPath
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
@@ -37,8 +37,7 @@ class ReferenceExistsRule : ToolCallRule {
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 resolvedInput = ToolPath.resolve(raw, root)
val exists = input.probe.exists(resolvedInput)
val inWorkspace = input.probe.resolveReal(resolvedInput).startsWith(workspaceReal)
@@ -7,6 +7,7 @@ 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.tools.contract.ToolPath
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.Path
@@ -35,7 +36,7 @@ class StaleWriteRule : ToolCallRule {
val observations = mutableListOf<ToolCallObservation>()
var disposition = RiskAction.PROCEED
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
for (raw in writeTargetPathStrings(input.paramRoles, input.request.parameters)) {
val resolved = resolveInput(root, raw)
val real = input.probe.resolveReal(resolved)
val recorded = hashByReal[real]
@@ -60,10 +61,7 @@ class StaleWriteRule : ToolCallRule {
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 resolveInput(root: Path, raw: String): Path = ToolPath.resolve(raw, root)
private fun realOf(input: ToolCallAssessmentInput, root: Path, raw: String): Path =
input.probe.resolveReal(resolveInput(root, raw))
@@ -7,10 +7,10 @@ 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.tools.contract.ToolPath
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
@@ -37,11 +37,8 @@ class WriteScopeRule : ToolCallRule {
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),
)
for (raw in writeTargetPathStrings(input.paramRoles, input.request.parameters)) {
val resolvedReal = input.probe.resolveReal(ToolPath.resolve(raw, input.workspace.workspaceRoot))
if (!resolvedReal.startsWith(workspaceReal)) continue // out of workspace: not this gate
val rel = workspaceReal.relativize(resolvedReal)
val inScope = matchers.any { it.matches(rel) }
@@ -0,0 +1,149 @@
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.PathContainmentRule
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
import com.correx.core.toolintent.rules.ReferenceExistsRule
import com.correx.core.toolintent.rules.WriteScopeRule
import com.correx.core.tools.contract.ParamRole
import com.correx.core.tools.contract.ToolCapability
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Plane-2 must judge the SAME path the tool will operate on (ToolPath), and must judge a call's
* source path differently from its write target.
*
* The `~` cases pin the harness bug from the 2026-08 web-ui runs: a correct diagnosis of
* `~/.gradle/init.d/offline.gradle` was resolved to `<workspace>/~/.gradle/…`, so the reference gate
* called a real file a hallucination and the out-of-workspace prompt never fired.
*/
class PathNormalizationRuleTest {
private val workspace = Path.of("/work/project")
private val home: String = System.getProperty("user.home")
private val tildeTarget = "~/.gradle/init.d/offline.gradle"
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 input(
parameters: Map<String, Any>,
capabilities: Set<ToolCapability>,
probe: WorldProbe,
paramRoles: Map<String, ParamRole> = emptyMap(),
reads: Set<String> = emptySet(),
tool: String = "file_read",
activeTask: ActiveTask? = null,
) = ToolCallAssessmentInput(
request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), tool, parameters),
capabilities = capabilities,
workspace = WorkspacePolicy(workspace, emptyList()),
probe = probe,
paramRoles = paramRoles,
session = SessionContext(reads = reads, activeTask = activeTask),
)
@Test
fun `a tilde path is not mistaken for a non-existent in-workspace file`() {
val real = Path.of(home, ".gradle/init.d/offline.gradle")
val r = ReferenceExistsRule().assess(
input(
mapOf("path" to tildeTarget),
setOf(ToolCapability.FILE_READ),
FakeProbe(existing = setOf(real)),
),
)
assertEquals(RiskAction.PROCEED, r.disposition)
assertTrue(r.issues.isEmpty(), "a real home-directory file must not be reported as a hallucination")
assertEquals("true", r.observations.single().facts["exists"])
assertEquals("false", r.observations.single().facts["inWorkspace"])
}
@Test
fun `a tilde path still prompts as an out-of-workspace read`() {
// Containment behaviour is preserved: expansion moves the path OUT of the workspace, which is
// where it always belonged, so the operator approval fires instead of being silently skipped.
val r = PathContainmentRule().assess(
input(mapOf("path" to tildeTarget), setOf(ToolCapability.FILE_READ), FakeProbe()),
)
assertEquals(RiskAction.PROMPT_USER, r.disposition)
assertEquals("PATH_OUTSIDE_WORKSPACE", r.issues.single().code)
assertEquals(Path.of(home, ".gradle/init.d/offline.gradle").toString(), r.observations.single().facts["resolved"])
}
@Test
fun `containment judges a source path as well as a write target`() {
val r = PathContainmentRule().assess(
input(
mapOf("source" to "/etc/hosts", "dest" to "public/hosts"),
setOf(ToolCapability.FILE_WRITE),
FakeProbe(),
paramRoles = mapOf("source" to ParamRole.SOURCE_PATH, "dest" to ParamRole.PATH),
tool = "file_copy",
),
)
assertEquals(RiskAction.PROMPT_USER, r.disposition)
assertEquals(2, r.observations.size)
}
@Test
fun `the write-scope gate judges the write target only, not the source`() {
// A source path is read, not mutated, so it is not charged against the task's write scope —
// otherwise every copy of an in-repo asset would have to widen affected_paths to include it.
val r = WriteScopeRule().assess(
input(
mapOf("source" to "assets/logo.png", "dest" to "public/logo.png"),
setOf(ToolCapability.FILE_WRITE),
FakeProbe(),
paramRoles = mapOf("source" to ParamRole.SOURCE_PATH, "dest" to ParamRole.PATH),
tool = "file_copy",
activeTask = ActiveTask("42", listOf("public/**")),
),
)
assertEquals(RiskAction.PROCEED, r.disposition)
assertEquals(listOf("public/logo.png"), r.observations.map { it.facts["path"] })
}
@Test
fun `a content-from-disk call is exempt from read-before-write even when the dest exists`() {
// Overwriting an existing binary via a copy must not demand a file_read of it first — that
// read is impossible to satisfy usefully and is exactly the round-trip file_copy removes.
val dest = Path.of("/work/project/public/logo.png")
val r = ReadBeforeWriteRule().assess(
input(
mapOf("source" to "assets/logo.png", "dest" to "public/logo.png"),
setOf(ToolCapability.FILE_WRITE),
FakeProbe(existing = setOf(dest)),
paramRoles = mapOf("source" to ParamRole.SOURCE_PATH, "dest" to ParamRole.PATH),
tool = "file_copy",
),
)
assertEquals(RiskAction.PROCEED, r.disposition)
assertTrue(r.issues.isEmpty())
}
@Test
fun `a model-authored write still requires a prior read`() {
val target = "/work/project/src/A.kt"
val r = ReadBeforeWriteRule().assess(
input(
mapOf("path" to target),
setOf(ToolCapability.FILE_WRITE),
FakeProbe(existing = setOf(Path.of(target))),
paramRoles = mapOf("path" to ParamRole.PATH),
tool = "file_write",
),
)
assertEquals(RiskAction.BLOCK, r.disposition)
assertEquals("READ_BEFORE_WRITE", r.issues.single().code)
}
}
+2 -1
View File
@@ -20,7 +20,8 @@ CORREX kernel team.
- `FileMutationRecord` — records file-affecting side effects.
- `FileAffectingTool` — extended `Tool` interface for tools that write files; must declare `affectedPaths`.
- `OutputCompressionSpec` / `ToolOutputCompressor` / `DeclarativeCompressor` — compress large tool outputs to fit token budgets (Hard Invariant #6: compressed output is informational; original events are preserved).
- `ParamRole` — annotates tool parameter semantic roles (input path, output path, etc.).
- `ParamRole` — annotates tool parameter semantic roles: `PATH` (the path a call acts on / mutates), `SOURCE_PATH` (a path it only reads from while acting on another target, e.g. `file_copy`), `EXEC_COMMAND`, `NETWORK_TARGET`. Plane-2 gates dispatch on these, never on tool names.
- `ToolPath` — the ONE canonical path normalization rule: expands a leading `~`/`~/` to the user home, keeps absolute paths, anchors relative paths on the session's working dir (never the JVM cwd). Every filesystem tool and every plane-2 path rule must resolve through it so policy checks and execution act on the same path. `~other/…` is deliberately NOT expanded.
- `ValidationResult` — result from tool-level parameter validation (pre-execution check).
- Hard Invariant #5: all tool side effects are captured in events. Silent execution is not allowed.
@@ -1,3 +1,12 @@
package com.correx.core.tools.contract
enum class ParamRole { PATH, EXEC_COMMAND, NETWORK_TARGET }
/**
* The semantic role of a declared tool parameter, so plane-2 rules can judge a call by what each
* argument DOES rather than by the tool's name.
*
* [PATH] is the path a call acts ON (its write/read target). [SOURCE_PATH] is a path a call reads
* FROM while acting on some other target — e.g. `file_copy(source, dest)`. Containment and
* privileged-location gates cover both; write-target gates (read-before-write, stale-write, write
* scope, write manifest) cover only [PATH], because a source is not being mutated.
*/
enum class ParamRole { PATH, SOURCE_PATH, EXEC_COMMAND, NETWORK_TARGET }
@@ -0,0 +1,49 @@
package com.correx.core.tools.contract
import java.nio.file.Path
import java.nio.file.Paths
/**
* The ONE canonical rule for turning a model-supplied path string into the absolute path a tool
* will actually operate on. Every filesystem tool and every plane-2 path rule must resolve through
* here, so the policy/existence check and the execution act on the same path.
*
* The bug this exists to prevent: `~/.gradle/init.d/offline.gradle` used to be treated as a
* RELATIVE path (it is not absolute per `Path.isAbsolute`), resolved to
* `<workspace>/~/.gradle/init.d/offline.gradle`, reported as "does not exist" — and, worse, as
* *inside* the workspace, so the out-of-workspace approval prompt never fired. The agent was then
* told a real file it had correctly identified was a hallucination.
*
* Rules, in order:
* 1. A leading `~` or `~/` expands to the current user's home. `~other/…` is NOT expanded — only
* the shell knows other users' homes, and guessing one would widen the jail on a lookalike path.
* 2. An absolute path is normalized and used as-is.
* 3. A relative path resolves against [base] (a session's workspace/working dir), never the JVM
* process cwd; with no [base] it falls back to the process cwd.
*
* Symlink resolution and containment stay where they were (`PathJail` / the plane-2 world probe):
* this object only decides WHICH path is meant, not whether it is allowed.
*/
object ToolPath {
/** Expands a leading `~`/`~/` in [raw] to [home]. Any other string is returned unchanged. */
fun expandHome(raw: String, home: String? = System.getProperty("user.home")): String = when {
home.isNullOrEmpty() -> raw
raw == "~" -> home
raw.startsWith("~/") -> home + raw.substring(1)
else -> raw
}
/**
* Resolves [raw] to the absolute, normalized path the tool will operate on: home-expanded,
* then anchored on [base] when relative.
*/
fun resolve(raw: String, base: Path?, home: String? = System.getProperty("user.home")): Path {
val expanded = Paths.get(expandHome(raw, home))
return when {
expanded.isAbsolute -> expanded.normalize()
base != null -> base.resolve(expanded).normalize()
else -> expanded.toAbsolutePath().normalize()
}
}
}
@@ -0,0 +1,71 @@
package com.correx.core.tools.contract
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertEquals
class ToolPathTest {
private val home = "/home/tester"
private val base: Path = Path.of("/work/project")
@Test
fun `a leading tilde expands to the user home`() {
// The bug this guards: `~/.gradle/init.d/offline.gradle` used to resolve to
// <workspace>/~/.gradle/... — reported as non-existent AND as inside the workspace, so the
// agent was told a real file it had correctly identified did not exist.
assertEquals(
Path.of("/home/tester/.gradle/init.d/offline.gradle"),
ToolPath.resolve("~/.gradle/init.d/offline.gradle", base, home),
)
}
@Test
fun `a bare tilde is the home directory itself`() {
assertEquals(Path.of("/home/tester"), ToolPath.resolve("~", base, home))
}
@Test
fun `another users home is not expanded`() {
// Only the shell knows other users' homes; guessing would widen the jail on a lookalike path.
assertEquals(Path.of("/work/project/~other/notes.md"), ToolPath.resolve("~other/notes.md", base, home))
}
@Test
fun `an absolute path is normalized and kept`() {
assertEquals(Path.of("/etc/hosts"), ToolPath.resolve("/etc/./hosts", base, home))
assertEquals(Path.of("/work/a.txt"), ToolPath.resolve("/work/project/../a.txt", base, home))
}
@Test
fun `a relative path anchors on the base, not the process cwd`() {
assertEquals(Path.of("/work/project/src/A.kt"), ToolPath.resolve("src/A.kt", base, home))
assertEquals(Path.of("/work/project/src/A.kt"), ToolPath.resolve("./src/A.kt", base, home))
}
@Test
fun `a relative path escaping the base stays escaped for the containment check to see`() {
// Normalization must not silently clamp `..` back inside: the jail decides, not this object.
assertEquals(Path.of("/work/secrets.txt"), ToolPath.resolve("../secrets.txt", base, home))
}
@Test
fun `with no base a relative path falls back to the process cwd`() {
assertEquals(
Path.of("").toAbsolutePath().resolve("src/A.kt").normalize(),
ToolPath.resolve("src/A.kt", null, home),
)
}
@Test
fun `an unknown home leaves the tilde untouched`() {
assertEquals(Path.of("/work/project/~/x"), ToolPath.resolve("~/x", base, home = null))
}
@Test
fun `expandHome only rewrites a leading tilde`() {
assertEquals("/home/tester/x", ToolPath.expandHome("~/x", home))
assertEquals("src/~/x", ToolPath.expandHome("src/~/x", home))
assertEquals("/a/b", ToolPath.expandHome("/a/b", home))
}
}