Jump to content

Postmortems/2026-05-24 Ultraplan Exposure

From Stronghold Wiki

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, username craniums.
  • 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 protected].

Top-level constraints for this remediation

  1. The credentials store IS the root of trust. Vaultwarden + OpenBao
  are how we manage every other secret. Compromising either means
  compromising everything else.
  1. 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.
  1. 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.
  1. No secret values in command-line arguments. They show up in
  /proc/PID/cmdline and ps auxw output. Use stdin, env vars, or
  token files mode 0600.
  1. 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:

 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">

  1. 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')

  1. (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'
  1. Expect: a number (count of workflows), no errors.

unset NEW_KEY </syntaxhighlight>

(c) Update:

  • Vaultwarden item "n8n API Key": done above.
  • OpenBao at secret/n8n field api-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 the
 N8N_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/n8n field admin-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] / Htdml0fI1aZgtPBVv5oz
 Replace 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

  1. Install gitleaks if not present:

which gitleaks || sudo apt install -y gitleaks

  1. 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

  1. Add a .gitleaks.toml if you want custom rules; defaults are fine.
  2. 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.yaml to 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-n8n and
 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

  1. 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.
  1. 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.
  1. 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.