# AI penetration test, fifth run (2026-11-06)

A short targeted step 4 of `docs/ai-pentest.md` on commit `1c4a7d0`, as the launch gate asks for a change to a deploy
script (`docs/mainnet.md` §1, gate 1): that commit pins mainnet's own attestor key, `0x2164…82b8`, in
`contracts/script/Networks.sol`, after the testnet's attestor key leaked on 2026-11-03 (`docs/secrets.md` §7). The
contracts in `contracts/src/` are unchanged since the fourth run's commit `1e08fee`. One fresh attack agent on area F
(deployment), aimed at everything in the deploy path since `1e08fee`: v0.34's deploy-script changes, which no run had
attacked, and the new pin. Every earlier proof ran as a regression test (the full suite, 829 tests, passes on
`1c4a7d0`), and a fresh verifier checked each finding. Proofs are under `contracts/test/pentest/fifth/` and
`scripts/pentest/fifth/`; the verifier's test is `contracts/test/pentest/verify/V5_F-2_AttestorPinChecks.t.sol`.

# Part 1. Summary

## Result

No Critical, High or Medium finding, and **no finding needs a contract change**. What deploys is what was audited: the
creation-code pins are this build and the deploy and gate scripts embed exactly those bytes; the via-IR/legacy split
fails closed; the `FOUNDRY_*`/`DAPP_*` refusal reads the only `.env` forge loads; on Arbitrum One today the deployer is
unused, the promised escrow addresses are empty, the judge Safe is as documented, and the new attestor pin is a fresh
address, neither the leaked testnet key nor Reclaim's. The ops tool's key-to-address code (which printed the pin)
matches `cast` on 49 keys.

One Low and one Info finding survived verification, both in the procedure around the contracts:

- **Low: R5-F-1.** `scripts/deploy-all.sh` and `scripts/rehearse-mainnet.sh` derive addresses with
  `cast wallet address --private-key <key>`: the deployer and promo keys (and the relayer key in the rehearsal) sit
  for 13–20 ms on a command line that every local account can read, since `/proc` here has no `hidepid`. That includes
  `teo`, the account Claude Code runs in, which `docs/secrets.md` keeps away from every secret. The deployer key is
  the one that matters: it leaks in every rehearsal, before launch day, and with it anyone can use the deployer's
  nonces and so occupy the promised escrow addresses, holding up the launch for a new deployer, new pins and an audit
  round. No party's money or fee is at risk (the verifier lowered it from Medium: the promo and relayer keys already
  reach code running as `teo` through the services, a gap `docs/secrets.md` accepts).
- **Info: R5-F-2.** Nothing at deploy time re-derives the pinned attestor address from the key the mainnet attestor
  runs with. The vault ties them when the key is made (`secret gen` writes `YESCROW_ATTESTOR`, which `DeployAll`
  compares with the pin), so a mismatch needs a hand edit (`secret set` or `sudoedit`) that skips the address; then no
  proof would ever pass on the new escrows, which fail closed. The verifier lowered it from Low and corrected the
  claim that no money enters: same-registrar deals take the buyer's money at `accept` with no proof, so the §5 smoke
  test would show a mismatch only after the user's own 5 USDC is in; cancel or confirm recovers it.

## All findings

"Attacker" is the severity the attack agent claimed; "Verifier" is the fresh agent's verdict; "Final" is the
verifier's severity. The rule (the user's): a finding whose proof runs and shows the claimed impact can't be dropped
by argument alone.

| ID | Finding | Attacker | Verifier | Final | Where the fix goes |
|---|---|---|---|---|---|
| R5-F-1 | The launch scripts put the deployer, promo and (rehearsal) relayer keys on `cast` command lines every local account can read | Medium | Confirmed, lowered: the promo and relayer keys already reach `teo`'s code through the services; the deployer key leaks in every rehearsal, so the launch-day race doesn't matter | **Low** | Scripts: the pinned addresses instead of keys on Arbitrum One, a throwaway key for the rehearsal's router, a forge-side key check; the server: `/proc` with `hidepid=invisible` |
| R5-F-2 | No step before the deployment proves the pinned attestor address is the key the mainnet attestor signs with | Low | Confirmed, lowered: the vault ties the address to the key at `secret gen`; only a hand edit breaks it, and the old key stays in `retired/`; "no money enters" corrected (same-registrar deals) | **Info** | `DeployAll`: an unconditional key-to-pin check on mainnet; `yescrow-ctl`: `secret set` can't change a key without its address; the runbook: a live two-attestor proof between §4 and §5 |

Six ideas stayed unconfirmed (U-1 to U-6 in the area report): a lying RPC provider, the judge Safe's fallback
handler not compared with Safe's canonical list, a tampered compiler, an attestor started without its key, RPC URLs
with API keys on command lines, and a failed `cast nonce` after a failed send.

The R5-F-1 verifier also asked whether the attestor container, which has no `user:` line in `docker-compose.yml`,
runs as uid 1000 (then `teo` could read its key from `/proc/<pid>/environ`). Checked on the server on 2026-11-06: no
container process runs as uid 1000; the prover and service run as 10001, the attestor as root inside its container,
whose environment `teo` can't read. The R5-F-2 verifier noted that the first vault backup predated the attestor key:
the user made a second encrypted backup after `secret gen` the same day.

