Skip to content

claude attach backs up a malformed env/hooks block instead of discarding it - #495

Merged
philcunliffe merged 3 commits into
masterfrom
fix/issue-454
Jul 31, 2026
Merged

claude attach backs up a malformed env/hooks block instead of discarding it#495
philcunliffe merged 3 commits into
masterfrom
fix/issue-454

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The gap

hyp attach for Claude Code must end with an object at env, an object at
hooks, and arrays at hooks.<event>. When one of those was present on disk
with the wrong JSON type
, ensureObject
(hypaware-core/plugins-workspace/claude/src/settings.js) assigned a fresh {}
over it and installManagedHooks started from an empty group list. Silently: no
backup, no prev_* record on the marker, no warning, and a success exit. The
user's hand-edit was destroyed with nothing on disk to recover it from.

Reproduction (pre-fix FAIL, post-fix PASS)

New test file test/plugins/claude-settings-malformed-block.test.js, run
against a pristine origin/master worktree (73b4618) with the fix absent:

not ok 1 - attach backs a non-object env up into the marker instead of discarding it
not ok 2 - attach backs a non-array hooks.<event> up into the marker instead of discarding it
not ok 3 - attach backs up a non-object hooks root, and a null block counts as present
not ok 4 - a re-attach keeps the first attach backup and stops warning about it
ok 5 - an absent env/hooks block attaches normally, with no backup and no warning
ok 6 - a present, well-formed but unusual env/hooks block is updated, not backed up
not ok 7 - detach restores the backed-up env block from the marker
not ok 8 - detach restores a backed-up hooks.<event>, recreating the emptied hooks root
not ok 9 - detach leaves a backed-up path alone, and reports it, when something else is using it now
ok 10 - detach of an attach that displaced nothing is unchanged by the restore step
# tests 10
# pass 3
# fail 7

The three that pass on master are the other direction, deliberately: absent
and well-formed-but-unusual blocks (a JSON-boolean env value, an empty event
array, an unmanaged Stop event) must keep attaching normally with no backup
and no warning. A fix that fired on those would be its own bug, so they are
pinned green on both sides.

Same file on this branch:

1..10
# tests 10
# pass 10
# fail 0

Root cause

ensureObject answered "present but wrong type" by overwriting, and the answer
was never written down. The check was correct; the response to a failed check
was the bug (as the issue puts it). The same shape one level down for a
non-array hooks.<event>.

Which sibling contract, and why not that one

The issue's title compares against ensureObjectAt in
hypaware-core/plugins-workspace/openclaw/src/settings.js:756, which throws
MALFORMED_CONFIG naming the offending path. That is option 1 in the issue
body.

The maintainer's decision comment on #454 selects option 2, back up then
repair
, explicitly: record the malformed value in the _hypaware marker so
hyp detach restores it, warn at attach time, keep the success path, expand the
marker schema as needed, cover both instances, and do not change openclaw in
this PR
. That is what this implements. The contract it matches is therefore the
in-file one, not the sibling adapter's: prev_base_url in this same attach()
already displaces a user value and already backs it up on the marker, and the
core undo already restores it under a never-clobber rule. A malformed block is
the same kind of event, so it gets the same treatment.

Per the decision comment's ask for a note on whether openclaw should converge:
LLP 0163 argues the two adapters are not symmetric (OpenClaw's own loader
rejects a non-object at models.providers.*, so proceeding produces a file the
client will not read; Claude Code tolerates junk keys), flags that the
asymmetry may not survive inspection, and leaves converging OpenClaw as a
separate change. OpenClaw is untouched here.

The fix

Attach (claude/src/settings.js) collects every block it had to rebuild,
keyed by dotted path, into the marker's new prev_malformed map:

"_hypaware": {
"prev_malformed": {
"env": "ANTHROPIC_API_KEY=sk-x",
"hooks.SessionStart": "hyp claude-hook session-context"
}
}

Path-keyed rather than fixed prev_env / prev_hooks fields because the hook
case is a family, not a key: any of four events can independently be the wrong
type, and the hooks root can be malformed too. It also keeps the replay in
core format-generic.

  • Presence, not type, separates absent from malformed. JSON cannot encode
    undefined, so Object.hasOwn is the whole test and a hand-written null is
    a value the user put there. This is the rule prev_base_url and the
    managed-env ownership guard already follow in this file.
  • A prior backup survives re-attach, for the reason prev_base_url does:
    once attach repairs the block the live value is ours, so the second attach
    finds nothing malformed and must not drop the first one's record. A prior
    entry wins at the same path.
  • ClaudeAttachResult gains warnings?: string[] (a list, not a joined string:
    the callers render it). hyp attach prints a line each, --json echoes the
    array, the plugin emits a client.attach.malformed_block log per block and a
    malformed_blocks_repaired span attribute so a reconciler-driven attach
    records it too.

Detach (src/core/config/client_detach_disk.js) replays prev_malformed
after the env/hook strip, shallowest path first. Restoring obeys the existing
never-clobber-a-user-edit rule as a presence test: the backup goes back only
into a path the strip has just emptied; anything still there arrived after the
attach and is left alone and reported through DetachFromDiskResult.warning.
The new restoreAtDottedPath recreates an object parent the strip deleted (the
hooks root goes once its last managed event array empties) and returns false
only when a parent is present as a non-object, which is reported rather than
forced. Legacy pre-record markers never wrote the field and are untouched.

