# AIW Security Audit & Remediation — Pass 3

**Date:** 2026-05-18
**Branch:** `aiw-genesis`
**Scope:** `ui/server.mjs`, `ui/start.mjs`, `ui/public/dashboard.js`, `ui/public/chat.js`, `core/agent-sdk-chat.mjs`, `core/chain-manager.mjs`, `core/strike.mjs`, `main.js`, ignore/env config, `tests/api/*`
**Method:** Static analysis + data-flow tracing from request entry to fund-moving / key-disclosure sinks, across a fixed 10-area threat checklist. Builds on the prior remediation passes recorded in [`SECURITY-REVIEW.md`](SECURITY-REVIEW.md) and [`SECURITY-AUDIT2.md`](SECURITY-AUDIT2.md) (the `AUD-#` annotations in the source originate there).

---

## Executive Summary

The codebase entered this pass already well-defended by two prior remediation passes: a denylist-gated RPC proxy, a forced `do_not_relay` staging model for AI-initiated spends, timing-safe PIN/password comparison, per-IP brute-force lockout keyed on the real TCP peer (not `X-Forwarded-For`), exact-host same-origin checks, an environment **allowlist** for the Agent SDK subprocess, and an HTML-escaping markdown renderer that closes the prompt-injection→XSS chat path.