## Limits

- **Targeted and short.** One attack agent, on area F only; the rest was covered by the regression run of every
  earlier proof. The service changes since v0.33 (v0.34's fixes, sponsored AI-court fees, kept dispute answers) were
  not attacked in this run.
- **Not independent of the author.** Launched from the session that made the pin, at the user's request; each agent
  had a fresh context and the procedure's prompt word for word; the attack agent used Fable 5.1, the verifiers Opus
  5.5, all at maximum reasoning effort. No model from another vendor was run.
- **Some points are read or reasoned, not run:** the vault's contents (no agent can read them), the launch commands as
  the admin runs them, the attestor container without its key (U-4).
- **No findings is not proof of no bugs** (the area report's "Checked and found sound" lists what was tried).
- **Not fixed yet.** The fixes follow the user's decisions; a fix to a deploy script is itself a change the gate asks
  a targeted round for.

---

# Part 2. Area report, with each verification

## Area F: deployment

### AI penetration test, fifth run, Area F: deployment (commit `1c4a7d0`)

Targeted at what changed in the deploy path since the last attacked commit `1e08fee` (v0.33): v0.34's deploy-script changes (never attacked by a run) and 1c4a7d0's `YESCROW_ATTESTOR` pin. Attack model and rules: `docs/ai-pentest.md`. Work was done on an export of 1c4a7d0 only; nothing under `contracts/src/`, `contracts/script/`, `scripts/*.sh`, `ops/` or `docs/` was changed; no `.env` file, key, vault, `sudo`, `yescrow-ctl`, Docker or running stack was touched; nothing was sent to a live network (reads from a fork of Arbitrum One at the latest block, a local `forge script` without RPC, `cast` on throwaway keys from `/dev/urandom`). Date 2026-11-06, forge 1.8.5, Arbitrum One block 26,355,312. Attack agent: Claude Fable 5.1, maximum reasoning effort.

New files: `contracts/test/pentest/fifth/F5_ArbitrumOneToday.t.sol` (5 tests, pass: the new pins against the chain today, DeployAll's pre-broadcast check, the gate's expectations); `contracts/test/pentest/fifth/F5_AttestorPinUnlinked.t.sol` (3 tests, pass: R5-F-2); `scripts/pentest/fifth/F5_private_keys_on_command_lines.sh` (passes: R5-F-1).

```bash
cd contracts && FOUNDRY_DYNAMIC_TEST_LINKING=false systemd-run --user --scope -p MemoryMax=3300M -p MemorySwapMax=3G --quiet -- \
  ~/.foundry/bin/forge test --match-path 'test/pentest/fifth/F5_*.t.sol' -vv      # 8 passed
scripts/pentest/fifth/F5_private_keys_on_command_lines.sh                           # PASS
```

**Headline.** The v0.34 pins and refusals hold: this checkout builds exactly the creation code `Networks.sol` pins, the deploy and gate scripts embed those very bytes (what the tests run is what deploys), the via-IR copy of `DeployRouter.s.sol` that a test build leaves behind is neither linked nor accepted, the `FOUNDRY_*`/`DAPP_*` refusal looks at the only `.env` forge reads, and on Arbitrum One today the deployer is untouched, the promised addresses are empty, the judge Safe is what the docs describe, and the new attestor pin is a fresh address that is neither the leaked testnet key nor Reclaim's. Two gaps remain, both in the procedure around the contracts rather than in what they deploy: the launch scripts put mainnet private keys on command lines every local account can read (R5-F-1, Medium), and nothing before the one-shot deployment proves the pinned attestor address belongs to the key the mainnet attestor runs with (R5-F-2, Low).

#### Findings

#### R5-F-1. The launch scripts put the mainnet deployer, promo and (in the rehearsal) relayer keys on `cast` command lines, readable by every local account

- **Severity:** Medium. A leaked promo key signs fee vouchers on-chain until `promoEnds` (92 days) whatever the service does (`docs/keys.md` §1: "on-chain it stops by itself at `promoEnds`"), so yescrow's fee and the referrers' shares are lost on every deal it is used on, and the key can't be replaced without new escrows (the rubric's "loss of yescrow's fees"). A leaked deployer key on launch day lets anyone spend nonce 0 of `0xA8C2…D14D` (it holds 0.01 ETH then) and burn the promised addresses `0xe13E…aBa` and `0x3A73…fbB` before `DeployAll` sends, stopping the launch until a new deployer, new pins and an audit round exist; after the launch it has no power. The relayer key (rehearsal) is its ETH float and the sponsored-fee budget. It also breaks the rule `docs/secrets.md` opens with ("no secret value ever reaches the account Claude Code runs in (`teo`)… The operating system enforces it") for the two keys that can't be rotated cheaply. The verifier may lower it to Low for the race and the local precondition.
- **Attacker and preconditions:** any process running as a local account on the server while the admin runs `scripts/deploy-all.sh arbitrum-one` (docs/mainnet.md §3 step 2) or `scripts/rehearse-mainnet.sh` (§1 gate 5) — concretely `teo`, the account every Claude Code session, review round and the dev tooling run as, which `docs/secrets.md` §1 defines as the untrusted side. It needs only to poll `/proc/*/cmdline`; `/proc` on this server is mounted without `hidepid`, so each process's argv is a `0444` file (`/proc/1/cmdline`, root's, is readable by uid 1000). The window is the life of each `cast` process (tens of milliseconds, two to three times per run); a 0.5 ms poll loop caught it at the first try. `ops/claude-guard` refuses `env`, `printenv` and `/proc/*/environ` (lines 24, 35–36) but not `ps`, `pgrep -a`, `top -c` or `/proc/*/cmdline`, and its output filter redacts only values whose hashes are in the vault's list: after the documented keystore import the deployer key is removed from the vault (`docs/secrets.md` §6, `secret rm mainnet DEPLOYER_PRIVATE_KEY` → `refresh_hashes`, `ops/yescrow-ctl:590`), so a `ps` line with it would reach a transcript unredacted.
- **Steps** (`F5_private_keys_on_command_lines.sh`, throwaway key only):
  1. `scripts/deploy-all.sh:75` runs `cast wallet address --private-key "$DEPLOYER_PRIVATE_KEY"` (new in v0.34, the balance check), `:93` the same with `PROMO_SIGNER_PRIVATE_KEY`, `:118` the deployer again after a failed send, and `:88` the attestor key whenever `YESCROW_ATTESTOR` is unset (it is set in `mainnet.env`, so not at launch); `scripts/rehearse-mainnet.sh:25–26` do it with the deployer and relayer keys from `.env.mainnet`. The admin runs these with the vault's real keys (docs/mainnet.md §3 step 2, §1 gate 5).
  2. The script shows `/proc`'s mount line (no `hidepid`) and reads root's command line as `teo`.
  3. A watcher polling `/proc/*/cmdline` is started; `deploy-all.sh:75` is run verbatim with a throwaway key; the watcher prints the whole key from `cast`'s argv; the script exits 0 only when the captured string equals the key.
  4. `cast wallet address --help`: a raw key is accepted only as `--private-key <RAW_PRIVATE_KEY>` (the env variables are for keystores); `--interactive` needs a tty (stdin fails with "No such device or address"), so there is no non-argv way with `cast` as the scripts use it. forge reads keys from the environment (`vm.envUint`), which is owner-only in `/proc`.
