The LLP 0063 D4 exclusivity gate fails closed on an unreadable central layer (#623) - #626
The LLP 0063 D4 exclusivity gate fails closed on an unreadable central layer (#623)#626philcunliffe wants to merge 5 commits into
Conversation
…l layer (#623) `resolveLayeredConfigFromDisk` maps a central layer that exists but does not parse to `centralConfig: null`, indistinguishable from no central layer at all. `readCentralSinkOrigins` read that as `[]`, and the D4 gate's `connectedOrigins.length > 0` test then skipped its rejection branch: a machine already enrolled to org A could be logged in to org B in one command, which is the single thing D4 exists to prevent. Surface the third state instead of collapsing it: - `resolveLayeredConfigFromDisk` returns `centralLoaded` raw alongside the `localLoaded` it already returned, so a caller can tell "no layer" from "cannot read the layer". Boot itself keeps collapsing them, correctly. - `readCentralEnrollment` returns `{ origins, unreadable }`. A `config_missing` load failure is the absent case, not the ambiguous one. `readCentralSinkOrigins` stays as its origins-only reduction for the session-start classification hook, which is deliberately inert on anything it cannot read. - Both halves of the D4 gate (the pre-auth check in `runBrowserLogin`, the seed-time recheck in `enrollCentralSink`) refuse on `unreadable`, naming the layer and its load error rather than claiming the machine is not connected. Co-Authored-By: Claude <[email protected]>
… too (#623) The fail-closed reading carved out 'config_missing' as the absent case. It is not: 'resolveCentralLayerPath' returns null when there is no layer, and that null never reaches a load. A path that resolves but ENOENTs is either a race with a concurrent removal (the seed branch existsSync-checks before returning the path) or an active-slot pointer whose slot file went away out of band, and the apply engine only ever flips that pointer AFTER writing the slot file. Both are applied-to machines: 'hyp leave' calls them connected and tears them down, so the gate permitting a second org's login there was the same fail-open one step further in. Verified by probe on the pre-fix branch: an active-slot pointer with its slot file removed permitted 'hyp remote login' to a different origin (exit 0, browser flow invoked). Now exit 2 before any auth, and 'hyp leave' still clears the state the message names. Co-Authored-By: Claude <[email protected]>
…#623) Co-Authored-By: Claude <[email protected]>
Neutral review, round 1 of 2Verdict: approve after one fix, which I landed on the branch. The hole is closed and the fix does not overshoot. I verified both empirically rather than by reading, by driving 1. Is the hole closed? Yes, and pre-authProbed on
2. Does it overshoot? No
3. Finding (Medium):
|
…er (#623) Round 1 made 'unreadable' every load failure. The proxy moved up a level rather than away: 'resolveCentralLayerPath() === null' is now the stand-in for 'not enrolled', and resolution is lossy in exactly the same way the loader was. 'readActiveSlot' swallows every readlink error into null (a pointer replaced by a regular file, a symlink loop, a target that is not a slot) and 'existsSync' swallows EACCES on config-control/ into false. Probed against the real runRemoteLogin with an auth stub: on six such states - damaged pointer with 'config.a.json' still naming org A verbatim, and an unreadable control or state directory - login to a DIFFERENT origin returned 0 and entered auth. The enrollment is fully intact in each; a chmod or a repaired symlink brings it straight back. 'centralLayerResolutionFailure' re-checks a null path against the control directory: unlistable, or still holding a central layer file the resolution did not manage to name, is 'cannot tell' and the gate refuses. Absence has to be verifiable - an empty or missing control directory, apply state and orphan etags - and those still permit login. 'hyp leave' keyed on the same lossy resolution, so the states the gate now refuses on were ones it called 'not connected - nothing to do': the gate's own advice would have been a dead end. It now tears down the residue (resetCentralLayerToSeed removes the pointer and both slots by name, no resolution needed), and its central-layer step is counted-and-reported like every other step rather than an uncaught EACCES that stranded the attach reversal. Co-Authored-By: Claude <[email protected]>
Round 2 (of 2), security-relevant: one High finding, fixed in
|
| state | before this round | after |
|---|---|---|
| C1 no central layer at all | permitted (correct) | permitted |
| C2 parseable seed -> org A | refused | refused |
| C3 corrupt seed (invalid JSON) | refused | refused |
| C4 pointer -> missing slot file | refused | refused |
E1 pointer replaced by a regular file (readlink EINVAL); config.a.json still names org A |
PERMITTED, auth entered | refused (2) |
E2 pointer -> config.c.json (neither slot); config.a.json still names org A |
PERMITTED, auth entered | refused (2) |
E3 pointer is a symlink loop (active -> active); config.a.json still names org A |
PERMITTED, auth entered | refused (2) |
| E4 pointer is an absolute symlink to slot a | refused (correct) | refused |
E5 config-control/ EACCES, seed inside names org A |
PERMITTED, auth entered | refused (2) |
E6 config-control/ EACCES, pointer + slot a name org A |
PERMITTED, auth entered | refused (2) |
E7 state dir hypaware/ EACCES, seed inside names org A |
PERMITTED, auth entered | refused (2) |
E8 seed.json EACCES, dir readable |
refused | refused |
E9 seed.json is a directory (EISDIR) |
refused | refused |
| E10 slot a is a dangling symlink | refused | refused |
| E11 slot a is a symlink loop (ELOOP) | refused | refused |
| E12 seed parses but is shape-invalid | refused | refused |
Six live fail-opens. In every one the enrollment is completely intact: the slot
file or seed still holds org A's @hypaware/central sink verbatim, and a chmod
or a repaired symlink brings it straight back. That is a strictly worse state than
the config_missing one round 1 closed, where the layer's contents were at least
gone.
The fix (70b9cd1)
centralLayerResolutionFailure({ stateRoot }), new in
src/core/config/apply.js:133, re-checks a null path against the control
directory itself, reading no file contents:
- the directory cannot be listed (anything but
ENOENT/ENOTDIR) -> cannot tell; - the directory still holds
active,seed.json,config.a.jsonor
config.b.jsonthat the resolution did not manage to name -> cannot tell; - otherwise the absence is verified -> genuinely not enrolled.
resolveCentralLayerPath is untouched, so boot and hyp status keep the one
null they are right to see. Only the permission decision asks the second
question, exactly as round 1 made only it ask about load failure.
readCentralEnrollment (src/core/remote/gateway_seed.js:142) folds it into
unreadable, so both halves of the D4 gate (pre-auth, and the
enrollCentralSink seed-time recheck) pick it up together.
No overshoot, probed: an empty control directory (what hyp leave and
resetCentralLayerToSeed leave behind), a directory holding only state.json
and an orphan config.a.etag, a state dir with no config-control/, and
config-control being a file (ENOTDIR) all still permit login. Only
layer-bearing names count as evidence.
Medium (introduced by the fix above, fixed in the same commit) - hyp leave was the same dead end
hyp leave keyed on the same lossy resolution
(src/core/commands/central.js:363), so every state the widened gate refuses on
was one leave called "not connected to a central server - nothing to do" -
returning 0 while config.a.json and the damaged pointer stayed on disk. The
gate's own advice would have been a permanent lockout. Verified by probe, then
fixed:
leavenow proceeds whencentralLayerResolutionFailurereports residue. The
teardown was already the right one:resetCentralLayerToSeedforce-removes the
pointer and both slots by name, no resolution required.- its central-layer step is counted-and-reported like every other step rather than
an uncaughtEACCESthat aborted before the attach reversal in step 3.
Escape verified end-to-end for every fail-closed state - leave exits 0, leaves
config-control/ empty, and a subsequent login to the other org is permitted:
damaged pointer (3 shapes), corrupt seed, pointer-without-slot. For an
EACCES control directory leave now exits 1 with
✗ could not remove the central config layer: EACCES ... and later steps still
run; login stays refused, correctly - the user owns the directory, chmod is the
real repair, and the gate's message names the exact path and errno.
Re-assessed from round 1
enrollCentralSinkseed-time recheck (src/core/commands/central.js:208):
correct after the widening. It runs before any write, and a fresh enrolling
login sees no control directory (ENOENT-> absent), so it does not refuse
itself. Its comment is updated to stay honest about what it now covers.evaluateCwdClassification(src/core/usage-policy/classification.js:190):
unchanged. It still callsreadCentralSinkOrigins, which discardsunreadable;
deliberately inert on anything it cannot read (LLP 0106 #interactive).hyp joinoverwrite semantics and the first-sync hold marker ordering:
unchanged, out of scope, as round 1 judged.
Checks
npm teston this head: 3387 tests, 3385 pass, 1 skipped, 1 fail.
The single failure isa corrupt marker fails open ... LLP 0101 fail-open polarity(test/core/status-first-sync-hold.test.js:99). It is not this
PR's, and it is not permanently environmental either: this branch is based on
fb60a9f, and master has since landeddb33f04("npm test no longer drives
the host's real service manager", npm test kills the real daemon on macOS: attach tests reach the real launchctl through the label namespace #602/npm test no longer drives the host's real service manager (#602) #606), which fixes exactly it. Verified
both ways - a cleanorigin/masterworktree passes that file, and
origin/mastermerged into this head runs 3418 tests, 3417 pass, 1 skipped,
0 fail with no conflicts. The branch just needs master merged in before
landing.npm run typecheck: clean.- Regression tests added: 5 in
test/core/remote-login-command.test.js(three
pointer shapes, the unlistable directory, and the no-overshoot cases) and 2 in
test/core/leave-command.test.js(teardown through an unresolvable pointer,
and the reported-not-thrown EACCES). The login ones fail ona814be6and pass
on70b9cd1.
Residual findings
- Low, display-only.
src/core/daemon/status.js:265-268now collapses
three states intohasCentral: false- no layer, a layer that will not
load, and a layer whose path will not resolve. A user hitting the gate's
refusal seeshyp statusreport no central layer, which contradicts it.
Every consumer is reporting, not gating, so it is still out of scope for a
permission fix, but it is a more visible inconsistency than round 1 judged.
Worth its own issue. - Low, unchanged from round 1. The seed-time refusal in
enrollCentralSink
throws after the first-sync hold marker is written. Pre-existing, bounded by
LLP 0101. - Informational.
readActiveSlotandresolveCentralLayerPathstay lossy by
design;centralLayerResolutionFailureis the compensating read, and the D4
gate is its only caller. Any future permission decision keyed on
resolveCentralLayerPath() === nullreopens this hole. The new function's doc
comment says so. - Stale base, above: merge
origin/masterbefore landing, or the one red
test travels with the PR.
|
Neutral triage (LLP 0017) on this PR at head Divergent-edge check on round 2's fix. The task for this triage pass was specifically to check whether Residual findings, classified:
All four are non-blocking; none is a production defect, security hole, or lockout with no recovery. Filed the deferrable ones (findings 1-3) as #628 for follow-up; nothing here needs to hold this PR further. |
# Conflicts: # src/core/commands/central.js
Re-triaged at
|
Root cause
The D4 exclusivity gate ("one org enrollment per machine", LLP 0063 D4) decides a
permission from an input it could not tell apart from its own absence.
src/core/runtime/boot.jsresolveLayeredConfigFromDiskmaps an unparseablecentral layer to
nullwithout throwing:const centralConfig = centralLoaded?.ok ? centralLoaded.config : null. Aparse failure and "no central layer at all" become the same value.
src/core/remote/gateway_seed.jsreadCentralSinkOriginsconsumed thatdirectly (
centralConfig?.sinks ?? {}), so a corrupt layer returned[].src/core/cli/remote_commands.jsgated onif (!alreadyEnrolled && connectedOrigins.length > 0). With[]therejection branch is skipped and the login proceeds.
Verified against
origin/masterby test, not by reading: with a central seed ondisk containing
{ "version": 2, "sinks": {,hyp remote login otherreturned 0and the browser flow was invoked.
The fail-open direction
A machine in this state is enrolled by every other definition the codebase uses:
hyp leaveandresolveCentralLayerPathkey on the central layer file, not onwhether it parses. The gate alone read it as "not enrolled" and permitted a second
org's enrollment, which is precisely what D4 exists to prevent. A gate that cannot
read its own input must refuse, not permit.
The fix
Surface the third state rather than collapsing it into an empty list.
resolveLayeredConfigFromDisknow returnscentralLoadedraw alongside thelocalLoadedit already returned. Boot itself keeps collapsing the two states,correctly: either way there is nothing to merge. Only a caller deciding a
permission needs them apart.
readCentralEnrollmentreturns{ origins, unreadable }(
CentralEnrollmentinsrc/core/remote/types.d.ts). Aconfig_missingloadfailure is deliberately not unreadable: the layer path can come from an
active-slot pointer naming a since-removed file, and "the file is gone" is the
absent case, not the ambiguous one.
readCentralSinkOriginssurvives as that reduced to its origin list, for theone caller that is not making a permission decision and has its own documented
answer for an unreadable layer (
evaluateCwdClassification, deliberately inerton anything it cannot read: LLP 0106 #interactive). Unchanged behaviour there.
unreadable: the pre-auth check inrunBrowserLogin(exit 2, before any auth) and the seed-time recheck inenrollCentralSink, which D4 specifies as part of the same gate. Leaving therecheck fail-open would have made its
@ref LLP 0063#d4dishonest; nothing iswritten at that point, so it throws and the caller's existing "signed in, but
enrollment failed" path reports it.
The rejection names the real problem:
A same-origin re-login is refused too: with no parse there is no origin to compare
against. The advice stays actionable because
hyp leaveclears the layer by path,not by contents.
Regression test
test/core/remote-login-command.test.js, four tests, all under LLP 0063 D4:an unreadable central layer fails the D4 gate CLOSED: login to a different server is rejected- the failing-then-passing one. Onorigin/masterit fails(
code0, the browser flow ran, no rejection); with the fix it returns 2, theflow is never invoked, and the message names the unreadable layer rather than
"this machine is connected to".
an unreadable central layer also refuses a same-origin re-login- alsofails on
master, passes after.an ABSENT central layer is not an enrollment and still permits login-passes before and after; proves the fail-closed branch does not overshoot.
a PARSEABLE central layer keeps its D4 behavior- passes before and after;same-origin re-login allowed, different origin rejected with the existing
message.
Run on
origin/masterwith only the tests applied: 2 failed, 2 passed. After thefix: 65/65 in that file.
status.jsneighbour: out of scopesrc/core/daemon/status.js:266-268collapses the same two states forhasCentral.Left alone deliberately. Every consumer (
buildClientActionsReport, thelayeredreport block, the wizard's
managedhint) is display and reporting; none gates anaction. Changing what
hyp statusshows for a corrupt layer is a reporting designquestion with its own message and its own tests, not part of restoring this
permission decision. Worth its own issue.
Checks
npm test: 3381 tests, 3379 pass, 1 skipped, 1 fail. The single failure,a corrupt marker fails open ... LLP 0101 fail-open polarity, is environmental(a
daemon_loaded_no_pidsystemd diagnostic from the host) and reproducesidentically on
origin/masterwith this branch stashed.npm run typecheck: clean.test/core/classify-inactive-state.test.jsneededcentralLoaded: nullin its hand-rolledresolveLayeredConfigFromDiskfixture.LLP
No LLP edit. LLP 0063 D4 is Active and this changes nothing it settled: D4 states
the gate is total and re-checked at seed time, and never contemplated a layer it
could not read. Failing closed is that decision implemented correctly, not a new
one. The
@ref LLP 0063#d4annotations moved with the code they annotate andstill hold.
Fixes #623