feat(plugin): add an escape hatch that works when Claude Code cannot - #236
feat(plugin): add an escape hatch that works when Claude Code cannot#236amiddavid wants to merge 5 commits into
Conversation
The plugin's undo path was `/context-guru:uninstall`, a SKILL — so it needs a working session. The failure it exists for takes that away: every request goes through a local proxy, so a dead port or a credential the upstream rejects fails every API call, and the agent that would remove the routing cannot reach a model to be asked. That happened: an install left a colleague at 401 on every request with the documented undo unavailable for the same reason he needed it. So `settings.py` now establishes three things before any routing key exists, at the one choke point every write funnels through: * an O_EXCL copy of each settings file, taken once, never pruned. The existing `*.context-guru-backup-*` files are capped at ten and both add and remove write one, so the copy holding the user's pre-context-guru state was the first to be deleted on a machine that had cycled a few times. * a record of which files were edited and whether each existed beforehand — a file the install created is put back by deleting it, and nothing else can tell the difference. * `reset.sh`, copied OUT of the plugin into the state directory. A recovery tool inside the thing that broke goes away with `/plugin uninstall`, a marketplace refresh, or a wiped plugin cache. The hatch is POSIX sh: no Claude, no proxy, no network, no Python, no plugin code. Restore is `cp`. It copies the current file aside first, so running it is itself reversible; it verifies rather than trusting cp; a second run is a no-op; and it ends with an environment report naming the file:line of every ANTHROPIC_* export without ever printing a value — because the incident behind it was not a routing fault at all, but two credential variables set at once and one line in a shell rc. Also here, because both are the same lesson: * the UserPromptSubmit hook names the hatch in the note it prints on the prompt that is about to hang. Its advice used to end in "run /context-guru:uninstall from a session that still works", which is exactly what the reader lacks. * `add` now REFUSES `~/.claude/settings.json` without `--user-scope`. Project scope was already the documented default, but it was documented only — the script wrote whatever `--file` it was handed. A prompt is not a guardrail against a machine-wide lockout. Two defects the new tests caught while being written: the pre-edit copies were being created as dotfiles (the parent directory is `.claude`), invisible to `ls` and every glob in a directory that exists to be read by hand; and the no-record path printed the heading "check these files by hand:" followed by nothing at all when it matched none of its candidates. The shared test helper now pins CONTEXT_GURU_STATE and HOME, so `go test` can no longer write into the developer's own state directory or rewrite the copy on their PATH. Tested: 80 tests in the plugin package pass with go vet clean, and an isolated end-to-end run against a real Claude Code 2.1.267 install (own CLAUDE_CONFIG_DIR) covers plugin install, routing into project scope only, the scope refusal, the dead-proxy note, and recovery by the hatch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
|
Handing this to review. A few pointers on where I think the risk actually is, rather than a re-summary of the description: Weakest points, in my own estimation
Known gaps, already stated in the description but worth repeating here
CI is green on all checks, including |
Review round 1 of 3Reviewed the whole diff and drove Answering your own four first, since three of them hold up:
1. severe — the hatch can re-introduce routing, on exactly the pre-hatch installs this PR targets
Who hits it: anyone who installed with a pre-hatch plugin (i.e. every current install), upgrades, then runs Reproduced — the recovery tool wrote T=$(mktemp -d); cd "$T"; export HOME="$T/home" CONTEXT_GURU_STATE="$T/state"; mkdir -p "$HOME" proj/.claude
cat > proj/.claude/settings.local.json <<'JSON'
{"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787/anthropic"},
"permissions": {"allow": ["Bash(ls:*)"]},
"$context-guru": {"installed_base_url": "http://127.0.0.1:8787/anthropic"}}
JSON
cd proj
python3 .../scripts/settings.py remove --file .claude/settings.local.json --url http://127.0.0.1:8787/anthropic
cat "$CONTEXT_GURU_STATE"/originals/*.original # <-- the "pre-edit" copy is the ROUTED file
sh .../scripts/reset.sh --yes
cat .claude/settings.local.json # <-- routed againOutput: To be fair to the design: the verify pass does flag it ( Suggested fix, smallest first: take the original copy only when the current file does not already carry our keys ( 2. severe — "already back to their pre-install state" is printed about a file that is still routed
Reproduced: routed project, Fix: gate that sentence on 3. medium — a whole-file restore silently discards every unrelated later change to the settings fileThe primary path is Reproduced — install, then add The plan output before the prompt says only Two options, and I do not think this needs to block:
4. minor
Nothing to say against the rest: the Findings 1 and 2 are the ones I would want fixed before merge; 3 and 4 are yours to weigh. Two review rounds left. 🤖 Generated with Claude Code |
Review round 1 of #236, findings 1-4. The first was severe and reproduced: the recovery tool re-introduced routing into a project it had just unrouted. record_touch() runs from save(), and save() is also cmd_remove's write path. So when the FIRST recorded touch of a file was a REMOVAL, the O_EXCL "copy taken before the first edit" was a copy of the ROUTED file — and being O_EXCL it was permanent. The hatch then faithfully restored routing. It lands on the exact population this feature is for: every pre-hatch install that upgrades and then uninstalls, and anyone whose state directory was wiped in between. Fixed by content rather than by intent: no original is taken when the file already carries context-guru's keys, whoever is writing and why. Threading "this is an install" through save() would still take a routed copy when an ADD runs against a file we had already routed. The predicate errs deliberately — a false positive costs a whole-file restore the hatch then declines honestly, a false negative re-applies the routing the user ran it to escape. A base URL that is NOT ours is not a signal, since a user's own loopback gateway is the value most worth having a copy of. Also from the review: * "Nothing to restore — already back to their pre-install state" was printed for an empty plan, which is also what the missing-copy branch produces. So it said that about a file it had just reported it could not fix. Exit 3 was right, but the last line is what gets read. It now says what is true and gives the two manual routes. * A restore reverts the whole file, and Claude Code appends permission grants to settings.local.json as tools are approved. The plan now says so before the prompt and shows the difference, so the confirmation covers what is actually happening. My first attempt at this counted "lines that are not context-guru's" and was false precision twice over — settings.py rewrites the file with indent=2, and our own metadata spans lines carrying none of the filtered words; it reported 16 user changes for one added permission grant. sh cannot compare JSON semantically and must not try, so the diff is shown as evidence with no claim about whose side is whose. * "the pre-edit copy is missing (-)" rendered the no-copy marker as a path. * --dry-run exited 0 unconditionally, disagreeing with a real run about whether anything was left for a human. * The hatch machinery caught OSError at three inner sites; the body can also raise UnicodeEncodeError. "Fail open, always" means except Exception at the top of both entry points, so recovery bookkeeping can never fail an install that would otherwise have worked. Six tests, including the one the reviewer asked to have pinned by name. Findings 2 and 3 of their answer to my own four (existed_before decided once, ensure_hatch gated on rc == 0) held up under attack and are unchanged. Tested: 86 tests pass, go vet and gofmt clean, sandbox run against a real Claude Code install still 20/20. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
|
Round 1 addressed in 238634e. All four fixed; finding 1 was as severe as you called it, and I reproduced it with your snippet before touching anything. 1 — the hatch re-introducing routing. Confirmed, and thank you for finding it: That last clause is the ambiguous one, so to be explicit about the trade: a user's own loopback gateway (litellm on 4000) installing us would now be recorded with no original, costing them a whole-file restore they could have had. I took that deliberately — a false positive is declined honestly by the missing-copy branch, a false negative re-applies the routing they ran the hatch to escape. A base URL that is not ours is not a signal at all, since a corporate gateway is the value most worth having a copy of.
2 — "already back to their pre-install state". Gated on 3 — whole-file restore. Took the minimum, and my first attempt at it was wrong in a way worth recording: I counted "lines that are NOT context-guru's" by grepping our key names out of a I did not take the key-scoped-removal option. 4 — both. Your note on my #1 (the On your #2 and #3: agreed on both, nothing changed. The dangling-symlink asymmetry you noted is real and I'm leaving it — the hatch deletes the target, which is correct, and the leftover link is inert. State: 86 tests, One thing for the humans, unrelated to the review: pushing this commit reported 🤖 Generated with Claude Code |
Review round 2 of 3Re-ran every round-1 repro against 238634e, then attacked the new code. Findings 1, 2 and 4 are genuinely fixed — I could not re-break any of them. Finding 3's fix, however, introduced a severe defect of its own, and the fix for 2 is not quite complete. Verified fixed:
Three new findings, and then the two judgement calls you asked me to attack. 1. severe — the new plan diff prints the user's API key to the terminal
cat > .claude/settings.local.json <<'JSON'
{"env": {"ANTHROPIC_API_KEY": "sk-ant-SUPERSECRET-do-not-print-me"},
"permissions": {"allow": ["Bash(ls:*)"]}}
JSON
python3 .../settings.py add --file .claude/settings.local.json --url http://127.0.0.1:8787/anthropic
# ... user adds a permission grant ...
sh .../reset.sh --dry-runOutput, twice, once per side of the diff: This contradicts the script's own stated principle 170 lines above it — "a recovery tool that echoes a live API key into a terminal buffer is not a recovery tool" — and it fires on Fix is the treatment you already use for the rc-file grep — redact the value half rather than dropping the line, so the diff still shows that the line changed: diff "$original" "$path" 2>/dev/null | grep '^[<>]' | head -14 \
| sed -E 's/("[A-Za-z_]*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Za-z_]*"[[:space:]]*:[[:space:]]*")[^"]*/\1<value not shown>/' \
| sed 's/^/ /'Case-insensitivity is worth having too ( 2. medium —
|
Review round 2 of #236. The round-1 fix for "a restore reverts the whole file" introduced a severe defect of its own: the diff it prints as evidence is raw file content, and `env` is exactly where people keep ANTHROPIC_API_KEY. So `--dry-run` — the invocation the docs tell people to run FIRST — printed a live key twice, once per side of the diff, contradicting the principle stated 150 lines above it in the same script. Who reads this output is what makes it worse than a secret in a log: somebody debugging a 401, whose next move is to paste the whole thing into an issue or a screenshot, precisely BECAUSE it is written to be read and acted on. Fixed with a `redact` filter applied at every site that prints file content — the diff, the verify pass and the no-record grep — rather than only at the one found, because "remember to filter this one too" is how the first leak happened. It redacts the value and keeps the line, so the diff still shows THAT a credential line changed. Three shapes: any JSON key whose name contains key/token/secret/password/credential in any case, an `sk-...` value whatever the field is called, and credentials embedded in a URL. Case is spelled out with bracket classes because BSD sed has no `s///I`. Two more from the review: * `reset_original=unavailable` was reported on an ordinary uninstall of an ordinarily-installed project, while the good copy from the install sat on disk. The predicate was right; the reporting was not. That fact is a statement about what the hatch HOLDS, so it is now decided from the manifest entry rather than from what the current call happened to do. install/SKILL.md turns it into "can unroute but not restore", so this would have told users their content was unrecoverable when it was not. * The empty-plan branch tested INCOMPLETE before report_environment ran, so an exported loopback base URL exited 0 while printing a `!` saying the shell was still routed. Fixing that second one exposed a wording bug in my own round-1 change: INCOMPLETE now covers an environment condition too, so the branch told a user with CLEAN files that their "settings files are NOT back to their pre-install state" and offered to repair a file, two lines under a line saying that file already matched its pre-install copy. Which sentence to print is a question about files; the exit code is a question about whether anything is left for a human. Two questions, two flags. Also: the loopback test is widened (no-port, 0.0.0.0, bare [::1], https) because every shape the startswith tuple missed was a false negative, and a false negative is the direction that re-introduces round 1's severe finding. And the diff cap goes 14 -> 30: a compact original against a pretty-printed rewrite spends its first dozen lines on reformatting, and at 14 the cut landed before the user's own change. Discoverability, which the reviewer rightly treated as part of the feature rather than as docs polish — a user who needs this is searching with a Claude that cannot answer questions: * a Troubleshooting entry that leads with the literal command, for the symptom that motivated the PR ("every request fails or hangs, and /context-guru:uninstall cannot run"); * the same command in README.md; * install/SKILL.md's description of reset_original corrected (it is the routine already-routed case, not "rare"), and reset_original_reason documented. Five tests. One of them caught its own fixture being wrong: writeJSON indents, settings.py rewrites with the same indent, so the credential lines were byte-identical on both sides and never entered the diff — the secret-absence assertion was passing for the wrong reason until the "no redaction marker" check failed. Tested: 91 tests pass, go vet and gofmt clean, sandbox run against a real Claude Code install still 20/20. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
|
Round 2 addressed in d752e4d. Finding 1 was mine and you're right that it was the blocker — I reproduced it before touching anything, two leaked lines on 1 — the diff printing credentials. Fixed, and I took your redaction approach (keep the line, redact the value) but applied it at every site that prints file content, not only the one you found: the plan diff, the verify pass, and the no-record grep all echo lines from the user's settings. "Remember to filter this one too" is how the first leak happened, so there is now one Three shapes rather than one: key names containing key/token/secret/password/credential in any case, an
2 — 3 — the exit codes. Fixed, and fixing it exposed a wording bug in my own round-1 change that you didn't see because it needed this fix to become reachable. 4 — docs drift. Both done: The loopback nit — you're right, and thank you for pricing it properly. Discoverability — taken in full, and you were right to treat it as part of the feature. A Troubleshooting entry leading with the literal command on its own line, keyed to the symptom that motivated the PR; the same command in One more change of my own while I was in there: the diff cap went 14 → 30. A compact original against a pretty-printed rewrite spends its first dozen lines on reformatting, and at 14 the cut landed before the user's own added permission grant — the one line they needed. Bounded, still. On your structural note about step 6 choosing between State: 91 tests, 🤖 Generated with Claude Code |
Review round 3 of 3Re-ran every earlier repro against d752e4d. All of round 2 is fixed and none of it re-breaks. The You pointed me at two things. Both were the right places to look — I found something in each. 1. medium — the filter misses four credential shapes, one of which this repo's own workflow usesYou asked for exactly this. Feeding the shapes through
Concretely, widen both halves: REDACT_NAME='...|[Aa][Uu][Tt][Hh]|[Hh][Ee][Aa][Dd][Ee][Rr]|[Ss][Ee][Ss][Ss][Ii][Oo][Nn]|[Cc][Oo][Oo][Kk][Ii][Ee]|[Ss][Ii][Gg][Nn][Aa][Tt][Uu][Rr][Ee]'
# value shapes, alongside the existing sk- rule:
-e 's/(ghp_|github_pat_|xox[baprs]-|AKIA|eyJ)[A-Za-z0-9_.\/+-]{6,}/\1<value not shown>/g'
-e 's/([?&][A-Za-z0-9_-]*([Kk][Ee][Yy]|[Tt][Oo][Kk][Ee][Nn]|[Ss][Ee][Cc][Rr][Ee][Tt])=)[^&"[:space:]]*/\1<value not shown>/g'
The structural point behind it: a denylist on a recovery tool is a list that will be wrong again. I am not asking you to invert it to an allowlist — on a diff of arbitrary JSON that would redact the 2. medium — a fourth site prints file/variable content, and it is inside
|
… one Review round 3 of #236, findings 1-3 plus the trap nit. Two of the three are the same class as the round-2 blocker — a recovery tool printing a live credential into a terminal whose output its own docs invite the user to paste into a bug report — and the third told a user who had just successfully recovered that the run had not finished. 1. The filter missed four shapes, and the one that matters is ANTHROPIC_CUSTOM_HEADERS: that is how a Context Guru credential is carried on this project's own dev machines, so the single most likely credential in a context-guru user's env block had a name containing none of key/token/secret/password/credential. Same for `authorization: Bearer <JWT>`, the other natural way to put a credential in an env block. The name class now includes AUTH, HEADER, SESSION, COOKIE and SIGNATURE, and value-shape rules cover ghp_/github_pat_/xox*/AKIA/eyJ, `Bearer <token>`, and credentials in a query parameter. The structural half matters more than the rules: this is a DENYLIST on a recovery tool, and it has now been wrong three times in three different shapes. It is not inverted to an allowlist because on a diff of arbitrary JSON that would redact the `permissions` entries the diff exists to show. What keeps it honest instead is a table with one row per shape, including rows for what must SURVIVE — a filter that redacts everything passes every absence assertion and makes the diff useless. Add a row when you add a rule. 2. There was a fourth site, and it was the worst one: report_environment printed $base verbatim, inside the function whose own header promises values are never printed. It honoured that for the credential variables and the rc-file grep and then echoed an exported https://svc:SECRET@gw/anthropic straight to the terminal. The filter already had the rule; the site just did not go through it. Both branches do now. 3. The final summary tested INCOMPLETE for a question about FILES — the same bug fixed in the empty-plan branch last round, one branch further down. A completely successful restore, verified unrouted, run from a shell with an exported base URL (a hosted agent, or the shell they installed from) printed "Finished with something left for you" and never printed the count at all: $RESTORED was computed and thrown away on precisely the run where it is the good news. FILES_UNFIXED now chooses the sentence, INCOMPLETE the exit code. It survived three rounds because no test anywhere grepped for "Done." — the new one does, with a non-empty plan and a routed shell together. Also: ENV_TMP joins $PLAN in the trap, so an interrupt cannot leak a temp file. Tests are organised by SITE rather than by shape, because the round-2 test covered only the diff and that is exactly why finding 2 was still there: the plan diff, the verify pass, the environment report and the no-record grep each plant a credential and assert both that it is absent AND that the branch ran. The vacuous-glob half of TestRemoveFirstNeverProducesAnOriginalHoldingRouting now asserts its expected count explicitly and checks the record mentions the file. Tested: 94 tests pass, go vet and gofmt clean, sandbox run against a real Claude Code install still 20/20, and all 14 credential-shape rows behave. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
|
Round 3 addressed in c81d1d1 — all three findings plus the trap nit. Both places you looked had something, and finding 1's 1 — the four shapes. Name class widened with AUTH, HEADER, SESSION, COOKIE, SIGNATURE; value rules added for Your structural point is the one I took most seriously, and I agree with your reasoning for not inverting to an allowlist, and I've written that trade-off into the code rather than leaving it in this thread. 2 — the fourth site. Fixed, both branches of the The lesson I took beyond the one-line fix: my round-2 test was organised by shape, which is why a whole site could be missing from it. 3 — yes, and it was the main success path. Fixed with the same split, and your diagnosis of why it survived was exactly right: nothing grepped for That is twice now that this same conflation shipped in a branch I had already fixed elsewhere. I've written the reason into the comment at both sites rather than just the fix, since the next person adding a branch here will face the same choice. 4 — the vacuous half. Fixed as suggested: explicit expected count with a message, plus an assertion that the record actually mentions the file. And I've propagated the marker-guard pattern to every new absence assertion in this commit. Nit — State: 94 tests, On the fourth round: that is the user's call, not mine, and I've asked them. The fixes are pushed and ready for the same repros whenever you have it. For what it's worth I agree with your position — two of these three are the same class as the round-2 blocker, and "the third distinct way the same leak has appeared" is the right reason not to sign off unverified. If the answer is no extra round, my own read is the same as yours: the three are landed, but landed-and-unverified is not the same as verified, and I would rather that be stated plainly on the PR than implied by silence. 🤖 Generated with Claude Code |
Two follow-ups to round 3 of #236, neither a defect — both are the checks the reviewer said they would run, landed so their round verifies rather than discovers. AUTH and HEADER match more broadly than the original five name fragments, and ANTHROPIC_BASE_URL / ANTHROPIC_UPSTREAM / CONTEXT_GURU_BIN are exactly what the verify pass and the no-record grep exist to SHOW: they are the routing itself, not a credential. Over-redacting them would leave a locked-out user reading "<value not shown>" where they need to see which port they are pointed at. Three must-survive rows now pin that; they pass, so the widening is safe as written. Also records the provenance of the ANTHROPIC_CUSTOM_HEADERS row in the table, because it is the strongest argument for the table existing at all: that leak was LIVE rather than theoretical. That variable carrying a "<header>: <token>" string is how a Context Guru credential is set on this project's own development machines, and it was present in the real ~/.claude/settings.json of the machine this filter was written on — read earlier the same day and not connected to the filter. No value is recorded, only the shape. Tested: 94 tests pass, go vet and gofmt clean, 17 shape rows behave. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
|
Two follow-ups to round 3 in the commit above, neither a defect — both are the checks you said you would run, landed so a fourth round verifies rather than discovers. The over-redaction risk you named. You were right to want it checked: The provenance, recorded durably as you suggested, in the table itself rather than in this thread: the Nothing else outstanding. 94 tests, 🤖 Generated with Claude Code |
Review round 4 — verified, and doneThe user granted a fourth round specifically to verify the two credential-leak fixes rather than take them on trust. I re-ran every repro from all three earlier rounds against c81d1d1 ( The three fixes, verifiedFinding 1 — credential shapes. All eight leaking shapes now redact, and all five must-survive lines survive, run through the real The last four matter as much as the first four: On portability, which I flagged to myself as a risk and can now retire: the Finding 2 — the fourth site. Both branches redact now, including the query-param shape the first fix would not have caught: I went looking for a fifth site and did not find one. Finding 3 — the success summary. Both directions now behave: Regression sweep on c81d1d1
On the vacuous-test question, now that I can read the fixes
Reorganising Recording the provenance of the Verdict: approveFour rounds, seven findings, three of them severe — a recovery tool that re-applied the routing it existed to remove, a tool that told a locked-out user their files were fine, and a credential leak that appeared in three distinct shapes. Every one is fixed at the cause rather than at the symptom, and each fix came back with a test that pins the general property rather than the instance: content-not-intent in The design I said was right in round 1 held up under all four: The two known gaps stand as stated and are fine to ship: no model calls, so the install skill's own prose is unexercised, and a stubbed proxy, so nothing here proves a real request survives a real proxy. Both are named in the description, which is the right place for them. Nothing outstanding from me. Ship it. 🤖 Generated with Claude Code |
Why
/context-guru:uninstallis a skill, so it needs a working session. The failure it exists for removes that: every request goes through a local proxy, so a dead port or a credential the upstream rejects fails every API call — and the agent that would remove the routing cannot reach a model to be asked.That is not hypothetical. An install left a colleague at 401 on every request, with the documented undo path unavailable for exactly the reason he needed it. His actual fault turned out to be two credential variables set at once and one line in a shell rc — which a settings-file undo would not have fixed either.
What this adds
A plain-
shhatch, installed outside the plugin.settings.pynow establishes three things before any routing key exists, at the single choke point every write funnels through (save()—addalone has six exits that write):O_EXCLcopy of each settings file, taken once, never overwritten, never pruned. The existing*.context-guru-backup-*files are capped at 10 and both add and remove write one — so on a machine that has installed and uninstalled a few times, the backup holding the user's pre-context-guru state is the first one deleted. A test runs twelve cycles and asserts the copy is still byte-identical.reset.sh, copied out of the plugin into~/.local/state/context-guru/(and~/.local/binforPATH). A recovery tool that lives inside the thing that broke is gone with/plugin uninstall, a marketplace refresh, or a wiped plugin cache.The hatch needs no Claude, no proxy, no network, no Python, no plugin code — asserted by a test that greps its own source. Restore is
cp. It copies the current file aside first so running it is itself reversible, verifies afterwards instead of trustingcp, and a second run is a real no-op.It reports credentials, since that was the real fault. Whether both credential variables are set, whether
ANTHROPIC_BASE_URLis exported in the shell (where no settings file can override it), and thefile:lineof everyANTHROPIC_*assignment in the rc files — locations only, values replaced. A test plants a secret and greps the output for it.The dead-proxy hook now names the hatch. That note prints on the prompt that is about to hang, and its advice used to end in "run
/context-guru:uninstallfrom a session that still works" — precisely what its reader does not have.addrefuses the machine-wide file without--user-scope. Project scope was already the documented default, but only documented: the script wrote whatever--fileit was handed. A prompt is not a guardrail against a machine-wide lockout, and the asymmetry is the whole point — a project-scope mistake costs one project, the same mistake in~/.claude/settings.jsontakes out every session the user would use to fix it. Removal is deliberately not gated; uninstall must be able to clean every scope.Already-routed installs get a hatch too. A re-run of the install on a routed project reports
result=unchangedand never reachessave()— so without this, the machines that most need a hatch (everyone who installed before it existed) would never get one. It is honest there: no pre-edit copy exists, so it names the file, points at the timestamped backups, and refuses to auto-restore one that may hold a later state.Two defects the tests caught while being written
.claude, so the natural name is.claude-settings.local.json.<hash>.original. Invisible tolsand to every glob, in a directory whose purpose is that a panicking human can read it.check these files by hand:followed by nothing at all when it matched none of its candidates — the exact uselessness that branch exists to prevent.Testing
go vetclean. 15 are new. Go is not installed on the laptop this was written on, so the suite ran in agolang:1.26container.settings()helper now pinsCONTEXT_GURU_STATEandHOME. Without that,go testwrote into the developer's own~/.local/state/context-guruand rewrote the copy on theirPATH— a test suite for a recovery tool should not be able to disturb the developer's recovery tool.CLAUDE_CONFIG_DIR,HOMEand state dir, verified not to touch the real config): plugin installed from the local marketplace, install steps driven in order, routing landing in the project file with the machine-wideenvblock byte-identical throughout, the scope refusal, the proxy killed so the hook fires, then recovery by pasting the hatch path. Also re-verified on macOSsh, since the container runsdash./healthz. What is therefore not exercised is the install skill's own prose — the model's judgment about scope and chaining — and a real request through a real proxy.🤖 Generated with Claude Code