- **Impact:** the mainnet deployer key (launch day) and promo key (every launch and rehearsal) are exposed to every local account for the life of a `cast` process; the relayer key in rehearsals. Consequences as under severity. No buyer's or seller's money: none of these keys can move deal funds (`docs/keys.md`).
- **Command:** `scripts/pentest/fifth/F5_private_keys_on_command_lines.sh`
- **Root cause:** `scripts/deploy-all.sh:75`, `:88`, `:93`, `:118`; `scripts/rehearse-mainnet.sh:25–26` (a key as an argument of `cast wallet address`); `/proc` mounted without `hidepid`; `ops/claude-guard:24,35–36` (environment reads refused, command lines not); `docs/mainnet.md:138` and `:90–91` run them as admin with the vault.
- **Suggested fix:** never pass a key as an argument. On `arbitrum-one` the script needs only addresses: use the pins (`Networks.MAINNET_DEPLOYER` for the balance and nonce checks — `DeployAll` already refuses any other deployer — and `MAINNET_PROMO_SIGNER`), or keep each key's address in the vault as a setting next to it (the `COMPANION` mechanism of `yescrow-ctl secret gen` already does this for `ATTESTOR_PRIVATE_KEY → YESCROW_ATTESTOR`; add `PROMO_SIGNER_PRIVATE_KEY → PROMO_SIGNER` and `DEPLOYER_PRIVATE_KEY → DEPLOYER`), and let `DeployAll`'s forge-side check confirm `vm.addr(vm.envUint("…_PRIVATE_KEY")) == <address>` (forge reads the environment, never argv). Where a shell derivation is unavoidable, a one-line forge script (`vm.addr(vm.envUint(name))`) or the keystore (`cast wallet address --account mainnet-deployer` with `ETH_PASSWORD`) keeps it off the command line. Same in `rehearse-mainnet.sh:25–26`. Also keep RPC URLs with API keys off `--rpc-url` (the example uses public URLs; `cast`/`forge` honour `ETH_RPC_URL`). Defence in depth: mount `/proc` with `hidepid=invisible` (plus `gid=` for admin/root), and add `ps`, `pgrep -a`, `top`, `/proc/*/cmdline` to `claude-guard`'s refusals — care, not enforcement, as `docs/secrets.md` says.

#### R5-F-2. No step before the one-shot deployment proves that the pinned attestor `0x2164…82b8` is a key yescrow's attestor signs with

