Skip to content

resolveClientSettingsPath rejects an absolute settings_file instead of re-anchoring it under $HOME - #447

Merged
philcunliffe merged 5 commits into
masterfrom
fix/issue-446
Jul 29, 2026
Merged

resolveClientSettingsPath rejects an absolute settings_file instead of re-anchoring it under $HOME#447
philcunliffe merged 5 commits into
masterfrom
fix/issue-446

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

The fork, and which way it went

The issue offers two coherent fixes: reject an absolute settings_file, or
honour it. This PR rejects, per the issue's own leaning, for three reasons
that come out of the code rather than taste:

  1. There is no coherent honour-it semantics for the env override.
    $<CLIENT>_HOME exists to relocate a client's config home, which is the
    first segment of a home-relative path. An absolute path has no config home,
    so honouring would have to publish a second, silently-different resolution
    rule for one field.
  2. One resolver serves both the read and the write side.
    resolveClientSettingsPath backs the attach probe and the picker's
    settings_file detect and the disk-driven undo (LLP 0045 Part 3). A value
    core cannot resolve must fail, or attach and detach can disagree about which
    file they own.
  3. Nothing depends on the re-anchoring. All four callers are listed below;
    the only shipped manifest that declared an absolute settings_file is Claude
    Desktop's, and that declaration is the defect hyp detach --client claude-desktop always fails with MALFORMED_MARKER #444/claude-desktop: drop the attach_probe core cannot read or reverse #445 is removing.

