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
+1
View File
@@ -19,6 +19,7 @@ All sources under `apps/server/src/`.
- `GET /health` — health report (probes: event-store, llama-server, disk watermark) - `GET /health` — health report (probes: event-store, llama-server, disk watermark)
- `GET /stats` — metrics report (MetricsProjection) - `GET /stats` — metrics report (MetricsProjection)
- `GET /metrics/tool-reliability` — per-model tool-call validity across the event log (`ToolReliabilityInspectionService`); groundwork for capability-aware routing - `GET /metrics/tool-reliability` — per-model tool-call validity across the event log (`ToolReliabilityInspectionService`); groundwork for capability-aware routing
- `GET /metrics/failure-attribution` — terminal-failure attribution across the event log (`FailureAttributionInspectionService`): count and share per `FailureAttribution` layer, the UNKNOWN share, the preserved reasons behind each row, and the `FailureTicketOpened` categories from the same sessions. Read-only: events recorded before `WorkflowFailedEvent.attribution` existed are classified at read time by `FailureAttributor` and reported as `inferred`, never written back.
- Optional `[git]` transport creates `run/<sessionId>` from a server-local checkout and pushes it at terminal state; clients review with ordinary Git and never supply a remote URL as `cwd`. - Optional `[git]` transport creates `run/<sessionId>` from a server-local checkout and pushes it at terminal state; clients review with ordinary Git and never supply a remote URL as `cwd`.
- Repo-map L3 embeddings use bounded, recorded source descriptors (module/package, imports, leading purpose comment, symbols); raw file bodies are never embedded. Their versioned `repomap:v2` namespace forces a one-time re-embed when the semantic document format changes. - Repo-map L3 embeddings use bounded, recorded source descriptors (module/package, imports, leading purpose comment, symbols); raw file bodies are never embedded. Their versioned `repomap:v2` namespace forces a one-time re-embed when the semantic document format changes.
- At boot, `tools.workspace_root` is the authoritative default tool jail. Every session records its own resolved workspace binding; repo maps, project memory, profile/instruction snapshots, and git run branches use that binding and skip unbound sessions. `[project]` never supplies a workspace root. - At boot, `tools.workspace_root` is the authoritative default tool jail. Every session records its own resolved workspace binding; repo maps, project memory, profile/instruction snapshots, and git run branches use that binding and skip unbound sessions. `[project]` never supplies a workspace root.
@@ -1,6 +1,7 @@
package com.correx.apps.server package com.correx.apps.server
import com.correx.apps.server.health.HealthInspectionService import com.correx.apps.server.health.HealthInspectionService
import com.correx.apps.server.metrics.FailureAttributionInspectionService
import com.correx.apps.server.metrics.ToolReliabilityInspectionService import com.correx.apps.server.metrics.ToolReliabilityInspectionService
import com.correx.apps.server.routes.providerRoutes import com.correx.apps.server.routes.providerRoutes
import com.correx.apps.server.routes.sessionRoutes import com.correx.apps.server.routes.sessionRoutes
@@ -40,6 +41,7 @@ fun Application.configureServer(module: ServerModule) {
val globalStreamHandler = GlobalStreamHandler(module) val globalStreamHandler = GlobalStreamHandler(module)
val healthInspection = HealthInspectionService(module.eventStore) val healthInspection = HealthInspectionService(module.eventStore)
val toolReliability = ToolReliabilityInspectionService(module.eventStore) val toolReliability = ToolReliabilityInspectionService(module.eventStore)
val failureAttribution = FailureAttributionInspectionService(module.eventStore)
routing { routing {
get("/health") { get("/health") {
@@ -59,6 +61,13 @@ fun Application.configureServer(module: ServerModule) {
call.respond(toolReliability.inspect()) call.respond(toolReliability.inspect())
} }
// Terminal-failure attribution across the whole event log: which layer each WorkflowFailed
// belongs to, and the UNKNOWN share. Read-only; events recorded before the attribution field
// are classified at read time and reported as `inferred`, never written back.
get("/metrics/failure-attribution") {
call.respond(failureAttribution.inspect())
}
webSocket("/stream") { webSocket("/stream") {
globalStreamHandler.handle(this) globalStreamHandler.handle(this)
} }
@@ -8,6 +8,8 @@ import com.correx.apps.server.registry.ProviderRegistry
import com.correx.apps.server.registry.WorkflowRegistry import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.apps.server.workspace.WorkspaceResolver import com.correx.apps.server.workspace.WorkspaceResolver
import com.correx.apps.server.workspace.WorkspaceResolution import com.correx.apps.server.workspace.WorkspaceResolution
import com.correx.core.events.events.FailureAttribution
import com.correx.core.events.events.FailureAttributor
import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.kernel.orchestration.WorkspaceContext import com.correx.core.kernel.orchestration.WorkspaceContext
import com.correx.core.approvals.ApprovalProjector import com.correx.core.approvals.ApprovalProjector
@@ -478,6 +480,10 @@ class ServerModule(
stageId = failingStageId, stageId = failingStageId,
reason = reason, reason = reason,
retryExhausted = retryExhausted, retryExhausted = retryExhausted,
// This is the catch-all for a throwable that escaped the orchestrator, so the
// default layer is correx itself; the reason text still wins when it names an
// outer layer (provider timeout, missing program, operator cancellation).
attribution = FailureAttributor.classify(reason, fallback = FailureAttribution.HARNESS),
), ),
), ),
) )
@@ -7,6 +7,7 @@ import com.correx.core.events.events.CapabilityGapVerdict
import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.FailureAttribution
import com.correx.core.events.events.NewEvent import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.PlanGroundingEvaluatedEvent import com.correx.core.events.events.PlanGroundingEvaluatedEvent
import com.correx.core.events.events.PlanGroundingVerdict import com.correx.core.events.events.PlanGroundingVerdict
@@ -371,6 +372,8 @@ class FreestyleDriver(
stageId = StageId("architect"), stageId = StageId("architect"),
reason = "execution plan rejected ($source): $reason", reason = "execution plan rejected ($source): $reason",
retryExhausted = false, retryExhausted = false,
// The rejected plan is the model's own output; the harness evaluated it correctly.
attribution = FailureAttribution.AGENT,
), ),
), ),
) )
@@ -0,0 +1,140 @@
package com.correx.apps.server.metrics
import com.correx.core.events.events.FailureAttribution
import com.correx.core.events.events.FailureAttributor
import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.stores.EventStore
import kotlinx.serialization.Serializable
private const val PERCENT = 100.0
private const val REASON_BUCKET_MAX = 110
private const val TOP_REASONS = 10
@Serializable
data class AttributionRow(
val attribution: String,
val count: Long,
val sharePct: Double,
/** Failures whose event carried this attribution when it was recorded. */
val recorded: Long,
/** Failures classified from the preserved reason at read time (the field was absent/UNKNOWN). */
val inferred: Long,
/** The preserved [WorkflowFailedEvent.reason] texts behind this row, most frequent first. */
val topReasons: List<ReasonCount>,
/** Categories of the [FailureTicketOpenedEvent]s in the same sessions — the causal chain when
* more than one layer contributed. Empty when no tickets were opened. */
val contributingTicketCategories: List<ReasonCount>,
)
@Serializable
data class FailureAttributionReport(
val totalFailures: Long,
val recordedFailures: Long,
val inferredFailures: Long,
val unknownCount: Long,
val unknownPct: Double,
val byAttribution: List<AttributionRow>,
/** Every reason that no marker matched, so UNKNOWN can never sit unexamined. */
val unknownReasons: List<ReasonCount>,
)
/**
* Failure attribution across the whole event log: how many terminal [WorkflowFailedEvent]s belong to
* each layer, and what share is still UNKNOWN. The denominator you need before changing execution
* behaviour — "correx failed N runs" is not actionable, "N of them were harness defects" is.
*
* Read-only and idempotent by construction: historical events recorded before
* [WorkflowFailedEvent.attribution] existed are classified at READ time by [FailureAttributor], the
* same function live emission uses. Nothing is written back — history is append-only, and a
* re-derived classification is a projection, not a fact (Hard Invariant #2). Each row separates what
* was `recorded` at emission from what this service `inferred`, so a backfilled baseline never
* masquerades as originally-recorded data. Re-running it over the same log yields the same numbers.
*/
class FailureAttributionInspectionService(private val eventStore: EventStore) {
private class Agg {
var recorded: Long = 0
var inferred: Long = 0
val reasons: MutableMap<String, Long> = linkedMapOf()
val sessions: MutableSet<String> = linkedSetOf()
}
@Suppress("NestedBlockDepth")
fun inspect(): FailureAttributionReport {
val byAttribution = linkedMapOf<FailureAttribution, Agg>()
val ticketsBySession = linkedMapOf<String, MutableMap<String, Long>>()
val unknownReasons = linkedMapOf<String, Long>()
eventStore.allEvents().forEach { stored ->
when (val payload = stored.payload) {
is FailureTicketOpenedEvent -> {
val categories = ticketsBySession.getOrPut(payload.sessionId.value) { linkedMapOf() }
categories[payload.category] = (categories[payload.category] ?: 0) + 1
}
is WorkflowFailedEvent -> {
val wasRecorded = payload.attribution != FailureAttribution.UNKNOWN
val attribution =
if (wasRecorded) payload.attribution else FailureAttributor.classify(payload.reason)
val agg = byAttribution.getOrPut(attribution) { Agg() }
if (wasRecorded) agg.recorded++ else agg.inferred++
val bucket = payload.reason.lineSequence().firstOrNull().orEmpty().take(REASON_BUCKET_MAX)
agg.reasons[bucket] = (agg.reasons[bucket] ?: 0) + 1
agg.sessions += payload.sessionId.value
if (attribution == FailureAttribution.UNKNOWN) {
unknownReasons[bucket] = (unknownReasons[bucket] ?: 0) + 1
}
}
else -> Unit
}
}
val total = byAttribution.values.sumOf { it.recorded + it.inferred }
val unknown = byAttribution[FailureAttribution.UNKNOWN]?.let { it.recorded + it.inferred } ?: 0
val rows = byAttribution.entries
.sortedByDescending { it.value.recorded + it.value.inferred }
.map { (attribution, agg) -> row(attribution, agg, total, ticketsBySession) }
return FailureAttributionReport(
totalFailures = total,
recordedFailures = byAttribution.values.sumOf { it.recorded },
inferredFailures = byAttribution.values.sumOf { it.inferred },
unknownCount = unknown,
unknownPct = share(unknown, total),
byAttribution = rows,
unknownReasons = topOf(unknownReasons),
)
}
private fun row(
attribution: FailureAttribution,
agg: Agg,
total: Long,
ticketsBySession: Map<String, Map<String, Long>>,
): AttributionRow {
val count = agg.recorded + agg.inferred
val tickets = linkedMapOf<String, Long>()
agg.sessions.forEach { sessionId ->
ticketsBySession[sessionId]?.forEach { (category, n) ->
tickets[category] = (tickets[category] ?: 0) + n
}
}
return AttributionRow(
attribution = attribution.name,
count = count,
sharePct = share(count, total),
recorded = agg.recorded,
inferred = agg.inferred,
topReasons = topOf(agg.reasons),
contributingTicketCategories = topOf(tickets),
)
}
private fun topOf(counts: Map<String, Long>): List<ReasonCount> =
counts.entries.sortedByDescending { it.value }.take(TOP_REASONS).map { ReasonCount(it.key, it.value) }
private fun share(part: Long, total: Long): Double =
if (total == 0L) 0.0 else part.toDouble() / total * PERCENT
}
+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. - `JsonEventSerializer` / `EventSerializer` — serialize/deserialize `StoredEvent` to JSON.
- `EventDispatcher` — broadcasts events to in-process listeners. - `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. - 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. - `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`. - 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. - 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. - `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. - `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. - `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 ## 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 stageId: StageId,
val reason: String, val reason: String,
val retryExhausted: Boolean, 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 ) : 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 package com.correx.core.kernel.orchestration
import com.correx.core.tools.contract.ToolPath
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json 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 — // 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 // resolving against the daemon CWD showed the operator the wrong file (or nothing) when
// server CWD ≠ workspace_root. // server CWD ≠ workspace_root.
val raw = java.nio.file.Paths.get(path) val filePath = ToolPath.resolve(path, workspaceRoot)
val filePath = if (raw.isAbsolute || workspaceRoot == null) raw else workspaceRoot.resolve(raw)
if (java.nio.file.Files.exists(filePath)) { if (java.nio.file.Files.exists(filePath)) {
java.nio.file.Files.readString(filePath) java.nio.file.Files.readString(filePath)
} else null } 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.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload 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.NewEvent
import com.correx.core.events.events.TransitionExecutedEvent import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowCompletedEvent
@@ -108,6 +110,9 @@ internal suspend fun SessionOrchestrator.failWorkflow(
stageId: StageId, stageId: StageId,
reason: String, reason: String,
retryExhausted: Boolean, 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 { ): WorkflowResult.Failed {
log.warn( log.warn(
"[Orchestrator] FAILED session={} stage={} reason={} retryExhausted={}", "[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 // (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. // full throwable so it is never silent, then still return a clean Failed result.
runCatching { runCatching {
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted)) emit(
sessionId,
WorkflowFailedEvent(
sessionId,
stageId,
reason,
retryExhausted,
attribution ?: FailureAttributor.classify(reason),
),
)
}.onFailure { e -> }.onFailure { e ->
log.error( log.error(
"[Orchestrator] failWorkflow: FAILED to record terminal WorkflowFailedEvent — event " + "[Orchestrator] failWorkflow: FAILED to record terminal WorkflowFailedEvent — event " +
@@ -1,5 +1,6 @@
package com.correx.core.kernel.orchestration 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.ToolCallAssessedEvent
import com.correx.core.events.events.RepoMapComputedEvent import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.OutsidePathAccessGrantedEvent import com.correx.core.events.events.OutsidePathAccessGrantedEvent
@@ -212,7 +213,16 @@ internal suspend fun SessionOrchestrator.handleCancellation(
stageId: StageId, stageId: StageId,
): WorkflowResult.Cancelled { ): WorkflowResult.Cancelled {
log.warn("[Orchestrator] CANCELLED session={} stage={}", sessionId.value, stageId.value) 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) cancellations.remove(sessionId)
return WorkflowResult.Cancelled(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. - `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. - `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`). - `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`. - `RiskMapping` — maps rule violations to risk levels for `core:risk`.
- `SessionContext` — session-scoped context passed to rules during evaluation. - `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 #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`. - 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`. - 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 ## Verification
@@ -7,6 +7,7 @@ import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallRule import com.correx.core.toolintent.ToolCallRule
import com.correx.core.toolintent.maxAction import com.correx.core.toolintent.maxAction
import com.correx.core.tools.contract.ToolCapability 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.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.FileSystems import java.nio.file.FileSystems
@@ -52,10 +53,8 @@ class ManifestContainmentRule : ToolCallRule {
val observations = mutableListOf<ToolCallObservation>() val observations = mutableListOf<ToolCallObservation>()
var disposition = RiskAction.PROCEED var disposition = RiskAction.PROCEED
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { for (raw in writeTargetPathStrings(input.paramRoles, input.request.parameters)) {
val candidate = Path.of(raw) val resolvedInput = ToolPath.resolve(raw, input.workspace.workspaceRoot)
val resolvedInput =
if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
val resolvedReal = input.probe.resolveReal(resolvedInput) val resolvedReal = input.probe.resolveReal(resolvedInput)
val inWorkspace = resolvedReal.startsWith(workspaceReal) val inWorkspace = resolvedReal.startsWith(workspaceReal)
val relative = if (inWorkspace) workspaceReal.relativize(resolvedReal).toString() else raw 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 * Every path-like argument of a tool call the call's own targets ([ParamRole.PATH]) and any path
* [ParamRole.PATH], or — when none are declared — any string value that looks like a * it merely reads from ([ParamRole.SOURCE_PATH]). Used by the containment/existence gates, which
* path. Shared by the path-containment and write-manifest rules so both judge exactly * must judge both: a source outside the workspace is as much an escape as a destination outside it.
* the same set of targets.
*/ */
internal fun candidatePathStrings(paramRoles: Map<String, ParamRole>, parameters: Map<String, Any>): List<String> { internal fun candidatePathStrings(paramRoles: Map<String, ParamRole>, parameters: Map<String, Any>): List<String> =
val declared = paramRoles.filterValues { it == ParamRole.PATH }.keys 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()) { return if (declared.isNotEmpty()) {
declared.flatMap { extractParamStrings(parameters[it]) } declared.flatMap { extractParamStrings(parameters[it]) }
} else { } else {
@@ -7,9 +7,9 @@ import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallRule import com.correx.core.toolintent.ToolCallRule
import com.correx.core.toolintent.maxAction import com.correx.core.toolintent.maxAction
import com.correx.core.tools.contract.ToolCapability 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.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.Path
/** /**
* Effect-based path containment. Dispatches on FILE_READ / FILE_WRITE. For every * Effect-based path containment. Dispatches on FILE_READ / FILE_WRITE. For every
@@ -34,9 +34,7 @@ class PathContainmentRule : ToolCallRule {
var disposition = RiskAction.PROCEED var disposition = RiskAction.PROCEED
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
val candidate = Path.of(raw) val resolvedInput = ToolPath.resolve(raw, input.workspace.workspaceRoot)
val resolvedInput =
if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
val resolvedReal = input.probe.resolveReal(resolvedInput) val resolvedReal = input.probe.resolveReal(resolvedInput)
val exists = input.probe.exists(resolvedInput) val exists = input.probe.exists(resolvedInput)
val privileged = privilegedReal.any { resolvedReal.startsWith(it) } 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.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallRule import com.correx.core.toolintent.ToolCallRule
import com.correx.core.toolintent.maxAction 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.ToolCapability
import com.correx.core.tools.contract.ToolPath
import com.correx.core.validation.model.ValidationIssue import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.Path import java.nio.file.Path
@@ -27,6 +29,12 @@ class ReadBeforeWriteRule : ToolCallRule {
ToolCapability.FILE_WRITE in capabilities ToolCapability.FILE_WRITE in capabilities
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { 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 root = input.workspace.workspaceRoot
val readReal = input.session.reads.map { realOf(input, root, it) }.toSet() val readReal = input.session.reads.map { realOf(input, root, it) }.toSet()
@@ -34,7 +42,7 @@ class ReadBeforeWriteRule : ToolCallRule {
val observations = mutableListOf<ToolCallObservation>() val observations = mutableListOf<ToolCallObservation>()
var disposition = RiskAction.PROCEED 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 resolvedInput = resolveInput(root, raw)
val exists = input.probe.exists(resolvedInput) val exists = input.probe.exists(resolvedInput)
val read = input.probe.resolveReal(resolvedInput) in readReal val read = input.probe.resolveReal(resolvedInput) in readReal
@@ -58,10 +66,7 @@ class ReadBeforeWriteRule : ToolCallRule {
return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition) return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
} }
private fun resolveInput(root: Path, raw: String): Path { private fun resolveInput(root: Path, raw: String): Path = ToolPath.resolve(raw, root)
val candidate = Path.of(raw)
return if (candidate.isAbsolute) candidate else root.resolve(candidate)
}
private fun realOf(input: ToolCallAssessmentInput, root: Path, raw: String): Path = private fun realOf(input: ToolCallAssessmentInput, root: Path, raw: String): Path =
input.probe.resolveReal(resolveInput(root, raw)) 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.ToolCallRule
import com.correx.core.toolintent.maxAction import com.correx.core.toolintent.maxAction
import com.correx.core.tools.contract.ToolCapability 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.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity 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 * 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 var disposition = RiskAction.PROCEED
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
val candidate = Path.of(raw) val resolvedInput = ToolPath.resolve(raw, root)
val resolvedInput = if (candidate.isAbsolute) candidate else root.resolve(candidate)
val exists = input.probe.exists(resolvedInput) val exists = input.probe.exists(resolvedInput)
val inWorkspace = input.probe.resolveReal(resolvedInput).startsWith(workspaceReal) 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.ToolCallRule
import com.correx.core.toolintent.maxAction import com.correx.core.toolintent.maxAction
import com.correx.core.tools.contract.ToolCapability 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.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.Path import java.nio.file.Path
@@ -35,7 +36,7 @@ class StaleWriteRule : ToolCallRule {
val observations = mutableListOf<ToolCallObservation>() val observations = mutableListOf<ToolCallObservation>()
var disposition = RiskAction.PROCEED 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 resolved = resolveInput(root, raw)
val real = input.probe.resolveReal(resolved) val real = input.probe.resolveReal(resolved)
val recorded = hashByReal[real] val recorded = hashByReal[real]
@@ -60,10 +61,7 @@ class StaleWriteRule : ToolCallRule {
return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition) return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
} }
private fun resolveInput(root: Path, raw: String): Path { private fun resolveInput(root: Path, raw: String): Path = ToolPath.resolve(raw, root)
val candidate = Path.of(raw)
return if (candidate.isAbsolute) candidate else root.resolve(candidate)
}
private fun realOf(input: ToolCallAssessmentInput, root: Path, raw: String): Path = private fun realOf(input: ToolCallAssessmentInput, root: Path, raw: String): Path =
input.probe.resolveReal(resolveInput(root, raw)) 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.ToolCallRule
import com.correx.core.toolintent.maxAction import com.correx.core.toolintent.maxAction
import com.correx.core.tools.contract.ToolCapability 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.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.FileSystems 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 * 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>() val observations = mutableListOf<ToolCallObservation>()
var disposition = RiskAction.PROCEED var disposition = RiskAction.PROCEED
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { for (raw in writeTargetPathStrings(input.paramRoles, input.request.parameters)) {
val candidate = Path.of(raw) val resolvedReal = input.probe.resolveReal(ToolPath.resolve(raw, input.workspace.workspaceRoot))
val resolvedReal = input.probe.resolveReal(
if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate),
)
if (!resolvedReal.startsWith(workspaceReal)) continue // out of workspace: not this gate if (!resolvedReal.startsWith(workspaceReal)) continue // out of workspace: not this gate
val rel = workspaceReal.relativize(resolvedReal) val rel = workspaceReal.relativize(resolvedReal)
val inScope = matchers.any { it.matches(rel) } 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. - `FileMutationRecord` — records file-affecting side effects.
- `FileAffectingTool` — extended `Tool` interface for tools that write files; must declare `affectedPaths`. - `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). - `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). - `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. - 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 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))
}
}
+2
View File
@@ -16,6 +16,8 @@ Adapter for `core:tools`. Depends on `core:tools`, `core:events`, `core:approval
- Web search and web fetch results are environment observations; they must be recorded as events by callers to preserve replay determinism (invariant #9). - Web search and web fetch results are environment observations; they must be recorded as events by callers to preserve replay determinism (invariant #9).
- `ToolConfig` is the only configuration surface; pass via `InfrastructureModule.createToolExecutor()`. - `ToolConfig` is the only configuration surface; pass via `InfrastructureModule.createToolExecutor()`.
- `buildTools()` extension on `ToolConfig` assembles the full tool list; add new tools there, not in the registry directly. - `buildTools()` extension on `ToolConfig` assembles the full tool list; add new tools there, not in the registry directly.
- `file_copy` copies one existing file to another path (`{source, dest}`) so static/binary assets never pass through the model's token stream. Same jail, anchor and `fileWrite.enabled` toggle as `file_write`; `dest` is the mutated target (`ParamRole.PATH`, the only affected path), `source` is read-only (`ParamRole.SOURCE_PATH`) and may sit under an operator-granted out-of-workspace path exactly as a `file_read` may. One regular file per call: no recursion, no globs.
- Every path parameter resolves through `ToolPath.resolve` (`core:tools`), which expands a leading `~` and anchors relatives on the bound workspace's working dir. Do not re-implement path resolution in a tool.
- Filesystem mutation is split by intent: `file_write` only writes (`{path, content}`), `file_edit` edits, and `file_delete` only deletes (`{path}`) — deletion is a separately-named capability so a model can never delete by getting a write-mode parameter wrong. `file_delete` shares `file_write`'s path jail and `fileWrite.enabled` toggle and carries `ToolCapability.FILE_WRITE`. - Filesystem mutation is split by intent: `file_write` only writes (`{path, content}`), `file_edit` edits, and `file_delete` only deletes (`{path}`) — deletion is a separately-named capability so a model can never delete by getting a write-mode parameter wrong. `file_delete` shares `file_write`'s path jail and `fileWrite.enabled` toggle and carries `ToolCapability.FILE_WRITE`.
- `list_dir` is shallow by default, but collapses a non-symlink single-child directory chain (bounded depth) to the first branch point and explains that expansion in its output; recursive listings retain normal tree traversal. - `list_dir` is shallow by default, but collapses a non-symlink single-child directory chain (bounded depth) to the first branch point and explains that expansion in its output; recursive listings retain normal tree traversal.
@@ -0,0 +1,205 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.ParamRole
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolPath
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.io.IOException
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
/**
* Copies one file to another path (#713). Without it, moving an existing asset — a static file, an
* image, anything binary — has to go `file_read` → model context → `file_write`, which burns tool
* rounds, floods the context window and corrupts any byte the tokenizer cannot round-trip. A copy
* never puts the bytes in front of the model.
*
* Carries [ToolCapability.FILE_WRITE] (it mutates the filesystem) and shares [FileWriteTool]'s jail
* and toggle. `dest` is the mutated target ([ParamRole.PATH]); `source` is only read
* ([ParamRole.SOURCE_PATH]), so the plane-2 write-target gates (read-before-write, stale-write,
* write scope, write manifest) judge `dest` alone while containment judges both. `source` may
* additionally sit under an operator-approved out-of-workspace path (`grantedPaths`), exactly as a
* `file_read` may; `dest` never can.
*
* ponytail: single regular file per call — no recursive directory copy, no glob. Copying a tree is
* N calls; add recursion only if a real run shows N is the bottleneck.
*/
class FileCopyTool(
allowedPaths: Set<Path> = emptySet(),
private val workingDir: Path? = null,
) : Tool, FileAffectingTool, ToolExecutor {
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
override val name: String = "file_copy"
override val description: String =
"Copy the file at 'source' to 'dest' (creates missing parent directories, overwrites an " +
"existing dest). Use this for existing or binary files instead of reading and " +
"re-writing their contents."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("source") {
put("type", "string")
put("description", "Relative path of the existing file to copy from")
}
putJsonObject("dest") {
put("type", "string")
put("description", "Relative path to copy to")
}
}
put(
"required",
buildJsonArray {
add(JsonPrimitive("source"))
add(JsonPrimitive("dest"))
},
)
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
override val paramRoles: Map<String, ParamRole> =
mapOf("source" to ParamRole.SOURCE_PATH, "dest" to ParamRole.PATH)
/** Only `dest` is mutated, so only `dest` gets pre/post CAS images and undo coverage. */
override fun affectedPaths(request: ToolRequest): Set<Path> {
val dest = request.parameters["dest"] as? String ?: return emptySet()
return setOf(ToolPath.resolve(dest, workingDir))
}
override fun validateRequest(request: ToolRequest): ValidationResult {
val source = request.parameters["source"] as? String
?: return ValidationResult.Invalid(
"Missing 'source' parameter (string). Call file_copy with " +
"""{"source": "<existing path>", "dest": "<path to copy to>"}.""",
)
val dest = request.parameters["dest"] as? String
?: return ValidationResult.Invalid(
"""Missing 'dest' parameter (string). Call file_copy with {"source": "$source", "dest": "<path>"}.""",
)
if (normalizedAllowedPaths.isEmpty()) return ValidationResult.Invalid("No paths are allowed.")
return runCatching {
// The source is a read, so it may also be an operator-approved out-of-workspace path
// (OutsidePathAccessGrantedEvent), mirroring file_read. The dest is a write: jail only.
val readRoots = normalizedAllowedPaths + request.grantedPaths.map { Paths.get(it) }
when {
!PathJail.isContained(ToolPath.resolve(source, workingDir), readRoots) ->
ValidationResult.Invalid("Path '$source' is not in the allowed list.")
!PathJail.isContained(ToolPath.resolve(dest, workingDir), normalizedAllowedPaths) ->
ValidationResult.Invalid("Path '$dest' is not in the allowed list.")
else -> ValidationResult.Valid
}
}.getOrElse { e -> mapExceptionToValidationResult(e) }
}
private fun mapExceptionToValidationResult(e: Throwable): ValidationResult =
when (e) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}")
is IOException -> ValidationResult.Invalid("IO error: ${e.message}")
is SecurityException -> ValidationResult.Invalid("Security error: ${e.message}")
else -> ValidationResult.Invalid(e.message ?: "Unknown error occurred")
}
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
val validation = validateRequest(request)
if (validation is ValidationResult.Invalid) {
return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = validation.reason,
recoverable = false,
)
}
val sourceString = request.parameters["source"] as String
val destString = request.parameters["dest"] as String
val source = ToolPath.resolve(sourceString, workingDir)
val dest = ToolPath.resolve(destString, workingDir)
// Every rejection below is recoverable: each one is a mistake the agent can correct on the
// next tool round (fix the path, name a file inside the directory) rather than a dead stage.
when {
!Files.exists(source) -> return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = "Source file not found: $sourceString",
recoverable = true,
)
Files.isDirectory(source) -> return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = "Source '$sourceString' is a directory — file_copy copies one file per call.",
recoverable = true,
)
Files.isDirectory(dest) -> return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = "Dest '$destString' is a directory — name the file inside it " +
"(e.g. '$destString/${source.fileName}').",
recoverable = true,
)
source == dest -> return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = "Source and dest resolve to the same file ($destString) — nothing to copy.",
recoverable = true,
)
}
runCatching {
dest.parent?.let { Files.createDirectories(it) }
Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING)
ToolResult.Success(
invocationId = request.invocationId,
output = "Copied $sourceString to $destString (${Files.size(dest)} bytes)",
metadata = mapOf("source" to sourceString, "dest" to destString),
)
}.getOrElse { e -> handleExecutionException(e, request.invocationId, destString) }
}
private fun handleExecutionException(
e: Throwable,
invocationId: ToolInvocationId,
destString: String,
): ToolResult = when (e) {
is CancellationException -> throw e
// Recoverable for the same reason as file_write's IO errors: the agent can correct the
// path and retry inside the stage's tool loop.
is IOException -> ToolResult.Failure(
invocationId = invocationId,
reason = "IO error copying to '$destString': ${e.message ?: e.javaClass.simpleName}",
recoverable = true,
)
is SecurityException -> ToolResult.Failure(
invocationId = invocationId,
reason = "Access denied: $destString, ${e.message}",
recoverable = false,
)
else -> ToolResult.Failure(
invocationId = invocationId,
reason = e.message ?: "Unknown error occurred",
recoverable = false,
)
}
}
@@ -6,6 +6,7 @@ import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.FileAffectingTool import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.ParamRole import com.correx.core.tools.contract.ParamRole
import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolPath
import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult import com.correx.core.tools.contract.ToolResult
@@ -23,7 +24,6 @@ import java.io.IOException
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.InvalidPathException import java.nio.file.InvalidPathException
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths
/** /**
* Deletes a file. Split out of [FileWriteTool] so deletion is an explicitly-named capability a model * Deletes a file. Split out of [FileWriteTool] so deletion is an explicitly-named capability a model
@@ -59,12 +59,10 @@ class FileDeleteTool(
} }
private fun resolvePath(pathString: String): Path { private fun resolvePath(pathString: String): Path {
val raw = Paths.get(pathString) // ToolPath is the ONE normalization rule (expands a leading `~`, anchors relatives on the
return when { // bound workspace's working dir, never the JVM cwd) so the jail check, the existence check
raw.isAbsolute -> raw.normalize() // and the operation itself all act on the same path.
workingDir != null -> workingDir.resolve(raw).normalize() return ToolPath.resolve(pathString, workingDir)
else -> raw.toAbsolutePath().normalize()
}
} }
override fun validateRequest(request: ToolRequest): ValidationResult { override fun validateRequest(request: ToolRequest): ValidationResult {
@@ -6,6 +6,7 @@ import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.FileAffectingTool import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.ParamRole import com.correx.core.tools.contract.ParamRole
import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolPath
import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult import com.correx.core.tools.contract.ToolResult
@@ -33,12 +34,10 @@ class FileEditTool(
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet() private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
private fun resolvePath(pathString: String): Path { private fun resolvePath(pathString: String): Path {
val raw = Paths.get(pathString) // ToolPath is the ONE normalization rule (expands a leading `~`, anchors relatives on the
return when { // bound workspace's working dir, never the JVM cwd) so the jail check, the existence check
raw.isAbsolute -> raw.normalize() // and the operation itself all act on the same path.
workingDir != null -> workingDir.resolve(raw).normalize() return ToolPath.resolve(pathString, workingDir)
else -> raw.toAbsolutePath().normalize()
}
} }
override val name: String = "file_edit" override val name: String = "file_edit"
@@ -9,6 +9,7 @@ import com.correx.core.tools.compression.OutputCompressionSpec
import com.correx.core.tools.compression.ToolOutputCompressor import com.correx.core.tools.compression.ToolOutputCompressor
import com.correx.core.tools.contract.ParamRole import com.correx.core.tools.contract.ParamRole
import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolPath
import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult import com.correx.core.tools.contract.ToolResult
@@ -38,12 +39,10 @@ class FileReadTool(
// process cwd. Mirrors FileWriteTool/FileEditTool so all three jail against the // process cwd. Mirrors FileWriteTool/FileEditTool so all three jail against the
// same anchor and agree with the Plane-2 tool-call-intent containment check. // same anchor and agree with the Plane-2 tool-call-intent containment check.
private fun resolvePath(pathString: String): Path { private fun resolvePath(pathString: String): Path {
val raw = Paths.get(pathString) // ToolPath is the ONE normalization rule (expands a leading `~`, anchors relatives on the
return when { // bound workspace's working dir, never the JVM cwd) so the jail check, the existence check
raw.isAbsolute -> raw.normalize() // and the operation itself all act on the same path.
workingDir != null -> workingDir.resolve(raw).normalize() return ToolPath.resolve(pathString, workingDir)
else -> raw.toAbsolutePath().normalize()
}
} }
override val name: String = "file_read" override val name: String = "file_read"
@@ -6,6 +6,7 @@ import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.FileAffectingTool import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.ParamRole import com.correx.core.tools.contract.ParamRole
import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolPath
import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult import com.correx.core.tools.contract.ToolResult
@@ -23,7 +24,6 @@ import java.io.IOException
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.InvalidPathException import java.nio.file.InvalidPathException
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths
/** /**
* Writes content to a file. Write-only by design: deleting is the separate, explicitly-named * Writes content to a file. Write-only by design: deleting is the separate, explicitly-named
@@ -73,12 +73,10 @@ class FileWriteTool(
} }
private fun resolvePath(pathString: String): Path { private fun resolvePath(pathString: String): Path {
val raw = Paths.get(pathString) // ToolPath is the ONE normalization rule (expands a leading `~`, anchors relatives on the
return when { // bound workspace's working dir, never the JVM cwd) so the jail check, the existence check
raw.isAbsolute -> raw.normalize() // and the operation itself all act on the same path.
workingDir != null -> workingDir.resolve(raw).normalize() return ToolPath.resolve(pathString, workingDir)
else -> raw.toAbsolutePath().normalize()
}
} }
override fun validateRequest(request: ToolRequest): ValidationResult { override fun validateRequest(request: ToolRequest): ValidationResult {
@@ -4,6 +4,7 @@ import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.ParamRole import com.correx.core.tools.contract.ParamRole
import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolPath
import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult import com.correx.core.tools.contract.ToolResult
@@ -43,12 +44,10 @@ class ListDirTool(
) : Tool, ToolExecutor { ) : Tool, ToolExecutor {
private fun resolvePath(pathString: String): Path { private fun resolvePath(pathString: String): Path {
val raw = Paths.get(pathString) // ToolPath is the ONE normalization rule (expands a leading `~`, anchors relatives on the
return when { // bound workspace's working dir, never the JVM cwd) so the jail check, the existence check
raw.isAbsolute -> raw.normalize() // and the operation itself all act on the same path.
workingDir != null -> workingDir.resolve(raw).normalize() return ToolPath.resolve(pathString, workingDir)
else -> raw.toAbsolutePath().normalize()
}
} }
override val name: String = "list_dir" override val name: String = "list_dir"
@@ -4,6 +4,7 @@ import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.ParamRole import com.correx.core.tools.contract.ParamRole
import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolPath
import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult import com.correx.core.tools.contract.ToolResult
@@ -224,12 +225,7 @@ class GrepTool(
private fun resolveSearchPath(request: ToolRequest, workingDir: Path?): Path { private fun resolveSearchPath(request: ToolRequest, workingDir: Path?): Path {
val pathString = (request.parameters["path"] as? String)?.takeIf { it.isNotBlank() } ?: "." val pathString = (request.parameters["path"] as? String)?.takeIf { it.isNotBlank() } ?: "."
val raw = Paths.get(pathString) return ToolPath.resolve(pathString, workingDir)
return when {
raw.isAbsolute -> raw.normalize()
workingDir != null -> workingDir.resolve(raw).normalize()
else -> raw.toAbsolutePath().normalize()
}
} }
private fun validateSearchPath(request: ToolRequest, allowedPaths: Set<Path>, workingDir: Path?): ValidationResult = private fun validateSearchPath(request: ToolRequest, allowedPaths: Set<Path>, workingDir: Path?): ValidationResult =
@@ -0,0 +1,162 @@
package com.correx.infrastructure.tools.filesystem
import com.correx.core.events.events.ToolRequest
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.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import java.util.UUID
class FileCopyToolTest {
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
private fun request(parameters: Map<String, String>, grantedPaths: Set<String> = emptySet()): ToolRequest =
ToolRequest(
invocationId = invocationId,
sessionId = SessionId(UUID.randomUUID().toString()),
stageId = StageId(UUID.randomUUID().toString()),
toolName = "file_copy",
parameters = parameters,
grantedPaths = grantedPaths,
)
private fun workspace(): Path = Files.createTempDirectory("file_copy_test")
@Test
fun `copies bytes without routing them through the model`(): Unit = runBlocking {
val dir = workspace()
val bytes = byteArrayOf(0, 1, 2, -1, -128, 127)
Files.write(dir.resolve("logo.png"), bytes)
val tool = FileCopyTool(allowedPaths = setOf(dir), workingDir = dir)
val result = tool.execute(request(mapOf("source" to "logo.png", "dest" to "public/assets/logo.png")))
assertTrue(result is ToolResult.Success, (result as? ToolResult.Failure)?.reason)
// Parent directories are created, and every byte survives — the point of the tool.
assertArrayEquals(bytes, Files.readAllBytes(dir.resolve("public/assets/logo.png")))
}
@Test
fun `only dest is reported as an affected path`(): Unit = runBlocking {
val dir = workspace()
val tool = FileCopyTool(allowedPaths = setOf(dir), workingDir = dir)
assertEquals(
setOf(dir.resolve("b.txt")),
tool.affectedPaths(request(mapOf("source" to "a.txt", "dest" to "b.txt"))),
)
}
@Test
fun `overwrites an existing dest`(): Unit = runBlocking {
val dir = workspace()
Files.writeString(dir.resolve("a.txt"), "new")
Files.writeString(dir.resolve("b.txt"), "old")
val tool = FileCopyTool(allowedPaths = setOf(dir), workingDir = dir)
assertTrue(tool.execute(request(mapOf("source" to "a.txt", "dest" to "b.txt"))) is ToolResult.Success)
assertEquals("new", Files.readString(dir.resolve("b.txt")))
}
@Test
fun `a missing source is a recoverable failure`(): Unit = runBlocking {
val dir = workspace()
val tool = FileCopyTool(allowedPaths = setOf(dir), workingDir = dir)
val result = tool.execute(request(mapOf("source" to "ghost.txt", "dest" to "b.txt")))
assertTrue(result is ToolResult.Failure)
assertTrue((result as ToolResult.Failure).recoverable)
assertTrue(result.reason.contains("Source file not found"))
}
@Test
fun `a directory source or dest is rejected with the corrective move`(): Unit = runBlocking {
val dir = workspace()
Files.createDirectories(dir.resolve("assets"))
Files.writeString(dir.resolve("a.txt"), "x")
val tool = FileCopyTool(allowedPaths = setOf(dir), workingDir = dir)
val fromDir = tool.execute(request(mapOf("source" to "assets", "dest" to "b.txt")))
assertTrue((fromDir as ToolResult.Failure).reason.contains("one file per call"))
val toDir = tool.execute(request(mapOf("source" to "a.txt", "dest" to "assets")))
assertTrue((toDir as ToolResult.Failure).reason.contains("assets/a.txt"))
}
@Test
fun `copying a file onto itself is rejected`(): Unit = runBlocking {
val dir = workspace()
Files.writeString(dir.resolve("a.txt"), "x")
val tool = FileCopyTool(allowedPaths = setOf(dir), workingDir = dir)
val result = tool.execute(request(mapOf("source" to "a.txt", "dest" to "./a.txt")))
assertTrue((result as ToolResult.Failure).reason.contains("nothing to copy"))
}
@Test
fun `both source and dest are jailed`(): Unit = runBlocking {
val dir = workspace()
val outside = Files.createTempDirectory("file_copy_outside")
Files.writeString(outside.resolve("other.txt"), "s")
Files.writeString(dir.resolve("a.txt"), "x")
val tool = FileCopyTool(allowedPaths = setOf(dir), workingDir = dir)
val readEscape = tool.validateRequest(
request(mapOf("source" to outside.resolve("other.txt").toString(), "dest" to "a.txt")),
)
assertTrue(readEscape is ValidationResult.Invalid)
val writeEscape = tool.validateRequest(
request(mapOf("source" to "a.txt", "dest" to outside.resolve("copied.txt").toString())),
)
assertTrue(writeEscape is ValidationResult.Invalid)
}
@Test
fun `an operator-granted path widens the source jail but never the dest`(): Unit = runBlocking {
val dir = workspace()
val granted = Files.createTempDirectory("file_copy_granted")
Files.writeString(granted.resolve("asset.bin"), "a")
val tool = FileCopyTool(allowedPaths = setOf(dir), workingDir = dir)
val grants = setOf(granted.toString())
// Mirrors file_read: an approved out-of-workspace path may be READ from…
assertEquals(
ValidationResult.Valid,
tool.validateRequest(
request(mapOf("source" to granted.resolve("asset.bin").toString(), "dest" to "asset.bin"), grants),
),
)
// …but a write target never escapes the workspace, approved or not.
assertTrue(
tool.validateRequest(
request(mapOf("source" to "asset.bin", "dest" to granted.resolve("out.bin").toString()), grants),
) is ValidationResult.Invalid,
)
}
@Test
fun `a tilde path resolves to the user home instead of a phantom workspace path`(): Unit = runBlocking {
val home = Path.of(System.getProperty("user.home"))
val dir = workspace()
val tool = FileCopyTool(allowedPaths = setOf(dir, home), workingDir = dir)
// The canonical normalization applies to every path param, not just file_read's.
assertEquals(setOf(home.resolve("x.bin")), tool.affectedPaths(request(mapOf("dest" to "~/x.bin"))))
}
@Test
fun `missing parameters name the exact call shape`(): Unit = runBlocking {
val dir = workspace()
val tool = FileCopyTool(allowedPaths = setOf(dir), workingDir = dir)
val noSource = tool.validateRequest(request(mapOf("dest" to "b.txt")))
assertTrue((noSource as ValidationResult.Invalid).reason.contains("Missing 'source'"))
val noDest = tool.validateRequest(request(mapOf("source" to "a.txt")))
assertTrue((noDest as ValidationResult.Invalid).reason.contains("Missing 'dest'"))
}
}
@@ -200,4 +200,22 @@ class FileReadToolTest {
val compressed = tool.outputCompressor.compress(raw, ToolOutputContext(exitCode = 0)) val compressed = tool.outputCompressor.compress(raw, ToolOutputContext(exitCode = 0))
assertEquals("fun main() {\nprintln()\n}", compressed) assertEquals("fun main() {\nprintln()\n}", compressed)
} }
@Test
fun `a home-relative path is read, not reported as missing`(): Unit = runBlocking {
// The 2026-08 harness bug: `~/…` was treated as relative, resolved under the workspace, and
// reported as "File not found" — telling the agent a real file it had correctly identified
// did not exist. Every path param normalizes through ToolPath now.
val home = java.nio.file.Path.of(System.getProperty("user.home"))
val marker = Files.createTempFile(home, "correx_tilde_read", ".txt")
try {
Files.writeString(marker, "real content")
val tool = FileReadTool(allowedPaths = setOf(home), workingDir = Files.createTempDirectory("ws"))
val result = tool.execute(createRequest("~/${marker.fileName}"))
assertTrue(result is ToolResult.Success, (result as? ToolResult.Failure)?.reason)
assertEquals("real content", (result as ToolResult.Success).output)
} finally {
Files.deleteIfExists(marker)
}
}
} }
@@ -1,6 +1,7 @@
package com.correx.infrastructure.tools package com.correx.infrastructure.tools
import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.Tool
import com.correx.infrastructure.tools.filesystem.FileCopyTool
import com.correx.infrastructure.tools.filesystem.FileDeleteTool import com.correx.infrastructure.tools.filesystem.FileDeleteTool
import com.correx.infrastructure.tools.filesystem.FileEditTool import com.correx.infrastructure.tools.filesystem.FileEditTool
import com.correx.infrastructure.tools.filesystem.FileReadTool import com.correx.infrastructure.tools.filesystem.FileReadTool
@@ -104,6 +105,14 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
workingDir = fileWrite.workingDir, workingDir = fileWrite.workingDir,
), ),
) )
// file_copy moves existing/binary files without routing their bytes through the model's
// context (#713); same jail, anchor and toggle as the writer.
add(
FileCopyTool(
allowedPaths = fileWrite.allowedPaths,
workingDir = fileWrite.workingDir,
),
)
} }
if (fileEdit.enabled) { if (fileEdit.enabled) {
add( add(