- **Severity:** Low (at the bottom: yescrow's own operational risk; fails closed; no money). Reported because the brief asks for every place the attestor's key flows through the deploy path, and the answer is that it never does.
- **Attacker and preconditions:** none; yescrow's own launch. Precondition: the vault's `ATTESTOR_PRIVATE_KEY` does not belong to `Networks.YESCROW_ATTESTOR` (a vault restored from a backup made before `secret gen` ran on 2026-11-06; a hand-copied pin; a container started without the key). The most likely cause was excluded: the address `secret gen` printed comes from `yescrow-ctl`'s own secp256k1 and Keccak code in Python (`key_info`, `ops/yescrow-ctl:355–360`), compared here with `cast wallet address` on 49 keys including 1, 2, n−1, n−2, 2^255 and 2^128, with no mismatch.
- **Steps** (`F5_AttestorPinUnlinked.t.sol`, fork of Arbitrum One, router through the real keyless proxy):
  1. With `ATTESTOR_PRIVATE_KEY` set to a key whose address is not the pin and `YESCROW_ATTESTOR` set to the pin (what `mainnet.env` holds, as `deploy-all.sh:88` takes it), `DeployAll.check` passes and `CheckDeployment.expected` wants the pin: neither reads a key (`DeployAll.s.sol:104`, `:167`; `CheckDeployment.s.sol:80`, `:230`, `:238`). The one address the scripts would derive from the key is refused ("YESCROW_ATTESTOR is not the one in Networks.sol"): the pin always wins, so the vault's key never matters to the deployment.
  2. The verifier `DeployAll` deploys (`new RdapVerifier(RECLAIM)`, `new RdapVerifier(pin)`, `new DualRdapVerifier`) refuses the only real two-attestor proofs the project can make today (`test/fixtures/rdap-dual/example.com.json`: Reclaim's half valid, yescrow's half signed by the testnet attestor `0xaac9…8975`) with `WrongAttestor()`, while a verifier over the testnet attestor accepts the same pair. No attestation by the pinned key exists in the repository, the fork test (`test/fork/ArbitrumOneMainnetFork.t.sol:55` still pins the testnet attestor), the rehearsal or the launch gate; the first thing that exercises the real key is the smoke test of docs/mainnet.md §5, after the deployment.
  3. A USDC escrow configured exactly as `DeployAll.config` does, over that verifier: a seller's 1,000 USDC proposal with the buyer's money at its deposit address can't be accepted with that proof (`accept` reverts `WrongAttestor`, the deal stays `Proposed`, the escrow credits nothing). Every deal needs a passing "before" proof (`YescrowEscrow.sol:442`), so no money ever enters an escrow whose attestor nobody can sign for; `isVerifier` has no setter and `RdapVerifier.attestor` is immutable.
- **Impact:** if the pin and the vault key disagree, the five contracts at the promised addresses (nonces 0–4 of the one-shot deployer) are wasted: no proof ever passes, no deal is accepted, and a new version needs new escrow addresses, a new verifier (docs/mainnet.md §6 "attestor key leaks"), new pins and an audit round. No party's money is at risk (fail closed); the loss is the launch and the addresses.
- **Command:** `cd contracts && FOUNDRY_DYNAMIC_TEST_LINKING=false forge test --match-path test/pentest/fifth/F5_AttestorPinUnlinked.t.sol -vv`
- **Root cause:** `scripts/deploy-all.sh:88` (derives the address from the key only when `YESCROW_ATTESTOR` is unset; `.env.mainnet.example:42` sets it); `contracts/script/DeployAll.s.sol:104`, `:123`, `:167`, `:79`; `contracts/script/CheckDeployment.s.sol:80`, `:230`, `:238–241` (addresses and code hashes only); `scripts/rehearse-mainnet.sh` (the same scripts); `docs/mainnet.md` §1 gate 5 and §3 (no attestation step), §5 step 1 (the first real proof, after deploying).
- **Suggested fix:** (1) In `DeployAll.check` on mainnet, when the environment holds the key (`vm.envExists("ATTESTOR_PRIVATE_KEY")`, true for the deploy and the rehearsal): `require(vm.addr(vm.envUint("ATTESTOR_PRIVATE_KEY")) == Networks.YESCROW_ATTESTOR, …)` — forge reads the environment, so this doesn't reopen R5-F-1. (2) A gate step before §3 step 2: with the mainnet attestor container up, ask the mainnet prover for a proof with `attestors=both` and verify it on a fork against a `DualRdapVerifier` over the pin (the shape of the second test with a fresh proof in place of the fixture); keep the proof as a mainnet fixture and move the fork test's attestor constant to `Networks.YESCROW_ATTESTOR`.

#### Unconfirmed

- **U-1. A lying RPC provider** can make every read the gate relies on pass (carried from runs 3–4). Smaller now: docs/mainnet.md §3 step 3 loops over the second and third providers of `RPC_URL` (R4-F-I3 fixed).
- **U-2. The judge Safe's fallback handler** (`0x3EfCBb83A4A7AfcB4F68D501E2c2203a38be77f4`, read from storage) and Safe 1.5.0's module-guard slot were logged, not compared with Safe's canonical deployments (no trusted list in the copy). A non-canonical handler could only affect the Safe's EIP-1271 answers, not `judgeRuling`, which needs a Safe transaction by two owners; owners, threshold, singleton `0xEdd1…7C7e`, no modules and an empty guard slot were confirmed.
- **U-3. A tampered compiler** (`SVM_HOME`, a replaced binary in `~/.local/share/svm`): the creation-code pins are compiled by that same compiler, so they can't catch it. Needs the admin's own environment; not built. forge honours only the uppercase `FOUNDRY_`/`DAPP_` prefixes the scripts refuse.
- **U-4. An attestor container started with an empty `ATTESTOR_PRIVATE_KEY`** (`docker-compose.yml:43` defaults to `""`): whether attestor-core 5.1.4 refuses to start or signs with some default wasn't run (no stack in the copy). Either way its address is not the pin and every proof fails closed on-chain (R5-F-2's shape).
- **U-5. RPC URLs with API keys on `--rpc-url`:** the rehearsal passes `ARBITRUM_ONE_RPC_URL` to `anvil --fork-url` and every `forge`/`cast` call takes `--rpc-url`, so a paid URL with a key is visible like R5-F-1's keys (a lesser secret). The example uses public URLs; not separately proven.
- **U-6. `cast nonce` failing at `deploy-all.sh:118`** (an RPC flake right after a failed `forge script`) leaves the stalled broadcast in place and prints no advice; a later hand `--resume` would replay it (R4-F-1's shape). The balance check makes the stall itself unlikely. Reasoning only.

#### Checked and found sound

- **The creation-code pins are this build, and the scripts deploy it** (`test_F5_today_creationCodePins_areThisBuild`; `jq`/`cast keccak` over the copied `out/`): `YescrowEscrow` `0x96ed…dbb2`, `RdapVerifier` `0x7a0c…63e9`, `DualRdapVerifier` `0xa5b8…78bf` equal `Networks.sol:99–103`; `out/DeployAll.s.sol/DeployAll.json` and `out/CheckDeployment.s.sol/CheckDeployment.json` contain those exact bytes as embedded creation code, so `new YescrowEscrow(...)` in the script, `type(X).creationCode` in the pin check and the gate's creation-input prefix are one artifact, the one the tests compile (via-IR, 200 runs, Cancun); the escrow's runtime is 24,134 bytes, under EIP-170. The pins exclude constructor arguments, so both RdapVerifiers share one pin.
- **The via-IR/legacy split fails closed:** the copied test build has `out/DeployRouter.s.sol/DeployRouter.via-ir.json` next to the default artifact (the case docs/mainnet.md §3 warns about); `forge script script/DeployRouter.s.sol:DeployRouter --sig predicted()` recompiles the script in the default profile and returns `Networks.ROUTER`; a via-IR router (`0x4e69…daa1`) would land elsewhere and `DeployRouter.run` refuses it before sending; the fork tests take the legacy artifact by path and `ArbitrumOneMainnetFork` asserts it lands at the pinned address.
- **The `FOUNDRY_*`/`DAPP_*` refusal looks in the right places** (`deploy-all.sh:30–44`): the environment is checked before and after sourcing (indented or multi-assignment lines are caught by the second check), `FOUNDRY_BROADCAST` is the only exemption and only moves the broadcast directory; `contracts/.env` and `~/.foundry/foundry.toml` are refused by existence; forge 1.8.5 loads a `.env` from the project root and the cwd only (shown: a `.env` one directory above is ignored, the project-root one applies) and every forge call runs from `contracts/`, so the checked file is the right one. With the pins as the backstop, a settings source the refusal misses can only make the simulation refuse, not deploy other code.
- **The judge and owner pins on Arbitrum One today:** `0xB9Ea…433a` is a Safe `1.5.0` proxy over singleton `0xEdd160fEBBD92E350D4D398fb636302fccd67C7e`, threshold 2, three owners including the pinned `0xF025…c039`, no modules, guard slot zero, not the fee wallet, which is not an owner; `Networks.safeThreshold`/`safeHasOwner` read it; the owner pin, fee recipient and promo key are plain accounts. The pin is the address, so the code at it is yescrow's Safe whatever a stranger deploys elsewhere, and a `JUDGE` that isn't the pin is refused with the pin's message; a Safe-shaped address answering 0 fails the threshold check first. The stand-in `0x…1d6e` has no code on Arbitrum One and `mainnetJudge()` no longer returns it; `rehearse-mainnet.sh` now requires `JUDGE` from `.env.mainnet`.
- **The attestor pin:** `0x2164…82b8` has no code, nonce 0 and no balance, differs from the leaked testnet attestor and from Reclaim's; `DeployAll` refuses the old pin before any send and the gate expects the new one; the gate compares the deployed verifier's immutable and code hash with it. Reclaim's published sample key (attestor-core `.env.sample`) is `0xF9f2…5265`, not the pinned Reclaim attestor `0x2448…9072`, so "one compromised attestor" isn't met by a public key. The service never reads the attestor address (no use in `service/src`); the prover reaches attestors by URL and the chain decides, so a URL mix-up during the §4 port switch fails closed.
- **The deployer and promised addresses today:** `0xA8C2…D14D` nonce 0, balance 0 (so `deploy-all.sh:74–78` stops at the 0.01 ETH check until §2 is done); `CREATE(deployer, 3)`/`(…, 4)` are the pinned escrow addresses, both empty; the keyless proxy exists; the router is not yet on Arbitrum One (step 1 pending); Kleros's resolver is allow-listed and points at the core; fees 0.005/0.00081 ETH are under the 0.05/0.01 ETH caps. No way was found to burn the promised addresses through the scripts (`DeployRouter` refuses the mainnet deployer, `Deploy.s.sol` is Sepolia-only, `DeployAll` refuses a used deployer in the simulation); only the key itself can (R5-F-1).
- **`--resume` and the promo-end bounds** (`deploy-all.sh:73–78`, `:106–124`, `:141`): the balance check runs only on a first run; a failed `forge script` with the nonce still 0 moves `run-latest.json` aside and says to run again; `RESUME=1` refuses a broadcast whose first transaction has no hash; `CONFIRM_MAINNET`, `RESUME`, `SKIP_VERIFY`, `REQUIRE_KLEROS` come only from the command line; in `RESUME` mode `PROMO_ENDS` for the record is read back from the chain. `DeployAll` bounds `PROMO_ENDS` to ±1 h of the simulation's `block.timestamp` + 92 days and the gate to ±1 h of `DEPLOY_TIME` + 92 days (an explicit `PROMO_ENDS` or another `PROMO_DAYS` in the env file is refused, not deployed). A later send that stalls still shortens the promo by the stall (the fourth run's accepted residual).
- **`DEPLOY_TIME`:** derived from the chain in both places (first verifier's receipt block in `deploy-all.sh:136,146`; the record's `deployBlock` on a second and third provider in §3 step 3), required on mainnet by the gate, bounded both ways; a wrong value can only make the gate refuse a right deployment or accept a promo end within the hour window, never a different setting.
- **The rehearsal script:** strict local-URL regex (userinfo forms refused), numeric `FORK_PORT`, temporary record and broadcast directories, router by the relayer key, `key()` fails closed on quoted or commented values; the attestor key is not on its command lines (`YESCROW_ATTESTOR` is set), the deployer and relayer keys are (R5-F-1).
- **`CheckDeployment` still covers every immutable** and, since v0.34, the release build: besides the fourth run's list it requires the three creation-code pins of the running build and the judge's owner pin, and `constructorArgs` requires the on-chain creation input to begin with the pinned creation code, which ties the deployed escrows to the audited build from the chain's side.
- **The ops tool's key-to-address derivation** (`ops/yescrow-ctl` `key_info`, its own secp256k1 and Keccak): equal to `cast wallet address` on 49 keys including 1, 2, n−1, n−2, 2^255, 2^128 and 40 random ones, and its compressed public key matches `cast wallet public-key`; the pin `secret gen` printed is the key's real address.

