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