ho-04.9 — identifier & path validation
Second post-sweep hardening (fable sweep, 2026-07-03; Tier B candidate #3). Closes the traversal class: scope IDs, keys, shared-entry IDs, .link payloads, and .sharibako marker fields all sync via git from other machines and all became path components with no . A pre-session scout confirmed — and sharpened — the sweep's finding: clean would removeItem() whatever path a crafted materialize_to resolved to (arbitrary file deletion, not just writes), absolute targets were honored outright, and a tampered .link payload aimed rotateShared's re-encrypt at any path.
Authored light per the FABLE 2 planning session; the findings document and scout report scoped the work.
Out of scope:
- Scan resilience (one malformed marker aborting a whole
scan) — Tier B #6, ho-04.11. This ho makes markers stricter, so a hostile marker in a scan root aborts the scan until 04.11 makes scan skip-and-report. - Ingest/link collision policy — Tier B #5, ho-04.10.
Decisions
-
One identifier grammar everywhere:
^[A-Za-z0-9_][A-Za-z0-9._-]*$. Scope IDs, keys, shared-entry IDs, and.linkpayloads share it. The alphabet has no/and the first-character class excludes., so./.., hidden-file names, and ho-04.8's.sharibako-tmp-staging prefix are structurally impossible rather than special-cased. Env-style uppercase keys and the ingest sanitizer's lowercase-dash IDs both conform — existing vaults pass unchanged. -
VaultLayoutis the throwing chokepoint. Every ID-taking URL helper validates before the ID becomes a path component (invalidIdentifier(kind:value:source:), a newVaultErrorcase carrying the originating file when the value arrived via vault data). Boundaries also validate early for better errors:loadMarker(scope field →markerMalformed),readLinkTarget(payload, with the.linkfile assource),link()(write side of the payload contract),InitCommand's scope-ID prompt (re-prompt loop; the suggested default is sanitizer output and always valid).VaultCore.isValidIdentifieris the public gateway for surfaces. -
Listing verbs skip, they don't throw.
listScopes/listShared/inspect/linkGraphsilently exclude out-of-grammar names read from disk — one stray directory must not brickstatus. -
materialize_tocontainment: relative-only, inside the marker's subtree.ScopeMarker.validatedTargetURL()rejects absolute,~-prefixed, and (post-standardization) escaping targets;materialize/clean/heal/updateconsume it, andloadMarkerfails hostile markers at load so they surface onscan/statusbefore anything acts. The rawtargetURLproperty remains for display. A legitimate cross-directory materialize becomes a deliberate future opt-in, not a default. -
Remote-URL transport allowlist, defense-in-depth.
setRemoteisn't reachable from git-synced data today (no CLI verb wires it), but git transport helpers (ext::<cmd>) turn a remote string into command execution, so the safe property is made structural: allowhttps://,ssh://,file://, absolute local paths (test remotes), and scp-styleuser@host:path; reject transport helpers,git://,http://, and relative paths (remoteURLRejected).
Verification
- 533 tests green (was 516): grammar accept/reject tables; per-class traversal specs — scope ID via marker and via API, key via
addSecret, tampered.linkpayload (asserts the error names the link file),link()write-side rejection, listing-verbs skip; marker containment (absolute /~/..rejected, contained relatives accepted,cleancannot delete andmaterializecannot write outside the subtree — both with victim-file assertions); remote allowlist accept/reject with git-config round-trip. swift-format lint --strict,swiftlint lint --strictclean; warning-free build.- Coverage 94.26% by CI's exact computation (named-exclusion list from ho-04.8).
Rendered from the corpus, verbatim · source on GitHub →