### Verifications for area F

Each by a fresh agent (Claude Opus 5.5, maximum reasoning effort) that tried to refute the finding and ran its proof
again, under the user's rule.

#### R5-F-1: keys on `cast` command lines — Verdict: CONFIRMED, lowered to Low

**Severity: Low.** The leak is real and the proof shows it. But the impact that carries the Medium (yescrow's fees
lost to a leaked promo key) is not something this finding newly hands the attacker: code running as `teo` already
reaches the promo and relayer keys through the gap `docs/secrets.md` accepts ("code the agent writes runs with keys"):
the mainnet images are built from `teo`'s working tree (`ops/yescrow-ctl`, `compose_cmd`/`project_dir`/`cmd_up`;
`docker-compose.yml` `build:`), and the service container gets every mainnet secret. The fee at stake is also small:
vouchers work only on open deals before `promoEnds` (`YescrowEscrow.sol:755–756`), and NAMEBROS already waives each
seller's first fee for 90 of those 92 days. The key this finding newly exposes is the deployer, the only one kept out
of the services after the keystore import; its worst case is the promised addresses used or occupied and the launch
held up for a new deployer, new pins and an audit round, with no party's money and no fee lost. Runs 4 and 5 graded
that impact Low (R4-F-1). The race is not a reason to lower it further: `cast` lives 13–20 ms here, the poller caught
it at the first try, and the rehearsal hands over the deployer key before launch day.

