Uh oh!
There was an error while loading. Please reload this page.
claude attach: a non-string user env value is the user's, and one verified-version stamp - #449
Conversation
…ified-version stamp (#448) Finding 1: `manageEnvAdditions`'s ownership gate tested the *type* of the existing value (`typeof env[key] === 'string'`), so a hand-written JSON boolean or number at a managed key fell straight through it. Attach coerced the value, recorded the key in the `_hypaware` marker's `managed.env`, and the core undo - doing exactly what that record authorized - then deleted a setting the user owned. Reproduced end to end: `ENABLE_TOOL_SEARCH: true` plus `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL: 0` came back from attach as `"true"`/`"1"`, and detach removed both, pruning the emptied `env` block with them. The `0` case is the sharpest: that is the user turning the flag off, and attach reversed their intent before deleting it. The guard now tests presence (`key in env`), which is what the helper's JSDoc always promised and what LLP 0045's "only manage the key when it is ours" rule means. Coercion was never intended, so the guard moves to match the doc rather than the doc moving to bless the data loss. Finding 2: the three verified-version stamps disagreed - settings.js and llp/0045:373 said 2.1.220, llp/0045:330 said 2.1.215. Both values entered in the same commit (a1100f0, #438), so this was never an introduced-at vs last-verified distinction, just an unverified number that review round 2 corrected in one place of three. 2.1.215 is the release the predicate was actually read off the shipped bundle, and it is the version installed here (`claude --version`, `@anthropic-ai/claude-code@2.1.215`). All three stamps now read 2.1.215. The stamp is load-bearing: the LLP sends a future reader to re-verify this undocumented, underscore-prefixed flag against a later release, and an ambiguous baseline defeats that mitigation. Regression tests fail on master and pass here: a round trip in test/core/client-detach-disk.test.js proving a non-string user value survives attach *and* detach byte-for-byte, plus attach-level coverage of boolean/number/null in test/plugins/claude-settings-attach.test.js. Co-Authored-By: Claude <noreply@anthropic.com>
The undo side of the same ownership rule this PR fixes on attach. The attach guard now decides ownership by the key being present rather than by the type of its value, so a hand-written JSON boolean at a managed key is a value the tree expects to meet. The undo's never-clobber notice was still type-gated: } else if (typeof current === 'string') so it stayed silent about exactly that value. The key correctly survived the detach, but the operator was never told a managed key had been left behind on disk - which is the whole job of the notice, since LLP 0045 promises "core accumulates one message for each key it left in place", and those keys stay on disk after a detach that otherwise reports success. The `json_path` undo in the same file already tests presence (`current !== undefined`); only the `json` marker branch diverged. Gated on `key in envObj`, not a bare `else`: a key the user deleted outright was not left in place and must not be reported. Both directions are pinned by tests - reverting the predicate fails "reports a managed key the user overrode with a non-string", widening it to `else` fails "stays silent about a managed key the user deleted outright". Doc-conformance only, so LLP 0045 is unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Jul 29, 2026
Neutral review round - PR #449 @ |
| Attack | Result |
|---|---|
Explicit "ENABLE_TOOL_SEARCH": null | 'k' in env is true, so the user's null is left alone and stays out of managed.env. Covered by not ok 34 below. |
| Inherited prototype property | Cannot land.JSON.parse defines "__proto__" as an own data property and never mutates the prototype, so a parsed env is always plain Object.prototype. None of the three managed keys (ENABLE_TOOL_SEARCH, _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL, ANTHROPIC_BASE_URL) exist on Object.prototype - verified [false, false, false]. Live probe with {"env":{"__proto__":{"ENABLE_TOOL_SEARCH":"sneaky"}}} attaches normally and claims the key, which is correct. Object.hasOwn is therefore exactly equivalent here, not safer: I ran it as a mutant and got 38/38, identical behaviour. in is fine. |
Key present but undefined | Not expressible in JSON, so unreachable through readSettings. Mutant env[key] !== undefined also passes 38/38, confirming the two are indistinguishable on any real input. |
| RE-ATTACH: does the new predicate skip our own key and leave a stale value? | No.!weOwnIt && short-circuits first, so when a prior marker recorded the key we fall through to env[key] = value regardless of what is live. Probed directly: after attach, I drifted the live value to 'false', false, 0 and null in turn and re-attached. All four reclaim to live="true", managed="true". No stale value in any case. |
| Re-attach claiming a user key it skipped on attach #1 | No. The key was never recorded, so weOwnIt stays false forever. Probed: user writes true, attach x2, then detach - file is byte-for-byte the original. |
The re-attach path is also genuinely pinned, not just correct by luck. Mutating the guard to if (key in env) continue (dropping the ownership short-circuit) fails three named tests:
not ok 29 - re-attach keeps managing an ENABLE_TOOL_SEARCH it owns
not ok 35 - re-attach keeps managing a _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL it owns
not ok 38 - the marker undo record is stable across re-attach (modulo attached_at)
2. Undo round trip - mixed case
Probed the exact mixed case: one key attach owns, one the user set as a non-string.
before attach : {"env":{"_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL":0}}
after attach : {"_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL":0,"ENABLE_TOOL_SEARCH":"true","ANTHROPIC_BASE_URL":"http://127.0.0.1:4123"}
managed.env : {"ANTHROPIC_BASE_URL":"...","ENABLE_TOOL_SEARCH":"true"} <- user key absent
after detach : byte-for-byte identical to the original, warning: undefined
Detach removes exactly what attach added and nothing else: detachJsonMarker only ever iterates Object.entries(managedEnv), so keeping the user's key out of the marker is the whole protection - and the test asserts the absence, which is the load-bearing half.
3. Test teeth - confirmed
Reverting onlysettings.js to origin/master, new tests in place:
not ok 4 - claude attach + undo leave a non-string user-owned env value byte-for-byte intact
not ok 32 - attach leaves a user-owned boolean env value untouched and unmanaged
not ok 33 - attach leaves a user-owned number env value untouched and unmanaged
not ok 34 - attach leaves a user-owned null env value untouched and unmanaged
# pass 34 / # fail 4
Exactly not ok 4, 32, 33, 34 as claimed; with the fix restored, 38/38. Mutants: Object.hasOwn(env, key) 38/38 (equivalent), env[key] !== undefined 38/38 (equivalent), key in env without the ownership guard fails the 3 re-attach tests above. Full suite at de1873d: 2848 pass / 8 fail, the 8 being the known test/core/leave-command.test.js baseline, unchanged in name and count. npm run typecheck clean.
4. Version stamps - confirmed
git grep -nE '2\.1\.[0-9]{2,3}' finds exactly three stamps, all now 2.1.215:
hypaware-core/plugins-workspace/claude/src/settings.js:61llp/0045-client-attach.design.md:337llp/0045-client-attach.design.md:380
No fourth stamp anywhere. The only other 2.1.x in the tree is claude-cli/2.1.118 inside an example log string in hypaware-core/plugins-workspace/claude/agents/hypaware-analyst.md:58 - illustrative sample data, not a verification stamp, correctly left alone. Evidence independently reproduced in this environment: claude --version -> 2.1.215 (Claude Code), and npm ls -g -> @anthropic-ai/claude-code@2.1.215. The added LLP line ("that one version is the baseline every stamp in the tree carries, code comment included") is accurate as written, and is now the thing that keeps it accurate.
5. Conventions
All 5 @ref anchors in settings.js resolve against LLP 0044/0045 headings (checked with GitHub slug rules - the -- from the em-dashed ## Part 3 — heading is right). No U+2014 in any added line. No semicolons. JSDoc types only, @import at the top, no inline import('...'). The new @ref on manageEnvAdditions earns its place: the ownership rule it points at lives under the ENABLE_TOOL_SEARCH heading but binds every managed env key, and the gloss says so - not something the code or filename tells you.
Finding (Low, fixed and pushed)
src/core/config/client_detach_disk.js:182 - the undo's override notice carried the same type test this PR is removing from attach.
}elseif(typeofcurrent==='string'){warnings.push(`${key} was overridden externally; leaving in place`)}This PR establishes that a hand-written JSON boolean at a managed key is a legitimate user value. The undo's never-clobber notice was still gated on the value being a string, so it stayed silent about exactly that value. Reproduced against a mkdtemp fixture - attach, then the user re-points our own ENABLE_TOOL_SEARCH:
override="manual-string" -> survived="manual-string" warning="ENABLE_TOOL_SEARCH was overridden externally; leaving in place"
override=false -> survived=false warning=undefined
override=0 -> survived=0 warning=undefined
override=null -> survived=null warning=undefined
Not data loss - the value correctly survives - which is why this is Low and not a blocker. But it is a documented-contract violation: LLP 0045 §Never clobber a user edit: report every override, not just the last says core "accumulates one message for each key it left in place", and that those keys "stay on disk after a detach that otherwise claims success, which is exactly the case an operator needs told". It is also inconsistent within the same file: the json_path undo at line 480 already tests presence (current !== undefined); only the json marker branch diverged.
Fixed in 871016e: gated on key in envObj, matching this PR's own "presence is the whole test" language. Deliberately not a bare else - a key the user deleted was not left in place and must not be reported. Two tests, both with teeth:
- reverting the predicate fails
not ok 25 - claude undo reports a managed key the user overrode with a non-string - widening it to
elsefailsnot ok 26 - claude undo stays silent about a managed key the user deleted outright
LLP 0045 is unchanged: the doc already said the right thing, so this is code-to-doc conformance, the same shape as Finding 1.
Noted, not fixed
client_detach_disk.js:338 (the legacy pre-record marker path) has the same typeof current === 'string' gate, but only for ANTHROPIC_BASE_URL, which must be a URL string to mean anything - a non-string there is not a plausible user setting, and that branch reverses markers that predate the undo record. Left alone deliberately rather than widening this PR.
After the fix:npm test 2850 pass / 8 fail (same 8 leave-command.test.js baseline, +2 from the new tests), npm run typecheck clean.
Reviewed in a detached worktree. No hyp subcommand was run and no host state was touched: every exercise was a library-level call against mkdtemp fixtures.
Round 2 on the same ownership bug class, which now has three instances.
1. `detachLegacyJsonMarker` (the pre-record branch) still gated its notice on
`typeof current === 'string'`. A legacy marker meeting an
`ANTHROPIC_BASE_URL` the user had switched off with JSON's `false` or `null`
left the key on disk - correctly - and said nothing about it. Reproduced:
env before : {"ANTHROPIC_BASE_URL": false, "ANTHROPIC_API_KEY": "sk-x"}
result : {"changed": true} <- no warning
env after : {"ANTHROPIC_BASE_URL": false, "ANTHROPIC_API_KEY": "sk-x"}
with the string case one line away reporting correctly. That is the same
LLP 0045 promise the record-driven branch was fixed against last commit:
"core accumulates one message for each key it left in place ... those keys
stay on disk after a detach that otherwise claims success". The legacy
branch is the one that most needs it, since it reverses by convention and
therefore meets settings this tree never wrote. Reachable today: any
pre-upgrade marker without a `managed` record still dispatches here.
2. The record-driven branch's new presence test is now `Object.hasOwn`, not
`key in`. Its key names come off disk, from whatever `managed.env` a
plugin's attach recorded, so an inherited `Object.prototype` name satisfied
`in` and reported a key that was not on disk at all:
marker managed.env : {"toString": "x"}
settings env : {"ANTHROPIC_API_KEY": "sk-x"}
result : "toString was overridden externally; leaving in place"
which is precisely the false "left in place" report the presence test was
introduced to prevent. The attach-side guard keeps `key in` correctly: its
keys are in-tree literals, not disk input.
Every predicate is pinned in both directions by mutation: reverting either
branch to the type gate fails one test, widening either to a bare `else` fails
another, and swapping `Object.hasOwn` back to `in` fails a third. No test
fires for more than its own mutant.
Doc-conformance only; LLP 0045 already states the rule, so it is unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>The fourth and worst instance of issue #448's bug class, on the one key the PR had not swept: `ANTHROPIC_BASE_URL` itself. Unlike the managed additions this key has no ownership guard to fall through, because attach always repoints it. The backup IS the guard, and it was type-gated: const liveBaseUrl = typeof env.ANTHROPIC_BASE_URL === 'string' ? ... : undefined so a hand-written non-string read as nothing-to-back-up. Attach then recorded the key in `managed.env` with no `prev_base_url`, and the core undo, finding a managed key with no prior to restore, deleted it. Reproduced end to end on 871016e, for `8080`, `false` and `null` alike: BEFORE ATTACH : {"ANTHROPIC_BASE_URL": false, "ANTHROPIC_API_KEY": "sk-x"} MARKER : prev_base_url absent, managed.env.ANTHROPIC_BASE_URL present AFTER DETACH : {"ANTHROPIC_API_KEY": "sk-x"} <- user's value gone result : {"changed": true, "removed": "http://127.0.0.1:4123"} No warning, no backup, nothing to recover from: strictly worse than the managed-additions bug this PR opened on, where the value at least survived on disk. `null` and `false` are the sharpest cases again, being how a user switches an override back off. Fixed across all three points the value passes through, since fixing any one alone still loses it: - attach takes the backup unconditionally (JSON cannot encode `undefined`, so `undefined` already means absent, and the existing `!== undefined` checks are the presence test the type test was standing in for), - re-attach carries the recorded prior forward on the field being present rather than on its type, - the core undo reads `prev_base_url` by presence, so a backup the marker is holding can no longer be discarded into the delete branch. The marker keeps the real JSON value; only the human-readable `prevValue` / `restoredValue` report coerces to a string, the pattern the undo already used for `removed`. LLP 0045 stated presence-not-type for "every env key attach adds beside the base URL" and left the base URL's own backup unstated, which is the gap that let this through. It now states the rule for the backup too, and why this key needs it more rather than less. Mutation-pinned in all three places: reverting the attach backup fails 8 tests, reverting the undo read fails the 3 round-trips, and reverting the re-attach read fails the two-attach round-trip. npm test: 2862 pass / 8 fail (the known leave-command baseline). typecheck clean. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Jul 29, 2026
Round 2 (final, cap 2) - reviewed |
| mutation | result |
|---|---|
:182 -> typeof current === 'string' (round 1's claim) | not ok 25 only |
:182 -> bare else (round 1's claim) | not ok 26 only |
:182 -> key in envObj | not ok 29 only |
:348 -> typeof current === 'string' | not ok 27 only |
:348 -> bare else | not ok 28 only |
attach backup -> typeof ... === 'string' | 8 fail (3 round-trips, re-attach, 4 attach-level) |
undo prev_base_url -> typeof ... === 'string' | the 3 round-trips fail |
re-attach prev_base_url -> typeof ... === 'string' | the two-attach round-trip fails |
Round 1's two claims are exact. I also caught one of my own first-cut tests being toothless (an Object.hasOwn wrapper that was behaviourally identical to a plain read) and replaced the guard with the simpler honest expression plus a re-attach round-trip that does have teeth.
5. Further instances of the bug class
Swept hypaware-core/plugins-workspace/*/src/, src/core/config/, src/core/daemon/, plus the attach handler and client-asset undo. Beyond the four above, no more instances of this exact class. Cleared as legitimate: the JSON-record parse-hardening in detachJsonPathMarker (:479, :498), typeof marker.port === 'number' at :333 (fail-safe direction), the pre-regex typeof ... === 'string' guards, the codex TOML attach/detach (presence tests throughout, clean), and OpenClaw's marker-header parsing.
Two adjacent defects, different class (a schema check standing where a refusal belongs), left for triage:
claude/src/settings.js:284ensureObjectreplaces a present-but-non-objectenvwith{}. Confirmed: attach on{"env": "ANTHROPIC_API_KEY=sk-x"}silently writes the block away, no backup, no warning. The siblingopenclaw/src/settings.js:756does the same job correctly, throwingMALFORMED_CONFIGwhen the value is present but wrong-shaped. Claude's is that function minus the presence check. Medium.claude/src/settings.js:307installManagedHooksreplaces a non-arrayhooks.<event>wholesale. Confirmed by the same probe. Low (already-broken config), same shape.
One Info: client_detach_disk.js:352's typeof current === 'string' ? current : String(current) is dead in its branch (reached only when current equals a template literal). Harmless, but it is the copy-paste that made :348 look reasonable.
Fixed and pushed
1ea12be presence tests in both undo branches; 3aa623b the base-URL backup across attach/re-attach/undo plus the LLP 0045 rule.
npm test: 2862 pass / 8 fail; the 8 are the knowntest/core/leave-command.test.jsbaseline, unchanged in count and name.npm run typecheck: clean.- 11 new tests, all mutation-verified. All four LLP 0045 anchors used in the touched files resolve against the document's headings. No em dash introduced; the file's 18 are pre-existing on
master. - No
hypsubcommand run; every check usedmkdtempfixtures against the library entry points. Host state untouched.
For triage
The two ensureObject / installManagedHooks defects above. They are real and reproduced, but they are a different class from #448 (malformed config rather than a deliberate user value at a valid key) and fixing them means deciding whether attach should refuse rather than repair, which is a design call this PR should not make in passing.
philcunliffe
commented
Jul 29, 2026
Triage - PR #449 @ |
Uh oh!
There was an error while loading. Please reload this page.
Both findings deferred from PR #438 and dropped when #440 auto-closed. #438 has merged (
a1100f0), so both are actionable onmaster.Finding 1 - a non-string user value is clobbered and recorded as managed
Changed:
hypaware-core/plugins-workspace/claude/src/settings.js(manageEnvAdditions), plus its JSDoc and the LLP 0045 ownership rule.The gate tested the type of the existing value:
A hand-written JSON boolean or number at a managed key fell straight through it. Reproduced end to end on
masterbefore touching anything:Attach coerced the values and claimed both keys in the
_hypawaremarker'smanaged.env; the core undo then did exactly what that record authorized and deleted settings the user owned, pruning the emptiedenvblock with them. The0case is the sharpest: that is the user deliberately turning the flag off, and attach reversed their intent before deleting it.Coercion was never intended - the helper's JSDoc and LLP 0045 both already promised a user-set value is left untouched and stays out of the undo record. So the guard moves to match the doc, not the other way round. Ownership now turns on the key being present, whatever its JSON type. After the fix the same script reports
RESULT: user values intact, andmanaged.envcontains onlyANTHROPIC_BASE_URL.Regression tests (fail on
master, pass here):test/core/client-detach-disk.test.js-claude attach + undo leave a non-string user-owned env value byte-for-byte intact. The round trip is the assertion that matters, since the bug needed attach and detach together to destroy data; it asserts the final file equals the user's original text byte-for-byte.test/plugins/claude-settings-attach.test.js- attach-level coverage forboolean/number/null, each asserting the value is untouched and absent frommanaged.env.Verified by reverting only
settings.jstoorigin/masterwith the new tests in place: exactly those 4 fail (not ok 4,32,33,34); with the fix restored, 38/38 pass.Finding 2 (issue calls it finding 3) - contradictory verified-version stamps
Changed: the stamp in
hypaware-core/plugins-workspace/claude/src/settings.jsand the one atllp/0045-client-attach.design.md:373. All three stamps now read 2.1.215.Both values entered in the same commit (
a1100f0, #438), so this was never an introduced-at vs last-verified distinction - just an unverified number that review round 2 corrected in one place of three. 2.1.215 is the release theisFirstPartyBaseUrlpredicate was actually read off the shipped bundle, and it is what is installed in this environment:claude --versionreports2.1.215 (Claude Code)and the global npm tree shows@anthropic-ai/claude-code@2.1.215. Nobody has ever confirmed 2.1.220.The stamp is load-bearing rather than cosmetic: the LLP sends a future reader to re-verify this undocumented, underscore-prefixed flag against a later release, and an ambiguous baseline defeats that mitigation. The LLP now also says explicitly that this one version is the baseline every stamp in the tree carries.
Documentation-only, no runtime effect, and it is not the sole content of this PR.
Checks
npm test: 2848 pass / 8 fail - the 8 are the known pre-existingtest/core/leave-command.test.jsfailures, unchanged in count and name from the stated baseline.npm run typecheck: clean (exit 0).@refonmanageEnvAdditionsresolves; all three LLP 0045 anchors used in the file were checked against the document's headings.hypsubcommand was run and no host state was touched.Corrected during triage: this description stops at two findings and is now understated - the review fix-loop found two more instances of the same bug class (a type check standing in for a presence/ownership test) after this text was written, and the numbers above are the round-1 snapshot, not the head.
client_detach_disk.jshad the identical type gate on its never-clobber override notice, in both the record-driven undo (:182) and the legacy pre-record path (:338); round 1 fixed the former and declined the latter, round 2 judged that call wrong and fixed it too. Round 2 also swappedkey in envObjforObject.hasOwnthere, since that loop's key names come off disk rather than being in-tree literals.settings.js:123gated theANTHROPIC_BASE_URLbackup itself on type. A non-string live value (a hand-writtennullor a stray number) read as nothing to back up, so attach recorded the key as managed with noprev_base_urlto restore, and detach then deleted the user's setting outright -{"changed":true}, no warning, nothing to recover. Fixed across all three places the value passes through (attach's backup, the re-attach carry-forward, and the undo's read ofprev_base_url); verified independently during triage by reverting just these two files against the new tests, which reproduces the loss (14 tests fail, including the byte-for-byte round trips for8080/false/nulland the two-attach carry-forward case) and confirming the pre-existing re-attach behaviour (recorded original, not the gateway URL, on re-attach) still holds with the fix restored.3aa623bthe suite is 2862 pass / 8 fail (same baseline, +14 tests over the number above),npm run typecheckremains clean, and nohypsubcommand was run at any point.The two adjacent defects round 2 found in the same sweep (a different bug class: a schema check where a refusal belongs, in
ensureObjectfor a non-objectenvand a non-arrayhooks.<event>) are filed separately as #454 rather than fixed here, since choosing refuse-vs-repair is a design call this PR should not make in passing.Fixes#448