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

A short targeted step 4 of `docs/ai-pentest.md` on commit `bea2011`, the fixes for the sixth run
(`reports/AI pentest, sixth run (2026-11-06).md`), as the launch gate asks for a change to a deploy script
(`docs/mainnet.md` §1, gate 1). `bea2011` changed `scripts/deploy-all.sh` (a format check of the keys forge reads,
before it runs), the testnet scripts (they refuse mainnet's env file), `contracts/script/DeployAll.s.sol` and
`Deploy.s.sol` (the mainnet deployer refused off Arbitrum One), `ops/yescrow-ctl` (the `/etc/fstab` step of `lockdown`
reads the file line by line and compares the boot's units before writing) and `docs/mainnet.md` §3. The contracts in
`contracts/src/` are unchanged since `1e08fee`. One fresh attack agent on area F, aimed at those changes. Every earlier
proof ran as a regression test on `bea2011`: the whole suite, 845 tests in parts (the first pentest folder in two
halves; whole, its build no longer fits this server's memory), all pass. A fresh verifier checked each finding. Proofs
are under `scripts/pentest/seventh/`; the R7-F-2 verifier's is `scripts/pentest/verify/V7_F-2_step3_rpc_shapes.sh`.

# Part 1. Summary

## Result

No Critical, High or Medium finding, and **no finding needs a contract change**. The sixth run's three fixes hold: the
format check covers every key forge reads on the mainnet path, the testnet scripts refuse mainnet's file before any
`cast`, `DeployAll` refuses the mainnet deployer off Arbitrum One, and the new `/etc/fstab` code was found sound on a
copy of this server's real file (it treats the line the admin's `lockdown` wrote as done, so it won't touch it again).

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

- **Low: R7-F-1.** The scripts read the env file by running it as shell code (`set -a; . file`), and only then check
  the keys. A key line edited by hand into a shape bash runs as a command (the key on a line of its own,
  `export KEY = key`, the key pasted twice, a stray `)` in an interactive shell) makes bash print the whole key in its
  error before any check sees it: in `deploy-all.sh`, the three testnet scripts and §3 step 1, which then goes on to
  run forge. The verifier widened it: every secret in the file is exposed the same way, not just the four keys; two of
  the shapes survive the vault tool's own repair, which skips such lines (so the guard and the nightly scan never learn
  the old value); in the interactive shell Ubuntu's command-not-found helper passes the key to another process's
  command line; and a lower-case line can overwrite the script's own variables. None of the documented ways of writing
  a key line produces such a shape. The verifier found the suggested line check incomplete and recommended reading the
  file without running it.
- **Info: R7-F-2.** §3 step 3, the launch gate run again against other RPC providers, does nothing or checks step 2's
  own provider, silently, when the vault's `RPC_URL` line isn't in the template's shape (a missing or empty line, one
  URL, a paid URL put first). The verifier corrected that step 2 uses `ARBITRUM_ONE_RPC_URL`, not `RPC_URL`, and that
  only the second read of state that isn't immutable is lost: step 2 ran the gate, and the source verification on
  Arbiscan and Sourcify doesn't trust its RPC.

## 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 |
|---|---|---|---|---|---|
| R7-F-1 | Running the env file as shell code prints a key from a malformed line before any check | Low | Confirmed, wider: every secret in the file, the vault tool keeps such lines through a repair, command-not-found's command line, lower-case names overwrite the script's variables; the suggested line regex is incomplete | **Low** | A shared loader (`scripts/lib/env.sh`) that reads `KEY=value` lines with the vault tool's grammar and never runs the file, in all four scripts and §3 step 1; the vault tool refuses a file with other lines |
| R7-F-2 | §3 step 3 runs nothing, or step 2's provider, silently | Info | Confirmed: step 2 uses `ARBITRUM_ONE_RPC_URL`; the likeliest shape is a paid URL first; a failed run didn't stop the loop | **Info** | Step 3 names PublicNode and dRPC, refuses one that is step 2's provider (by host), prints hosts only, stops at the first failure |

Six ideas stayed unconfirmed (U-1 to U-6 in the area report): `fstab_units` ignoring the generator's exit status, the
re-read before the `/etc/fstab` append not re-checking, `.` searching `PATH` for an env file named without a slash
(reproduced by the R7-F-1 verifier), Ubuntu's command-not-found helper, a `noauto` `/proc` line counted as done, and a
paid RPC URL in forge's cache file.

## 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 were not attacked in this run either.
- **Not independent of the author.** Launched from the session that wrote the fixes, 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 server's next boot with the `/proc` line.
- **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; they change deploy scripts, which the gate asks a targeted
  round for.

---

# Part 2. Area report, with each verification

## Area F: deployment

### AI penetration test, seventh run, Area F: deployment (commit `bea2011`, the sixth run's fixes)

A short targeted round (`docs/mainnet.md` §1, gate 1; `docs/ai-pentest.md` step 4) on the fixes for the sixth run: `scripts/deploy-all.sh` (the key format check, the `DEPLOYMENT=arbitrum-one` refusal), `scripts/deploy-escrow.sh`, `scripts/deploy.sh`, `scripts/fund-testnet-wallets.sh` (the same refusal), `contracts/script/DeployAll.s.sol` and `Deploy.s.sol` (the mainnet deployer refused off Arbitrum One), `docs/mainnet.md` §3 (step 1 in a subshell with a relayer key check; step 3's RPC line), and `ops/yescrow-ctl` (`proc_in_fstab`, `fstab_with_proc`, `fstab_units`, the reworked `hide_processes`). Everything ran on a local export of `bea2011`, with throwaway keys, temporary directories and stub `cast`/`forge` where a script was run; the real `/etc/fstab` and `/proc/mounts` were read (world-readable) and never written. Nothing was sent to any network. `contracts/src/` is untouched.

**Result.** No Critical, High or Medium finding, and no contract change needed. The sixth run's three findings are fixed as claimed (the regressions `scripts/pentest/sixth/F6_fixed_deploy_scripts.sh` and `python3 -I ops/test-yescrow-ctl.py` pass on this copy; what each fix leaves open is below). One Low and one Info (table as in the summary above). The `ops/yescrow-ctl` fstab code was found sound; `python3 -I scripts/pentest/seventh/F7_fstab_checks.py` (28 checks, 0 failures) records what was compared, including the two nits listed under "unconfirmed".

#### Findings

#### R7-F-1. bash prints a whole private key before the format check, when a key line is one `set -a; . file` runs as a command

**Severity:** Low (the same class as R6-F-1: only the admin's own terminal, and only after a hand edit of a key line; the keys concerned are the deployer, the relayer, the attestor and the promo key).

**Attacker and preconditions.** No attacker. The admin edits a key line of `.env.mainnet` (or of the vault file it is copied from, with `sudoedit`) into one of these shapes, which none of the documented commands produce (§3 step 0's `echo "DEPLOYER_PRIVATE_KEY=$(cast wallet decrypt-keystore …)"` and `secret gen`/`secret set` write `KEY=0x…` lines):
- `DEPLOYER_PRIVATE_KEY= 0x…` (a space after the `=`): bash assigns the empty string and runs `0x…` as a command;
- `export DEPLOYER_PRIVATE_KEY = 0x…` or `export DEPLOYER_PRIVATE_KEY= 0x…`: `export` rejects `0x…` as "not a valid identifier", naming it;
- the key on a line of its own (a paste that broke the line after `KEY=`): a command;
- the key pasted twice with a space: the second copy is a command;
- in the interactive shell of §3 step 1 only: a line with a syntax error (a stray `)`, `(`, `;;`): interactive bash prints the whole offending line; the non-interactive scripts print only the token.

**Steps.** `scripts/deploy-all.sh` runs `set -a; . "$env_file"; set +a` (line 43) and only then the R6-F-1 format check (lines 54–57). The sourcing executes the file: a line in the shapes above makes bash print the key in its own error message (`.env.mainnet: line 11: 0x<key>: command not found`, ``export: `0x<key>': not a valid identifier``) before any check sees a value. `set -e` then stops the script (exit 127 or 1), so `cast` and `forge` never run; the key is already on the screen. The same line sources the file in `deploy-escrow.sh:17`, `deploy.sh:9`, `fund-testnet-wallets.sh:8` and in `docs/mainnet.md` §3 step 1 (`( set -a; . ./.env.mainnet; set +a …`), whose check covers only `RELAYER_PRIVATE_KEY`: a deployer line in these shapes (the line step 0 appends last) prints the deployer key and the subshell goes on to run `forge` (the relayer line is intact). On Ubuntu's interactive shell the message comes from `command_not_found_handle` (`/usr/lib/command-not-found`), with the same content.

**Impact.** The whole key on the admin's terminal (scrollback, a terminal log if any), exactly what R6-F-1's fix was meant to stop, for a class of malformed lines the fix can't see because they never become a value. No money; no other account sees it (`hidepid=invisible` is live and the error goes to the admin's tty). Low rather than Info for the sixth verifier's reasons: a leaked deployer or attestor key before launch means new pins and another audit round, and nothing in the guard or the nightly scan would flag a key the admin then pastes somewhere.

**Proof.** `bash scripts/pentest/seventh/F7_sourcing_prints_key.sh`. It copies the four scripts and `Networks.sol` into a temporary directory with stub `cast`/`forge`/`anvil` that log every call and exit 1, writes an env file with throwaway keys in each shape, runs `deploy-all.sh arbitrum-one` (launch mode and rehearsal mode), the four testnet scripts, and §3 step 1's subshell verbatim in an interactive bash (`env -i HOME PATH bash --norc -i`), and counts output lines that hold a whole key (every line it shows is redacted first). Result: PASS, 14 of 14 — 5 launch/rehearsal shapes each print the key once with 0 `cast`/`forge` calls; the 4 testnet scripts likewise; §3 step 1 prints the deployer key (space, or stray `)`) and then calls `forge` once, and prints the relayer key then refuses; the controls (a stray `)` non-interactively, the sixth run's CR case) print nothing.

**Root cause.** `scripts/deploy-all.sh:43` (and `deploy-escrow.sh:17`, `deploy.sh:9`, `fund-testnet-wallets.sh:8`, `docs/mainnet.md:133`): the env file is executed by bash before any line or value is checked; the R6-F-1 check at `deploy-all.sh:54–57` and `docs/mainnet.md:134` runs after.

**Suggested fix.** Check the file's lines before sourcing it, without printing any: refuse the file unless every line is blank, a comment, or `^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*=([^[:space:]'"]*|'[^']*'|"[^"]*")[[:space:]]*$` (what `yescrow-ctl`'s `line()` writes: plain, or single-quoted); report only the line number ("line N of $env_file isn't KEY=value: fix it by hand"). Put it before line 43 of `deploy-all.sh`, in the three testnet scripts, and in §3 step 1 before the `.`. Alternatively stop executing the file at all: read `KEY=value` lines in a loop and `export` each (the sixth run's suggestion (b)).

#### R7-F-2. §3 step 3's second-provider run of the launch gate is silent when it runs nothing, or runs against the provider step 2 already used

**Severity:** Info (no money, no key; a documented check does less than it says without saying so).

**Attacker and preconditions.** No attacker. The vault's `RPC_URL` line is not in the template's exact shape: `export RPC_URL=…`, an empty value, a single URL (for instance a paid endpoint alone, which §1 allows later), URLs separated by ", ", or a value `yescrow-ctl line()` single-quotes because it holds a `?` or `&`.

**Steps.** `docs/mainnet.md:151–157`: `RPC_URL=$(grep -E '^RPC_URL=' .env.mainnet | cut -d= -f2-)` then `for rpc in $(echo "$RPC_URL" | tr , ' ' | cut -d' ' -f2,3); do … forge script CheckDeployment … done`. With `export` or an empty value the loop runs zero times and prints nothing; with one URL `cut -d' ' -f2,3` prints the whole line (no delimiter), so the gate runs once against the same provider as step 2, the case the step exists to rule out ("so one provider can't fake the chain to it"); with ", " the third URL is dropped; a single-quoted value keeps a trailing `'` on the last URL, so that run fails loudly (visible, no harm). The admin's interactive shell has no `-e`/`-u`, so nothing stops.

**Impact.** The launch continues to §4 with the second-provider check unrun or run against the first provider, and the procedure reads as done. Everything else the gate checks ran in step 2 (`deploy-all.sh` runs `CheckDeployment` before recording); only the independent-provider half is lost.

**Proof.** `bash scripts/pentest/seventh/F7_step3_rpc_line.sh` (the two documented lines verbatim, in a temporary directory; the loop body records the URL it would hand to forge). PASS, 7 of 7: template 2 runs; `export` 0; empty 0; one URL 1 (the first provider); ", " 1; single-quoted 2 with a trailing quote; two `RPC_URL` lines 4.

**Root cause.** `docs/mainnet.md:151–152`: a `grep`/`cut` parse of a free-form line, a `for` over a word list that may be empty, no check that at least two providers other than step 2's ran.

**Suggested fix.** Build the list with the same parse `deploy-all.sh` uses after sourcing (`${RPC_URL//,/ }`), require at least two URLs beyond the first and say so otherwise, and echo each provider before its run. Or move step 3 into `deploy-all.sh`, which already knows the RPC list and fails on an empty one.

#### Unconfirmed

- **U-1. `fstab_units` ignores the generator's exit status.** `subprocess.run([FSTAB_GENERATOR, d, d, d], …)` at `ops/yescrow-ctl:713` checks neither `returncode` nor stderr. A generator that can't run gives two empty lists, the comparison passes, and the `/proc` line is appended with the guard effectively off (`F7_fstab_checks.py` records it with `/bin/false` as the generator). On this server it runs (rc 0, every unit produced), and a missing binary raises `FileNotFoundError`, which `main()` turns into a message, not a traceback. No lockout path. `returncode != 0 → die` would close it.
- **U-2. The re-read before the append doesn't recompute `in_fstab`.** `hide_processes` decides `in_fstab` at `:724` before the admin's second login and re-reads at `:742` only to append. A `/proc` line added by hand in between gets a second one (both applied in order at boot, same options), and a hand-added line *another way* in that window isn't refused. Harmless here; `in_fstab = proc_in_fstab(old)` at the re-read would close it.
- **U-3. `. "$env_file"` with a name without a slash looks in `$PATH` first.** bash's `.` searches `PATH` for a name with no `/` (`shopt sourcepath`, default on) before the current directory: `env_file=.env.mainnet` (`deploy-all.sh:27,43`) would source `$HOME/.foundry/bin/.env.mainnet` (the script prepends that directory) or `/usr/local/bin/.env.mainnet` if one existed. Reproduced with two files; no finding, since only root or the admin can write to the admin's `PATH` directories. `. "./$env_file"` removes the dependence.
- **U-4. Ubuntu's `command_not_found_handle`.** In §3 step 1's interactive shell the R7-F-1 message comes from `/usr/lib/command-not-found`, which looks the "command" up in a local database. I read no code path that logs or sends the name; not verified beyond that it printed the key and nothing else.
- **U-5. A `noauto` `/proc` line counts as done.** `proc_in_fstab` accepts `proc /proc proc noauto,hidepid=invisible,gid=proc 0 0`; whether `systemd-remount-fs` applies a `noauto` entry to an API file system I did not establish. Nobody writes such a line; the live file has none.
- **U-6. A paid RPC URL with a key in it.** `ARBITRUM_ONE_RPC_URL` goes on `cast`/`forge` command lines (the sixth run's U-5, hidden by `hidepid` now) and, new here, into forge's cache file `contracts/cache/DeployAll.s.sol/42161/run-latest.json.recovery.json` (an `rpc` field per transaction), which stays in the admin's clone (`contracts/cache/` is git-ignored; §3 copies only `broadcast/` and `deployments/`). The broadcast records hold no `rpc` field and no key. At launch every RPC is public.

#### Checked and found sound

**The sixth run's fixes, each re-run.**
- **R6-F-1 (the format check).** `F6_fixed_deploy_scripts.sh` PASS on this copy. The regex is evaluated on the sourced value, so every value forge would read is what is checked; `[[ =~ ]]` prints nothing; `$` is end-of-string in bash's ERE (a value with an embedded newline inside quotes is refused); the admin's shell is `env -i` (C locale), and glibc ranges are by code point anyway. The keys forge reads on the mainnet path are exactly the ones checked: `DEPLOYER_PRIVATE_KEY` (`DeployAll.s.sol:74`), `ATTESTOR_PRIVATE_KEY`, `PROMO_SIGNER_PRIVATE_KEY` (`:210,:214`) by `deploy-all.sh`, and `ROUTER_DEPLOYER_PRIVATE_KEY`=`RELAYER_PRIVATE_KEY` by §3 step 1 (`DeployRouter.s.sol:43`; the `DEPLOYER_PRIVATE_KEY` fallback at `:44` is unreachable while the relayer key is set, and an empty relayer value fails the regex first). `CheckDeployment.s.sol` reads no key. `cast wallet decrypt-keystore` (cast 1.8.5, throwaway keystore) prints one line `<name>'s private key is: 0x<64 hex>` to stdout, nothing to stderr; `awk '{print $NF}'` yields the key alone; a wrong password prints `Error: Mac Mismatch` on stderr with empty stdout, so step 0 appends `DEPLOYER_PRIVATE_KEY=` (refused by `deploy-all.sh:97` before forge). A well-formed key ≥ the curve order or zero passes the regex but `vm.addr` reverts naming the order, not the key (F6_KeyChecks). Successful `env*` reads are redacted in forge's traces, so a correct-shape but wrong attestor key fails `checkKeys` without showing it. What remains is R7-F-1: lines bash runs before the check.
- **R6-F-2 (the fstab step).** `ops/test-yescrow-ctl.py`: 0 failures. `F7_fstab_checks.py` on a copy of this server's real `/etc/fstab`: it ends with a newline; the live `/proc` line is found (`proc_in_fstab` True, so a second `lockdown` returns at `:726`: idempotent); without that line the generator makes `-.mount`, `boot-efi.mount`, `swapfile.swap`, `swapfile2.swap`, and with it the same four. The generator parses the `/proc` entry ("Found entry … where=/proc") and makes no unit for it (API file system), which is why the before/after comparison is meaningful; a glued last line (what `d451174` wrote) still changes the list and is refused. Parsing: tabs, a trailing comment and extra options count as done; `hidepid=2`, a numeric gid, `hidepid` alone and a 3-field line are refused as "another way"; a commented-out copy, an indented comment, a bind of `/proc` elsewhere and another mount point are ignored (the sixth run's U-3 is closed). `fstab_with_proc` puts the comment and the line on their own lines for every ending (`\n`, none, blank lines, empty file) and `new[len(old):]` is exactly that tail. Boot-time half (the sixth run's U-1): fstab options for `/proc` are applied by `systemd-remount-fs.service` (man page: "the kernel API file systems"; the binary references `mount_point_is_api` and runs `/usr/bin/mount -o remount`, whose libmount resolves `gid=proc`), pulled in by the generator at every boot (`UnitFileState=enabled-runtime`, `Result=success`); `/proc/mounts` shows `hidepid=invisible,gid=982` now. Not rebooted here. No lockout path: logind and polkit get the `proc` group by persistent drop-ins; sshd/sudo/cron run as root or need only their own `/proc` entries; the "no" answer remounts `hidepid=off` and writes nothing.
- **R6-F-3.** The four testnet scripts refuse `DEPLOYMENT=arbitrum-one` right after sourcing, before any `cast`; the marker is certainly in the vault file, since the mainnet compose file reads `deployments/${DEPLOYMENT:-arbitrum-sepolia}.json` (`docker-compose.yml:78`) and only `arbitrum-one.json` is in `/etc/yescrow/mainnet-deployments/`. `DeployAll.check` refuses `MAINNET_DEPLOYER` first thing when `!n.mainnet` (`F6_Fixed.t.sol` re-run, PASS), `Deploy.s.sol:48` the same after its chain-id check, `DeployRouter.s.sol:45` on every chain; `DeployTestUSDT.s.sol` has no such check but is run only from `deploy-escrow.sh:70`, behind the file refusal. A caller's `DEPLOYMENT=x` can't bypass it (the file's value wins at sourcing). The refusals can't hit a correct launch or rehearsal: both pass `arbitrum-one`, and the rehearsal's anvil fork has chain id 42161 (`n.mainnet` true); a Sepolia fork run with the mainnet key is refused, which is right.

**`docs/mainnet.md` §1–§5 for a key reaching a screen, log, file, command line or transcript.** Step 0: the key is in the shell's memory and the file only; `~admin/.bash_history` holds the substitution text, not its result. Step 1: `exit 1` ends only the subshell; `ROUTER_DEPLOYER_PRIVATE_KEY=$RELAYER_PRIVATE_KEY forge …` is an environment prefix, not an argument; keys and `ENV_FILE` no longer survive in the admin's shell. Step 2: a separate process. Step 3: `RPC_URL` is a public list. Files forge writes: `contracts/broadcast/*/42161/run-latest.json` (fields `chain commit libraries pending receipts recovery_generation returns timestamp transactions`; no key, no RPC URL), `*.recovery.lock` (empty), the cache's `run-latest.json.recovery.json` (transactions and the RPC URL; git-ignored, stays in `~admin/deploy`). §3 ends with `shred -u .env.mainnet`; the copied records carry nothing secret, so nothing reaches Claude's context or the repo. §4's `secret set` reads from `/dev/tty` with echo off; §4's proof and §5 use no key. The v0.34 `FOUNDRY_*`/`DAPP_*`/`contracts/.env` refusals are unchanged and still before sourcing.

**`contracts/script/`, the via-IR split, the pins, the promised addresses** (forge regressions re-run after the server was free, each as `FOUNDRY_DYNAMIC_TEST_LINKING=false systemd-run --user --scope -p MemoryMax=3300M -p MemorySwapMax=3G --quiet -- forge test --match-path <file>` from `contracts/`): `test/pentest/sixth/*.t.sol` (F6_Fixed 1/1, F6_KeyChecks 4/4 incl. the fork run); `test/BuildPins.t.sol` 1/1 (the release build equals the `Networks.sol` creation-code pins); `test/pentest/fifth/F5_ArbitrumOneToday.t.sol` 5/5 on Arbitrum One at the latest block (deployer nonce 0 and the promised addresses still free, the attestor pin fresh, the judge Safe as documented, the owner pin refusing a stranger's Safe); `test/pentest/F_DeploymentConstants.t.sol` 6/6 (tokens, Kleros core/resolver/courts/fees, router routes; the test build equals the deploy artifact; the router artifact is legacy and lands at `Networks.ROUTER`, a via-IR build would not); `test/pentest/F_DeployAllGuards.t.sol` 3/3 (every refusal, the router-first order, the mainnet defaults). 21 of 21 pass; `bea2011` changed nothing else on the contract side.

**Not done.** No reboot (the boot-time application of the `/proc` line is reasoned from the unit and the man page, not observed); the vault's real contents are root-only, so R7-F-2's shapes are what the template and `line()` can produce, not what the file holds; service changes were out of scope.

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

#### R7-F-1: running the env file prints a key — Verdict: CONFIRMED, Low (wider than reported)

**Verdict:** Confirmed, at Low.

**Severity: Low (unchanged).** The impact matches R6-F-1 (Low): a whole key appears on the admin's own terminal. In the scripts, `set -e` stops the run before `cast` or `forge`. In §3 step 1 nothing stops it: the subshell prints the key and then runs `forge` anyway. Like R6-F-1, this needs a hand edit of a key line. I checked that none of the documented writers can produce one of the shapes:
- `line()` writes either plain text from the PLAIN character class or a single-quoted value.
- `secret set` strips the value first.
- `secret gen` writes plain hex.
- §3 step 0 with cast 1.8.5 writes exactly one `KEY=0x…` line. I re-checked this with a throwaway keystore.
- If the vault file had no final newline, step 0's append would glue onto the last line. That makes a single assignment, and bash prints nothing.

The shapes are visible (a space, a broken line, a duplicate), unlike R6-F-1's invisible CR or non-breaking space. That makes them somewhat less likely. Also, docs/secrets.md:105 already shows the deployer key on that screen once, on purpose. Pulling the other way:
- step 1 keeps going after printing;
- two of the shapes survive the documented repair and are invisible to the vault tool (see the corrections);
- every secret in the vault is in scope, not just four keys.

On balance it stays Low, consistent with R6-F-1. The testnet-script half has no impact of its own.

**Proof result**
- **The proof:** `bash scripts/pentest/seventh/F7_sourcing_prints_key.sh` from the copy's root gives `PASS`, with 14 of 14 cases ok.
  - **`deploy-all.sh arbitrum-one`, launch and rehearsal:** 5 shapes each printed the key once and made 0 `cast`/`forge` calls (exit 127, or 1 for `export`).
  - **The 4 testnet scripts:** the same.
  - **§3 step 1:** the deployer key was printed for both the space and the stray `)` shapes, then `forge` ran once. The relayer key was printed, then refused.
  - **Controls:** a stray `)` run non-interactively and the CR case printed nothing.
- **My extra runs** (harness in `<scratchpad>/pentest7/verify-R7-F-1/`, files `verify.sh` and `t2.sh`):
  - `deploy.sh`, `deploy-escrow.sh USDC` and `fund-testnet-wallets.sh` with `ENV_FILE=.env.mainnet` and a bad key line: each exited 127 with 1 line holding the mainnet key and 0 `cast` calls. The key is printed before R6-F-3's refusal.
  - U-3 reproduced: `. "$env_file"` read the copy of `.env.mainnet` found in PATH, not the one in the working directory.

**Corrections to the finding**
- **Citations:** every cited line is real: deploy-all.sh:27, :43 and :54–57; deploy-escrow.sh:17; deploy.sh:9; fund-testnet-wallets.sh:8; docs/mainnet.md:127, :133 and :134.
- **"Before the format check" only fits deploy-all.sh (:54–57) and step 1 (docs/mainnet.md:134).**
  - The other three testnet scripts have no format check at all.
  - They already put keys on `cast` command lines (deploy-escrow.sh:27,32,40; deploy.sh:18; fund-testnet-wallets.sh:13,17,25).
  - Their `.env` keys are treated as public anyway (docs/secrets.md:36).
  - The only real testnet-script case is one the finding misses: a testnet script given mainnet's file prints the mainnet key before the R6-F-3 refusal (deploy-escrow.sh:19, deploy.sh:11, fund-testnet-wallets.sh:10). That takes two mistakes at once.
- **It isn't limited to the deployer, relayer, attestor and promo keys.** Bash runs every line whatever its name, and step 0 copies the whole vault. So the attestor pass key, `POSTGRES_PASSWORD`, `ETHERSCAN_API_KEY`, `RESEND_API_KEY`, `VAPID_PRIVATE_KEY` and `PINATA_JWT` are exposed the same way (.env.mainnet.example:23, 44, 67, 117, 120, 122).
- **Missed: two shapes survive the documented repair and are invisible to the vault tool.**
  - `set_in_file`'s pattern (ops/yescrow-ctl:123) doesn't match `export K = v` or a key on a line of its own. After `secret set` or `secret rotate`, the old key's line stays in the file; I tested this.
  - `parse_env` (:86) skips those lines too. So `retire()` (:396) doesn't save the old key, `secret list` doesn't show it, and `vault_secrets()`/`refresh_hashes()` (:154–161, :203–211) never hash it.
  - The result: the claude-guard and the nightly scan can't see it, and every later step 0 copy prints it again.
- **The severity sentence "nothing in the guard or the nightly scan would flag" is only partly true.**
  - It holds for the deployer, which leaves the vault per docs/secrets.md:105, and for the `export`, own-line and pasted-twice shapes.
  - It fails for the most plausible shape, a space after `=`, on a vault key. `parse_env` strips the space (:89), so the scan would find that key in a transcript.
- **The most plausible way to get there is still a hand edit:** `sudoedit` to change a setting (docs/secrets.md:58) opens the key lines in the same file.
- **"No other account sees it" depends on `hidepid`.** In Ubuntu's interactive shell (the step 1 path), `/usr/lib/command-not-found` gets the key as an argument. It then runs `/usr/bin/snap advise-snap --format=json --command <key>` (CommandNotFound.py:107–110, reached through `advise()` :349, since 66 characters is under the 256 limit at :304). That puts the key on two processes' command lines, R5-F-1's class, hidden from `teo` only by `hidepid=invisible`.
  - The tool itself does no logging or network calls; on a crash it writes only to stderr.
  - I didn't run it, because `/etc/bash.bashrc` is out of bounds. This partly answers U-4.
- **The proof's "step 1 verbatim" leaves out docs/mainnet.md:140** (`forge verify-contract`), so in the real procedure `forge` runs twice after the print. It also uses `bash --norc -i`, so the command-not-found path above is never exercised.
- **U-3 confirmed, and it hides a mismatch.** deploy-all.sh:20 prepends `$HOME/.foundry/bin` to PATH. Line 37's grep, and any line check added before line 43, read `./$env_file`, while `.` may load a different file found in PATH.
  - The suggested `. "./$env_file"` breaks an absolute `ENV_FILE`: it becomes `./` plus the absolute path, which bash reports as no such file.
  - No documented caller passes an absolute path (docs/mainnet.md:143, :166; rehearse-mainnet.sh:48).

**Fix assessment**
- **The regex (a), as written:**
  - It accepts every line of `.env.example` and `.env.mainnet.example`, with both LF and CRLF endings. It also accepts all 16 `line()` shapes, loaded from the copy's `ops/yescrow-ctl`.
  - It refuses all five shapes in the finding while printing only line numbers.
- **But (a) is incomplete; it still accepts:**
  - `K=0x<key>)` and `K=0x<key>(`, the finding's own step 1 shape. Interactive bash still prints the whole line.
  - `K=x;0x<key>`, `K=x<0x<key>` and `K=${0x<key>}`, which print the key non-interactively.
  - `$(…)` or backticks, unquoted or inside double quotes, which run commands.
  - Lowercase names. Sourcing shares the script's namespace, so `caller_confirm=yes`, `caller_require_kleros=false` or `rehearsal=…` overwrite the variables that deploy-all.sh:26–31 says "no env file can set". I demonstrated this.
- **A tightened regex works on everything I tested:**
  - The regex: ``^[[:space:]]*(export[[:space:]]+)?[A-Z_][A-Z0-9_]*=([^][:space:];&|<>()$`\\'"[]*|'[^']*')[[:space:]]*$``. It drops the double-quoted alternative; neither `line()` nor the templates write one.
  - It accepts both templates (LF and CRLF) and every `line()` shape, and refuses all 15 bad shapes I tried.
  - I couldn't check it against the real testnet `.env` (out of bounds); a hand-written line there could be refused.
- **The check itself must print nothing.** `grep -vnE` prints the offending line, key included. Use a `[[ =~ ]]` loop, or pipe through `cut -d: -f1`.
- **(b), not executing the file, is the better fix.**
  - I wrote a loader with `parse_env`'s grammar (ops/yescrow-ctl:86–91): skip blanks and comments, accept optional `export`, strip spaces, remove one level of quotes, `export` each value, refuse any other line by number.
  - It loads both templates to exactly the values sourcing gives (98 and 69 variables) and reads back all 16 `line()` values unchanged.
  - It never runs `$(…)` or backticks, and prints no key for any shape I tried. `KEY= 0x…` simply loads the key; the other shapes are refused by line number or by R6-F-1's later check.
  - Because it is the vault tool's own grammar, what the deploy reads is what the guard and scan hash.
  - Limit names to `[A-Z][A-Z0-9_]*`, as `secret` already does (:572), and keep the `FOUNDRY_*`/`DAPP_*` check.
- **Put it in one helper that all four scripts and §3 step 1 use**, or move step 1 into a script. Pasting a loop into an interactive shell is error-prone, and a script also removes the interactive-only cases (syntax-error lines, the command-not-found handler).
- **Fix U-3 at the same time** with `case $env_file in */*) ;; *) env_file=./$env_file ;; esac`, placed before both the check and the load, so the file checked is the file loaded.
- **Close the persistence gap in the vault tool.** `secret set`, `rotate`, `rm` and `lockdown --check` should refuse to proceed, or report by line number, when the vault file has a line that isn't blank, a comment or `KEY=value`. Otherwise a bad key line outlives the documented repair.

#### R7-F-2: §3 step 3's second providers — Verdict: CONFIRMED, Info

**Verdict:** Confirmed, at Info.

**Severity: Info, as the finding says.** That is already the lowest level, so it can't be lowered. No money or key is at risk. The step that loses its check runs after the immutable contracts are deployed. What it guards against is a lying or compromised RPC provider, which earlier runs placed outside the attacker list (third run U-2, fourth run U-1). Step 2 already ran the full gate before recording (`scripts/deploy-all.sh:171-173`, ahead of the record at `:224-239`). Step 2 also runs the blocking Arbiscan and Sourcify verification (`:176-196`), which reads the code and constructor arguments without going through the RPC. §3 then has the admin check the verified settings on Arbiscan by hand (`docs/mainnet.md:174-175`). Step 3 adds the independent re-read of the state that isn't immutable: Kleros's resolver listing and the simulated cases, the judge Safe's owners and threshold, and the router's code. The condition is also narrower than the finding says. The template's line works. A list written by `secret set` keeps at least one provider other than step 2's in every case but one: a single URL that is also step 2's own provider. Every shape that runs nothing needs `secret rm` or a hand edit. This is the same class as R4-F-I3, which was rated Info, so Info stands.

**Proof result.**
- `bash scripts/pentest/seventh/F7_step3_rpc_line.sh`, run from the copy's root: exit 0, `PASS`, 7 of 7 shapes as reported.
  - Template: 2 runs (PublicNode, dRPC).
  - `export RPC_URL=…`: 0 runs.
  - Empty value: 0 runs.
  - One URL (`arb1`): 1 run.
  - `", "` separators: 1 run (PublicNode).
  - Single-quoted value: 2 runs, the second on `drpc.org'`.
  - Two `RPC_URL` lines: 4 runs.
- The "nothing runs, nothing printed" half is fully shown. The "runs against step 2's provider" half is shown only for the value `arb1`. The proof never models step 2's provider; it only matches because the template also sets `ARBITRUM_ONE_RPC_URL` to `arb1`.
- My independent check: `bash scripts/pentest/verify/V7_F-2_step3_rpc_shapes.sh`, which I added to the copy. It runs `docs/mainnet.md:151-157` word for word in a clean bash without `-e`/`-u`, with stub `forge`/`cast` that only log the URL they get. It takes step 2's provider from `${ARBITRUM_ONE_RPC_URL%%,*}`, as `deploy-all.sh:70,94` does. Results:

| `RPC_URL` shape | Runs | Other than step 2's | On step 2's provider |
|---|---|---|---|
| Template | 2 | 2 | 0 |
| `export`, empty, missing (`secret rm`), indented | 0 | 0 | 0 |
| One URL equal to step 2's | 1 | 0 | 1 |
| One paid URL, `ARBITRUM_ONE_RPC_URL` unchanged | 1 | 1 | 0 |
| One paid URL, also set as `ARBITRUM_ONE_RPC_URL` | 1 | 0 | 1 |
| Paid URL first (`p,a,b,c`), `ARBITRUM_ONE_RPC_URL` unchanged | 2 | 1 | 1 |
| `", "` single-quoted, as `line()` writes it | 1 | 1 | 0 |
| Double-quoted value | 2 | 2 | 0 (the second run is on a URL ending in `"`, so it fails loudly) |

**Corrections to the finding.**
- **Step 2 doesn't use `RPC_URL`.** It uses `${ARBITRUM_ONE_RPC_URL%%,*}` (`scripts/deploy-all.sh:70,89,94`). Step 3's `cut -f2,3` assumes the first entry of `RPC_URL` is step 2's provider, which holds only in the template (`.env.mainnet.example:11-12`). So "one URL means the same provider as step 2" is wrong in general. A paid URL alone, with `ARBITRUM_ONE_RPC_URL` left as is, runs one independent provider. The proof's case labelled "a paid endpoint alone" actually uses `arb1` (`scripts/pentest/seventh/F7_step3_rpc_line.sh:31`).
- **The finding misreads §1 and misses the shape it actually invites.** `docs/mainnet.md:73-74` says a paid endpoint can go *first* in each list, not alone. That gives `RPC_URL=p,a,b,c`. With `ARBITRUM_ONE_RPC_URL` unchanged, step 3 then runs once on step 2's own provider and once on PublicNode, and dRPC is dropped. Nothing says so. `secret set` can write this shape, and it is more likely than any shape the proof tests.
- **Hand-edit shapes are reachable, but not through `line()`.** The report says its shapes are "what the template and `line()` can produce" (REPORT-F.md:66). `secret set` writes through `set_in_file` and `line()` (`ops/yescrow-ctl:579-592`, `:117-131`, `:96-104`). That produces a plain value, or a single-quoted one when the value holds anything outside `[A-Za-z0-9_\-./:@,+=~%]`: not only `?` and `&` but also spaces, `#`, `;` and `$`. It never writes `export`, never an indented line and never an empty value (`:587`, `:119-120`). It even rewrites an existing `export` or indented `RPC_URL` line into the plain form (`:123-124`). The tool can, however, remove the line: `secret rm` (`:597-600`) means 0 runs, a shape the proof doesn't test. The `export`, empty, indented, plain `", "` and two-line shapes need a hand edit. A hand edit is the documented way to change a mainnet setting (`sudoedit`, `docs/secrets.md:58-59`), so those shapes are reachable.
- **The plain `", "` shape can't reach step 3.** When the env file is sourced, `b,` runs as a command and fails (`No such file or directory`, status 127). Under `set -e` (`deploy-all.sh:18,43`), step 2 then exits, so the line must be fixed before step 3 can run. Only the single-quoted `", "` shape reaches step 3: 1 run, still an independent provider.
- **The trailing quote only lands on a run with 2 or 3 entries.** In a single- or double-quoted value it sticks to the last entry. With 4 or more entries that entry is field 4 or later, which `cut -f2,3` drops, so both runs are clean.
- **The proof's two-line case is mislabelled.** It says "the first provider's checks run twice" (`F7_step3_rpc_line.sh:34`). In fact PublicNode and dRPC each run twice and the first provider never runs. It is harmless.
- **The cited lines are real.** `docs/mainnet.md:151-157` and `:151-152` are the step-3 lines. `CheckDeployment` does run before the record is written (`deploy-all.sh:172-173` against `:224-239`). The R4-F-I3 history checks out (`reports/AI pentest, fourth run (2026-11-02).md:365,383`). The only regression for the fix, `scripts/pentest/verify/V4_F-1_info_checks.sh:109-117`, tests only the template, and it compares against `ARBITRUM_ONE_RPC_URL`, which is the right comparison.
- **What the finding leaves out of the impact.** The blocking Arbiscan and Sourcify verification, and §3's manual Arbiscan check, already read code and constructor arguments without trusting step 2's RPC. Losing step 3 costs only the second read of state that isn't immutable.

**Fix assessment.**
- **"The same parse `deploy-all.sh` uses (`${RPC_URL//,/ }`)":** that parse appears nowhere in the repository, and `deploy-all.sh` never reads `RPC_URL`. Using it would also mean sourcing the env file in the admin's own shell. That brings every key into that shell, which step 1's subshell exists to avoid (`docs/mainnet.md:131`), and it reopens R7-F-1's printed-key problem. Any parse that sources the file has to do it in a subshell.
- **"At least two URLs beyond the first":** this is the wrong test. The `p,a,b,c` shape passes it and still runs on step 2's provider, while a single independent paid URL would be refused. The test should leave out `${ARBITRUM_ONE_RPC_URL%%,*}`, compared by host, and count what is left.
- **"Echo each provider":** this is useful, but it should print the host only. A paid URL carries its API key in the path, and printing it whole adds to U-6.
- **Not covered:** a failed run doesn't stop the loop, whose exit status is that of the last run. The fix should stop at the first failure.
- **"Move step 3 into `deploy-all.sh`":** the script knows only `ARBITRUM_ONE_RPC_URL` (one URL in the template; its empty check is `:95`), not the `RPC_URL` list. It would need its own second-provider source and the same exclusion. Done that way, it would be an improvement: the independent check would block the record instead of running after it. But it changes a deploy script, so §1 gate 1 would require another targeted pentest round.
- **The simplest complete fix:** list the two public URLs directly in the step 3 loop (`https://arbitrum-one-rpc.publicnode.com https://arbitrum.drpc.org`). The comment already names them, and they are public, so no secret is exposed and no parsing is needed. Add one check that neither has the same host as `${ARBITRUM_ONE_RPC_URL%%,*}`, print each host, and stop on the first failure.