**Proof result.** `bash scripts/pentest/fifth/F5_private_keys_on_command_lines.sh` (output masked for any 64-hex
string): exit 0, `PASS: R5-F-1 reproduced with a throwaway key`. `/proc` mounted `rw,nosuid,nodev,noexec,relatime`
(no `hidepid`); `/proc/1/cmdline` `-r--r--r-- root`, read by uid 1000; one capture of the whole throwaway key from
`cast`'s argv; `--interactive` fails without a tty. Extra: `cast` 1.8.5 also refuses the key from stdin and from
`PRIVATE_KEY`/`ETH_PRIVATE_KEY`; `cast wallet address --private-key` lived 13–20 ms over 8 runs. The capture was of a
`teo` process; reading another account's argv follows from the pid-1 read plus no `hidepid` (`cmdline` has no ptrace
check).

**Corrections to the finding:**
- The deployer key leaks in every rehearsal (`rehearse-mainnet.sh:25`) and after any failed forge run
  (`deploy-all.sh:118`), not only on launch day. Gate 5 comes before funding (§2), so with a captured key the attacker
  can fund the deployer and spend nonce 0 at leisure; "it holds 0.01 ETH then" and the race from `:75` to `:115` don't
  matter. `DeployAll` then refuses the launch (`DeployAll.s.sol:165`).
