Root Child DOX Index assembled, plus a per-module AGENTS.md across the tree
(core/*, infrastructure/*, apps/*, testing/*, and docs/examples/frontend/etc),
each following the DOX section shape: Purpose, Ownership, Local Contracts,
Work Guidance, Verification, Child DOX Index.
Block writing a file whose on-disk content changed since the session read it — a
concurrent/external edit the agent's view doesn't reflect. file_read records a
content hash on whole-file reads; the orchestrator's completion path now carries
the tool's structuredOutput (it previously dropped it, unlike SandboxedToolExecutor
— the two paths were inconsistent), so SessionContext.readHashes folds it.
StaleWriteRule compares the current hash (WorldProbe.contentHash) against the
read-time hash and BLOCKs a mismatch, telling the agent to re-read first. Files the
session wrote itself are excluded, so its own edits never look stale; partial reads
set no baseline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a session has claimed a task with declared affected_paths, an in-workspace
write outside that scope is BLOCKED — but declarably: the message tells the agent
to widen the task's affected_paths via task_update (which re-records the scope)
and retry. Legitimate, unforeseen changes (a dependency, a caller) are never
trapped, only forced to become a recorded, auditable scope change. Reads the
session's activeTask scope (recorded at claim) — no cross-stream lookup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claiming a task now emits SessionWorkingTaskEvent{taskId, affectedPaths} into the
session's own stream (via a SessionFactRecorder port + event-store adapter), and
SessionContextProjection folds it into SessionContext.activeTask. So a gate learns
"which task is this session working, and its scope" by reading the session stream
— no cross-stream scan, no kernel->tasks dependency. Re-emitted on an affected_paths
edit by the claimant, so the snapshot scope stays current; latest wins on replay.
This is the data source the write-scope gate needs (next).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the single-purpose sessionReadPaths plumbing with a SessionContext
{reads, writes, activeTask} folded by SessionContextProjection over the session
stream — the read-model every anti-hallucination gate consumes (plane-2 rules via
ToolCallAssessmentInput.session; tool gates via a port, next).
- reads: completions whose recorded capability includes FILE_READ.
- writes: the resolved paths the orchestrator already records on each completion's
receipt.affectedEntities (FileAffectingTool) — no param-parsing reinvention.
- activeTask: null for now; populated once claim records a session fact.
ReadBeforeWriteRule now reads input.session.reads; ReadFilesProjection is retired.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capability is the canonical signal the plane-2 gates dispatch on, but
ToolInvocationRequestedEvent only carried the tool name — so ReadFilesProjection
classified reads by a hardcoded "file_read" string, bypassing the abstraction and
(worse) re-deriving a semantic fact at replay from whatever the name meant. That
violates the event-sourcing invariant that replay reads recorded facts, not live
state.
Now the tool's requiredCapabilities are recorded on the event at emission, and the
projection classifies by ToolCapability.FILE_READ. To let the lowest module carry
it, ToolCapability moves from core:tools into core:events keeping its package, so
every existing import resolves unchanged and core:tools imports it from below. The
field is defaulted, so pre-existing events deserialize as empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A file_read of a path that is inside the workspace but does not exist is now
BLOCKED with "no such path" instead of silently returning empty — so an agent
that hallucinated a filename fails loud and self-corrects (list the directory,
fix the name) rather than proceeding on an absent reference. Out-of-workspace
targets stay PathContainmentRule's concern, so the gates don't double-handle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An LLM that edits or overwrites a file it never read is acting on hallucinated
contents. This plane-2 ToolCallRule blocks it: dispatching on FILE_WRITE (covers
file_write and file_edit), a write to a path that exists on disk but was not
file_read earlier this session is BLOCKED. A brand-new file passes — you can't
read what doesn't exist, and creating is legitimate.
- ReadFilesProjection folds the session's file_read events (request + completion,
so a failed read doesn't count) into the set of paths read, mirroring
EgressAllowlistProjection; threaded into ToolCallAssessmentInput.sessionReadPaths
via SessionOrchestrator.resolveSessionReadPaths.
- Read paths and the write target are both resolved through the probe (toRealPath),
so spelling differences and symlinks can't dodge the gate.
- The orchestrator already feeds a block's rationale back as a tool result, so the
agent reads the file and retries; the rejected-event reason now carries that
rationale too (specific audit trail instead of a generic string).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ToolCallAssessmentInput gains sessionEgressHosts; SessionOrchestrator resolves it at
the assessment build site by folding EgressHostsGrantedEvents through
EgressAllowlistProjection. NetworkHostRule now enforces session-granted hosts (union
with static), replacing the emptySet() TODO — the allowlist built in 027ff1f is live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- EgressHostsGrantedEvent (registered + serialization test): additive per-session
egress grants (e.g. an approved research source list)
- EgressAllowlist pure union helper + EgressAllowlistProjection (folds grants per
session); NetworkHostRule delegates to the union, preserving exact/suffix semantics
- rule not yet fed the session set live (ToolCallAssessmentInput has no sessionId);
precise TODO(wiring) left. batch fetch-approval skipped (needs approval-flow surgery)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scope creep — an implementer writing files it never declared — is the
dominant local-model failure that compiler/tests don't catch. A stage can
now declare a `writes` manifest (workspace-relative globs); a FILE_WRITE
that resolves inside the workspace but outside the declared set is raised
as PATH_OUTSIDE_MANIFEST → BLOCK, so the model gets the violation as tool
feedback and retries within scope (the §2 bounded-retry signal).
Implemented as a plane-2 rule beside PathContainmentRule (same effect-based
dispatch on FILE_WRITE, same symlink-safe WorldProbe resolution, same
recorded observations so replay reads them back — invariant #9). An empty
manifest is unrestricted; out-of-workspace targets stay PathContainmentRule's
concern, so the two rules don't double-flag.
- StageConfig.writeManifest + TomlWorkflowLoader `writes` parsing
- ToolCallAssessmentInput.writeManifest, threaded from the active stage via
SessionOrchestrator.runPlane2Assessment
- new ManifestContainmentRule, registered in the server assessor
- shared candidatePathStrings extracted so both path rules judge the same
target set
- role_pipeline.toml documents the option on the implementer stage
The dedicated ManifestViolationEvent the spec names is not added: the
violation is already recorded replayably in ToolCallAssessedEvent (issue +
observations + BLOCK) and surfaced in the TUI rationale band. A first-class
event is worth adding only once reconciliation consumes it.
Add ParamRole and a defaulted Tool.paramRoles to the tool contract; built-in
tools declare which params carry effects: file_read/write/edit -> path=PATH,
shell -> argv=EXEC_COMMAND. PathContainmentRule / ExecInterpreterRule /
NetworkHostRule now inspect only the declared params when roles are present, and
fall back to the looksLikePath / leading-token / extractHost heuristic only for
un-enriched (e.g. custom) tools - so a custom FILE_WRITE tool is still
containment-checked. Shared extractParamStrings flattens String and List<*>
(argv) values. SessionOrchestrator.runPlane2Assessment resolves the tool once
and passes tool.paramRoles into the assessment input.
This retires the arg-guessing from the first-party path: a slashy edit 'content'
arg is no longer mis-flagged as a path.
Implements the full vertical slice for invariant #9 tool-call assessment:
- core:toolintent — new module with ToolCallRule seam, ToolCallAssessor,
WorldProbe/FileSystemWorldProbe, WorkspacePolicy, PathContainmentRule,
and RiskMapping (assessment → RiskSummary / AssessedIssue)
- core:tools — ToolCallAssessmentRecord + ToolInvocationRecord.assessment
field; DefaultToolReducer handles ToolCallAssessedEvent (replay proof)
- core:config — ToolsConfig gains workspaceRoot + privilegedLocations;
ConfigLoader parses both; DEFAULT_PRIVILEGED_LOCATIONS built-in
- core:kernel — OrchestratorEngines gains toolCallAssessor/workspacePolicy/
worldProbe fields; SessionOrchestrator.dispatchToolCalls runs
runPlane2Assessment before the tier gate: BLOCK → hard-reject without
executing; PROMPT_USER → elevates tier into approval path with plane2Risk
in the ApprovalRequestedEvent
- apps/server — constructs PathContainmentRule + ToolCallAssessor +
WorkspacePolicy from config and wires them into OrchestratorEngines
Assessment is recorded as ToolCallAssessedEvent (environment observed once,
facts stored, replay reads events — invariant #9). Assessor and WorldProbe
are only invoked on the live orchestrator path, never in replay.