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