- The promised addresses can be occupied, not just burned: creations at nonces 3 and 4 put the attacker's code at
  `Networks.sol:38–39`. `0xe13E…aBa` is already public (the first run's report). Nothing trusts these addresses before
  the deployment record exists, so this is at most a phishing aid.
- The promo and relayer consequences are not new to this attacker (above); the Medium rests on them without saying so.
- "Lost on every deal it is used on" overstates: given the promo window and NAMEBROS, the extra loss is repeat
  sellers' deals, deals past 1,000 uses, and days 90–92.
- The redaction point is right, and the nightly scan is blind too: after `secret rm`, neither the guard's hashes nor
  the scan (both built from the vault files only; removed values go to `retired/`) know the deployer key. The promo
  and relayer keys stay in the vault, so the guard would redact them in a `ps` line; the transcript risk is the
  deployer key only.
- New lines: `deploy-all.sh:75` and `:118` are v0.34's; `:88`, `:93` and `rehearse-mainnet.sh:25–26` are older, so
  run 4 missed them. `:93` also runs on every `RESUME=1` run (it sits outside the RESUME branch).
- `cast` also takes the key as a positional argument (argv too). In the suggested fix, `ETH_PASSWORD` is the variable
  for `--password-file` (a path), and `--password` would put the keystore password on argv.
- `:88` at launch: whether `mainnet.env` sets `YESCROW_ATTESTOR` can't be checked from the copy; `secret gen`/`rotate`
  update that line only when it exists. It is consistent with `docs/keys.md` and `.env.mainnet.example`; if it were
  missing, `:88` would put the attestor key on argv.
- Not in the finding, not verified: the attestor service has no `user:` (unlike the prover and service). If its image
  ran as uid 1000, `teo` could read its key from `/proc/<pid>/environ`. (Checked afterwards on the server: it runs as
  root in its container; see Part 1.)

**Fix assessment:**
- The pins work on `arbitrum-one`: `:75` and `:118` can use `MAINNET_DEPLOYER`, `:93` `MAINNET_PROMO_SIGNER`;
  `DeployAll` already refuses any other deployer or promo signer (`DeployAll.s.sol:148`, `:169`). A forge-side
  `vm.addr(vm.envUint(…))` check ties the vault's promo key to its pin. Keep it per network (on Arbitrum Sepolia the
  deployer isn't the pin) and rework `:92–102`, where the key's presence decides whether the escrows get a promo
  signer at all.
- The rehearsal: `:25` can use the pin; `:26` has none. Simpler than a forge one-liner: drop the relayer key from the
  rehearsal. `DeployRouter` takes any key but the mainnet deployer and the router's address doesn't depend on it, so a
  throwaway key funded with `anvil_setBalance` does the same.
- Keeping each key's address in the vault needs more than the finding says (`secret gen`/`rotate` only, and only when
  the line exists; the promo and deployer keys predate the vault; a `DEPLOYER` line would outlive its key after the
  keystore import). The pins are simpler.
- The keystore route works but is redundant: §3 step 0 decrypts the key into `.env.mainnet` anyway.
- Complete for argv: no other place in these scripts or the documented commands (`echo` is a builtin, `awk` sees only
  its program, the rest are environment assignments or interactive; `yescrow-ctl` passes secrets by env file,
  in-process HTTP or `/dev/fd`; no secret-bearing `command:` in `docker-compose.yml`).
- RPC URLs: `ETH_RPC_URL` covers `cast` and `forge`, but anvil 1.8.5's `--fork-url` has no environment variable and
  lives for the whole rehearsal; today's URLs are public.
- Mounting `/proc` with `hidepid=invisible` is the only change that closes this for every other account, including
  future slips; make it part of the fix. Root is unaffected; give admin a `gid=` group only if it needs to see other
  users' processes, and check polkit after remounting.
- The `claude-guard` additions only stop accidental `ps` output in sessions; a script bypasses them.

#### R5-F-2: the attestor pin and the key — Verdict: CONFIRMED, lowered to Info