This pass re-reviewed **10 attack surfaces**. No new **Critical** or **High** exploitable issue was found within the stated trust model (single-user, local, loopback-bound, optionally password-gated). Seven areas were found sound. **Three actionable findings** were identified and **all three have been remediated** in this pass. One of these — the CLI bind-guard gap (#3) — was a genuine network-exposure regression in the primary `npm start` path that the prior passes' guard did not cover.

| # | Finding | Severity | Status |
|---|---|---|---|
| 1 | RPC injection via chat pipeline | Low (mitigated by design) | No change needed |
| 2 | Prompt injection → unauthorized tx | Low (do-not-relay gate holds) | No change needed |
| 3 | `.env` exposure | Info | Pass |
| 4 | Auth bypass (blank `DASHBOARD_PASSWORD`) | Medium | ✅ **Fixed (#3 below)** |
| 5 | Path traversal | Low (mitigated) | No change needed |
| 6 | XSS in transaction fields | Low (injectable fields escaped) | No change needed |
| 7 | Queen's Decree kill switch | Info | Pass — strong |
| 8 | CORS / CSRF | Low | Pass |
| 9 | Agent SDK subscription-session leakage | Medium | ✅ **Fixed (#2 below)** |
| 10 | Wallet / seed file exposure | Medium | ✅ **Fixed (#1 below)** |
| + | Global `_pendingPreview` cross-request bleed | Low | Tracked (not fixed this pass) |

The three fixes, in priority order: **(#1)** add a second factor + lockout + audit to the standing seed-reveal route; **(#2)** lock down the Agent SDK subprocess (cwd/MCP/settings isolation, credential-env hard-strip, model clamp); **(#3)** enforce the existing `secureBindHost()` guard in the CLI startup path.

---

## Areas Reviewed — Findings

### 1. RPC injection (chat endpoint) — Low, no change

The chat agent extracts RPC tool calls from the model reply and dispatches AI-chosen `method`/`params`. Contained by three gates: the key-exfil/raw-broadcast **blocklist** (`RPC_BLOCKED_WALLET_METHODS` / `RPC_BLOCKED_DAEMON_METHODS`, enforced in both `handleChat` and `handleChatStream` *and* the `/api/rpc/*` proxies), the forced `do_not_relay:true` spending gate that stages a tx into `_pendingPreview` pending explicit UI confirmation, and hardcoded loopback RPC targets (no URL/host injection). Residual: a prompt-injected agent can invoke non-blocked, non-spending methods and *stage* (never broadcast) a transfer. **Recommendation (tracked):** add a positive method allowlist to complement the blocklist.

### 2. Prompt injection → unauthorized transactions — Low, no change

The do-not-relay gate means injection cannot send funds; `relay_tx`/`submit_transfer` are blocked so a staged tx cannot be self-relayed. Residual is persuasion-only (the model controls chat narrative around the staged tx). **Recommendation (tracked):** render the Confirm dialog's destination/amount from server-trusted `_pendingPreview` fields rather than model-influenced markdown.

### 3. `.env` exposure — Info, pass

`.env` is git-ignored (`!.env.example`), Vercel-excluded, and lives outside the static doc-root. `/api/settings` returns secrets only as booleans or via `maskSecret()`. The Agent SDK subprocess receives an env allowlist (further hardened in fix #2).

### 4. Auth bypass — **Medium → Fixed (see Fix #3)**

`isAuthenticated()` returns `true` for everyone when `DASHBOARD_PASSWORD` is blank. Mitigated by `secureBindHost()`, **but that guard was only wired into the Electron `main.js` path** — see Fix #3.

### 5. Path traversal — Low, pass

Static serving normalizes via `join()` + `startsWith(PUBLIC_DIR)` and 403s escapes; `.env`, `wallets.json`, `swaps/asb-data/mainnet/seed.pem`, and the asb-wallet are all outside the doc root and unreachable by any route. Cosmetic hardening (`+ sep` on the prefix check) noted, not required.

### 6. XSS in transaction fields — Low, no change

Genuinely attacker-influenced fields (tx `note`, subaddress/address `label`, contact/invoice `notes`) are escaped via `escapeHtml`. Chat AI replies pass through `renderMarkdown`, which HTML-escapes `& < >` first and never emits `<a href>`/`javascript:` — so prompt injection cannot inject script. Unescaped sinks (`payment_id`, `tx.address`, `tx.txid`) are charset-constrained by the crypto protocols (hex / base58 / bech32) and not practically exploitable; defensive escaping recommended (tracked).

### 7. Queen's Decree kill switch — Info, pass

Manual `/api/decree/execute` requires authenticated session (explicit AUD-#6 re-check) **and** correct `DECREE_PIN` (constant-time `safeStrEqual`) **and** a 5-attempt/15-min IP lockout, plus terminal-state guards. Decree-rerouting settings keys require the current PIN as a second factor even with a valid session. The automatic path is heartbeat-driven and server-clamped — "absence of owner," not "anyone." No bypass found.

### 8. CORS / CSRF — Low, pass

Same-origin computed via exact parsed-host equality (the old `.includes()` substring bypass was already fixed, AUD-#4). Cross-origin `/api/*` from a non-allowlisted Origin → 403; CORS headers only for the explicit `MOBILE_ORIGINS` allowlist, never `*`. Session cookie is `HttpOnly; SameSite=Strict`; mobile uses `Authorization: Bearer`. CSRF impractical under this model.

### 9. Agent SDK subscription-session leakage — **Medium → Fixed (see Fix #2)**

No network/log leak; allowlist + `allowedTools:[]` + `maxTurns:1` already in place. Gaps: dead `delete ANTHROPIC_*` lines (no-ops), no `cwd`/MCP/settings lockdown (a repo-tree `.mcp.json`/`.claude/settings.json` could spawn processes under the subscription-authed CLI), and client-chosen `model` billable to the owner's subscription.

### 10. Wallet / seed file exposure — **Medium → Fixed (see Fix #1)**

The chat agent and RPC proxies cannot exfiltrate keys — `query_key` is blocked at all four choke points. `seed.pem`/asb-wallet are not served. The only seed path is the intentional `POST /api/wallets/seed` (and the create-time reveal), which returned the 25-word mnemonic protected **only** by the dashboard session — and by nothing at all in passwordless mode — with no second factor, rate limit, or audit.

### + Global `_pendingPreview` cross-request bleed — Low, tracked

`globalThis._pendingPreview` is a single process-wide slot; concurrent sessions could race a confirm against a different staged tx. Single-user desktop makes this unlikely. **Recommendation (tracked):** key the pending preview by conversation/session and validate ownership on confirm.

---

## Fixes Implemented This Pass

### Fix #1 — Second factor + lockout + audit on `/api/wallets/seed` (Finding #10)

**Risk.** Revealing the 25-word mnemonic is total wallet compromise. The route was POST-only (good — no prefetch) and behind the session gate, but a stolen 7-day session cookie — or *any* local caller in the documented passwordless-loopback mode — could exfiltrate the seed in one request. Unlike fund-moving operations and Decree rerouting, seed reveal had **no second factor**, no brute-force limit, and only an unattributed `console.log`.

**Change.**

- `ui/server.mjs`: new `checkSeedRevealGate(req, body)` + `evaluateSeedSecret(body)` and a `seedRevealAttempts` limiter (3 attempts / 15-min lockout), mirroring the existing Strike/Decree PIN pattern. The gate requires:
  1. an **explicit authenticated session** (defence-in-depth, matching the Decree-execute AUD-#6 pattern — protects even if the global gate is bypassed or no password is set);
  2. a **second factor** — the dashboard password when configured, else the wallet RPC password when configured, compared with the constant-time `safeStrEqual`; only when *neither* secret exists (zero-config local dev) does it fall back to a deliberate `body.confirm === true`, and even then it is rate-limited and audited;
  3. an `[wallet][AUDIT]` log line (client IP + ISO timestamp) on every **grant, denial, and lockout**.
- `ui/public/index.html`: added a password input + inline error region to the existing reveal-confirm modal.
- `ui/public/dashboard.js`: `confirmRevealSeed()` now POSTs `{password, confirm}` as JSON, and on a gate rejection keeps the modal open, shows the reason inline, clears the field, and refocuses (instead of silently toasting).
- `tests/api/aiw-api.postman_collection.json`: the `POST /api/wallets/seed` request now sends `{"password":"{{password}}","confirm":true}` so the suite passes whether or not a password is configured (the old test relied on the now-closed gap).

**Scope note.** `/api/wallets/create` was intentionally left unchanged: the seed is shown once at creation, in the same authenticated request the user just initiated and supplied the wallet password for. Gating it would break onboarding without closing a *standing* exposure. An `[wallet][AUDIT]`-style trail for the on-demand reveal is the meaningful control.

### Fix #2 — Agent SDK subprocess hardening (Finding #9)

**Risk.** The bundled `claude` CLI spawned by `@anthropic-ai/claude-agent-sdk` runs under the owner's Claude Code subscription session. Without cwd/MCP/settings isolation, a `.mcp.json` or `.claude/settings.json` present in AIW's working tree (note: `.claude/` is present-but-untracked in this repo) could auto-register an MCP server / settings under that subscription-authenticated subprocess. The dead `delete` lines gave false confidence in the credential denylist, and a client-chosen `model` could pin the priciest model against the owner's subscription credit.

**Change.** `core/agent-sdk-chat.mjs`:

- Replaced the dead `delete cleanEnv.ANTHROPIC_*` no-ops with a real post-filter that strips any key matching `^(ANTHROPIC|AWS|GOOGLE|GCP|AZURE|CLAUDE_CODE)_` from the subprocess env — this survives a future maintainer adding a cloud-credential var to the passthrough allowlist.
- Added SDK isolation to the `query()` options: `cwd` pinned to a per-process empty scratch dir (`mkdtemp` under the OS temp dir), `settingSources: []` (per the SDK type docs, this disables filesystem-settings discovery — "SDK isolation mode"), `mcpServers: {}`, and `permissionMode: 'default'`.
- Clamped `model` to a 3-entry allowlist (`claude-opus-4-7`, `claude-sonnet-4-6`, `claude-haiku-4-5-20251001`); anything else falls back to the conservative default, so the request body / a prompt-injected turn cannot select the priciest model.
- Extracted `buildCleanEnv()` to keep `_runQuery` within the project's cognitive-complexity limit.

No command injection, `shell:true`, or new network listener exists in this provider; those were confirmed absent and unchanged.

### Fix #3 — Enforce `secureBindHost()` in the CLI startup path (Finding #4)

**Risk (regression found this pass).** The prior passes added `secureBindHost()` — it hard-exits on an explicit insecure bind and otherwise forces `127.0.0.1` when no password is set. But it was **only invoked by the Electron `main.js` path**. `ui/start.mjs` (the documented `npm start` / `start-aiw.sh` path) called `server.listen(PORT, cb)` with **no host argument**, so Node bound to all interfaces (`0.0.0.0`). Running the kit the documented way with a blank `DASHBOARD_PASSWORD` therefore exposed the **unauthenticated, fund-moving API on the network** — the exact scenario the guard was designed to prevent.

**Change.** `ui/start.mjs`: both listen sites (`startLightMode()` and `main()`) now resolve `BIND_HOST` via `serverModule.secureBindHost()` and listen with it, byte-for-byte mirroring the already-reviewed `main.js` pattern (`BIND_HOST ? server.listen(PORT, BIND_HOST, onListening) : server.listen(PORT, onListening)`). The blank-password + non-loopback `DASHBOARD_BIND` case now hard-exits with the existing operator guidance; blank-password + loopback/default is pinned to `127.0.0.1`.

---

## Verification

- All modified JS files pass `node --check`: `core/agent-sdk-chat.mjs`, `ui/start.mjs`, `ui/server.mjs`, `ui/public/dashboard.js`, `ui/public/chat.js`.
- `tests/api/aiw-api.postman_collection.json` re-validates as JSON and the seed test now supplies the second factor.
- No wallet/spending semantics were altered. The seed gate preserves the zero-config local-dev flow (explicit confirm) while materially raising the bar for every configured deployment.
- Remaining IDE style warnings in `start.mjs`/`server.mjs` are pre-existing and on lines untouched by this pass.

## Residual Risk / Tracked Recommendations

| Item | Severity | Recommendation |
|---|---|---|
| RPC blocklist vs allowlist | Low | Add a positive agent-invokable method allowlist alongside the blocklist. |
| Confirm-dialog source of truth | Low | Render staged-tx destination/amount from server `_pendingPreview`, not model markdown. |
| Defensive output escaping | Low | `escapeHtml` `payment_id` / `tx.address` / `tx.txid` at all `innerHTML`/attribute sinks. |
| `_pendingPreview` scoping | Low | Key by conversation/session; validate ownership on confirm; expire stale previews. |
| Passwordless mode | Medium (config) | Consider defaulting to a required `DASHBOARD_PASSWORD`; the seed gate (Fix #1) and bind guard (Fix #3) now bound the blast radius. |

**Overall posture:** Strong within the single-user / loopback / optionally-password-gated trust model. The three fixes in this pass close the standing seed-disclosure exposure, harden the subscription-session boundary, and eliminate the CLI network-exposure regression. Residual items are Low and tracked.