This does not change a published contract that needs ratification. The
home-relative rule was already the stated contract (it lived in
resolveClientSettingsPath's JSDoc); it was simply never enforced.
hypaware-plugin-kernel-types.d.ts had no doc comment on settings_file at
all, so this PR writes down the rule that was already in force rather than
introducing a new one.

The silent part is the bug

An absolute settings_file was not an error. path.join(homeDir, ...settingsFile.split('/')) swallows the leading empty segment, so
/Library/Managed Preferences/com.anthropic.claudefordesktop.plist quietly
became $HOME/Library/Managed Preferences/.... The env-override branch was
wrong the same way: parts.slice(1) assumes a relative first segment, so it
dropped the leading / and grafted the remainder onto $<CLIENT>_HOME. Both
branches are settled by one guard before either runs.

The probe then reported on a file the manifest never named. Usually ENOENT,
which probeClientAttachFromDescriptor maps to a bare attached: false with no
error field: a wrong negative indistinguishable from a right one, which is
exactly how the Desktop attach_probe defect stayed invisible. It can also be a
wrong positive, which the new probe test demonstrates directly.

So the probe now returns { attached: false, error } for an unresolvable
settings_file. src/core/commands/status.js already forwards a client
report's error into hyp status --json, so no rendering change was needed.

Every caller of resolveClientSettingsPath, and how it behaves now

CallerBeforeAfter
src/core/daemon/status.jsprobeClientAttachFromDescriptorsilent wrong attached: false (or a wrong true){ attached: false, error }, surfaced by hyp status
src/core/commands/clients.jsdetachClientViaCore (dry-run path)printed a $HOME-re-anchored settings_paththrows; a client core cannot locate is one it must not claim to reverse
src/core/config/client_detach_disk.jsdetachClientFromDiskoperated on the wrong filethrows (file untouched by this PR, see sequencing)
src/core/cli/detect.jsprobeIsPresentstat'd the wrong config homethrows into the caller's existing best-effort catch, so "not present"

None relies on the re-anchoring. The picker's absolute-literal needs are
already served by the sibling app_bundle and path detect variants (LLP
0136), so no manifest loses expressiveness.

Ground truth

Two new tests in test/core/daemon.test.js, both proven failing on the pre-fix
behaviour and passing after (verified by reverting only the behaviour, keeping
the new export, so the failure is behavioural and not a module-load error):

  • resolveClientSettingsPath rejects an absolute settings_file rather than re-anchoring it - covers both branches: no override, and
    CLAUDE_DESKTOP_HOME set. Pre-fix: Missing expected exception (it returned
    /Users/hyp/Library/... and /tmp/claude-desktop-home/Library/...). Asserts
    the typed ClientSettingsPathError with code: 'settings_file_absolute'.
  • probeClientAttachFromDescriptor errors on an absolute settings_file instead of probing $HOME - plants a marked decoy at the re-anchored $HOME
    location, so the pre-fix code reports attached: true against a file the
    manifest never named. Pre-fix: true !== false. Asserts attached: false, no
    settingsPath, no version, and an error naming the violation.

npm test: 2794 pass, 8 fail - exactly the known pre-existing
test/core/leave-command.test.js baseline, unchanged by this PR.
npm run typecheck: clean.

(Corrected post-review: the review rounds added more resolver and renderer
tests after this line was written. At the merged head 9fdb606 this PR alone
runs 2801 pass / 8 fail, still exactly the leave-command.test.js baseline;
npm run typecheck remains clean. Independently re-verified during triage.)

What is deliberately NOT in this PR

Rejecting at manifest load would be better (the plugin author learns at
install, not at probe time), and the issue suggests it. It is left to follow-up
because contributes.client is not validated at all today, and the bundled
@hypaware/claude-desktop manifest on master still declares the absolute
path. Adding the check now would take a shipped plugin out of the catalog to
punish a defect already being fixed in #445. LLP 0045 records the deferral and
its sequencing.

Sequencing against held PRs

Based on origin/master. Trial git merge-tree --write-tree against all four
held heads (#438fix/issue-437, #439fix/issue-432, #441fix/issue-440,
#445fix/issue-444): clean, no conflicts.

Docs

llp/0045-client-attach.design.md Part 3 gains
#### settings_file is home-relative, and a violation is loud, landed in the
same commit as the code, and the three new @refs point at it.

Fixes#446

neutral-loopand others added 3 commits July 29, 2026 05:39
…f re-anchoring it under $HOME
An absolute `settings_file` was not an error: `path.join(homeDir,
...settingsFile.split('/'))` swallows the leading empty segment, so
`/Library/Managed Preferences/com.anthropic.claudefordesktop.plist`
silently became `$HOME/Library/Managed Preferences/...`. The env-override
branch was wrong the same way (`parts.slice(1)` assumes a relative first
segment, so it dropped the leading `/` and grafted the rest onto
`$<CLIENT>_HOME`).
The probe then answered about a file the manifest never named. The usual
answer was ENOENT, which reads exactly like a correct "not attached", and
that wrong-negative-indistinguishable-from-a-right-one is how the Claude
Desktop attach_probe defect (#444) stayed invisible. It can also be a
wrong positive: the new probe test builds the marked decoy at the
re-anchored `$HOME` location and the old code reports `attached: true`.
Reject rather than honour: the `$<CLIENT>_HOME` override relocates a
config home, which an absolute path does not have, so honouring would
publish a second silently-different resolution rule for one field. The
resolver is shared by the read side (attach probe, picker detect) and the
write side (the disk-driven undo), so a value core cannot resolve must
fail rather than resolve to something else.
Each caller renders the throw on its own terms: the status probe returns
`{ attached: false, error }` (already carried through to `hyp status`),
`hyp detach` fails loudly, and the init picker keeps its documented
best-effort "not present". Load-time manifest validation is left to
follow-up, sequenced after the Desktop manifest is corrected.
LLP 0045 Part 3 gains the governing section, and the kernel type now
states the home-relative contract it only ever implied.
Co-Authored-By: Claude <noreply@anthropic.com>
…under --json
The guard makes an unresolvable settings_file an `error` on the probe
result, but `renderStatusText` dropped the field: it printed the client
as a bare `not attached` and, because such a client is typically not
`configured`, usually collapsed the whole section into `clients: (none)`.
On stock master + this branch that is exactly what happened to
claude-desktop, so the wrong negative the PR set out to end survived one
layer up, on the surface a human actually reads. --json already carried it.
Print the message under the client's line, and keep an errored client out
of the `(none)` collapse. LLP 0045 Part 3 records that both surfaces carry
it.
… not the first character
The isAbsolute guard rejects '/etc/passwd' and lets '../../../etc/passwd'
through to exactly the same place. Both land on a file the manifest never
named, which is the whole harm this guard exists to stop, and the escaping
spelling is the one that survives the check:
resolveClientSettingsPath('codex', '../../../etc/passwd', {}, '/Users/hyp')
-> '/etc/passwd' (pre-fix)
.codex/../../../etc/passwd + CODEX_HOME=/tmp/ch -> '/etc/passwd'
It matters more here than in a read-only resolver: this is also the write
side, detachClientFromDisk reads and rewrites whatever it is handed, and
contributes.client is unvalidated, so the value can arrive from a
remotely-installed or org-pushed plugin.
Require the resolved path to stay under the base it resolved against
(settings_file_escapes_base). Each branch checks its own base - $HOME
normally, $<CLIENT>_HOME when set, since the override is exactly a licence
to leave $HOME. A '..' that normalizes away stays legal.
Also drops two overclaims the section had picked up: attach_probe really
does lose expressiveness (it has no absolute sibling, which is why #445
deletes Desktop's probe rather than respelling it), and the one-resolver
argument holds for core but not for the per-plugin attach writers - the
claude plugin hardcodes ~/.claude/settings.json and ignores $CLAUDE_HOME.
And the ClientSettingsPathError JSDoc no longer claims callers avoid
message-text matching when its only consumer flattens to a message.
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Verdict: the approach is right; two actionable findings, both fixed on the branch

The reject-not-honour call is correct, the guard is placed correctly (before both branches), the tests are honest under mutation, and the cross-PR ordering hazard is not real - I traced every caller and exercised the real CLI. Two things were missed:

  1. MEDIUM - the "loud" error was loud only under --json; the text surface still showed the same indistinguishable negative. Fixed in 9403fba.
  2. MEDIUM/security-relevant - the guard enforces one spelling: ../../../etc/passwd walks straight out of $HOME and is not caught. Fixed in 623eb6b.

Head reviewed 607925e -> now 623eb6b.


1. The ordering hazard: traced per caller, and it does not bite

Every caller of resolveClientSettingsPath with its try/catch boundary, from the code:

CallerCatches?Evidence
src/core/daemon/status.js:969probeClientAttachFromDescriptoryes, in-functionthe PR's own try/catch -> { attached: false, error }
src/core/cli/detect.js:80probeIsPresentyes, in the callerdetectPickerSources wraps await probeIsPresent(...) in a per-descriptor try { } catch { } at src/core/cli/detect.js:44-51
src/core/commands/clients.js:422detachClientViaCore dry-runno (by design), containedbelow
src/core/config/client_detach_disk.js:89detachClientFromDiskno (by design), containedbelow
hypaware-core/plugins-workspace/openclaw/src/settings.js:83n/afifth caller, not in the PR body's table - passes the module constant .openclaw/openclaw.json, cannot be absolute

The two throwing paths both flow through detachClientViaCore, and all three of its callers already wrap it per client and keep going:

  • src/core/commands/clients.js:108-128 - the hyp detach loop: for (const name of clientNames) { try { ... } catch (err) { ctx.stderr.write(...); exitCode = 1 } }. The catch is inside the loop.
  • src/core/commands/central.js:470-486 - hyp leave's per-client reversal: same shape, failures += 1 plus a "run hyp detach X to finish reversing it" hint, then continues.
  • src/core/config/action_attach.js:333-337 - reconciler reverse(): try { result = await detach(...) } catch (err) { return { status: 'failed', reason } }.

The all-clients read paths never throw at all, because they go through the probe, which now catches: collectHypAwareStatus (status.js:512-515, iterates every descriptor), probeAttachedClients (status.js:1108-1116, the hyp remote login attach-wait tight loop), clients.js:225. And bin/hypaware.js:53-58 has a top-level try/catch around dispatch(argv) printing hyp: <message> and exiting 1, so even an escaped throw could not stack-trace.

Exercised against the real CLI on this branch, temp HYP_HOME, bundled catalog with claude-desktop's absolute settings_file still present:

$ hyp detach --client all --dry-run
(dry-run) Would detach claude from ~/.claude/settings.json
(dry-run) Would detach codex from ~/.codex/config.toml
(dry-run) Would detach openclaw from ~/.openclaw/openclaw.json
error: detach client 'claude-desktop' failed: ... absolute settings_file ... exit=1
$ hyp detach --client all
Detached claude (...) / No HypAware marker found in .codex/config.toml / ...openclaw...
error: detach client 'claude-desktop' failed: ... exit=1
$ hyp detach # default client is 'claude', NOT 'all'
No HypAware marker found in ~/.claude/settings.json; nothing to do. exit=0
$ hyp status exit=0

parseClientArgs defaults client: 'claude' (clients.js:611) and expandDetachClientNames (clients.js:692) only fans out on the literal all, so bare hyp detach is untouched. No all-clients command breaks. The one interim change is hyp detach --client all exiting 1 with a single honest per-client error while every other client still detaches - the intended loudness, not a blocker.

Worth recording for scale: @hypaware/claude-desktop is excluded from default activation (src/core/runtime/bundled.js:82), but both descriptor builders deliberately include excluded bundled plugins (src/core/daemon/status.js:1071, src/core/commands/clients.js:1287), so the descriptor is in the map on every install. The interim surface therefore reaches users who never enabled the plugin. Still contained, still exit 0 for hyp status.

Also confirmed: claude-desktop's picker row uses detect: { app_bundle: "/Applications/Claude.app" }, not settings_file, so hyp init / the walkthrough is unaffected in practice as well as in principle. A repo-wide grep finds exactly one absolute declaration (hypaware-core/plugins-workspace/claude-desktop/hypaware.plugin.json:23); no smoke flow under hypaware-core/smoke/ declares a settings_file at all; the only absolute value in test/ is the new intentional fixture.

2. (a) reject vs (b) honour: reject is right, and the "already in force" claim checks out

Both stated reasons survive independent scrutiny:

  • No coherent $<CLIENT>_HOME semantics. The override replaces the first path segment (client_settings_path.js, parts.slice(1)). An absolute path's first segment is the empty string. Honouring needs a second, silently-different rule for one field.
  • One resolver, read and write side. It backs the probe (status.js:969), the picker detect (detect.js:80), and the disk-driven undo (client_detach_disk.js:89). A value the write side cannot resolve must fail.

The "documents a rule already in force" claim is true, verified against origin/master:

  • hypaware-plugin-kernel-types.d.ts:166 on master is a bare settings_file: string with no doc comment at all.
  • resolveClientSettingsPath's JSDoc on master already said "The manifest settings_file is relative to $HOME" - unchanged context in the diff.

So it enforces a stated-but-unenforced contract. Deferring manifest-load validation is right and is recorded with its sequencing.

Two things the section overclaimed, now corrected in 623eb6b:

  • "no manifest loses expressiveness" - true for the picker (app_bundle / path siblings), false for attach_probe, which has only settings_file and no absolute variant. That is exactly why claude-desktop: drop the attach_probe core cannot read or reverse #445deletes Desktop's probe rather than respelling it.
  • "core resolves this field for the attach probe and for the disk-driven detach alike" - true for core, but the per-plugin attach write side is not routed through it: hypaware-core/plugins-workspace/claude/src/settings.js:72 hardcodes ~/.claude/settings.json and ignores $CLAUDE_HOME, while core's resolver honours it; hypaware-core/plugins-workspace/codex/src/config-file.js:20-26 keeps its own $CODEX_HOME copy. Only openclaw uses the shared resolver. Pre-existing, not a regression, out of scope to fix here - but recorded in the LLP so the next reader does not read "one resolver" as "every writer agrees".

3. Test quality: verified by mutation, not by reading

Reverted only the behaviour, keeping the ClientSettingsPathError export intact (if (path.isAbsolute(...)) -> if (false && path.isAbsolute(...))):

not ok 4 - resolveClientSettingsPath rejects an absolute settings_file rather than re-anchoring it
not ok 5 - probeClientAttachFromDescriptor errors on an absolute settings_file instead of probing $HOME
Expected values to be strictly equal: true !== false
# pass 12 # fail 2

Both failures behavioural, not module-load, exactly as claimed. Test 5's true !== false confirms the marked decoy works: pre-fix the probe reports attached: true about a file the manifest never named - a wrong positive, strictly better evidence than an ENOENT wrong negative. Test 4 asserts instanceofandcode === 'settings_file_absolute'and that the message does not leak the re-anchored $HOME path, across both branches. Guard restored; suite green.

npm test at 607925e: 2794 pass / 8 fail, exactly the known test/core/leave-command.test.js baseline. npm run typecheck clean. Both reproduced independently.

4. Findings

MEDIUM - fixed in 9403fba: the error was loud only under --json

src/core/commands/status.js forwarded c.error into the JSON payload (:190) but renderStatusText did not: the client line at :342 printed only [not in config, not attached]. Worse, the (none) collapse at :330 (clientNames.length === 0 && report.clients.every((c) => !c.configured)) swallowed the whole section - and an unresolvable client is typically not configured. Measured on a stock host at 607925e:

 clients:
(none)

The wrong negative indistinguishable from a right one, reinstated one layer above the probe, on the surface a human reads. The PR body's caller table says the error is "surfaced by hyp status"; that was true of --json only.

Fixed: print error: <message> under the client's line, and keep an errored client out of the (none) collapse. Now:

 clients:
- claude-desktop [not in config, not attached]
error: client 'claude-desktop' declares an absolute settings_file '/Library/Managed Preferences/...'; settings_file must be relative to $HOME (e.g. '.codex/config.toml')

Four tests in test/core/status-client-error.test.js; LLP 0045 Part 3 amended in the same commit; the code carries an @ref to it.

MEDIUM - fixed in 623eb6b: .. walks straight through the guard

path.isAbsolute rejects one spelling of "escapes $HOME". Measured against the 607925e resolver:

settings_fileresolved to
../../../etc/passwd/etc/passwd
./../../etc/passwd/etc/passwd
.codex/../../../etc/passwd (+ CODEX_HOME=/tmp/ch)/etc/passwd
/Library/Managed Preferences/x.plistTHREW

Every word of the PR's own justification - "silently probes a file the manifest never named", "a wrong negative indistinguishable from a right one" - applies verbatim to ../../../etc/passwd, which escapes $HOME just as completely and is the spelling that survives the check. It matters more here than in a read-only resolver: detachClientFromDiskreads and rewrites the resolved path, and contributes.client is unvalidated anywhere (src/core/manifest.js has no client-side checks; src/core/plugin_catalog.js:77 copies client.attach_probe verbatim), so the value can arrive from a hyp plugin install <git url> or an org-pushed plugins[] entry.

Fixed by enforcing the contract on the resolved path: it must stay under the base it resolved against (code: 'settings_file_escapes_base'), each branch checked against its own base ($HOME normally, $<CLIENT>_HOME when set, since the override is precisely a licence to leave $HOME). A .. that normalizes away (.codex/sub/../config.toml) stays legal. Verified after the fix: all four escapes throw, and .claude/settings.json, .codex/config.toml (both branches), .openclaw/openclaw.json, .claude-desktop/settings.json + CLAUDE_DESKTOP_HOME all resolve exactly as before. Mutation-tested: disabling the containment check fails the new test alone.

LOW - the typed code is dropped at its only consumer (JSDoc corrected, 623eb6b)

The class doc said it is typed "so a caller can turn it into whatever observable means ... without matching on message text", but status.js:971 does error: err.message and the probe result type is error?: string, so the tests then match on message text - exactly what the doc says the type avoids. Adding error_code to the probe result would change the public hyp status --json shape, which a review should not do unilaterally, so I corrected the JSDoc to claim only what is true (the code is there for callers that need to branch; callers that only need observability forward the message). Worth a follow-up if you want error_code on the JSON surface.

INFO - a reconciler reverse for such a manifest now retries forever

action_attach.jsreverse() -> detachClientFromDisk now throws -> {status:'failed'} -> the reconciler keeps the marker and retries every pass. On master it resolved to a bogus $HOME path, no-op'd changed:false, and cleared the marker. The new behaviour is the documented intent ("core must not claim to have reversed"), but it is an unbounded retry + error-log loop that the LLP does not mention. Not reachable for claude-desktop (it registers no ctx.clients adapter, so desired() is inert for it), so this needs a third-party plugin with both an absolute settings_file and a live adapter. Left as-is; flagging for the record.

INFO - path.isAbsolute is correct for the paths in play; POSIX-only is fine

Platform-dependent, and both behaviours are the wanted one: on POSIX and Windows alike '/Library/...' -> true, '.codex/config.toml' -> false. A Windows-style C:\... would slip on POSIX, but the resolver's whole model is POSIX (settingsFile.split('/')), the daemon installer supports launchd and systemd only (src/core/daemon/platform.js), and no manifest is Windows-shaped - and it now lands inside$HOME as a weird filename rather than anywhere dangerous. Not worth a UNC/drive-letter branch.

INFO - @ref anchors resolve; house style clean

#### \settings_file` is home-relative, and a violation is loudslugifies tosettings_file-is-home-relative-and-a-violation-is-loud - underscores kept, backticks and comma stripped - matching repo precedent in the same file (#enable_tool_search-keep-deferred-tool-loading-on-through-the-gatewayfrom#### ENABLE_TOOL_SEARCH: keep deferred tool loading on through the gateway, and #conflict--back-up--override-restore-on-leave). The heading is unique, so no -1suffix. All refs well-formed and attached without a blank-line break. No em dash on any added line, no semicolons, no inlineimport('...')types, no@typedef. npm run typecheck` clean.

Separately: .claude/skills/ref-check/SKILL.md:80 describes slugification as "non-alphanumerics stripped", which read literally would strip the underscore and reject both this anchor and the pre-existing #enable_tool_search-... one. Pre-existing doc bug in the skill, not this PR's; worth a one-line fix elsewhere.

5. Verification of the fixes

npm test on 623eb6b: 2799 pass / 8 fail, the same 8 leave-command.test.js failures as origin/master. npm run typecheck clean. npm run smoke -- core_boot_noop and client_attach_idempotent pass. (claude_attach_detach and client_attach_on_join fail identically on origin/master in this environment - pre-existing, unrelated.) Both new guards mutation-tested: disabling either check fails its own test and nothing else.


Merge-ordering guidance (please read when sequencing)

#447 is safe to merge in either order relative to #445. It does NOT have to wait.

Reviewed at 607925e; fixes pushed as 9403fba and 623eb6b.

neutral-loopand others added 2 commits July 29, 2026 06:35
…prefix test
Round-2 review of the containment guard added in 623eb6b. The guard holds
against every escape spelling I could build (absolute, `..` in either
branch, prefix-sharing siblings, `..` landing on the base), but three
things about it were either untested or unwritten:
- The `base + separator` suffix in the prefix test is the entire check -
a bare `startsWith(base)` accepts `/home/username` under a `/home/u`
base - and deleting the suffix made no test fail. Pinned.
- `withinBase` validated `path.resolve(joined)` and returned the raw
`joined`. They diverge whenever the base is relative: `CODEX_HOME=..`
returned the relative `../config.toml`, i.e. a value re-resolved
against `process.cwd()` at read time rather than the one validated at
call time, and not the absolute path the resolver's contract promises.
Return the checked path.
- The check is lexical, not realpath-based, so a config home that is
itself a symlink out of $HOME still passes. That is the right
boundary - the field is resolved before the file must exist, and
planting such a symlink already needs write access to $HOME, whereas
the untrusted input is the unvalidated manifest value - but it was
nowhere stated, and an undocumented limit on a security control
invites over-trust. Written down in the JSDoc and in LLP 0045 Part 3.
Co-Authored-By: Claude <noreply@anthropic.com>
…o it
The errored `claude-desktop` row was pushed onto whatever
`collectHypAwareStatus` had already produced, and the bundled catalog
produces a `claude-desktop` row of its own carrying this very error - the
manifest defect #445 removes. So the list held two same-named rows and
the JSON assertion's `find` answered from the catalog's, passing for the
wrong reason.
It stops passing the moment #445 lands: the catalog row loses its error,
`find` still returns it first, and `the JSON renderer carries the same
client error` fails. Verified both ways - `git merge origin/fix/issue-444`
into this branch failed that test before this commit and passes after,
with the 8 known leave-command failures unchanged in both trees.
Replace the same-named row rather than joining it. These tests are about
the two renderers, so they own the whole client list and assert nothing
about which clients the bundled catalog happens to ship.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Round 2 (final, cap 2) — reviewed 623eb6b, fixed forward to 9fdb606

Verdict: the change is right and the containment guard holds. Every escape
I could construct is refused, and the two spellings the guard's own design
turns on (base + separator, absolute-vs-relative bases) are now pinned by
tests. Two findings were actionable, one of them a real cross-PR break that
would have failed CI the day #445 merged. Both fixed on the branch.

Round 1 reviewed 607925e and its findings are all present and correct in
623eb6b; I re-derived them independently rather than taking them on trust.


1. Adversarial attack on settings_file_escapes_base

Run by calling resolveClientSettingsPath directly, plus a real-fs symlink
fixture. No CLI, no host state. $HOME in the table is the homeDir
argument, not this machine's.

Refused

VectorResult
/etc/passwdsettings_file_absolute
//etc/passwd (double-slash spelling)settings_file_absolute
/settings_file_absolute
../../../etc/passwdsettings_file_escapes_base
./../../etc/passwdsettings_file_escapes_base
.. (lands on the parent)settings_file_escapes_base
../../homework/xsettings_file_escapes_base
../username/x against base /home/u (prefix-sharing sibling)settings_file_escapes_base
../hyp2/x against base /Users/hypsettings_file_escapes_base
../../etc/passwd with base /Users/hyp/ (trailing slash)settings_file_escapes_base
.codex/../../../etc/passwd + CODEX_HOME=/tmp/chsettings_file_escapes_base
../../../etc/passwd + CODEX_HOME=/tmp/chsettings_file_escapes_base
a/../../../etc/passwd + CODEX_HOME=/tmp/chsettings_file_escapes_base
../outside/secret.toml with a symlinked basesettings_file_escapes_base

The path.relative/startsWith pitfall is not present. The check
(client_settings_path.js:100) is
target !== root && !target.startsWith(root + path.sep) on both sides
path.resolved. The + path.sep is what makes /home/username fail against a
/home/u base, and it is there. Trailing slashes on the base are normalized
away by path.resolve first (/Users/hyp/ and /Users/hyp// both behave as
/Users/hyp), so a trailing-slash base is not a hole either.

Not refused, correctly (documented as such after this round)

  • Symlink escape. A settings_file of .codex/secret.toml where
    $HOME/.codex is a symlink to /outside resolves lexically inside the base
    and is accepted; realpath of the result is /outside/secret.toml. The
    guard does not resolve symlinks, and it should not: the field is resolved
    before the file has to exist (attach creates it, the picker only stats its
    directory), so realpath would fail on exactly the paths that matter most —
    and planting that symlink already requires write access to $HOME, at which
    point the settings file is the attacker's regardless. The untrusted input
    here is the manifest value, and that is what the guard contains. This was
    nowhere written down; an undocumented limit on a security control invites
    over-trust, so it is now stated in the JSDoc and in LLP 0045 Part 3.
  • .. landing exactly ON the base.'', '.', .codex/.. all return the
    base itself; target === root is explicitly allowed. Benign and loud: the
    callers then readFile a directory, EISDIR is not ENOENT, so
    probeClientAttachFromDescriptor's outer catch (status.js:1025-1032)
    returns { attached: false, settingsPath, error }. Not a silent wrong
    answer, so not the class of bug this PR exists to kill. Same for the
    override branch's single-segment degenerate case (config.toml +
    CODEX_HOME=/tmp/ch yields /tmp/ch, the directory) — pre-existing, loud.
  • A hostile $<CLIENT>_HOME.CODEX_HOME=/etc with .codex/passwd
    returns /etc/passwd and passes, because the override is checked against
    itself. By design and correct: $<CLIENT>_HOME is the user's own
    environment, not the untrusted manifest, and the override is precisely a
    licence to leave $HOME. The guard still contains the manifest value
    within the override, which is the part that matters.
  • NUL / newline in the path. Pass the guard (they are legal path
    characters lexically) and are rejected downstream by fs with
    ERR_INVALID_ARG_VALUE, which is not ENOENT and so surfaces as an error
    on the probe result. Loud, not silent.
  • Backslash and C:\... spellings. Treated as literal single segments on
    POSIX and contained. The repo is POSIX-targeted (systemd units, ~/.claude),
    so not a gap.
  • A hostile client name ('../codex'). Used only for the env-key
    derivation (already sanitized by replace(/[^A-Z0-9]/g, '_')) and the error
    text, never for the path. No effect.

2. Test teeth — verified by mutation

Each guard was mutated in place and the named test confirmed to fail. This also
re-establishes round 1's pre-fix-failure claims behaviourally: reverting only
the behaviour (M1, M3) reproduces the failures the PR body reports.

MutationTest that fails
M1 withinBase containment neuteredrejects a settings_file that climbs out of its base
M3 path.isAbsolute guard removedrejects an absolute settings_file rather than re-anchoring it, probeClientAttachFromDescriptor errors on an absolute settings_file…, the JSON renderer carries the same client error
M4 override branch checked against homeDir (wrong base)rejects a settings_file that climbs out of its base
M5 text renderer drops the error: linethe text renderer prints a client probe error instead of a bare not-attached
M6 (none) collapse ignores c.error againboth text-surface tests
M2 startsWith(root)path.sep deletednothing (before this round)
M7 return joined instead of return targetnothing (before this round)

M2 and M7 are findings, below. Both now fail against named tests.

Full suite on 623eb6b: 2799 pass / 8 fail — exactly the known
test/core/leave-command.test.js baseline. npm run typecheck clean. Same
baseline on 9fdb606 (2801 pass, +2 new tests, 8 fail).


3. Findings

MEDIUM — the tests break the moment #445 lands (cross-PR, fixed)

test/core/status-client-error.test.js:48-58 built its fixture by pushing
an errored claude-desktop row onto whatever collectHypAwareStatus returned.
The bundled catalog already produces a claude-desktop row carrying this exact
error (hypaware.plugin.json:23, the defect #445 removes), so the list held
two same-named rows and the JSON assertion's rows.find(r => r.name === …)
answered from the catalog's row. It passed for the wrong reason.

Once #445 lands the catalog row loses its error, find still returns it first,
and the test fails. Demonstrated, not inferred:

git merge origin/fix/issue-444 # into 623eb6b+
npm test -> 9 failures: the 8 leave-command baseline
+ not ok - the JSON renderer carries the same client error
actual: undefined

Fixed by replacing the same-named row instead of joining it. Re-verified: the
merged tree now runs 2804 pass / 8 fail, the baseline exactly.

This is also why the sibling-prefix and check-vs-return findings below matter —
all three are "the test passes but not for the reason it claims".

LOW (security-hygiene) — the + path.sep had no test (fixed)

Deleting the separator from client_settings_path.js:100 — the single most
common way a containment check is silently wrong — broke no test (M2). The
implementation is correct; nothing was pinning it. Added
resolveClientSettingsPath does not mistake a prefix-sharing sibling for the base, covering /home/u vs /home/username, /Users/hyp vs /Users/hyp2,
/home/hyp vs /home/hypaware, plus the override branch. Confirmed it fails
under M2.

LOW — the guard checked one string and returned another (fixed)

withinBase validated path.resolve(joined) but returned the raw joined.
They diverge whenever the base is relative: with CODEX_HOME=.. the function
returned the relative../config.toml, i.e. a value re-resolved against
process.cwd() at read time rather than the one validated at call time — and
not the absolute path the resolver's own contract promises ("Resolve the
absolute settings-file path"). Not exploitable (both sides resolve against the
same cwd within a call, and $<CLIENT>_HOME is the user's env), but a
check/use divergence introduced by this PR's own guard is worth closing while
it is one line. Now returns target; pinned by
resolveClientSettingsPath returns the absolute path it checked, confirmed
failing under M7. No behaviour change for absolute bases (path.resolve is
identity on path.join's output there) — full suite unchanged.

LOW (doc) — the symlink limitation was unstated (fixed)

See the attack section. Recorded in the JSDoc and LLP 0045 Part 3 rather than
changed, with the reasoning for why lexical is the right boundary.

Confirmed sound, no action

  • Round 1's three findings all land as described in 623eb6b.
  • "hyp detach fails loudly" — verified end to end rather than assumed:
    detachClientViaCore's dry-run path (clients.js:422) and
    detachClientFromDisk (client_detach_disk.js:89) both let the throw
    propagate, and bin/hypaware.js:53-58 catches it into hyp: <message> with
    exit 1. Clean message, no stack trace.
  • All four callers enumerated in the PR body are the only ones.
    grep resolveClientSettingsPath also finds
    plugins-workspace/openclaw/src/settings.js:83, which passes a hardcoded
    constant — unaffected, and the PR body's own "one honest caveat" paragraph
    already covers the per-plugin writers.
  • @ref anchors resolve. All eight point at
    llp/0045-client-attach.design.md:287, #### \settings_file` is
    home-relative, and a violation is loud`; the slug matches.
  • House style. No semicolons, no em dashes on any added line (the two the
    scanner sees are pre-existing context in LLP 0045), no @typedef, no inline
    import('...') types, JSDoc types throughout. npm run typecheck clean.

Nothing left for triage.


4. Merge ordering — round 1's conclusion needs one amendment

Round 1 concluded #447 is safe in either order relative to #445
(fix/issue-444), preferring #445 first. On 623eb6b that was wrong: the
combined tree failed a test (finding 1). On 9fdb606 it is right, and now
demonstrated in both directions rather than argued:

Guidance: either order is safe. Mild preference for #445 first — it avoids
shipping a hyp status that shows an error row for a bundled plugin, and round
1's text-surface fix makes that row more visible than it was at 607925e, so
the cosmetic argument for #445-first is slightly stronger now, not weaker. No
code or test dependency either way.


What changed this round

623eb6b9fdb606 (2 commits, +114/-13 across 4 files):

  • b3351a1withinBase returns the path it checked; symlink boundary
    documented in JSDoc + LLP 0045; two new resolver tests (prefix-sharing
    sibling, absolute-return).
  • 9fdb606status-client-error.test.js owns its client list instead of
    appending to the catalog's.

No host state touched: no hyp subcommand of any kind was run, no
~/.claude/settings.json, no ~/.codex/config.toml, no ~/.config/systemd/.
All work in a detached worktree; every attack ran as a direct function call or
against a mkdtemp fixture.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Triage: PR #447 safe to ship (draft, ready to un-draft)

Classification: PREFERENCE only. No true blocker found. Round 2's verdict
("nothing left for triage") holds up under independent re-derivation.

Independent verification of the merge-ordering claim

Round 2's crux claim is that its own round-1 merge-ordering conclusion was
wrong at 607925e/623eb6b (the status-client-error.test.js fixture pushed
an errored claude-desktop row onto the catalog's own same-named row, so the
JSON assertion's find passed for the wrong reason) and that 9fdb606 fixes
it by replacing the row instead. Verified from scratch, not taken on trust:

  • Fresh detached worktree off origin/fix/issue-446 at 9fdb606, merged
    origin/fix/issue-444 (--no-edit, ort strategy): clean, no conflicts
    (5 files touched, all on the #445 side).
  • npm test on the merged tree: 2804 pass / 8 fail, matching round 2's
    reported result exactly.
  • The 8 failures are all and only in test/core/leave-command.test.js
    (leave after join removes the seed... through ...stays clean) - the
    known pre-existing baseline, nothing new.
  • node --test test/core/status-client-error.test.js on the merged tree: all
    4 pass, including "the JSON renderer carries the same client error" -
    the specific assertion round 2 said would fail pre-fix and pass post-fix.
  • 9fdb606's own test diff confirms the mechanism: it filters
    report.clients by name before pushing the replacement row, with a comment
    explaining exactly the same-name-collision failure mode round 2 describes.
  • Also ran 9fdb606alone (no merge): 2801 pass / 8 fail, same baseline -
    confirms the PR is independently green before claude-desktop: drop the attach_probe core cannot read or reverse #445 lands too, and this is
    the number the body's stale ground-truth line has been corrected to (see
    below).

Conclusion: the merged-tree result is real, reproducible, and not an
artifact of round 2's own framing.

Lexical (non-realpath) containment guard - judged independently

Read src/core/daemon/client_settings_path.js end to end rather than taking
round 2's attack table at face value. The withinBase check
(target !== root && !target.startsWith(root + path.sep), both sides
path.resolved) correctly avoids the classic prefix-sibling hole
(/home/u vs /home/username) and is checked against the same value it
returns (fixed this round via the M7 finding).

On lexical-vs-realpath: agree this is the right boundary given the threat
model in the issue (an untrusted settings_filevalue arriving via
contributes.client, which is unvalidated). The field must resolve before
the file necessarily exists (attach creates it; the picker only stats a
directory), so realpath would throw on exactly the paths that matter most.
And a symlink-based escape requires prior write access to $HOME - at which
point the attacker can write the real settings file directly and does not
need this code path at all. The guard's actual job is catching a manifest
string that names a path outside the client's config home, which it does
correctly for every spelling tested (absolute, ..-climbing, double-slash,
env-override abuse, NUL/newline, Windows spellings). No gap found beyond what
round 2 already documented and the JSDoc now states.

Body correction

The body's "Ground truth" section still quoted npm test's pass count from
before the review rounds added more resolver/renderer tests (2794 pass).
Corrected in place (precedent: PRs #438/#441/#445) to the current,
independently-reverified number (2801 pass / 8 fail for this PR alone,
typecheck clean), preserving the rest of the section verbatim.

Merge-ordering guidance

Round 2's amended position stands and I confirm it independently: either
order is safe
at 9fdb606. Mild preference for #445 first - it avoids
an interim hyp status run where claude-desktop shows a visible error row
(this PR's own text-surface fix makes that row more visible than before,
which is the intended behavior, but there's no reason to show it if #445 is
ready to remove the underlying manifest defect first). No code or test
dependency runs the other way; git merge-tree is clean from 9fdb606
against all four held heads.

Follow-up issue

None filed. Both review rounds found and fixed everything they raised in-round
(1 MEDIUM + 3 LOW in round 2, round 1's 3 findings folded in); there is
nothing genuine to defer. Checked neutral:fix open issues referencing PR
#447 first - none exist, and filing an empty "deferred findings" issue would
be spam. The originating issue is #446.

No host state touched: no hyp subcommand run anywhere, only direct
node --test / npm test in a detached, disposable worktree with a symlinked
node_modules. Worktree removed after use.

@philcunliffe
philcunliffe marked this pull request as ready for review July 29, 2026 06:43
@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 29, 2026
@philcunliffe
philcunliffe merged commit f2bc65d into masterJul 29, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-446 branch July 29, 2026 18:57
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

resolveClientSettingsPath silently re-anchors an absolute settings_file under $HOME instead of rejecting it

1 participant

@philcunliffe