Plan 04-04 Summary: workspace handlers, audit phase marker, no-bypass guard (TOOL-03)
Requirement: TOOL-03. Decisions: D-34, D-42, D-88, D-89, D-90, D-98, D-99, D-104, D-107, C-4, C-5.
Status: implemented. The gates (tsc, node --test) were not run in this lane, per the wave policy. The orchestrator runs them after the wave lands.
Files
src/audit/log.ts(edited):AuditPhase = "admission" | "result"and the optionalphasefield onAuditEntryInput. The entry object gainsphase: input.phase. JSON serialization drops an absent phase, so entries without a phase keep the old seven-key shape.src/tools/workspace.ts(new):readWorkspaceFile,listWorkspaceDir,writeWorkspaceFile, withWorkspaceDepsandWriteOutcome. It is the only module undersrc/tools/that importslockedWrite,readFileCapped, orlistDir(D-88).test/audit/log.test.ts(extended): two TOOL-03 cases appended. Existing assertions are unchanged.test/tools/workspace.test.ts(new): 19 TOOL-03 cases against real files, realLockTable, and the real audit log.test/tools/no-bypass.test.ts(new): five TOOL-03 static guard cases oversrc/.
Stage order per handler (D-89)
- Write: state gate (injected
mutationGate; a refusal throws with no audit line), then canonicalize in write mode and the credential check, then the lock probe, then the admission line, thenlockedWrite, then the result line. - Read and list: admission line, then
readFileCappedorlistDir, then the result line. - A failed admission append throws
AUDIT_WRITE_FAILEDbefore execution. A failed result append throwsAUDIT_WRITE_FAILEDwithout rollback.
Must-have evidence
| Truth | Evidence (test titles) |
|---|---|
A write with a stale ifMatchSha256 returns LOCK_CONFLICT and leaves the file byte-identical (TOOL-03, D-98, D-34) | TOOL-03: a write with a stale ifMatchSha256 is LOCK_CONFLICT, the file stays byte-identical, and the result line records the code (D-34, D-98); TOOL-03: a write with a matching ifMatchSha256 replaces the content, returns its sha256 and byte count, and logs admission then result (D-34, D-98, D-90) |
| Audit lines carry byte count and sha256, never content (TOOL-03, D-90, D-42) | TOOL-03: write audit lines carry the byte count and sha256 of the content and never the content (D-42, D-90); TOOL-03: a read returns the Phase 2 result shape and logs admission and result lines with byte count and sha256, never content (D-36, D-42, D-98); TOOL-03: the byte count of a write is its UTF-8 length, not its character count (D-42) |
| Read and list pass the credential and root checks and return Phase 2 shapes (TOOL-03, D-98) | TOOL-03: a read capped at maxBytes returns the capped content, the sha256 of the whole file, and truncated true (D-36, D-98); TOOL-03: a read of an escaping path is PATH_ESCAPE and a credential path is CREDENTIAL_PATH_DENIED, each logged as its result code (D-36, D-31, D-98); TOOL-03: a credential path is readable when credentialAllow names it (D-31); TOOL-03: a list returns the Phase 2 result shape sorted by name, with symlinks reported by link text and never followed (D-36, FS-03); TOOL-03: a list of a credential directory is CREDENTIAL_PATH_DENIED and logged as its result code (D-31, D-36); TOOL-03: a list of an escaping path is PATH_ESCAPE (D-36, FS-01) |
| Audit entries carry admission or result stage (TOOL-03, D-89, D-90, C-4) | TOOL-03: an entry carries its phase when the caller names one, and each phase is its own line (D-90, D-107); TOOL-03: an entry with no phase has no phase key, so the pre-stage shape is unchanged (D-107); the write and read tests above assert the admission then result order |
Further cases that pin the pipeline:
- Lock refusal before admission:
TOOL-03: a write to a path another owner holds is LOCK_CONFLICT before admission, and that owner keeps the lock (D-89 stage 3, D-34). - Symlink and credential refusal before admission:
TOOL-03: a write whose target is a symlink ...,TOOL-03: a write to a credential path .... - State gate refusal:
TOOL-03: a write refused by the state gate in required mode is REALM_NOT_ACTIVE .... - Advisory warning:
TOOL-03: a write in a non-active state under advisory policy proceeds and returns the advisory warning (D-98, D-23). - S-21 and OQ-P4-07:
TOOL-03: a write whose admission line cannot be appended ...,TOOL-03: a result line that cannot be appended after a write ...,TOOL-03: a read whose admission line cannot be appended ....
Static guard (plan 04-04 Task 3, D-99, D-104, C-5):
TOOL-03: the D-99 symbols are referenced only from src/tools/, the two shared runtime modules, and their own modules (D-99)TOOL-03: spawn( appears in src/ only in src/runtime/session-execution.ts (D-99)TOOL-03: OPENSHELL_BINARY is referenced only from its allowlist, and the doctor probe is allowlisted for that literal alone (D-99, D-104, C-5)TOOL-03: under src/tools/ only workspace.ts imports lockedWrite, readFileCapped, or listDir (D-88)TOOL-03: the guard reports a module outside the allowlist that references a guarded symbol, spawns a process, or names the binary. This last case runs the guard on synthetic sources, so the guard cannot pass vacuously.
Deviations from the plan
- Read and list do not repeat canonicalize or the credential check.
readFileCappedandlistDiralready run canonicalize (read mode), then the credential check, in that order (D-36). Repeating them in the handler adds nothing. Write does run them, because its lock probe needs the canonical target. - Lock pre-check uses
acquireplusrelease, not a bareisHeldBy.LockTablehas no "held by another owner" query.isHeldBycovers the session's own lock. For another owner's lock, the probe callsacquire, which throwsLOCK_CONFLICT, then releases the lock it just took. The effect is the D-89 stage 3 refusal before admission.lockedWritestill performs the authoritative acquire (D-34). - The write gate is injected as
mutationGate: () => GateOutcome. This keepssrc/tools/workspace.tsoff the controller type. The dispatcher will pass() => controller.mutationGate(). - Handlers write their own audit lines. The plan injects
auditinto the workspace handlers, so each file call writes its admission and result lines. Plan 04-06 has the dispatcher write audit lines for every tool. For file tools, 04-06 must not write them a second time. I raised this with the orchestrator. ListParams.depthis not used. The schema acceptsdepth, butlistDiris single-level (Phase 2 shape). The handler takesPick<ListParams, "path">.- Audit
bytesfor a read is the length of the returned content (at mostmaxBytes), andsha256covers the whole file. This is whatreadFileCappedreports. A write records the UTF-8 length and sha256 of what it wrote. - A non-
RealmErrorthrown during execution is rethrown with no result line. Phase 2 maps every expected failure to aRealmError, so any other error is a defect. The catalogue has no generic internal code, so I did not invent one (D-101).
Open questions and defaults applied
- Should the dispatcher or the workspace handlers own the file-tool audit lines? Default applied: the handlers own them, per Task 2. This is pending the orchestrator's answer on 04-06.
- Should
listhonourdepth? Default applied: no. Single level, per Phase 2. - Should an unexpected non-
RealmErrorget an audit result line? Default applied: no. It is rethrown.
Not run
Per the lane policy, I did not run tsc, node --test, or any linter. The orchestrator's gate run should cover test/audit/log.test.ts, test/tools/workspace.test.ts, and test/tools/no-bypass.test.ts. Also check that src/tools/workspace.ts compiles with exactOptionalPropertyTypes (the ifMatch conditional spread is the spot to look at).