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,3 +1,12 @@
package com.correx.core.tools.contract
enum class ParamRole { PATH, EXEC_COMMAND, NETWORK_TARGET }
/**
* The semantic role of a declared tool parameter, so plane-2 rules can judge a call by what each
* argument DOES rather than by the tool's name.
*
* [PATH] is the path a call acts ON (its write/read target). [SOURCE_PATH] is a path a call reads
* FROM while acting on some other target — e.g. `file_copy(source, dest)`. Containment and
* privileged-location gates cover both; write-target gates (read-before-write, stale-write, write
* scope, write manifest) cover only [PATH], because a source is not being mutated.
*/
enum class ParamRole { PATH, SOURCE_PATH, EXEC_COMMAND, NETWORK_TARGET }
@@ -0,0 +1,49 @@
package com.correx.core.tools.contract
import java.nio.file.Path
import java.nio.file.Paths
/**
* The ONE canonical rule for turning a model-supplied path string into the absolute path a tool
* will actually operate on. Every filesystem tool and every plane-2 path rule must resolve through
* here, so the policy/existence check and the execution act on the same path.
*
* The bug this exists to prevent: `~/.gradle/init.d/offline.gradle` used to be treated as a
* RELATIVE path (it is not absolute per `Path.isAbsolute`), resolved to
* `<workspace>/~/.gradle/init.d/offline.gradle`, reported as "does not exist" — and, worse, as
* *inside* the workspace, so the out-of-workspace approval prompt never fired. The agent was then
* told a real file it had correctly identified was a hallucination.
*
* Rules, in order:
* 1. A leading `~` or `~/` expands to the current user's home. `~other/…` is NOT expanded — only
* the shell knows other users' homes, and guessing one would widen the jail on a lookalike path.
* 2. An absolute path is normalized and used as-is.
* 3. A relative path resolves against [base] (a session's workspace/working dir), never the JVM
* process cwd; with no [base] it falls back to the process cwd.
*
* Symlink resolution and containment stay where they were (`PathJail` / the plane-2 world probe):
* this object only decides WHICH path is meant, not whether it is allowed.
*/
object ToolPath {
/** Expands a leading `~`/`~/` in [raw] to [home]. Any other string is returned unchanged. */
fun expandHome(raw: String, home: String? = System.getProperty("user.home")): String = when {
home.isNullOrEmpty() -> raw
raw == "~" -> home
raw.startsWith("~/") -> home + raw.substring(1)
else -> raw
}
/**
* Resolves [raw] to the absolute, normalized path the tool will operate on: home-expanded,
* then anchored on [base] when relative.
*/
fun resolve(raw: String, base: Path?, home: String? = System.getProperty("user.home")): Path {
val expanded = Paths.get(expandHome(raw, home))
return when {
expanded.isAbsolute -> expanded.normalize()
base != null -> base.resolve(expanded).normalize()
else -> expanded.toAbsolutePath().normalize()
}
}
}
@@ -0,0 +1,71 @@
package com.correx.core.tools.contract
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertEquals
class ToolPathTest {
private val home = "/home/tester"
private val base: Path = Path.of("/work/project")
@Test
fun `a leading tilde expands to the user home`() {
// The bug this guards: `~/.gradle/init.d/offline.gradle` used to resolve to
// <workspace>/~/.gradle/... — reported as non-existent AND as inside the workspace, so the
// agent was told a real file it had correctly identified did not exist.
assertEquals(
Path.of("/home/tester/.gradle/init.d/offline.gradle"),
ToolPath.resolve("~/.gradle/init.d/offline.gradle", base, home),
)
}
@Test
fun `a bare tilde is the home directory itself`() {
assertEquals(Path.of("/home/tester"), ToolPath.resolve("~", base, home))
}
@Test
fun `another users home is not expanded`() {
// Only the shell knows other users' homes; guessing would widen the jail on a lookalike path.
assertEquals(Path.of("/work/project/~other/notes.md"), ToolPath.resolve("~other/notes.md", base, home))
}
@Test
fun `an absolute path is normalized and kept`() {
assertEquals(Path.of("/etc/hosts"), ToolPath.resolve("/etc/./hosts", base, home))
assertEquals(Path.of("/work/a.txt"), ToolPath.resolve("/work/project/../a.txt", base, home))
}
@Test
fun `a relative path anchors on the base, not the process cwd`() {
assertEquals(Path.of("/work/project/src/A.kt"), ToolPath.resolve("src/A.kt", base, home))
assertEquals(Path.of("/work/project/src/A.kt"), ToolPath.resolve("./src/A.kt", base, home))
}
@Test
fun `a relative path escaping the base stays escaped for the containment check to see`() {
// Normalization must not silently clamp `..` back inside: the jail decides, not this object.
assertEquals(Path.of("/work/secrets.txt"), ToolPath.resolve("../secrets.txt", base, home))
}
@Test
fun `with no base a relative path falls back to the process cwd`() {
assertEquals(
Path.of("").toAbsolutePath().resolve("src/A.kt").normalize(),
ToolPath.resolve("src/A.kt", null, home),
)
}
@Test
fun `an unknown home leaves the tilde untouched`() {
assertEquals(Path.of("/work/project/~/x"), ToolPath.resolve("~/x", base, home = null))
}
@Test
fun `expandHome only rewrites a leading tilde`() {
assertEquals("/home/tester/x", ToolPath.expandHome("~/x", home))
assertEquals("src/~/x", ToolPath.expandHome("src/~/x", home))
assertEquals("/a/b", ToolPath.expandHome("/a/b", home))
}
}