Postmortems/2026-05-24 Ultraplan Exposure
As of: 2026-07-05 — migrated from `postmortems/2026-05-24-ultraplan-exposure.md`
Postmortem & Remediation Playbook: Ultraplan upload exposure (2026-05-24)
Status: Remediation in progress. This document is BOTH the postmortem and the step-by-step playbook for executing the remediation manually.
Incident summary
While working on the tsbuffer project plan in Claude Code, the /ultraplan
command was invoked. /ultraplan uploads the current working directory
plus CLAUDE.md and MEMORY.md to an Anthropic-hosted "Claude Code on
the web" session for remote planning by an agent there.
The remote session terminated with error_during_execution before
producing any output. The uploaded content remains in Anthropic cloud
session storage tied to session ID 01GUjtsXpaAw1CqHShfePPmq.
No warning was surfaced about the upload before it occurred. This is the root cause of the exposure — there is no broken security control to fix; the tool worked exactly as designed, and we did not know what it did.
What was in the upload
Live credentials (rotation required)
| Item | Where it was | Why it's bad |
|---|---|---|
| n8n API JWT | CLAUDE.md, "n8n API" section, pasted in plaintext, no expiry
|
Anyone with this can call any n8n API endpoint as admin |
n8n admin UI password (Htdml0fI1aZgtPBVv5oz)
|
MEMORY.md, n8n DB Recovery memory entry
|
Direct UI login as [email protected]
|
Topology / metadata exposed (no rotation possible, hygiene only)
- Internal domains:
agent.wilsoz.com,vault.wilsoz.com,key.wilsoz.com,
photos.wilsoz.com,ntfy.wilsoz.com,mymail.wilsoz.com,next.wilsoz.com,wilsoz.cloudflareaccess.com.
- Internal IPs:
192.168.0.109(stronghold),192.168.0.144(ollama),
Docker bridges, Tailscale IP 100.67.169.50 for the SDF relay VPS.
- OpenBao endpoint and secret path layout (paths only, no tokens).
- Vaultwarden item-naming convention ("Stronghold Infrastructure" folder).
- MXRoute server
blizzard.mxrouting.net, usernamecraniums. - Authentik admin URL + admin usernames
akadmin,marcus; Cloudflare
Access OIDC client ID.
- Matrix bot @zordon:wilsoz.com, room ID, internal Synapse endpoint.
- All n8n workflow JSONs in
n8n-workflows/and every service doc in
services/.
- Email addresses including
[email protected],[email protected],
[email protected].
Top-level constraints for this remediation
- The credentials store IS the root of trust. Vaultwarden + OpenBao
are how we manage every other secret. Compromising either means compromising everything else.
- Never paste a secret into chat, ever. Not even temporarily, not
even "just to verify." Once it's in the conversation, it's leaked exactly the same way the original JWT was.
- Verify each new credential works BEFORE deleting the old one. A
half-rotated credential where the new one doesn't work is worse than the original exposure.
- No secret values in command-line arguments. They show up in
/proc/PID/cmdlineandps auxwoutput. Use stdin, env vars, or token files mode 0600.
- Two-person rule (where applicable). For high-blast-radius
rotations (root tokens, master password) — don't rotate without confirming the recovery path first.
Remediation steps (manual playbook)
Each step has: (a) what to do, (b) how to verify, (c) what to update, (d) rollback.
Step 1. Rotate n8n API JWT
(a) Action:
- Open https://agent.wilsoz.com in a browser; log in as
[email protected].
Password: retrieve from Vaultwarden item "n8n UI Login". (This is the old password — Step 2 will rotate it. The order matters: API key first, because once we change the admin password we want to verify with the API that everything's healthy.)
- Settings → n8n API → see the existing key (do NOT copy its value).
- Click "Revoke" on the existing key. Confirm.
- Click "Create an API key". Set "Time to live" to "No expiration".
Copy the new JWT once shown (the UI shows it exactly once).
- Paste the new JWT directly into a Vaultwarden item — open
Vaultwarden in another tab, edit item "n8n API Key", paste, save. Do not paste it into a terminal, a text file, or any chat.
(b) Verify: <syntaxhighlight lang="bash">
- Retrieve the new key from Vaultwarden via the bw CLI (NOT by pasting):
NEW_KEY=$(bw get item "n8n API Key" | jq -r '.fields[] | select(.name=="apiKey") | .value')
- (Field name may differ — adjust per your existing Vaultwarden item layout.)
curl -sf "https://agent.wilsoz.com/api/v1/workflows" \
-H "X-N8N-API-KEY: $NEW_KEY" \ | jq '.data | length'
- Expect: a number (count of workflows), no errors.
unset NEW_KEY </syntaxhighlight>
(c) Update:
- Vaultwarden item "n8n API Key": done above.
- OpenBao at
secret/n8nfieldapi-key:
```bash # Read the new key from Vaultwarden into a shell var (no echo): NEW_KEY=$(bw get item "n8n API Key" | jq -r '.fields[] | select(.name=="apiKey") | .value') # Write to OpenBao using stdin (NOT command-line arg): printf '%s' "$NEW_KEY" | BAO_TOKEN="$(cat /library/openbao-app/.claude-mcp-token)" \ bao kv put -mount=secret n8n api-key=- unset NEW_KEY ```
~/Developer/SystemResiliency/CLAUDE.md: replace the JWT-bearing line
with:Retrieve from Vaultwarden item: "n8n API Key". Remove theN8N_KEY='...'example line that contains the actual JWT in the curl examples; replace with a# N8N_KEY=$(bw get item "n8n API Key" | ...)pattern.
- Commit CLAUDE.md change. Verify the diff contains NO JWT value before
committing (git diff CLAUDE.md and visually scan).
(d) Rollback: If verification fails, the old JWT was already revoked at n8n — there is no rollback to the previous value. Instead, immediately mint another new JWT from the n8n UI and retry. The window of "no working API key" is acceptable because no automation actively uses it during this rotation.
Step 2. Rotate n8n admin UI password
(a) Action:
- Still logged into n8n as
[email protected]. - Settings → Personal → Change Password.
- Open Vaultwarden, edit item "n8n UI Login", click the password
regenerate button (32 chars, full charset). Save.
- Copy the new password from Vaultwarden.
- Paste into n8n's "New Password" field. Save.
(b) Verify:
- Sign out of n8n.
- Sign in with email
[email protected]and the new password. - Expect: dashboard loads, workflows visible.
(c) Update:
- Vaultwarden item "n8n UI Login": done above.
- OpenBao at
secret/n8nfieldadmin-password(if used by anything):
check first whether anything reads it. If yes, update via stdin pattern above. If no, skip.
MEMORY.md(auto-memory): edit/home/mnw/.claude/projects/-home-mnw-Developer-SystemResiliency/memory/MEMORY.md.
Find the line:Created new owner: [email protected] / Htdml0fI1aZgtPBVv5ozReplace with:Created new owner: [email protected] / (rotated 2026-05-24; see Vaultwarden "n8n UI Login")
(d) Rollback: Same situation as Step 1 — old password is already invalidated. If the new password doesn't work, n8n's password reset relies on SMTP email which is functional. Worst case: re-init the n8n DB (same recovery we did 2026-03-11).
Step 3. Audit CLAUDE.md and MEMORY.md for OTHER secrets
This is a one-pass sweep to catch anything I missed when triaging.
(a) Action: <syntaxhighlight lang="bash"> cd ~/Developer/SystemResiliency
- Install gitleaks if not present:
which gitleaks || sudo apt install -y gitleaks
- Scan the project + memory dirs:
gitleaks detect --source . --no-git -v gitleaks detect --source ~/.claude/projects/-home-mnw-Developer-SystemResiliency/memory --no-git -v </syntaxhighlight>
(b) Verify:
- Read the findings line by line.
- For each finding: is it actually a secret, or a false positive (e.g.
an ID, a hash of a non-sensitive value)?
- True positives: add to the rotation list, restart from Step 1 for each.
(c) Update:
- Document any new findings in this file (append a "Found in audit"
subsection below).
(d) Rollback: N/A — read-only operation.
Step 4. Attempt deletion of the cloud session
(a) Action:
browser. Log in as [email protected].
- Look for a delete / remove option in the session menu (three-dot icon,
settings gear, or similar).
- If found, click it. Confirm.
- If not found: send an email to
[email protected]with the session
ID and a request for deletion under data-deletion rights. Reference
https://privacy.anthropic.com. Subject:
Data deletion request — session 01GUjtsXpaAw1CqHShfePPmq.
(b) Verify:
- If UI delete worked: refresh the session URL, expect 404 or
"session not found".
- If email request: save the sent message; expect a reply within 30 days
per their stated SLA.
(c) Update:
- Append the outcome to this file: "Cloud session deletion: [UI delete |
email requested on YYYY-MM-DD | response received on YYYY-MM-DD]"
(d) Rollback: N/A. Best-effort operation either way.
Step 5. Install a pre-commit secret-scanner
So a future paste-in is caught before it reaches a commit.
(a) Action: <syntaxhighlight lang="bash"> cd ~/Developer/SystemResiliency
- Add a .gitleaks.toml if you want custom rules; defaults are fine.
- Install the pre-commit framework if not already:
which pre-commit || sudo apt install -y pre-commit cat > .pre-commit-config.yaml <<'EOF' repos:
- repo: https://github.com/gitleaks/gitleaks rev: v8.18.0 hooks: - id: gitleaks
EOF pre-commit install </syntaxhighlight>
Do the same in ~/Developer/jellyfin-adhoc/ and ~/Developer/tsbuffer/.
(b) Verify:
- Make a trivial change to a file in the repo.
- Stage it. Try to commit.
- Expect: pre-commit runs, gitleaks scans the staged diff, commit
proceeds (because the diff is clean).
- Manually test the catch: stage a file containing
EXAMPLE_KEY=eyJabc.def.ghi,
commit; expect gitleaks to block.
(c) Update:
- Commit the
.pre-commit-config.yamlto each repo.
(d) Rollback:
pre-commit uninstall.
Step 6. Adopt a policy on cloud-handoff tools
/ultraplan is convenient but its blast radius is the entire working
directory. Several reasonable options:
- Block it entirely via a Claude Code hook in
~/.claude/settings.json. - Allow but require confirmation via a pre-tool hook that shows the
user the upload manifest before proceeding.
- Sanitize first — never invoke from a directory that contains
CLAUDE.md / MEMORY.md.
Decision pending — capture in ~/Developer/SystemResiliency/TODO.md for
follow-up after this incident closes.
Outcome (filled in as work happens)
- [ ] Step 1 — n8n API JWT rotated
- [ ] Step 2 — n8n admin password rotated
- [ ] Step 3 — Audit complete; findings:
- [ ] Step 4 — Cloud session deletion attempt:
- [ ] Step 5 — Pre-commit hook installed
- [ ] Step 6 — Cloud-handoff policy decision:
Follow-ups (for TODO.md)
- Cloud-handoff tool policy decision (Step 6 above).
- After this exposure, audit existing OpenBao
scripts-n8nand
claude-mcp tokens: are they scoped tightly enough? Should they be
rotated on a schedule (e.g. every 90 days) regardless of leaks?
- Consider whether Vaultwarden master password should be rotated on a
cadence (high blast radius if leaked; can't be auto-rotated).
Lessons
- Any Claude Code tool that "uploads context" needs an explicit
user-confirmation prompt with the manifest of what will be sent. The silent default is a footgun.
- CLAUDE.md and MEMORY.md are read by everything: every Claude Code
session, plus any tool that exports project context. They must never contain literal secrets. The bar should be "would I publish this to a public README?" — if no, it doesn't belong in those files.
- Rotation tedium is the actual problem behind "I'll just inline this
credential for now." The exposure-remediation script (see
scripts/exposure-remediation/PLAN.md) is the load-bearing follow-up.