**Severity: Info.** The proof shows what it says: the deploy script and the launch gate take the attestor only as an
address and never derive it from a key. But the vault already ties the address to the key: `secret gen` and `secret
rotate` write the key's address into `YESCROW_ATTESTOR` (`ops/yescrow-ctl:369`, `:378–381`), `DeployAll` compares that
with the pin before anything is sent (`DeployAll.s.sol:167`), and `deploy-all.sh:88` derives it from the key when it is
unset. None of the named preconditions gets past that: a vault restored from before 2026-11-06 is refused before any
send (verifier's test b), a typo in the pin doesn't compile, and an attestor started without its key is fixed by a
restart. A real mismatch needs a hand change that skips the address (`secret set`, or `sudoedit` of `mainnet.env`), and
even then `secret set` keeps the pinned key in `retired/`, so the contracts are lost only if that key is gone too. In
the documented order no third party's money is at risk: §5's smoke test runs with the user's own wallets before
opening. The closest precedent, R4-F-I5 (the judge pin not tied to anything yescrow holds, fails closed on typos), was
Info. Fix (1) is still worth adding as cheap hardening.

**Proof result.** `F5_AttestorPinUnlinked.t.sol`: 3 passed (cached build, fork of Arbitrum One). The fixture's yescrow
half is signed by `0xaac9…8975` while the mainnet verifier needs `0x2164…82b8`; the 1,000 USDC deal isn't accepted.
Tests 1 and 2 show what is claimed (a grep confirms the pin appears only in `Networks.sol` and `docs/keys.md`; no
attestation by the pinned key exists in the repository); test 3 covers only a different-registrar deal and doesn't
support the general "no money enters" (correction below). The verifier's own test,
`contracts/test/pentest/verify/V5_F-2_AttestorPinChecks.t.sol`, 2 passed: (a) a same-registrar 1,000 USDC deal over
the pinned verifier is accepted and funded with no proof; its proof then reverts `WrongAttestor`, and the refund after
10 days returns the 1,000 USDC to the buyer; (b) an environment shaped like the vault before 2026-11-06
(`YESCROW_ATTESTOR=0xaac9…`) is refused by `DeployAll.check`; (c) fix (1)'s check on throwaway keys passes for the
matching key and refuses another; an empty value and an unprefixed key make `vm.envUint` revert. Not re-run: the
attack agent's comparison of `key_info` with `cast`.

**Corrections to the finding:**
- "Every deal needs a passing before proof (`YescrowEscrow.sol:442`), so no money ever enters" is wrong: the before
  proof is checked only for different-registrar deals (`YescrowEscrow.sol:441`; SPEC: "This mode needs no 'before'
  proof"). A same-registrar deal takes the buyer's money at `accept`; its proof then fails (`:471`), and it ends only
  by confirm or cancel (`:500–511`), a seller's dispute (`:518–523`), or a refund after the transfer window
  (`:493–497`); `docs/keys.md` already says so for a lost attestor key. The same premise is in the proof's comments
  (`F5_AttestorPinUnlinked.t.sol:44–45`, `:125–126`). The §5 smoke test is itself a same-registrar push, so it shows
  the problem only after the user's own ~5 USDC is in; their own wallets, so cancel or confirm recovers it.
- "Nothing ties the pin to a key" overstates: the vault's address update plus `DeployAll.s.sol:167` tie the pin to the
  key's address as it was at gen or rotate time; `:88` derives it when unset; `deploy-all.sh:39` exports the whole env
  file, so the key is already in forge's environment. The real gap is narrower: `secret set` and `sudoedit` can
  change the key without the address, and nothing re-derives it at deploy time.
- The named preconditions don't lead to a passing deployment: a vault restored from before `secret gen` holds
  `YESCROW_ATTESTOR=0xaac9…` and no key (refused at `:167`, or `:88` stops for lack of a key); a typo in the pin fails
  solc's EIP-55 checksum, and a wrong valid address disagrees with the vault and is refused at `:167`; a container
  started without the key loses nothing deployed.
- "The five contracts are wasted" holds only if the pinned key is also unrecoverable: `secret set`/`rotate` move the
  old value to `retired/`, and the vault backup includes that folder. Otherwise the remedy is to put the key back and
  restart the attestor.
- Two cited lines are off: the fork test's testnet attestor constant is at `ArbitrumOneMainnetFork.t.sol:52`, and
  `key_info` at `ops/yescrow-ctl:354–360`. Every other cited line checks out.
- Missed: `docs/secrets.md` listed the first vault backup before the attestor key was made; without a newer backup,
  losing the vault would lose the pinned key (`DeployAll` would refuse safely, at the cost of a new pin and an audit
  round). (The user made a newer backup the same day; see Part 1.)

**Fix assessment:**
- Fix (1) works and fits how the deploy gets its environment: `deploy-all.sh:39` exports `.env.mainnet`, a whole copy
  of the vault with the attestor key (§3 step 0), and the rehearsal uses the same file; the vault stores keys as `0x`
  plus 64 hex digits, which `vm.envUint` parses; the key is already in forge's owner-only environment, so this doesn't
  reopen R5-F-1; it checks the exact value the attestor container gets, independently of the ops tool's own code.
- Make it unconditional on mainnet, with a clear message: with `vm.envExists`, a file without the key line skips the
  check, exactly the restored-vault case if an admin hand-sets `YESCROW_ATTESTOR`. An empty value (the example ships
  `ATTESTOR_PRIVATE_KEY=`) makes `vm.envUint` revert: safe, but the message should name the problem.
- Also fix the root cause in the ops tool: have `secret set` update the companion address, or refuse `set` for
  `ATTESTOR_PRIVATE_KEY` and require `rotate`; have `scan` and `up mainnet` compare the key's address with
  `YESCROW_ATTESTOR`. That also covers changes after the deployment, which (1) can't.
- Fix (2), a live proof before §3, isn't feasible in the documented order: before §4 the testnet attestor holds
  `127.0.0.1:8001` (the mainnet attestor's default port), the mainnet prover's attestor URL defaults to
  `wss://yescrow.xyz/attestor/ws`, which Caddy routes to the testnet attestor (a false alarm), the prover publishes no
  port, and the mainnet service can't start without the deployment record. It would add little beyond what (1) proves
  offline.
- The better place for a live check is between §4 and §5: `POST /v1/proofs` on the mainnet API with both attestors,
  then an `eth_call` of `verify` on the deployed `DualRdapVerifier`, as done on testnet. It costs nothing, covers an
  attestor started without its key (U-4), and runs before the smoke test puts USDC into a deal that needs no proof to
  start. That proof can become the mainnet fixture, with `ArbitrumOneMainnetFork.t.sol:52` then pointing at
  `Networks.YESCROW_ATTESTOR`.