Docs

  • New LLP 0163 (llp/0163-attach-backs-up-a-malformed-block.decision.md),
    Decision / Accepted: the three-option question, why option 2, why
    prev_malformed is path-keyed, the restore rule, and the OpenClaw
    divergence. Number picked by scanning llp/ across origin/master and every
    origin/* branch (highest in use was 0162).
  • LLP 0045 Part 3 updated: the marker's undo record now also carries
    prev_malformed.
  • @ref annotations added on both sides of the seam; test/core/llp-ref-hygiene.test.js passes.

Checks

  • npm test on this branch: 3047 tests, 3038 pass, 8 fail. The 8 are the
    pre-existing test/core/leave-command.test.js failures, confirmed by name
    against a pristine origin/master worktree, which reports the same 8
    (leave after join removes the seed and reports the server, leave clears an applied central slot, not just the seed, leave reverses org-driven attaches and drops the forward identity, leave after join also warns about a local central sink that keeps forwarding, leave is idempotent: a second leave is the not-connected no-op, leave still tears down when only a stale attach marker survives a prior partial leave, leave removes the assets its attach marker records, and leaves manual copies alone, leave self-heals an org attach whose plugin is gone: drops the marker, warns, stays clean). Nothing
    new. The delta is +10 tests, all passing.
  • npm run typecheck: clean.
  • npm run smoke -- claude_attach_detach: FAIL on this branch and identically
    on a pristine origin/master worktree, same assertion and same output (a
    detached-worktree path artifact in the hook-command assertion, not this
    change). Local runs are advisory; CI on this PR is the authority.

Collision check

Touches only hypaware-core/plugins-workspace/claude/src/{settings.js,index.js,types.d.ts},
src/core/config/client_detach_disk.js, llp/0045, llp/0163, and one new
test file. Disjoint from the four in-flight PRs (codex exchange-projector /
rollout-cwd, src/core/usage-policy/*, src/core/cache/ + purge.js, ai-gateway
session_command.js).

Fixes#454

testand others added 2 commits July 30, 2026 23:23
…ing it
`attach()` has to write into Claude Code's `env` and `hooks` blocks. When
one was present on disk with the wrong JSON type (a hand-edit mistake),
`ensureObject` assigned a fresh `{}` over it and `installManagedHooks`
started from an empty group list. Silently: no backup, no `prev_*` record
on the marker, no warning, and a success exit. Whatever the user wrote was
gone and nothing said so (issue #454).
Per the maintainer's decision on the issue, attach now takes option 2,
back up then repair: the displaced value goes into the `_hypaware`
marker's new path-keyed `prev_malformed` map (`env`, `hooks`,
`hooks.<event>`), attach returns a `warnings` list naming each path, and
the core disk-driven undo restores the backup.
Restoring follows the existing never-clobber-a-user-edit rule as a
presence test: the backup goes back only into a path the strip has just
emptied. Anything still sitting there arrived after the attach, so it is
left in place and reported through `DetachFromDiskResult.warning`. The
restore recreates an object parent the strip deleted (the `hooks` root
goes when its last managed event array is emptied) and refuses only when
a parent is present as a non-object.
Presence, not type, is what makes a block malformed rather than absent,
matching the rule `prev_base_url` and the managed-env ownership guard
already follow in this file: an absent block records nothing, and a
hand-written `null` is a value the user put there.
OpenClaw still refuses with `MALFORMED_CONFIG` and is deliberately not
changed here; LLP 0163 records the divergence and why converging it is a
separate question.
Fixes#454
Co-Authored-By: Claude <noreply@anthropic.com>
Round 1 review of #495. The back-up-then-repair design is right and matches
the decision on #454; three places overstated what the record can deliver.
- Attach warned "backed up in _hypaware.prev_malformed and hyp detach
restores it" for a value it had just discarded. Break the same block again
between two attaches and the prior entry wins (correctly - it holds the
pre-hypaware content), so the second displacement is destroyed while the
user is told it is safe. The collision is now detected where it happens and
gets its own notice. Never echoes the value: a malformed `env` is where an
API key ends up, and the string is printed and logged.
- Detach's "leaving it in place" / "could not be restored" notices read as
deferrals. The marker goes out in the same write and held the only copy, so
both now end "discarded with the marker". `env is in use again` fires for
anyone who added an ordinary env key after attaching, so this is the common
path, not a corner.
- `prev_malformed` keys are the only dotted paths in the undo a settings file
names freely, and `restoreAtDottedPath` creates the parents it walks, so a
hand-edited `__proto__.<key>` assigned onto Object.prototype for the rest of
the process. Refused, and reported like any other unwritable path.
Two mutants survived the original 10 tests: reversing the replay order, and
letting a later run clobber a prior backup. Both are pinned now, along with
the `__proto__` guard and the discard wording. The shallowest-first order
turns out not to be forced by the restore helper (it recreates missing
parents either way), so LLP 0163 now records it as a choice with the
deepest-first alternative, plus the sidecar question, under Open questions.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Review round 1 of 2 - findings

Verdict: the design is right, four fixes landed. The back-up-then-repair
mechanism is sound and the evidence in the PR body reproduces exactly. What I
found is a cluster of one defect: three places where what the code says to the
user is stronger than what the record can actually deliver. On a change whose
entire value proposition is "your original is recoverable", a warning that
overstates the guarantee is the bug, not a nit.

No re-litigation of refuse-versus-repair below. The maintainer settled it.

Direct audit, no tooling: the code-review skill is not installed in this clone
and codex is not on PATH. Everything below is marked executed (I ran it) or
reasoned (I read it).


Does it implement the maintainer's decision?

Yes, faithfully. (executed + reasoned)

Decision clauseStatus
Back up then repair, keep the success pathYes. attach() never throws on a malformed block.
Record on the _hypaware marker so hyp detach restores itYes, as prev_malformed.
Warn the user at attach timeYes: warnings[], printed a line each, echoed in --json, plus a client.attach.malformed_block log and a malformed_blocks_repaired span attribute.
Cover both instancesYes. Non-object env, non-object hooks root, and non-array hooks.<event>.
Tests start from the malformed file, attach, assert recoverable via detach + warning firedYes. Tests 1-4 and 7-9 do exactly that.
Short note on whether OpenClaw should convergeYes. LLP 0163 argues the asymmetry and flags that it may not survive inspection.
Do not change OpenClaw in this PRHonoured. The diff touches no openclaw/ path. (executed)

The "prev_env / prev_hooks style" phrasing in your comment is a style pointer,
not a schema mandate; path-keying is a better fit for a four-event family and the
PR justifies it. Not a deviation.


The recoverability attack

I drove attach() and detachClientFromDisk() directly against 18 temp-fixture
scenarios. No hyp command was run against a real home. executed.

Held up (no finding):

  • Round-trip fidelity is exact. A malformed hooks.<event> object carrying
    unicode (é, , an emoji), mixed key order, integer-like keys, 1e21,
    -0, null and nested arrays came back identical. Key order preserved,
    including the integer-key reordering JS applies, because it applies identically
    on both sides of the trip.
  • A 1 MiB malformed value round-trips intact.
  • A malformed value that itself contains _hypaware (with a fake managed
    and a fake nested prev_malformed) round-trips intact and is not
    mis-interpreted as a marker.
  • Repeated attach/detach cycles are stable. Three full cycles from a file
    malformed at both env and hooks.SessionStart return the original file each
    time, character for character.
  • No over-eager repair. Eight well-formed-but-unusual shapes beyond the ones
    tests 5/6/10 cover - numeric-like env keys, an event array full of junk
    non-group entries, env: {}, hooks: {}, a group with no hooks array, an
    object-valued env entry, and a malformed unmanaged event (Stop,
    Notification) - all produced warnings: undefined and no prev_malformed.
    Zero false positives. The claim that 5/6/10 pin the other direction is correct,
    and I extended it.

Findings below.


Findings

1. HIGH (fixed) - attach promises a restore for a value it just discarded

hypaware-core/plugins-workspace/claude/src/settings.js:140-146 (pre-fix)

The prior-wins rule is right, but its loser was reported with the winner's
sentence. Sequence: attach a file with "env": "FIRST-ORIGINAL", break env
again by hand, attach again. executed:

attach2 warnings: ["env was not a JSON object; its previous value is backed up
in _hypaware.prev_malformed and hyp detach restores it"]
marker prev_malformed: {"env":"FIRST-ORIGINAL"}

SECOND-HANDEDIT is not in the marker and detach will never restore it. It was
destroyed, and the user was told it was safe. That is the original #454 failure -
silent destruction on a success exit - with a reassuring sentence added.

Fixed: the collision is now detected at the recording site (priorMalformed
is read before anything is displaced) and gets its own notice: ... prev_malformed already holds an earlier backup for that path, so this value was discarded and hyp detach will not restore it. The value is deliberately not echoed: a
malformed env is precisely where an ANTHROPIC_API_KEY ends up, and this
string is printed to the terminal and passed to logger.warn as detail. Repo
rule, no credentials in logs.

2. MEDIUM (fixed) - detach's failure notices read as deferrals, but the value is gone

src/core/config/client_detach_disk.js:227-235 (pre-fix)

delete value[markerKey] runs at line 156 and the marker held the only copy, so
a detach that cannot restore is the moment the value stops existing. Three
scenarios, all executed, all ending with the original absent from disk:

ScenarioResult
malformed env, user adds one ordinary key after attachenv is in use again - original destroyed
malformed hooks.SessionStart, user adds their own group after attachhooks.SessionStart is in use again - original destroyed
malformed hooks root, user adds a Stop event after attachhooks is in use again - original destroyed

The first row is not a corner case. It fires for anyone who adds an env key
after attaching
, which is the ordinary use of the file.

The loss itself is inherent - a string cannot go back under an object holding the
user's newer keys - and I did not invent a place to put it. But "leaving it
in place rather than restoring the backed-up value" reads as though the record
survives to be retried. Fixed: both notices now end ... the backed-up value is discarded with the marker. Test 9 already asserted a prefix match so it still
passes, and I extended it to assert the disposal wording and that sk-x is
genuinely absent from the file afterwards.

Design question recorded for you, not decided by me: giving an unrestorable
backup a sidecar file (settings.json.hypaware-backup-<ts>) would make the
promise unconditional, at the cost of on-disk surface nobody cleans up. That is
outside your decision on #454, so it is in LLP 0163 under Open questions.

3. LOW (fixed) - prototype pollution from a hand-edited marker

src/core/config/client_detach_disk.js:663 (pre-fix, restoreAtDottedPath)

prev_malformed keys are the only dotted paths in the undo that a settings file
names freely, and this helper is the only one that creates the parents it
walks. executed:

prev_malformed = { "__proto__.hyp_polluted": "PWNED" } -> ({}).hyp_polluted === "PWNED"

getAtDottedPath does not catch it (Object.prototype.hyp_polluted is
undefined, so the never-clobber check waves it through), then
restoreAtDottedPath walks into Object.prototype and assigns there for the
rest of the process. Reaching it needs write access to the user's own
~/.claude/settings.json, hence LOW.

Fixed:UNWRITABLE_PATH_SEGMENTS (__proto__, constructor, prototype)
returns false, which is reported like any other unwritable path. Attach never
records such a segment, so nothing legitimate is refused.

Pre-existing, not fixed, worth its own issue: the sibling setAtDottedPath
(line ~618, driven by managed.set[].path in the json_path branch) has the
same hole and predates this PR.

4. MEDIUM (fixed) - two mutants survived the new test file

I mutation-tested all 10 new tests. executed. Six of eight mutants died, two
survived, and both are rules the PR body and LLP 0163 explicitly claim:

MutantOriginal 10 tests
{...priorMalformed, ...displaced} - a later run clobbers the prior backupsurvived, 10/10 pass
replay order reversed to deepest-firstsurvived, 10/10 pass

Test 4 cannot catch the first: on its re-attach nothing new is displaced, so
displaced is empty and both spread orders agree. It pins the easy half of the
rule, not the half where the values conflict. The second survives because no test
ever records two nested paths.

Fixed: three tests added (13 total) - the collision case, the nested hooks +
hooks.<event> case, and the __proto__ guard. Re-mutated afterwards: the
collision guard, the replay order, the segment guard and the disposal wording are
all killed now.

5. LOW (recorded, not changed) - the shallowest-first rationale is factually wrong

src/core/config/client_detach_disk.js:225-226 and LLP 0163 both said
shallowest-first is needed "so a restored hooks root exists as a parent before a
hooks.<event> backup is written into it". It is not: restoreAtDottedPath
recreates missing parents in either direction, which is exactly why the
reversed-order mutant passed everything.

The order is a real choice with a real consequence. Nested backups are reachable:
attach (records hooks.SessionStart: "echo mine"), hand-break the whole root,
attach again (records hooks: "broken-by-hand"). executed:

prev_malformed = {"hooks":"broken-by-hand","hooks.SessionStart":"echo mine"}
after detach = {"hooks":"broken-by-hand"}
warning = hooks.SessionStart could not be restored ...

Shallowest-first keeps the later typo and discards the earlier
pre-hypaware content
. Deepest-first would do the opposite - and "the earliest
backup is the one holding the user's content" is LLP 0163's own justification for
prior-wins one level up. So the ordering contradicts the document's own principle.

I did not flip it: which of two user values to prefer is a judgement call
inside your decision's space, not a defect for a review pass to resolve. I
corrected the false justification in the comment and the LLP, pinned the current
behaviour with a test that names the trade, and filed the choice under Open
questions.

6. LOW (recorded, not changed) - the legacy branch drops prev_malformed silently

A marker with prev_malformed but no managed routes to
detachLegacyJsonMarker, which never replays it: backup destroyed, no warning,
changed: true. executed. Only reachable by hand-editing managed out of an
otherwise-current marker (nothing writes that shape), so I left the legacy path
alone rather than adding provably-dead code to it, and recorded it in LLP 0163 as
accepted corrupt-input behaviour.

7. LOW (recorded) - one genuine divergence from the prev_base_url contract

Also executed: if the user deletes the whole env or hooks block by hand
after attaching, detach resurrects the old malformed value, silently. The
prev_base_url path does the opposite in the same situation - an
externally-deleted managed key is left deleted and not reported ("An
externally-deleted leaf needs no reversal").

I think the block behaviour is defensible (deleting a block that at that moment
held only hypaware's keys is a partial manual detach, and restoring the
pre-attach state is the point), but it is a divergence and worth your eye.


The sibling contract (prev_base_url) - verified consistent

reasoned, with executed spot-checks. It is not a parallel mechanism that only
looks similar:

  • Same presence-not-type rule.Object.hasOwn on both sides, for the same
    documented reason: JSON cannot encode undefined, so a hand-written null is
    a value.
  • Same prior-wins-on-re-attach rule, for the same reason (once repaired, the
    live value is ours).
  • Same never-clobber-then-report shape, and the notices join into the same
    accumulated warning string via joinWarnings.
  • Same single core undo. I confirmed the claude adapter exports no detach(),
    so there is no plugin-side path that could delete a marker without replaying
    prev_malformed. This was my main hunt for a HIGH and it came up clean.
    executed.

One deliberate and correct asymmetry: prev_base_url restores when the slot still
holds exactly what we wrote, prev_malformed restores when the slot is empty.
Different tests, same rule - for a whole block the strip removes ours first, so
"empty" is the analogue of "still ours". Finding 7 is where that analogy has a
visible seam.


LLP checks

  • 0163 is unclaimed. Swept every refs/remotes/origin/* before and after my
    push: the only ref carrying llp/0163-* is fix/issue-454. Neighbours are
    0160 on fix/issue-481 and 0161/0162 on
    integration/openclaw-full-capture. The duplicate-numbers gate
    (llp-ref-hygiene test 5, "no LLP number is claimed by two documents") passes.
    executed. Per LLP 0098, 0099 and 0111 each have two claimants; the renumber-vs-qualified-citation choice is unowned and needs a decision #469's caveat this is true as of this push; a concurrent
    worker could still collide.
  • Anchors resolve.llp-ref-hygiene passes 9/9, and I proved it is not
    vacuous by repointing my new
    LLP 0163#prev_malformed-is-path-keyed-not-one-field-per-block at a fake anchor
    and watching test 2 fail. executed.
  • LLP 0045 Part 3 accurately describes the code: prev_malformed on the undo
    record, path-keyed, replayed under the never-clobber rule. reasoned.
  • Style. Zero em dashes on any added line across all seven files, no
    semicolons, no @typedef, no inline import('...') types, root-anchored .js
    type-import specifiers. executed.

Checks

CheckBranch e1f9619Pristine origin/master73b4618After my fixes 8aec74d
npm test3047 tests, 8 fail3037 tests, 8 fail3050 tests, 8 fail
npm run typecheckclean-clean
new test file10/10 pass3 pass / 7 fail13/13 pass

executed. The 8 failures are identical by name on all three
(test/core/leave-command.test.js, leave after join removes the seed and reports the server through leave self-heals an org attach whose plugin is gone), so
they are pre-existing on origin/master and nothing new was introduced. Both
worktrees had node_modules symlinked from the main clone, so the suite
collected fully.

The PR's claimed evidence reproduces exactly. 7 fail / 3 pass on pristine
master, the same three passing (5, 6, 10), 10/10 on the branch. executed.

npm run smoke -- claude_attach_detach: FAIL on the branch and identically on
pristine master, same assertion, in a detached worktree. Confirmed equally
broken on both and disregarded as agreed. My changes did not alter it.
executed.


What landed

Pushed 8aec74d to fix/issue-454 (e1f9619..8aec74d), verified against the
committed remote tree with git show origin/fix/issue-454:<file>, not against a
green suite:

  • hypaware-core/plugins-workspace/claude/src/settings.js:153 - collision guard
    present.
  • src/core/config/client_detach_disk.js:44,246,253,676 -
    UNWRITABLE_PATH_SEGMENTS, both disposal notices, and the segment check.
  • test/plugins/claude-settings-malformed-block.test.js - 13 test( blocks.
  • llp/0163-...decision.md:132,181,225 - the lost-displacement rule, the disposal
    paragraph, and the new Open questions section.

Round 1 of 2. Findings 5, 6 and 7 are yours to accept or overturn; nothing in them
revisits the decision you made on #454.

…ters
Three things the round-1 fixes left, all found by execution at 8aec74d.
`could not be restored` folded two causes into one sentence, and the one it
named is the one almost nobody hits. Restoring a nested `hooks.<event>` backup
under a `hooks` backup that went back first as a string fails because the parent
is no longer an object, not because the path is one this undo may not write.
Split the two, report the policy refusal before the in-use test so it is never
explained as somebody else's key in the way, and pin both reasons.
The `__proto__` guard was on the restore helper only. Its sibling
`deleteAtDottedPath` had the *reachable* hole: the nested-marker record replays
`managed.added` and `managed.created_parents` with no equality gate at all, so
an entry of `__proto__.toString` ran `delete Object.prototype.toString`. Both
executed against this head before the fix. One predicate now guards all three
writers; `setAtDottedPath` is defence in depth (its equality gate already
blocks a prototype path, since one always reads back undefined).
Shallowest-first is not the trade round 1 described. Depth is orthogonal to
age: break the event first and the shallow entry is the newer value, break the
root first and it is the older one, and the same sort keeps the later value in
one and the earlier value in the other. Flipping it would move the loss, not
remove it, so the order stands and the doc stops claiming otherwise. Both
sequences are now tests. Implementing prior-wins between nested paths needs the
record to carry age, which is a schema change and an open question.
Also pins the presence-not-type rule at the collision site: a recorded `null`
still outranks a later displacement.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Review round 2 of 2 - findings

Round 2 of 2. The next rung is triage; everything I did not fix below is
now triage's, and I say explicitly which is which.

Verdict: the round-1 fixes are correct, but one of them broke a diagnostic on
the way past, and its guard stopped one function short of the sibling that had
the actually-reachable hole.
Three findings, all fixed and pushed. I also
overturn one of round 1's conclusions on evidence: the shallowest-first ordering
is not the correctness defect it was described as, and flipping it would move
the loss rather than remove it.

Direct audit, no tooling: the code-review skill is not installed in this clone
and codex is not on PATH. Everything is marked executed (I ran it) or
reasoned (I read it). No hyp command touched a real home; every scenario
ran against a mkdtemp fixture.


1. The delta e1f9619..8aec74d - are the four fixes correct and complete?

executed, against a detached worktree at 8aec74d.

Round-1 fixCorrect?Complete?
HIGH - collision detected at the recording site, own notice, value not echoedYesYes, with one test gap (finding 2)
MEDIUM - detach's two notices say "discarded with the marker"YesNo - the reason is now wrong (finding 1)
LOW - UNWRITABLE_PATH_SEGMENTS closes the prev_malformed__proto__ pathYesNo - one guard, three writers (finding 3)
MEDIUM - 3 tests added, mutants killedYes, verified independentlyYes for the mutants it targeted

The HIGH fix did not open a new silent-drop path. I drove every branch that
can drop a value and each one pushes exactly one notice. The collision early
return skips only displaced[...], never the warnings.push, and the repair
still happens (ensureObject / installManagedHooks rebuild regardless).
Three collisions in a row on the same path each warn (C1). A collision on
hooks while hooks.SessionStart is already recorded warns and leaves the
marker untouched (C2). executed.

No malformed value is echoed on any branch. I ran a purpose-built leak audit
over eight branches - the three attach displacement kinds, the three collision
kinds, and both detach failure paths - with the malformed value set to
sk-ant-SUPERSECRET-DO-NOT-ECHO, capturing warnings, prevValue, warning,
removed and restoredValue. ANY LEAK: false. The notice strings are
built from the dotted path (an in-tree literal), the expected type, and
MARKER_KEY; nothing else reaches them. The only user-derived value on any of
these surfaces is prev_base_url, which is a URL, pre-existing, and by design.
logger.warn('client.attach.malformed_block', { detail: warning }) and the
stdout ! ${warning} line therefore carry no secret. executed.

Two things the collision guard gets right that are easy to get wrong, both
confirmed by execution: Object.hasOwn means a recorded null still outranks a
later displacement (C3b), and a prev_malformed that is not an object at all
degrades to "no prior backup" rather than throwing (C3).


2. Re-attack at the new head

executed. Round 1's attack still holds; nothing the four fixes touched
regressed it.

  • Round-trip fidelity is byte-exact. A malformed value carrying unicode
    (é, , an emoji), mixed and integer-like key order, 1e21, -0, null,
    an empty-string key, a key containing a dot, and a nested fake _hypaware
    marker; a 1 MiB string; and an event object holding a decoy prev_malformed -
    all four came back with the file text identical before and after.
  • Three attach/detach cycles return the same content every time and are
    idempotent from cycle 1. Contents identical; key order is not - a block
    that is deleted and restored moves to the end of the object, so a file with
    keys after env comes back reordered on the first round trip and stable after.
    Cosmetic, inherent to re-serializing the document, pre-existing. Not a finding.
  • Zero over-eager repair across eight well-formed-but-unusual shapes plus
    numeric-like env keys: warnings: undefined, no prev_malformed, every time.

The collision path specifically (it is brand new, so I pushed hardest here):

AttackResult
Two collisions on the same path (3 attaches, 2 hand-breaks)Both warn as discarded; marker still holds FIRST-ORIGINAL; detach returns it
Collisions on hooks and hooks.<event> recorded together, then the root collides againWarns; marker unchanged; both entries survive to detach
Collision where the incumbent backup is unreadable (prev_malformed a string)Treated as no prior backup; this run's value is recorded and restored; the corrupt string is dropped without a notice - hand-edit-only, noted below
Incumbent entry present with value nullPresence wins; null restored
prev_malformed holding a prototype-shaped key name (toString)No false collision; junk key survives onto the new marker as harmless noise

3. Findings

1. MEDIUM (fixed) - the disposal reword replaced the true reason with a false one

src/core/config/client_detach_disk.js:250-253 at 8aec74d

Round 1 rewrote "... could not be restored; a parent on its path is no longer a JSON object" into "... its path is not one this undo may write ...". That
sentence is only true of the __proto__ refusal it was written alongside. The
non-object-parent case is the one this branch actually reaches without a
hand-edited path, and it now gets the wrong explanation:

marker : {"hooks":"ORIG-ROOT","hooks.SessionStart":"ORIG-EVENT"}
warning : hooks.SessionStart could not be restored; its path is not one this
undo may write, so the backed-up value is discarded with the marker

Nothing about that path is unwritable. The hooks backup went back first as a
string and the event has nowhere to go. Round 1's own argument for the reword was
that "the user who reads this line is the last person who can act on it" - and
the line now tells them it is a policy refusal they cannot do anything about,
instead of naming the value sitting in the way. executed.

Fixed: the two causes are reported apart. hasUnwritableSegment is now a
named predicate the caller tests before the in-use check (so a refused path
is never explained as somebody else's key in the way), and the restore failure
below it says a parent on its path is no longer a JSON object. Both reasons are
asserted, positively and negatively, by the two tests that reach them.

2. LOW (fixed) - the presence-not-type rule at the collision site was unpinned

Mutation-testing the three new tests (below) left Object.hasOwn(priorMalformed, path) -> if (priorMalformed[path]) alive: 13/13 still passed. That is the
exact rule this file documents twice as having already caused real bugs
(prev_base_url, manageEnvAdditions), newly instantiated at the one site that
decides whether a recorded backup is displaced. Under the mutant a recorded
null loses, the second hand-edit overwrites it on the marker, and detach hands
back the wrong value. executed.

Fixed: a test that records { env: null }, breaks env again, and asserts
the null still wins and comes back. It kills both the truthiness mutant and a
typeof === 'string' mutant.

3. LOW (fixed, security, pre-existing) - the guard was on the wrong sibling

src/core/config/client_detach_disk.js - deleteAtDottedPath

Round 1 flagged setAtDottedPath as an unfixed twin of the prototype-pollution
hole. I attacked both at this head. The finding is the other way round:

  • setAtDottedPath is not exploitable. The caller only writes when
    getAtDottedPath(value, entry.path) === entry.value, and a prototype path
    always reads back undefined while a recorded value comes from JSON and
    never is. Attempted and it no-ops. executed.
  • deleteAtDottedPath is. The nested-marker record's managed.added and
    managed.created_parents lists are replayed with no gate at all. Against
    8aec74d:
added: ["__proto__.toString"] -> typeof {}.toString : function -> undefined
added: ["__proto__.constructor"] -> {}.constructor : Object -> undefined

A settings file the user (or anything with write access to their home) can edit
deletes an Object.prototype member for the rest of the process. Prototype
sabotage rather than injection, and it predates this PR, hence LOW.

Fixed anyway rather than left for triage, because the guard constant this PR
introduced sits twenty lines away and shipping a file that closes the hole in one
writer while leaving it open in its sibling is a worse artifact to hand on. One
predicate now guards all three writers; setAtDottedPath and restoreAtDottedPath
keep it as defence in depth (documented as such, since the caller-side check and
the equality gate respectively already cover them). Both directions have tests in
test/core/client-detach-json-path.test.js; the added one fails without the
guard, the set one is the belt.

Legitimate keys are unaffected. An event named constructor, an event named
prototype, and env keys named constructor / prototype all attach, survive,
and detach untouched with no warnings - the guard only ever applies to a dotted
path recorded in a marker
, and attach records only env, hooks and
hooks.<managed event>. executed.


4. Mutation-testing the three new tests - not vacuous

executed, ten mutants at 8aec74d, then seven more after my fixes.

MutantVerdict
Drop the collision guard entirelykilled by the collision test
Collision guard reuses the reassuring "detach restores it" wordingkilled
Replay order reversed to deepest-firstkilled by the nested-order test
Drop UNWRITABLE_PATH_SEGMENTSkilled by the __proto__ test
Drop "discarded with the marker" from the in-use noticekilled
Drop the reason clause from the could-not-restore noticekilled (2 tests)
Object.hasOwn -> truthinesssurvived -> finding 2, now killed
{...priorMalformed, ...displaced} spread-order clobbersurvived, and should: the guard keeps colliding keys out of displaced, so the order is genuinely equivalent now. Round 1's "belt and braces" comment is accurate.
Guard only __proto__, not constructor/prototypesurvived, equivalent mutant: root.constructor is a function, so isPlainObject already stops the walk. The extra two segments are defensive, not load-bearing. executed both ways.
Collision guard warns correctly but still records into displacedsurvived, equivalent given the spread order; the pair of it and the spread mutant is killed

After my fixes: folding the two reasons back into one message, dropping the
caller-side refusal check, dropping the deleteAtDottedPath guard, and reversing
the replay order are all killed. The two surviving mutants are the redundant
copies of a guard that another live check already covers - correct behaviour for
defence in depth, since removing either one alone changes nothing.

I also proved llp-ref-hygiene is not vacuous the same way: my first @ref
pointed at a bold-paragraph anchor that does not exist, and test 2 failed.
executed.


5. The recorded-not-changed items - my disposition as the last round

The shallowest-first ordering: I overturn round 1's reading. Not a correctness defect.

Round 1 concluded that shallowest-first "keeps the later typo over the earlier
pre-hypaware content, contradicting the LLP's own principle", from this sequence:

break the event first -> marker {"hooks":"NEW-shallow-typo",
"hooks.SessionStart":"OLD-deep-preexisting"}
-> survivor: NEW-shallow-typo (the later value)

I ran the mirror sequence. executed:

break the root first -> marker {"hooks.SessionStart":"NEW-deep-typo",
"hooks":"OLD-shallow-preexisting"}
-> survivor: OLD-shallow-preexisting (the earlier value)

Depth is orthogonal to age. The shallow entry is the newer value in one
recording sequence and the older value in the other, and the same sort keeps the
later value in the first and the earlier value in the second. So round 1's claim
is true of its example and false as a general statement, and flipping the sort
would move the loss, not remove it
- deepest-first destroys the pre-hypaware
content in exactly the sequence shallowest-first preserves it. Neither direction
implements "the earliest backup is the one holding the user's content", so
neither is more correct than the other, and this is a documentation defect rather
than the behaviour defect it was filed as.

What I did: left the order alone, and fixed the corpus so it stops asserting
something my execution disproves. The code comment, LLP 0163's ordering section,
and the Open question all now say depth cannot answer the question. Both
sequences are pinned by tests, so the next reader cannot repeat round 1's
inference from a single example.

What I recorded for you: implementing prior-wins between nested paths needs
the record to carry age. It nearly does by accident - { ...displaced, ...priorMalformed } leaves the newest key first, and both tests observe that
order - but JSON key order is not a thing to hang a user's data on, since a
reformat or hand-edit reorders it silently. An explicit per-entry order or
timestamp is a marker schema change, so it is an Open question, reworded from
"shallowest or deepest?" to the question that actually has an answer.

The other three

ItemDisposition
Sidecar file for an unrestorable backupFor you, not triage. It is a scope decision on #454, correctly recorded under Open questions. Unchanged.
The legacy branch drops prev_malformed silentlyFor triage, as an issue - and it is bigger than reported. I reproduced it and the legacy path also leaves ENABLE_TOOL_SEARCH, _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL and every classify-cwd hook entry orphaned on disk, because the retired convention it replays predates all of them. Only reachable by hand-deleting managed from a current marker, and entirely pre-existing, so I did not add code to a branch nothing writes. executed.
Detach resurrects a malformed block the user deleted by handFor you. Reproduced: env deleted after attach comes back as ORIGINAL-MALFORMED; prev_base_url leaves an externally-deleted leaf deleted. I agree with round 1 that the block behaviour is defensible, and it is a design divergence, not a defect. Recorded, unchanged. executed.

One more, new and minor, recorded not fixed: a prev_malformed that is present
but not an object (only reachable by hand-edit) is discarded without a notice.
Consistent with the corrupt-input stance already documented for the legacy
branch.


6. Checks

CheckMy pristine origin/master73b46188aec74dAfter my fixes c829325
npm test3037 tests, 8 fail-3054 tests, 8 fail
npm run typecheck-cleanclean
llp-ref-hygiene-9/99/9
malformed-block test file-13/1315/15
client-detach-json-path-18/1820/20

executed. I ran my own baseline in a clean path (r2-base), not a log
left by another worker, with node_modules symlinked from the main clone into
both worktrees so the suite collected fully. The 8 failures are identical by name
on both sides - test/core/leave-command.test.js, leave after join removes the seed and reports the server through leave self-heals an org attach whose plugin is gone - so they are pre-existing on master and I introduced nothing.

npm run smoke -- claude_attach_detach: FAIL on my branch and on pristine
master. I diffed the two logs modulo temp paths: identical. Confirmed
equally broken on both in a detached worktree, and disregarded. executed.

LLP 0163 is still unclaimed elsewhere. Swept every refs/remotes/origin/*
before and after my push: the only ref carrying an llp/0163-* file is
fix/issue-454. executed. True as of this push; a concurrent worker could
still collide.

Style. Zero em dashes on any added line across all four files, no semicolons,
no @typedef, no inline import('...') types. executed.


What landed

Pushed c829325 to fix/issue-454 (8aec74d..c829325). Verified against the
committed remote tree with git show origin/fix/issue-454:<file>, not
against a green suite:

  • src/core/config/client_detach_disk.js:60 - hasUnwritableSegment;
    :270,273 - the policy refusal reported first and by name; :292 - the
    non-object-parent reason restored; :687,723,761 - the guard on all three
    path writers.
  • test/plugins/claude-settings-malformed-block.test.js - 15 test( blocks,
    including :189 (presence, not truthiness) and :260 (the mirror sequence
    that disproves the depth-tracks-age reading).
  • test/core/client-detach-json-path.test.js:471,494 - the managed.added
    prototype-deletion guard and the managed.set belt.
  • llp/0163-...decision.md:177,181,200,210,247,260 - the managed.added hole,
    the two-refusals section, the reworked ordering section, and the reworded Open
    question.

Round 2 of 2. Nothing here revisits your refuse-versus-repair decision. What goes
to triage from me is exactly two things: the legacy-branch orphaning (an issue
in its own right, wider than prev_malformed), and, if you want it, the marker
schema change that would let nested backups follow prior-wins. The sidecar
question and the delete-then-detach divergence are yours to accept or overturn.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Triage verdict: ship it

Round 2 exhausted the review cap (maxReviewRounds) with four items left open
and handed them to triage for a blocker/preference call. I re-derived each
one by execution against a self-created worktree at c829325 (never against
the stale read-only clone) and a pristine origin/master baseline I ran
myself (73b4618), per LLP 0012.

Baseline check:npm test on c829325 — 3054 tests, 3045 pass, 8 fail,
matching the pre-existing test/core/leave-command.test.js failures by name
against pristine origin/master (3037 tests, same 8 fail by name). npm run typecheck clean. llp-ref-hygiene 9/9. The new test files (claude-settings- malformed-block.test.js 15/15, client-detach-json-path.test.js 20/20) pass.

All four residuals are non-blocking. None is a case where the original
becomes unrecoverable while the user is told it is safe — the bar for a
blocker on a PR whose whole point is a recoverability promise.

  1. Legacy branch drops prev_malformed and orphans settings — verified
    real by execution, but not reachable without a hand-edited or
    externally-corrupted marker
    . The current attach() always writes
    managed in the same write that would ever set prev_malformed; a
    genuinely pre-upgrade marker predates both managed and the env keys it
    would orphan, so there's nothing to orphan on that path either. Also
    confirmed pre-existing (detachLegacyJsonMarker untouched by this PR's
    diff) — PR claude attach backs up a malformed env/hooks block instead of discarding it #495 adds one more field to an already-existing corrupt-input
    gap, it doesn't create a new reachable one.
  2. Detach resurrects a hand-deleted malformed backup — verified real by
    execution (deleting the whole env block after attach, then detaching,
    brings the original malformed value back; the sibling prev_base_url
    leaves a hand-deleted leaf deleted). No data loss either way — the
    opposite direction from what claude attach silently replaces a malformed env/hooks block instead of refusing, unlike the openclaw sibling which throws MALFORMED_CONFIG #454 was about — and it's a defensible
    reading of the same "restore into an empty slot" rule the rest of the
    mechanism uses. A genuine divergence, not a defect.
  3. Sidecar-file backup — out of scope for the maintainer's decision on
    claude attach silently replaces a malformed env/hooks block instead of refusing, unlike the openclaw sibling which throws MALFORMED_CONFIG #454, correctly left as an open question.
  4. Marker schema for nested prior-wins — an enhancement; both possible
    orderings are equally correct given the current schema (no age tracked),
    both are pinned by tests, and the loser is always honestly reported as
    discarded rather than silently dropped.

Opened #500 with the full evidence and reproduction steps for each item,
backlinked to this PR.

@philcunliffe
philcunliffe marked this pull request as ready for review July 31, 2026 00: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 31, 2026
@philcunliffe
philcunliffe merged commit ff6777d into masterJul 31, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-454 branch July 31, 2026 02:16
philcunliffe added a commit that referenced this pull request Jul 31, 2026
… it restored (#500) (#506)
* detach: the undo replays every backup the marker holds, and says what it restored (#500)
Three deferred findings from the review of #495 (LLP 0163, `claude attach`
backs a malformed `env`/`hooks` block up onto the marker and repairs it).
All three are the undo failing to say something, two of them while dropping
a backup and reporting success.
Finding 3 (silence). A successful `prev_malformed` restore set none of
`removed`/`restoredValue`/`warning`, so `hyp detach` printed `✓ Detached
claude` and stopped while it rewrote a block. `DetachFromDiskResult` gains
`restoredPaths: string[]`; the command prints one line each and echoes
`restored_paths` into `--json`. Paths, never values: a malformed `env` is
exactly where an API key ends up, which is also why this cannot ride on
`restoredValue` (rendered as a bare value by both consumers).
Finding 2 (delete-then-detach resurrection) is deliberately NOT changed.
Two reviewers and triage judged restoring the pre-attach value defensible,
and reversing it would discard a value rather than keep one. It is now
audible instead of silent, which was the objectionable half.
Finding 1 (the legacy branch). `detachLegacyJsonMarker` is selected by
`managed` not being a plain object, so a current-shape marker whose record
has been damaged lands there still carrying its backups - and they were
dropped without a word, while the marker holding the only copy was deleted
in the same write. It now replays `prev_malformed` through the same helper
and with the same words as the record-driven branch, and restores a
recorded `prev_base_url` instead of deleting the key. The managed env keys
the unreadable record named stay (nothing on disk proves they are ours, and
never-clobber outranks tidiness) and the reversal reports itself partial;
managed hooks are stripped, because matching `hyp claude-hook …` is proof
of ownership rather than a guess. A genuine pre-record marker carries none
of the triggering fields and is reversed, and stays silent, exactly as
before.
The shape finding 1 needs is only reachable by hand-editing `managed` out
of a marker, so the fixtures construct it directly.
Findings 4a (sidecar backup file) and 4b (a marker schema change for
prior-wins between nested paths) are the maintainer's parked open
questions and are left open.
LLP 0163 updated in the same commit.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix-ci: stop the sk-x leak assertion tripping on its own settingsPath
CI intermittently failed test/core/client-detach-disk.test.js: 3 assertions
did JSON.stringify(result).includes('sk-x') / out.includes('sk-x') to prove
the ANTHROPIC_API_KEY test secret never leaks into the detach result or CLI
output. But settingsPath is always present in that text, comes from
stageHome()'s fs.mkdtemp('.../hyp-detach-disk-'), and its random suffix
starts with 'x' about 1/62 of the time - which, glued onto the fixed prefix's
trailing "...disk-", spells "sk-x" with no secret involved. Strip settingsPath
out of the checked text before the leak check in all three assertions.
Reproduced deterministically by forcing a home dir suffix that starts with
'x' and confirming the false positive, then confirming the fix clears it.
* review: declare restored_paths on the --json payload, and cover it
Three review findings on #506.
1. `hyp detach --json` gained a `restored_paths` key but `ClientResult`
(src/core/cli/types.d.ts) - the declared shape of that payload, and the
return type of the published `integration.detach()` helper - was not
extended, so a typed consumer sees a field the CLI emits but the contract
denies. Declared, with the paths-never-values rule stated where a caller
reads it.
2. The `--json` half of the new reporting had no test; only the prose line
did. Added the sibling `--json` case, asserting `restored_paths` and the
same no-secret-in-output check.
3. src/core/config/client_detach_disk.js imported `DetachFromDiskResult` as
`from './types.d.ts'` - the only such specifier left in src/ (299 use the
root-anchored form) and one CLAUDE.md forbids by name. It dangles in the
published declaration build: tsc does not copy `.d.ts` inputs, so there is
no `types/core/config/types.d.ts` for it to reach, and the interface this
PR extends resolved to `any` for package consumers. Re-anchored.
Co-Authored-By: Claude <noreply@anthropic.com>
* review: the reconciler-driven undo reports a replayed backup too (#500)
reverse() logged the failure half of the prev_malformed replay and not the
success half, so an org config drop could rewrite a block of the user's
settings file with nothing recorded anywhere. hyp detach prints a line per
restored path; this path has nobody at a terminal, so the log is the only
place it can be said. Paths, never values, as everywhere else.
Also refreshes two JSDoc paragraphs that still described the legacy hook
strip as session-context-only after this PR widened the pattern to
classify-cwd.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: neutral <neutral@example.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: test <test@test.com>
Co-authored-by: test <test@example.com>
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.

claude attach silently replaces a malformed env/hooks block instead of refusing, unlike the openclaw sibling which throws MALFORMED_CONFIG

1 participant

@philcunliffe