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

A short targeted step 4 of `docs/ai-pentest.md` on commit `c0d4233`, the fixes for the seventh run
(`reports/AI pentest, seventh run (2026-11-06).md`), as the launch gate asks for a change to a deploy script
(`docs/mainnet.md` §1, gate 1). `c0d4233` replaced how every deploy script reads its env file (`scripts/lib/env.sh`,
`load_env`: read, never run as shell code), rewrote `docs/mainnet.md` §3 steps 1 and 3, and made `yescrow-ctl secret`
refuse a vault file with lines the loader refuses. No Solidity changed since `bea2011`, on which the whole suite, 845
tests, passed. One fresh attack agent on area F, scoped to those changes (the user's terms for this round, below); a
fresh verifier checked each finding. Proofs are under `scripts/pentest/eighth/`; the verifiers' are in
`scripts/pentest/eighth/verify/` and `scripts/pentest/verify/V8_F-5_step3_aliases.sh`.

**The terms of this round (the user's, 2026-11-06).** Runs 5 to 7 each found only Low and Info findings in the
launch procedure, each fixed in a deploy script, which the gate then asked a new round for. For this round the scope was
fixed to `c0d4233`'s changes, and the stop rule agreed before it ran: if it finds nothing above Low, its findings are
fixed with regression tests and the loop closes, with no ninth run; the dress rehearsal (`docs/mainnet.md` §1, gate 5),
which runs the launch scripts on the real vault copy, covers these last fixes. A Medium or worse would have been fixed
and attacked once more.

# Part 1. Summary

## Result

No Critical, High or Medium finding, and **no finding needs a contract change**. The seventh run's fixes hold: the
loader runs and prints no line, both templates load to the values the old sourcing gave, and `DeployAll`'s pins mean
no line of the env file can change a deployed setting. One Low and four Info findings survived verification, all in how
the scripts and the vault tool read the env file:

- **Low: R8-F-3.** The vault tool wrote a value holding a character Python's `splitlines` ends a line at (U+2028,
  U+2029, NEL, form feed…), then read its own file as two lines and refused every later `secret` command and starting
  the stack, until a hand edit. The verifier added that the nightly `backup all` stopped at the first stack that
  failed, so one stack's broken file stopped the other's backup, with no alert.
- **Info: R8-F-1** (lowered from Low). The env file could set `PATH`, `HOME`, `CDPATH` and the other names the shell,
  forge or python read, and so steer the launch past `deploy-all.sh`'s checks. Only the admin or root can write the
  file, and they hold the keys already; the old sourcing had the same property. The verifier found the suggested
  deny-list incomplete (proxies, certificates, `VERIFIER_URL`) and recommended loading only the names each step uses.
- **Info: R8-F-2** (lowered from Low). A note after a value (`DEPLOYMENT=arbitrum-one # mainnet`) became part of the
  value, so the testnet scripts' refusal of mainnet's file (R6-F-3) no longer matched. Its impact is R6-F-3's, which
  was Info.
- **Info: R8-F-4.** Lines the vault tool accepted but the loader refused (a leading no-break space, a U+2028 line),
  because the two used different notions of space and line; a correct launch would stop with a line number.
- **Info: R8-F-5.** §3 step 3's "not step 2's provider" check compared host strings, which another spelling of the
  same provider passed. The verifier recommended requiring step 2 to use Arbitrum's own 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 |
|---|---|---|---|---|---|
| R8-F-1 | The env file can set PATH, HOME, CDPATH and other names that steer the launch | Low | Confirmed, lowered: only the admin can write the file; not new; the deny-list is incomplete | **Info** | Each launch step loads only the names it uses; every caller refuses the names the shell, forge or python read; `unset CDPATH` first |
| R8-F-2 | A note after DEPLOYMENT turns off the testnet scripts' refusal of mainnet's file | Low | Confirmed, lowered to R6-F-3's Info; the stack reads the vault through the tool, not compose | **Info** | One strict value grammar (plain, or wholly quoted); the refusal by `DEPLOYMENT` containing `arbitrum-one` or `NEXT_PUBLIC_CHAIN_ID=42161` |
| R8-F-3 | The vault tool writes a value its own reader splits, then refuses the file | Low | Confirmed; also: `backup all` stops at the first failed stack, without an alert | **Low** | `line()` refuses such characters before anything is retired; one line splitting (`'\n'`) for the tool and the loader; `backup all` per stack, `--alert` |
| R8-F-4 | Lines the vault tool accepts but the loader refuses | Info | Confirmed: the fixes for R8-F-3 and R8-F-4 must land together | **Info** | One grammar on both sides: the C locale's spaces, `'\n'` lines, bytes checked for UTF-8 by line number |
| R8-F-5 | Step 3's provider check compares host strings | Info | Confirmed: require Arbitrum's own RPC for step 2 | **Info** | `deploy-all.sh` requires `ARBITRUM_ONE_RPC_URL=https://arb1.arbitrum.io/rpc` on Arbitrum One; step 3 checks the same |

Six ideas stayed unconfirmed (U-1 to U-6 in the area report), among them integer-typed bash names echoing a value
(now refused with the other shell names), `rehearse-mainnet.sh` reading the file with `grep` (now with the loader),
the locale dependence of the loader (now the C locale), and lone-CR files.

## Limits

- **Targeted and short**, on the scope above; nothing else was attacked in this round.
- **The last fixes weren't attacked**: by the agreed stop rule, the fixes for this round's findings are covered by
  their regression tests and by the dress rehearsal, not by a ninth run.
- **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.
- **No findings is not proof of no bugs** (the area report's "Checked and found sound" lists what was tried).

---

# Part 2. Area report, with each verification

## Area F: deployment

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

A short targeted round (`docs/mainnet.md` §1, gate 1; `docs/ai-pentest.md` step 4) on `CHANGES-since-fb66a52.diff`: the
env-file loader `scripts/lib/env.sh` (`load_env`) and its callers `scripts/deploy-all.sh`, `scripts/deploy-escrow.sh`,
`scripts/deploy.sh`, `scripts/fund-testnet-wallets.sh`; `docs/mainnet.md` §3 step 1 (the router, in a subshell using
`load_env`, with the relayer key check) and step 3 (the gate against PublicNode and dRPC, refusing step 2's provider by
host, stopping at the first failure); `ops/yescrow-ctl` `odd_lines`/`cmd_secret`, `fstab_units` (refuses when the
generator fails) and `hide_processes` (re-checks `/etc/fstab` after the second login). Everything ran on this local
export of `c0d4233` with throwaway keys, temporary directories and stub `cast`/`forge` that log their arguments,
directory and environment; the real `forge` (1.8.5) was run only as `forge config` (no build, no network). Nothing was
sent to any network; `contracts/src/` is untouched; no real `.env` file, vault file or key was read.

The questions set for this round, in short: `load_env` gives the scripts the vault tool's reading of a vault-written
file exactly (the two templates load to the same 97 and 68 values as the old sourcing; every `line()` shape reads back
unchanged), but on hand-edited lines its reading differs from the old sourcing and from docker compose in one way that
undoes an earlier fix (R8-F-2), the vault tool writes values its own reader then splits (R8-F-3), and the loader exports
the names bash and forge themselves read, which the scripts' checks don't see (R8-F-1). No line can be executed or
printed by the loader itself (the `$(…)`, backtick, `$VAR`, backslash and syntax-error shapes of the seventh run are
all literal text now, and U-3 of that run is closed: a redirection doesn't search `PATH`). The refusals can stop a
correct launch only on lines the vault tool accepts (R8-F-4, Info). Step 3 checks two providers and stops on failure;
its "not step 2's provider" rule is a string comparison that aliases pass (R8-F-5, Info). Nothing in the change can
make the one-shot deployment use a setting other than the vault's or `Networks.sol`'s pins: `DeployAll` pins every
immutable, including the template hash that covers `POLICY_URI`, and refuses any other build; a redirected `PATH`,
`CDPATH` or `HOME` (R8-F-1) changes which binaries and which script directory see the keys, not which settings a
genuine `DeployAll` would accept.

**Result.** No Critical, High or Medium finding; no contract change needed. Three Low and two Info findings, all in the
procedure around the contracts, each with a proof under `scripts/pentest/eighth/`. The seventh run's two findings are
fixed as claimed (its regression `scripts/pentest/seventh/F7_fixed_env_and_step3.sh` passes on this copy, as do
`F6_fixed_deploy_scripts.sh`, `F5_fixed_no_keys_on_command_lines.sh` and `python3 -I ops/test-yescrow-ctl.py`).

| ID | Finding | Severity | Proof |
|---|---|---|---|
| R8-F-1 | The env file can set `PATH`, `CDPATH` and `HOME` (and the other names bash and forge read): the launch then runs another `forge`/`cast`, another `script/DeployAll.s.sol`, or a build with another global `foundry.toml`, past `deploy-all.sh`'s checks | Low | `bash scripts/pentest/eighth/F8_env_names_steer_the_launch.sh` |
| R8-F-2 | A note after a value (`DEPLOYMENT=arbitrum-one # mainnet`) is part of the value for `load_env` (and the vault tool), not for the old sourcing or compose: the four testnet scripts no longer refuse mainnet's file (R6-F-3 undone for that shape) | Low | `bash scripts/pentest/eighth/F8_grammar_divergences.sh` |
| R8-F-3 | `secret set` writes a value with U+2028, U+2029, FF, VT, NEL, FS/GS/RS inside, which `parse_env`/`odd_lines` read as two lines: every `secret` command and `up mainnet` then refuse the file the tool wrote; `load_env` reads a third value | Low | `python3 -I scripts/pentest/eighth/F8_vault_line_splitting.py` |
| R8-F-4 | Lines the vault tool accepts that `load_env` refuses (a leading U+00A0, a U+2028 "blank" line, any non-UTF-8 byte in a UTF-8 locale, comments included): a launch or rehearsal stops on a file the tool said was fine | Info | `bash scripts/pentest/eighth/F8_grammar_divergences.sh` (second half) |
| R8-F-5 | §3 step 3's "not step 2's provider" check compares host strings: the same provider with a port, upper case, userinfo, dRPC's keyed `lb.drpc.org` endpoint or PublicNode's other hostname passes, and the gate runs again on step 2's provider while the step reads as done | Info | `bash scripts/pentest/eighth/F8_step3_provider_aliases.sh` |

Each proof is run from the copy's root; each prints `PASS` and exits 0 on `c0d4233` (run 2026-11-06). None prints a
key: every shown line is checked against the throwaway keys first.

#### Findings

#### R8-F-1. The env file can redirect the launch's `PATH`, `CDPATH` and `HOME` past `deploy-all.sh`'s checks

**Severity:** Low. The file's writer is the admin (or root) through `sudo yescrow-ctl secret set`, which accepts any
`[A-Z][A-Z0-9_]*` name, or `sudoedit`; no other account can write the vault or `.env.mainnet`, so no privilege is
gained. The hazard is a planted or copied line (an env file merged from another project, a `HOME=`/`PATH=` line kept
from a container template) that the launch follows silently while every documented check passes. The seventh run's
fix says "Names are upper case, so a file can't set a script's own lower-case variables"; the names bash and forge read
are upper case.

**Attacker and preconditions.** Whoever can put a line in the vault file or in the clone's `.env.mainnet` (admin,
root), or an operator mistake. The line is an ordinary `KEY=value` line, so `odd_lines` and `cmd_secret` accept it and
`secret list` shows only its name.

**Steps.**
1. `PATH=/x/bin:…` in the file. `scripts/deploy-all.sh:20` sets `PATH` before the load; `:43` `load_env` exports the
   file's `PATH` over it; `:46–57` check only `FOUNDRY_*`/`DAPP_*` and the key formats. Every `cast` and `forge` from
   `:89` on comes from `/x/bin`, with `DEPLOYER_PRIVATE_KEY`, `ATTESTOR_PRIVATE_KEY` and `PROMO_SIGNER_PRIVATE_KEY` in
   its environment. `docs/mainnet.md` §3 step 1 and step 3 load the same file into their subshells and then call
   `forge`, `cast` and `jq` by name.
2. `CDPATH=/y` in the file, with `/y/contracts/script/DeployAll.s.sol`. `cd contracts` at `deploy-all.sh:129` (and
   `cd contracts || exit 1` in steps 1 and 3) goes to `/y/contracts`, since bash's `cd` searches `CDPATH` before the
   current directory: forge compiles and broadcasts another `DeployAll.s.sol` with the deployer key.
3. `HOME=/z` in the file, with `/z/.foundry/foundry.toml`. `deploy-all.sh:39–41` refuses `$HOME/.foundry/foundry.toml`
   before the load, with the shell's `HOME`; after the load forge runs with `HOME=/z` and merges that file (forge 1.8.5
   merges `~/.foundry/foundry.toml` under the project's: a setting the project file leaves unset, such as `offline`,
   `solc`, `optimizer_details`, `extra_output`, takes effect). A changed build is refused later by `DeployAll`'s
   creation-code pins (`Networks.*_CREATION_HASH`), unless the `solc` it names is not solc.
4. The same holds for `LD_PRELOAD`, `LD_LIBRARY_PATH`, `BASH_ENV` (read by any bash forge or a script starts), `ENV`,
   `LC_ALL`/`LANG` (changes what `[[:space:]]` and `.` match for the lines that follow), `TMPDIR`, `PS4`,
   `POSIXLY_CORRECT`, `EXECIGNORE`, `GLOBIGNORE`, `PYTHONPATH` (`fund-testnet-wallets.sh` runs `python3`); each loads
   (exploration table in the "checked" section). Readonly names (`SHELLOPTS`, `BASHOPTS`, `UID`, `EUID`, `PPID`) make
   `export` fail, which the loader turns into a refusal without a message of its own.

**Impact.** The launch, the rehearsal and steps 1 and 3 run binaries and a script directory the file chose, with the
mainnet keys in their environment, while `deploy-all.sh`'s build and key checks, the hidepid lockdown and the
documented `env -i HOME PATH bash` shell all pass. A genuine `DeployAll` still refuses any setting or build off the
pins (so the contracts can't be deployed with other settings this way), but a substituted `forge` or `DeployAll.s.sol`
could print or use the keys. No money moves; nothing an outside attacker can reach.

**Proof.** `bash scripts/pentest/eighth/F8_env_names_steer_the_launch.sh` (PASS). On a copy of `deploy-all.sh`,
`scripts/lib/env.sh` and `Networks.sol` with stub `cast`/`forge` in two directories and a throwaway key: with `PATH=`
in the file every check passes and the file's `forge`/`cast` run with the deployer key in their environment; with
`CDPATH=` the `forge script script/DeployAll.s.sol` call runs in `$CDPATH/contracts`; with `HOME=` the
`$HOME/.foundry/foundry.toml` refusal passes and forge gets the file's `HOME`, and the real forge's `forge config
--json` shows `offline false → true` from a `foundry.toml` under that `HOME`.

**Root cause.** `scripts/lib/env.sh:19–21`: `export "$name=$value"` for every name matching `[A-Z][A-Z0-9_]*`, no
deny-list; `scripts/deploy-all.sh:20` (`PATH` set before the load, not after), `:39–41` (the `HOME` check before the
load, not repeated), `:129` (`cd contracts`, not `cd ./contracts`); `docs/mainnet.md` §3 steps 1 and 3 likewise.

**Suggested fix.** In `load_env`, refuse (by line number, as now) the names bash, the dynamic loader, forge and python
read: `PATH HOME CDPATH IFS ENV BASH_ENV SHELLOPTS BASHOPTS PS4 TMPDIR POSIXLY_CORRECT EXECIGNORE GLOBIGNORE` and
every name starting with `BASH_`, `LD_`, `LC_`, `LANG`, `PYTHON`, `FOUNDRY_`, `DAPP_`, `ETH_`, `SOLC` (the last five
replace `deploy-all.sh:37/46`'s grep, which then needn't run on the file). In `deploy-all.sh`, set `PATH` and check
`$HOME/.foundry/foundry.toml` after the load, and `cd ./contracts`; the same in §3 steps 1 and 3. `yescrow-ctl secret
set` could refuse the same names, so the vault never holds one.

#### R8-F-2. A note after a value is part of the value for `load_env`: `DEPLOYMENT=arbitrum-one # mainnet` turns off the testnet scripts' refusal of mainnet's file

**Severity:** Low (R6-F-3, which this undoes for one line shape, was rated Info; here the refusal is the only guard
before `cast wallet address --private-key <mainnet key>` runs, and the shape is one the vault tool, the old sourcing
and docker compose all accept without complaint, so the operator has no signal).

**Attacker and preconditions.** No attacker: the admin adds a note after a value with `sudoedit` (the documented way to
change a mainnet setting), e.g. `DEPLOYMENT=arbitrum-one # mainnet`, and later runs a testnet script with
`ENV_FILE=.env.mainnet` (the R6-F-3 slip). `secret set` never writes such a line (it quotes a value with a space), so
only a hand edit reaches it.

**Steps.** `load_env` (`scripts/lib/env.sh:13–18`) takes `(.*)$` after `=`, trims and unquotes: the value is
`arbitrum-one # mainnet`. The old `set -a; . file` gave `arbitrum-one` (bash ends an unquoted word at the space and
treats `#` as a comment); docker compose's env-file parser cuts an unquoted value at ` #` too, so the mainnet stack
reads `arbitrum-one` from the same vault file. The guard `[ "${DEPLOYMENT:-}" != arbitrum-one ]`
(`deploy-escrow.sh:19`, `deploy.sh:11`, `fund-testnet-wallets.sh:10`, `deploy-all.sh:50`) therefore passes, and each
script goes on: `deploy-escrow.sh:32` and `fund-testnet-wallets.sh` put the mainnet attestor or relayer key on `cast`'s
command line, `deploy-all.sh:98` the mainnet deployer key, `deploy.sh` runs `forge script Deploy.s.sol` against
Sepolia with the mainnet keys in forge's environment (where `Deploy.s.sol:48` refuses the mainnet deployer).

**Impact.** The R6-F-3 class, for that shape: mainnet keys on command lines (hidden from other accounts by
`hidepid=invisible` since 2026-11-06, so Low rather than more) and forge asked to deploy on Sepolia with them. On the
mainnet path the same shape is harmless by luck of the checks that follow: a note on `POLICY_URI` is refused by
`DeployAll`'s template hash (`DeployAll.s.sol:194–198`), on `JUDGE`/`FEE_RECIPIENT`/`YESCROW_ATTESTOR` by forge's
address parsing, on `ARBITRUM_ONE_RPC_URL` by the chain-id check, on a key by the format check. Two readers of one
vault file (`load_env`, `parse_env`) now disagree with the third (compose) on every unquoted value with ` #` in it.

**Proof.** `bash scripts/pentest/eighth/F8_grammar_divergences.sh` (PASS; the first half). It prints the three readings
of the line (`load_env`: `arbitrum-one # mainnet`; sourced: `arbitrum-one`; the vault tool: `odd_lines []`,
`parse_env 'arbitrum-one # mainnet'`), then runs the four testnet scripts on a mainnet-shaped file with that line and
stub `cast`/`forge`: none refuses; `deploy-escrow.sh USDC`, `fund-testnet-wallets.sh` and `deploy-all.sh
arbitrum-sepolia` each run `cast wallet address --private-key <a mainnet key>`; `deploy.sh` runs `forge script
Deploy.s.sol` with the keys in its environment.

**Root cause.** `scripts/lib/env.sh:13` (`=(.*)$`, then trim and unquote): an unquoted value may hold spaces and `#`;
the vault tool's `line()` never writes one (`ops/yescrow-ctl:108–113`: a value outside `PLAIN` is single-quoted), and
the two other readers of the file drop the note.

**Suggested fix.** Make `load_env` refuse an unquoted value that holds whitespace (by line number: "quote it"), which
is exactly what `line()` never writes, and keeps `KEY=0x… 0x…` and `KEY=value # note` from loading as one value.
`odd_lines` should refuse the same shape (`^\s*(?:export\s+)?[A-Z][A-Z0-9_]*=(?:[^'"\s]*|'[^']*'|"[^"]*")\s*$`), so the
vault tool, the scripts and compose agree on every line the tool accepts.

#### R8-F-3. The vault tool writes a value that its own reader then splits into two lines, and refuses the file afterwards

**Severity:** Low. No attacker; a pasted value. The tool locks itself out of the mainnet scope (every `secret` action,
including the agent's `gen` and `list`, dies with "fix them with sudoedit first") and `up mainnet` refuses to start the
stack, until a `sudoedit` of a root-only file; the launch scripts meanwhile read the whole value, so the three readers
give three values for one line. The guard's hashes (`refresh_hashes` → `parse_env`) are of the wrong value, so the
real one isn't redacted from the agent's output or found by the nightly scan.

**Attacker and preconditions.** The admin runs `sudo yescrow-ctl secret set mainnet NAME` and pastes a value with one
of the characters Python's `str.splitlines` splits on inside it: U+2028 (LINE SEPARATOR, common in text copied from
web editors and some JSON exports), U+2029, form feed, vertical tab, NEL (U+0085), FS/GS/RS (0x1C–0x1E). `getpass`
reads them as ordinary characters; `.strip()` removes them only at the ends.

**Steps.** `line()` (`ops/yescrow-ctl:108–113`) refuses only `'` and `\n`; the value is outside `PLAIN`, so it is
written single-quoted on one physical line: `NAME='p4ss<U+2028>w0rd'`. `parse_env` (`:86`) and `odd_lines` (`:105`)
iterate `text.splitlines()`, which splits at U+2028: line *n* is `NAME='p4ss` (an unmatched quote, so `parse_env`
gives `'p4ss`) and line *n+1* is `w0rd'`, which `odd_lines` reports. `cmd_secret` (`:567–570`) dies on every action;
`stack_env` (`:232–262`) dies at `write_env` ("NAME: a value with a quote or a line break is not supported"), so `up
mainnet` can't start; `load_env` (`read -r`, which splits at `\n` only) exports `p4ss<U+2028>w0rd`.

**Impact.** A self-inflicted lockout of the vault tool and the mainnet stack with a confusing message, and three
readings of one secret (the tool's, the scripts', compose's — the last never reached, since `stack_env` dies first).
If the value is a password the stack uses, the stack can't be started until the line is edited by hand. No money.

**Proof.** `python3 -I scripts/pentest/eighth/F8_vault_line_splitting.py` (PASS). It loads `ops/yescrow-ctl` as
`ops/test-yescrow-ctl.py` does, on a temporary vault: for U+2028, form feed and NEL it stores `p4ss…w0rd` through
`set_in_file`/`line()` as `secret set` does, then shows the file holds one line, `parse_env` returns `'p4ss`,
`odd_lines` names the next line, `cmd_secret list` dies with "fix them with sudoedit first", `stack_env('mainnet')`
dies with "a value with a quote or a line break is not supported", and `load_env` returns the whole value. Control:
`line()` refuses `\n` but accepts U+2028.

**Root cause.** `ops/yescrow-ctl:110` (`line()` refuses `'` and `\n` only) against `:86` and `:105`
(`str.splitlines()`), which split on seven more characters; `scripts/lib/env.sh:10` splits on `\n` only.

**Suggested fix.** `line()`: refuse any character in `'\r\n\x0b\x0c\x1c\x1d\x1e\x85  '` (or any `C0`/`C1`
control or `Zl`/`Zp` character), naming the name only. `parse_env`, `odd_lines` and `set_in_file`: iterate
`text.split('\n')` (with a trailing `\r` stripped), the loader's grammar, so the tool and the scripts count lines the
same way. The seventh run's test could add a `line()` round trip through `load_env` for every such character.

#### R8-F-4. Lines the vault tool accepts but `load_env` refuses: a launch or rehearsal stops on a file the tool said was fine

**Severity:** Info. No money, no key; a correct launch stops with a line number and "fix it by hand", and nothing is
sent before that (the loader runs first in every script and both §3 steps). The cost is the stop itself and a message
whose line the operator may not be able to see the fault in.

**Attacker and preconditions.** No attacker. A hand edit (`sudoedit`) or a paste that leaves (a) a no-break space
(U+00A0) at the start of a line, (b) a U+2028/U+2029 or other Unicode space on a line of its own, or (c) a byte that
isn't UTF-8 (a Latin-1 `é` in a comment, a cp1252 quote) anywhere in the file, when the loader runs in a UTF-8 locale
(a normal login shell; the documented launch shell `env -i HOME PATH bash` is the C locale, where (c) loads).

**Steps.** `odd_lines`'s `ENV_LINE` (`ops/yescrow-ctl:97`) uses Python's `\s`, which matches U+00A0, U+2028 and the
other Unicode spaces; `load_env` uses bash's `[[:space:]]`, which doesn't (glibc leaves the no-break spaces out of the
class, and in the C locale every multibyte space is just bytes). So (a) and (b) pass `cmd_secret` and `parse_env`
(`^\s*`) and are refused by `load_env` as "isn't KEY=value with an upper-case name". For (c), bash's `=~` in a UTF-8
locale fails to match `.` against an invalid byte, so even a comment line is refused; the vault tool itself can't read
such a file at all (`open(path).read()` raises `UnicodeDecodeError`, which `main()` turns into a codec message without
a line number).

**Impact.** A launch, rehearsal or step 1/3 refused on a file the tool accepted, with a line number pointing at a line
that looks fine (an invisible U+00A0, a "blank" line) or, for (c), the tool unable to say which line. Nothing unsafe.

**Proof.** `bash scripts/pentest/eighth/F8_grammar_divergences.sh` (PASS; the second half): for each shape it prints
the vault tool's verdict (`odd_lines []`, or the codec error) next to `load_env`'s refusal, and the control that the
Latin-1 byte loads in the C locale.

**Root cause.** `ops/yescrow-ctl:97` (`\s`, Unicode) versus `scripts/lib/env.sh:12–13` (`[[:space:]]`, locale-bound);
`ops/yescrow-ctl:84,104` (`open(path).read()` in strict UTF-8).

**Suggested fix.** `ENV_LINE` and `parse_env` with `[ \t]` instead of `\s`; read the vault file as bytes and refuse,
by line number, any line that isn't valid UTF-8 (or isn't ASCII, which is all the vault ever needs); run `load_env`'s
loop under `LC_ALL=C` (set it inside the function, locally) so its reading doesn't depend on the caller's locale.

#### R8-F-5. §3 step 3's "not step 2's provider" check compares host strings, which the same provider's other spellings pass

**Severity:** Info (the same class as R7-F-2 and R4-F-I3: a documented check does less than it says; no money, no key;
step 2 already ran the whole gate before recording).

**Attacker and preconditions.** No attacker. `ARBITRUM_ONE_RPC_URL` in the vault names dRPC or PublicNode in a form
other than the two template strings: `https://arbitrum.drpc.org:443/`, `https://ARBITRUM.DRPC.ORG`, dRPC's keyed
endpoint `https://lb.drpc.org/ogrpc?network=arbitrum&dkey=…` (the shape a paid dRPC plan gives, which §1 invites "a
paid endpoint can go first"), `https://user:pw@…`, or PublicNode's `arbitrum-one.publicnode.com`.

**Steps.** `docs/mainnet.md:155–160`: `host_of` strips the scheme and the path and compares the rest, as a string,
with `arbitrum-one-rpc.publicnode.com` and `arbitrum.drpc.org`. Any of the spellings above differs as a string, so
the loop runs the gate on both template URLs, one of which is the provider step 2 used, and prints "the launch gate
against …" for each as if independent.

**Impact.** The independent-provider half of step 3 is lost for that provider while the step reads as satisfied; the
other template provider still runs, so one independent check remains in every case.

**Proof.** `bash scripts/pentest/eighth/F8_step3_provider_aliases.sh` (PASS): step 3's block taken from the doc, run
with stub `forge`/`cast`; the two template spellings are refused (after 0 and 1 run), the five aliases are not and the
gate runs twice.

**Root cause.** `docs/mainnet.md:156` (`host_of`: `${1#*://}`, `${h%%/*}`) and `:160` (string inequality).

**Suggested fix.** Normalise before comparing (lower-case, drop userinfo and port) and refuse when the host ends in
`.drpc.org` or `.publicnode.com` (or, simpler, when step 2's URL contains `drpc` or `publicnode` anywhere); say which
provider was skipped and require that at least one run was on a provider other than step 2's.

#### Unconfirmed

- **U-1. Integer-typed names print their value.** `RANDOM=/zzz` in the file makes bash print `export: /zzz:
  arithmetic syntax error … (error token is "/zzz")` from `export` (`scripts/lib/env.sh:21`), the loader's one path
  that can echo a value; `OPTIND`, `BASH_COMPAT` and `BASH_XTRACEFD` behave alike. A `0x…` key is a valid arithmetic
  literal, so a key under such a name loads silently; an API key with `-` or `.` would be printed. Nobody names a
  secret `RANDOM`; recorded, not kept as a proof. The deny-list of R8-F-1 closes it.
- **U-2. `rehearse-mainnet.sh` reads `.env.mainnet` with `grep | cut`, not `load_env`** (`scripts/rehearse-mainnet.sh:13,30`):
  a single-quoted `ARBITRUM_ONE_RPC_URL` (one with `?` or `&`, which `line()` quotes) reaches anvil as `'https://…`,
  and an `export JUDGE=` line is missed. Both fail loudly (anvil never answers; "set JUDGE"), and the values that reach
  `deploy-all.sh` come from `load_env` anyway (the file's values win over the placeholders at `:61`). Not in `c0d4233`.
- **U-3. The procedure now depends on "last occurrence wins".** The agent may run `sudo yescrow-ctl secret gen mainnet
  DEPLOYER_PRIVATE_KEY` (any `*_PRIVATE_KEY` name not yet in the file; the deployer left the vault for its keystore),
  which plants a fresh random key under that name; §3 step 0 appends the real key last, and both `load_env` and
  `parse_env` take the last line, so the real key wins and `DeployAll` would refuse the planted one anyway. Shown in
  exploration (duplicates: both readers give the last value). Worth a line in the docs, and `gen` could refuse the
  names the deploy scripts read.
- **U-4. Locale dependence of the loader.** Under `LC_ALL=C.UTF-8` a U+2028 line is blank and a U+3000 is trimmed;
  under `env -i` (C) they are refused or kept. The documented shell is `env -i`; a login shell differs (R8-F-4 covers
  the refusing direction; the accepting direction changes no value the scripts check).
- **U-5. A value over 128 KiB loads and then every exec fails** ("Argument list too long", shown with a 200 KB value):
  loud, nothing silent.
- **U-6. `load_env`'s line numbers versus an editor's** agree for LF and CRLF files; for a lone-CR (old Mac) file the
  whole file is "line 1" and `KEY=1\rKEY2=2` loads as one value `1\rKEY2=2` (`parse_env` sees two lines). No such file
  is written by anything; R8-F-3's fix (split on `\n` everywhere) makes the two readers agree.

#### Checked and found sound

**`load_env` against the vault tool's reading (`parse_env`), the old sourcing and docker compose**, with an
exploration table of 26 line shapes (kept in the scratchpad; the decisive ones are in the proofs):
- Both templates load to exactly the values the old `set -a; . file` gave (97 and 68 variables, `env` output
  identical), so a vault-written file (the only kind the launch is meant to see) reads the same as before.
- Blank and comment lines skipped; `export` with spaces or a tab; CRLF trimmed (bash's `[[:space:]]` has `\r`);
  a missing final newline (`|| [ -n "$line" ]`); duplicates: last wins in `load_env`, `parse_env` and compose; `=`
  inside a value; one level of matching quotes, with spaces outside them trimmed first, unmatched quotes kept, `''`
  empty: identical to `parse_env` in every case. `$VAR`, `$(…)`, backticks, backslashes and a trailing backslash
  (a line continuation for the old sourcing, which joined `A=abc\` and `B=1` into `A=abcB=1`) are literal text, as
  in `parse_env`; `export "$name=$value"` assigns the string and evaluates nothing (the name is `[A-Z][A-Z0-9_]*`, so
  no subscript, no `declare` option). Lower-case and `_`-first names refused, matching `odd_lines` (the vault's
  `parse_env` would accept them silently, but `secret set`/`gen` never write one). A NUL byte is dropped by bash's
  `read` and kept by Python: no vault value has one. Trailing U+00A0: `parse_env` strips it (Python's `strip`),
  `load_env` keeps it, so a key with one is refused by the R6-F-1 check (`F6_fixed_deploy_scripts.sh` expects that)
  while the vault tool would derive the address from the clean key: the safe direction.
- Compose (compose-go's dotenv) differs from both on hand-edited shapes only: ` #` ends an unquoted value (R8-F-2),
  `$VAR` expands in unquoted and double-quoted values, `\n` escapes expand in double quotes, `KEY = v`, `KEY: v` and a
  bare `KEY` (inherited from the host) are accepted; `line()` writes none of these, `load_env` refuses the last three
  by line number and keeps the first two literal (an RPC URL with `${KEY}` then fails the chain-id check, loudly).
- The seventh run's U-3 is closed: `< "$file"` is a redirection, which never searches `PATH`; `. scripts/lib/env.sh`
  has a slash. An unreadable file or a directory makes `load_env` return 1 (the redirection fails; bash's message
  names the path, not a value), so step 1 and 3's `|| exit 1` and the scripts' `set -e` stop.
- The loader prints nothing but a line number and the file name (verified on every refused shape; the one exception
  is bash's own arithmetic error for an integer-typed name, U-1).

**The callers.** `deploy-all.sh`: the lower-case variables read before the load (`rehearsal`, `env_file`, `caller_*`)
can't be set by the file; the upper-case ones the file is meant to set are checked after (`FOUNDRY_*`/`DAPP_*` at
`:46`, `DEPLOYMENT` at `:50`, the three key formats at `:54`, `CONFIRM_MAINNET`/`RESUME`/`SKIP_VERIFY`/`REQUIRE_KLEROS`
restored at `:58–59`) or taken from pins (`deployer`, `PROMO_SIGNER`) or set on forge's command line (`ESCROWS`,
`VERIFIER`, `ESCROW_TXS`, `DEPLOY_TIME`). An empty `FEE_RECIPIENT=` in the file (the template's shape) is exported
empty, filled from the rehearsal's placeholder at `:61` with the export attribute kept, and refused at `:97` on a
launch. `PROMO_ENDS=` empty (the template) takes the `PROMO_DAYS` path; a non-empty one is taken as an explicit end,
which `DeployAll.s.sol:188–192` accepts only within an hour of 92 days from now. The only upper-case variable set
before the load and used after is `PATH` (R8-F-1). The three testnet scripts: the load is guarded by `[ ! -f ]`, the
`DEPLOYMENT` refusal is right after it and before any `cast` (`F6_fixed_deploy_scripts.sh` PASS; R8-F-2 is the one
shape that passes it).

**What reaches forge on the mainnet path.** `DeployAll` reads `FEE_BPS`, `REFERRER_SHARE_BPS`, the three windows,
`SMALL_DEAL_MAX_PRICE`, `PROMO_SIGNER`, `PROMO_ENDS`, `POLICY_URI`, `JUDGE`, `FEE_RECIPIENT`, `YESCROW_ATTESTOR`,
`TEST_USDT` from its environment and, on chain 42161, requires each to equal the `Networks.sol` pin
(`DeployAll.s.sol:153–199`: deployer, creation hashes of all three contracts, judge Safe and its owner, nonce 0, fee
recipient, promo signer, fee, share, limit, windows, 92-day promo end, and the two template hashes, which cover
`POLICY_URI`), checks the attestor and promo keys against the pinned addresses (`:204–216`), and ignores `TEST_USDT`
(`Networks.get` returns the mainnet record for 42161). `CheckDeployment` on mainnet takes only `ESCROWS`, `VERIFIER`,
`DEPLOY_TIME`, `REQUIRE_KLEROS` and `ESCROW_TXS` from the environment (`:72–99,107`), all set by `deploy-all.sh:172`
or step 3's command line except `ESCROW_TXS`, which step 3 leaves to the record (`creationArgs`, `:110–121`) and which
`:385` requires on mainnet. So no line of the env file can make the one-shot deployment use a setting other than the
vault's or the pins, or pass the gate with one (`BuildPins.t.sol` and the sixth and seventh runs' forge regressions
were not re-run: `c0d4233` changed no Solidity and the copy's build cache is the same).

**§3 step 1.** The subshell loads the file, checks `RELAYER_PRIVATE_KEY` (the only key `DeployRouter` reads while it
is set, `DeployRouter.s.sol:43`; an unset one is the empty string in the interactive shell, refused), `cd contracts ||
exit 1`, `forge … || exit 1`, then verifies. The key is an environment prefix, not an argument. `exit 1` ends the
subshell only; no key or `ENV_FILE` stays in the admin's shell. The interactive-only shapes of R7-F-1 (syntax errors,
`command_not_found_handle`) can't occur: bash no longer parses the file. `F7_fixed_env_and_step3.sh` PASS on this copy
(the doc's block taken verbatim, in `bash --norc -i`).

**§3 step 3.** Two providers named in the doc, public URLs; `cd contracts || exit 1`; each run `|| exit 1`, so the
second doesn't run after a failed first (F7 regression: 1 run on failure); `DEPLOY_TIME` is read from the provider
under test (a lying provider must lie consistently about the block time too) and an empty one (a failed `cast block`)
is refused by `vm.envUint`; `VERIFIER` and the creation transactions come from the record step 2 wrote, so a provider
that fed step 2 a wrong chain would be caught by the other two; `REQUIRE_KLEROS=true` explicit; the hard-coded
`ESCROWS` are the promised addresses. The only soft spot is the alias check (R8-F-5). The rate limits of two free
endpoints under `CheckDeployment`'s many calls would show as a stop, not a pass.

**`ops/yescrow-ctl`.** `odd_lines` runs before any `secret` action, `list` included, and names lines by number only
(`test-yescrow-ctl.py`: "naming them by number only" and the `0xab` absence check pass); its grammar equals the
loader's except for the whitespace class and line splitting (R8-F-3, R8-F-4). `set_in_file`'s pattern replaces
`export KEY=` and indented lines too, so a repaired file has one line per name. `secret set`'s name rule is the
loader's `[A-Z][A-Z0-9_]*`; `gen` is limited to `*_PRIVATE_KEY` with a hex value (U-3). `fstab_units` dies on a
non-zero generator exit before anything is written (`test-yescrow-ctl.py` with `/bin/false`, pass), and a missing
binary is an `OSError` that `main()` reports without a traceback. `hide_processes` re-reads `/etc/fstab` after the
second login and returns if a matching `/proc` line appeared, dies (nothing written) if one appeared another way, and
re-runs the before/after unit comparison on the fresh contents: the seventh run's U-1 and U-2 are closed. `main()`
catches every exception, so a `UnicodeDecodeError` from a non-UTF-8 vault file is a message (byte offset, no content).

**Regressions on this copy.** `scripts/pentest/seventh/F7_fixed_env_and_step3.sh`,
`scripts/pentest/sixth/F6_fixed_deploy_scripts.sh`, `scripts/pentest/fifth/F5_fixed_no_keys_on_command_lines.sh` and
`python3 -I ops/test-yescrow-ctl.py` all PASS (0 failures).

**Not done.** No forge build or test (none needed: no Solidity changed); docker compose was not run (its env-file
rules are stated from compose-go's parser, not executed here); the vault's real contents and the admin's real shell
were not read; no reboot.

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

#### R8-F-1: names in the env file that steer the launch — Verdict: CONFIRMED, lowered to Info

**Verdict:** confirmed at a lower severity.

**Severity: Info (lowered from Low).** The proof works and shows what it claims. But it gives nobody an access they don't already have, and it isn't new.
- **Only the admin or root can write such a line.** The agent account `teo` can only run `secret list` and `secret gen` (`ops/yescrow-ctl:576-581`). `gen` takes only names matching `^[A-Z][A-Z0-9_]*_PRIVATE_KEY$` (`:377`) and writes a random hex value, so `teo` can't write `PATH`, `HOME` or `CDPATH`. `secret set` needs the admin (`:583`), and `.env.mainnet` is mode 600 in admin's own clone (`docs/mainnet.md:126`). Those same people already hold every key: they decrypt the deployer in step 0 and can `sudo cat` the vault. They could also replace `~admin/.foundry/bin/forge` or edit the clone directly.
- **An accidental line can't leak a key on its own.** Someone with those rights also has to plant a binary or directory at the path the line names. A copied `PATH` without `~/.foundry/bin` fails loudly at `deploy-all.sh:89` (`cast` not found). A copied `HOME` at worst makes forge fetch a checksummed solc, which gives the same build, and the pins pass.
- **Compared with earlier Low findings:** R7-F-1 put a key on the screen after one accidental edit. R4-F-2 silently changed the deployed build from a stray shell variable. This finding needs a malicious second part as well.
- **It is not new.** It behaves the same with the sourcing the scripts used before c0d4233.

It is still worth fixing, because it bypasses R4-F-2's `$HOME/.foundry/foundry.toml` check.

**Proof result**
- `TMPDIR=<scratch> HOME=<scratch>/fakehome bash scripts/pentest/eighth/F8_env_names_steer_the_launch.sh` gave `PASS`, exit 0. All three cases passed: `PATH` (the planted forge and cast ran with the deployer key in their environment), `CDPATH` (`forge script` ran in `elsewhere/contracts`) and `HOME`. No key leaked.
- I skipped the real-forge `forge config` sub-check: you said not to run forge, so I pointed `HOME` at an empty directory. That forge reads `~/.foundry/foundry.toml` is already assumed by `deploy-all.sh:39-41` itself.
- **Old sourcing:** on a copy with `deploy-all.sh:43` put back to `set -a; . "$env_file"; set +a` (the line removed in `CHANGES-since-fb66a52.diff`), the same proof also passes, all 3 cases.
- **Suggested fix:** on a copy with the report's deny-list added to `load_env`, all three cases are refused (the proof prints FAIL, as it should).

**Corrections to the finding**
- **Wrong line cited:** the root cause names `scripts/lib/env.sh:19-21` as the export. The `export` is at `:22` (bash's own error message names "line 22"); `:19-21` trim the value and strip quotes, and the name rule is `:13`.
- **Not stated as pre-existing:** the old `set -a; .` sourcing (`deploy-all.sh:43`, the three testnet scripts, `docs/mainnet.md` step 1) exported `PATH`, `HOME` and `CDPATH` the same way and could also run code. R4-F-2's `HOME` check at `:39-41` could be bypassed this way from the day it was added. c0d4233 narrowed the problem; it didn't cause it.
- **"Follows silently" is overstated for `CDPATH`:**
  - bash's `cd` prints the foreign directory on stdout (at `deploy-all.sh:129`).
  - `:215`, `:216` and `:229` also run `cd contracts` inside `$(…)`, so that printed path ends up in the record's tx fields.
  - After the `cd ..` at `:197`, the record is written to `$CDPATH/deployments/`, not into the clone. The admin's later `sudo install -m 644 deployments/arbitrum-one.json` (`docs/mainnet.md:179`) would then fail.
  - Only the `PATH` case, with a forge that passes calls through to the real one, is really silent.
- **Impact is wider than the three keys named:** `load_env` exports every name in the file. So every vault secret (`RELAYER_PRIVATE_KEY`, `ATTESTOR_PASS_PRIVATE_KEY`, `VAPID_PRIVATE_KEY`, `RESEND_API_KEY`, `PINATA_JWT`, `POSTGRES_PASSWORD`, `ETHERSCAN_API_KEY`; see `.env.mainnet.example`) is in the environment of every child process.
- **`HOME` reaches more than `foundry.toml`:**
  - jq 1.8.1 loads `$HOME/.jq` and lets it override built-ins; I tested this with `select`. `addr` and `txh` (`deploy-all.sh:151-152`) use `select`, and txh's DualRdapVerifier hash (`:229`) goes into the record unchecked. jq can also read the keys through `$ENV`.
  - svm's solc location under `HOME` (or `XDG_DATA_HOME`) is another route; I didn't verify it, since forge wasn't run.
- **What the pins catch is overstated:** the creation-code pins (`DeployAll.s.sol:157-159`, `CheckDeployment.s.sol:262-264`) only bind a genuine forge running the clone's own scripts.
  - Under `PATH` or `CDPATH` they catch nothing: forge itself, or `DeployAll`, `CheckDeployment` and `Networks.sol`, all come from the planted copy. Step 3 is redirected the same way (`docs/mainnet.md:157`).
  - Under `HOME` they catch build changes, but not a global `[etherscan]` URL for the verify step, nor the jq route.
- **Minor:** `BASH_ENV` matters only where a new bash is started, and forge doesn't start one. The readonly names do refuse, but bash's message names `env.sh` line 22, not the env file's line. No value is printed.

**Fix assessment**
- **It works for what was shown:** the proposed deny-list closes `PATH`, `HOME` and `CDPATH`, and also U-1 (`RANDOM`).
- **But it is incomplete.** These names all still load through the patched loader:
  - `HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, `NO_PROXY`
  - `SSL_CERT_FILE`, `SSL_CERT_DIR`
  - `XDG_DATA_HOME`, `XDG_CONFIG_HOME`
  - `VERIFIER_URL`, `VERIFIER_API_KEY`, `RUST_LOG`
  - `GCONV_PATH`, `LOCPATH`, `OPENSSL_CONF`, `CURL_CA_BUNDLE`

  I found all of the names in the first four lines inside the forge 1.8.5 binary with a text search; I didn't confirm forge's behaviour by running it.
  - A proxy plus a trusted certificate would sit between forge and every RPC provider, including step 3's two "independent" ones, since step 3 loads the same file.
  - `VERIFIER_URL` would send the source verification somewhere else, which could satisfy the R2-F-1 "verification must pass" rule falsely.
- **Recommend an allow-list per caller instead:** `load_env FILE NAME…` exports only the listed names. Step 1 needs `RELAYER_PRIVATE_KEY` and `ARBITRUM_ONE_RPC_URL`; step 3 needs only `ARBITRUM_ONE_RPC_URL`. `deploy-all.sh` needs its own names plus the `vm.env*` names `DeployAll` reads (FEE_*, windows, `SMALL_DEAL_MAX_PRICE`, PROMO_*, `POLICY_URI`, `JUDGE`, `FEE_RECIPIENT`, `YESCROW_ATTESTOR`, the three keys, `TEST_USDT`, `ETHERSCAN_API_KEY`). This is complete by construction, and it also keeps unrelated service secrets out of forge's environment. The testnet scripts can keep the broad loader plus the deny-list.
- **`HOME` must be refused, not just re-checked after the load:** a re-check of `$HOME/.foundry/foundry.toml` alone misses `~/.jq`, `~/.svm` and the caches. Once `PATH` and `HOME` are refused, moving `:20` and `:39-41` after the load adds nothing.
- **`cd ./contracts` at `:129` alone is incomplete:**
  - `:215`, `:216` and `:229` need it too, as do `docs/mainnet.md:136` and `:157`.
  - A single `unset CDPATH` at the top of the script and in both subshells is simpler.
  - It also covers a `CDPATH` from admin's `~/.bashrc`: the documented `env -i HOME="$HOME" PATH="$PATH" bash` (`docs/mainnet.md:118`) is an interactive shell that reads `.bashrc`, and steps 1 and 3 are its subshells. `load_env` can't see that.
- **`FOUNDRY_*` and `DAPP_*` in `load_env`:** refusing them changes the testnet scripts. Keep the `FOUNDRY_BROADCAST` exception at `:37`, or limit the refusal to arbitrum-one.
- **Validate before exporting:** `load_env` exports earlier lines before it refuses a later one. The scripts stop before running anything, so this is harmless, but checking every line first and then exporting would be cleaner.
- **`secret set` refusing the names helps, but isn't enough:** `sudoedit` bypasses it. The vault also feeds the containers (`docker-compose.yml:72`), and the `RESERVED` filter (`ops/yescrow-ctl:33`) covers only the agent's settings file, not the vault.

My harness and the patched copies are in `<scratchpad>/v81/` (`oldroot/` has the old sourcing, `fixroot/` the deny-list).

#### R8-F-2: a note after DEPLOYMENT — Verdict: CONFIRMED, lowered to Info

**Verdict:** confirmed at a lower severity (Info, not Low).

**Severity: Info.** The impact is exactly R6-F-3's, which was rated Info: the testnet scripts run on mainnet's file, a mainnet key sits on a short-lived `cast` command line that `hidepid=invisible` hides from other accounts, and forge simulates on Sepolia where `Deploy.s.sol:47` and `DeployAll` refuse the mainnet deployer. Nothing happens on Arbitrum One. The preconditions are strictly narrower than R6-F-3's. All of these must hold:
- The admin types `ENV_FILE=.env.mainnet` with a testnet script. Since step 1 now runs in a subshell, nothing exports `ENV_FILE`.
- Someone has hand-edited a note onto the one `DEPLOYMENT` line.
- For three of the four scripts, a Sepolia RPC is exported in a shell that the docs start clean.

The finding gives two reasons for Low, and neither holds:
- "The refusal is the only guard": R6-F-3 had no guard at all and was still Info.
- "The operator has no signal": wrong on the server (see the compose correction below). After the documented `sudoedit`, the next `up mainnet service` breaks the mainnet service.

Under the rubric (no funds, deadlines or fees), this is the R7-F-2/R8-F-5 class: a documented check does less than it says.

**Proof result**
- `TMPDIR=<scratch> bash scripts/pentest/eighth/F8_grammar_divergences.sh` from the copy's root: **PASS**, exit 0.
  - The three readings are as claimed: `load_env` gives `arbitrum-one\ #\ mainnet`, sourcing gives `arbitrum-one`, and the vault tool gives `odd_lines []` / `parse_env 'arbitrum-one # mainnet'`.
  - None of the four scripts refuses. `deploy-escrow.sh`, `fund-testnet-wallets.sh` and `deploy-all.sh arbitrum-sepolia` each log `cast wallet address --private-key <a mainnet key>`. `deploy.sh` runs `forge script Deploy.s.sol`. No key is printed.
- My check: `bash scripts/pentest/eighth/verify/R8F2_preconditions.sh`. It uses a mainnet file shaped like the template, plus the appended deployer line, and names each key it finds.
  - **(A) Documented shell, no Sepolia RPC:** only `deploy-escrow.sh` reaches a key (the attestor key, at `:32`, then it stops at `:34`). `deploy.sh` stops at `:20`, `fund-testnet-wallets.sh` at `:11` and `deploy-all.sh` at `:95`, each before any key and before any `cast` call.
  - **(B) Sepolia RPC exported:**

    | Script | Keys on the command line |
    |---|---|
    | `deploy-escrow.sh` | attestor, promo signer |
    | `fund-testnet-wallets.sh` | relayer, deployer (in a `cast send`) |
    | `deploy-all.sh` | deployer (promo signer too with the real `cast`, since the stub exits at `:98`) |
    | `deploy.sh` | none (forge only) |

  - **(C) Control, plain `DEPLOYMENT=arbitrum-one`:** all four refuse and run `cast` 0 times.
- `python3 -I scripts/pentest/eighth/verify/R8F2_stack_reading.py` copies `stack_env`'s merge in a temp directory. The file compose reads gets `DEPLOYMENT='arbitrum-one # mainnet'`.

**Corrections to the finding**
- **"Compose … reads `arbitrum-one` from the same vault file" is wrong for the server.**
  - Compose never parses the vault file. `yescrow-ctl` `stack_env` (`ops/yescrow-ctl:231–261`) reads it with `parse_env`, which keeps the note. `write_env`→`line()` (`:108–113`) then rewrites it single-quoted, and compose takes a single-quoted value literally.
  - So `docker-compose.yml:78` gives the service `DEPLOYMENT_FILE=/app/deployments/arbitrum-one # mainnet.json`, and `service/src/config.ts:176–177` (called at the top of `service/src/main.ts:18`) throws. The mainnet service crash-loops.
  - On the server, `load_env` now agrees with the stack. The old sourcing was the odd one out. "Disagree with the third (compose) on every unquoted value with ` #`" holds only for a direct `docker compose --env-file .env.mainnet`, the stale header at `.env.mainnet.example:2–3`.
  - Compose-go's cut is on a space then `#` only, not a tab. That is from its parser source; I did not run compose.
- **"No signal" is wrong for the documented edit.** `docs/secrets.md:58–59` says to `sudoedit` and then run `sudo yescrow-ctl up mainnet service`, and that run takes the service down. The silent window is before launch: §3 comes before §4, and nothing reads `DEPLOYMENT` yet. A hand edit of the clone's copy is also silent.
- **The Sepolia RPC precondition is missing.** It is part of R6-F-3 and absent from this finding. The proof supplies it (`F8_grammar_divergences.sh:75`). It also pads the file with `RELAYER_TEST_ONLY`/`TESTNET_*` keys (`:43–46`) that the mainnet template doesn't have.
- **Keys the finding leaves out:**
  - the promo key, at `deploy-escrow.sh:40` and `deploy-all.sh:113`;
  - the mainnet deployer key in `cast send` at `fund-testnet-wallets.sh:17`. The finding names only "the attestor or relayer" for that script.
- **Line references:**
  - `scripts/lib/env.sh:13–18` should be `:13–21`: the trim is at `:19–20` and the unquote at `:21`.
  - `Deploy.s.sol:48` should be `contracts/script/Deploy.s.sol:47`.
  - All other cited lines check out.
- **Shapes the finding misses, where every reader agrees but the guard still passes:**
  - `DEPLOYMENT=arbitrum-one#mainnet` (no space);
  - a note typed into `secret set`, which `line()` writes as `DEPLOYMENT='arbitrum-one # mainnet'`. So "only a hand edit reaches it" holds only for the divergent shape.
  - The brittle part is the guard's exact string match, not only the grammar.
- **`'arbitrum-one' # 'mainnet'` is one more divergent shape.** Sourcing and compose read `arbitrum-one`; `load_env` reads `arbitrum-one' # 'mainnet`.

**Fix assessment** (tested on patched copies by `scripts/pentest/eighth/verify/R8F2_fix_check.sh`; `scripts/lib/env.sh` is unchanged)
- **The finding's `load_env` rule (refuse an unquoted value holding whitespace)** refuses the space, tab and `"…" # note` shapes. It misses `'arbitrum-one' # 'mainnet'`, which the one-level unquote treats as quoted.
- **The finding's own strict grammar applied inside `load_env`** (`[^'"\s]*`, `'…'` or `"…"`) refuses all the divergent shapes. Both templates load to identical values (hashes equal), and all 16 value shapes `line()` writes load exactly as `parse_env` reads them. Use this form.
- **The `odd_lines` half only runs in `cmd_secret`** (`ops/yescrow-ctl:569`), not in `up`/`stack_env`. To refuse the line at the documented post-edit step, `stack_env` should check it too.
- **Don't strip notes in `load_env` alone "as compose does".** The stack reads `parse_env`'s value, so that would recreate the scripts-versus-stack split that c0d4233 removed. Stripping is only safe if `parse_env` does the same, with compose's space-only `#` rule. Refusing is simpler.
- **The fix is incomplete for the guard itself.** `arbitrum-one#mainnet` and a quoted note still pass. Make the refusal independent of one exact value. Options:
  - match `*arbitrum-one*`;
  - also refuse `NEXT_PUBLIC_CHAIN_ID=42161`;
  - also refuse a `YESCROW_ATTESTOR` equal to the `Networks.sol:46` pin;
  - the sixth run's suggestion: refuse any `ENV_FILE` other than `.env` unless explicitly allowed.
- **`deploy-escrow.sh`:** move the RPC check at `:34` above `:32`. Then a missing Sepolia RPC stops it before any key, as it already does for the other three scripts.

All three verifier scripts are in `<scratchpad>/pentest8/F/scripts/pentest/eighth/verify/`.

#### R8-F-3: the vault tool splits its own value — Verdict: CONFIRMED, Low

**Verdict:** confirmed at Low, the same severity the report gives. The finding misses several effects, most importantly that nightly database backups stop.

**Severity: Low.** There is no attacker. Only the admin (or root) can write the vault, and the agent has no way to put such a character into it. The agent's `.env` is read through the same `parse_env`, which has already split the line before anything is written. A character only matters if it sits *inside* the value, because `getpass(...).strip()` (`ops/yescrow-ctl:597`) removes it at either end. No API key, JWT, hex key, URL or generated password contains these characters, so a value with one inside is already a broken credential. The tool's flaw is that it takes that value, says "stored", and then locks itself out. No money is involved and no deal deadline moves: the running stack is untouched and the keeper keeps running. That rules out Medium, which needs griefing, delays past promised deadlines, or lost fees.

I don't drop it to Info either. While the line is in place, every compose-based command for that stack fails, and the nightly `backup all` fails silently. A split line in the testnet vault also stops the mainnet backup (details below). The backup unit has no failure alert, so mainnet backups could stop for days unnoticed.

**Proof result**
- `python3 -I scripts/pentest/eighth/F8_vault_line_splitting.py` from the copy's root printed PASS and exited 0. For U+2028, form feed and NEL it shows:
  - The value is written as one line.
  - `parse_env` reads back `'p4ss`.
  - `odd_lines` reports `[4]`.
  - `secret list` refuses with "fix them with sudoedit first".
  - `stack_env('mainnet')` refuses with "a value with a quote or a line break is not supported".
  - `load_env` reads the whole value.
- The proof calls `set_in_file` directly, not the real `set` path. My own end-to-end run, `scratchpad/verify-R8-F-3/e2e.py`, ran `cmd_secret set` with `getpass` and `/dev/tty` patched, and all checks passed:
  - The tool prints `stored (59 characters)`, so the admin isn't warned when the value is written.
  - The old value went to `retired/` before the write.
  - The full new value is on one physical line of the file, so `sudoedit` shows it intact.
  - The guard's hashes are of the quoted first half, not of the real value or its second half.
  - A second `secret set` to repair it is refused.
  - `status` (no stack), `logs mainnet`, `restart mainnet` (the agent's command), `up mainnet` and `backup mainnet` all fail before any mainnet docker command is sent.
  - With only the testnet vault split, `backup all` fails on testnet and never attempts mainnet's `pg_dump`.
- On a real pty, `getpass` returns U+2028, U+2029, FF, VT, NEL, GS and RS unchanged (`scratchpad/verify-R8-F-3/getpass_pty.py`).

**Corrections to the finding**
- `ops/yescrow-ctl:110` (root cause) should be `:111`. Line 110 is the docstring; the check that refuses `'` and `\n` is on 111.
- `parse_env` "(`:86`)" should be `:85`, where `splitlines` is called; 86 is the regex.
- `cmd_secret` "`:567–570`" should be `:569–571`.
- `stack_env` "`:232–262`" should be `:231–261`, with `write_env` at `:260`.
- `remove_from_file` (`:144`) also splits with `splitlines` and isn't mentioned. `set_in_file` is at `:131`. The other cited lines are correct: `:105`, `:108–113`, `:597` and `scripts/lib/env.sh:10`.
- FS (0x1C) cannot get into a value through `set`. It is the terminal's quit character (default `^\`), so on a pty the tool is killed by SIGQUIT and nothing is written. In addition, many terminals strip C0 control characters from pasted text (xterm does by default). The realistic characters are U+2028, U+2029 and NEL.
- "Common in text copied from web editors" overstates the risk. A trailing U+2028 is removed by `.strip()`, and an interior one means the copied token was already corrupted.
- "The real one isn't redacted … or found by the nightly scan" is true, but nothing ever delivers the full value where `teo` could see it. `stack_env` refuses, so no container receives it; only the admin's launch shell reads it, through `load_env` of `.env.mainnet` (`docs/mainnet.md:126`). There is no leak path.
- The impact understates how far the lockout reaches. Every command that goes through `compose_cmd` → `stack_env` fails for that stack: status, logs, restart, stop/start, build, exec, psql and backup (`:430`, `:434`, `:443`, `:449`, `:455`, `:462`, `:470`, `:476`, `:501`).
- **Missed effect on backups.** The nightly `yescrow-backup.service` runs `backup all`. It loops over testnet then mainnet and stops at the first error (`:493–495`), and the unit has no `OnFailure=`. So a split line in `testnet.secrets.env`, where pasted Resend, Pinata and Etherscan keys live, also stops mainnet's backup.
- **Missed effect on the ops scope.** If the line is in `ops.env`, `mail`, `pin` and the scan's alert email use the quoted first half and get a 401 (`:531`, `:552`, `:834–841`).
- `up mainnet` really does refuse, and nothing else happens to the running stack: no docker command is sent, the containers keep their environment, and `restart: unless-stopped` doesn't re-read the env file.
- Recovery needs only `sudoedit`, and nothing is lost: the previous value is in `retired/` (`retire` at `:603` runs before `set_in_file` at `:604`), and the new value is intact in the file.
- Related, but outside this finding: the `\n` check in `line()` can never trigger from `set`. `getpass` keeps only the first line of a multi-line paste, and its terminal restore (`TCSAFLUSH`) drops the rest, which I confirmed on a pty. A multi-line paste is therefore stored truncated, with only the "(N characters)" count as a hint.

**Fix assessment**
- **The `line()` refusal works.** I tested each of `\r \n \x0b \x0c \x1c \x1d \x1e \x85 U+2028 U+2029`: every one is refused and the file is unchanged.
  - The report's character string contains literal U+2028 and U+2029, which render as spaces. Write them as `\u2028\u2029`.
  - A simpler test is `v.splitlines() != [v]`, or refusing Unicode categories Cc, Zl and Zp.
  - For `secret set`, accepting only printable ASCII without spaces (`[\x21-\x7e]+`) would also work. Every value in both templates fits that, and it would additionally cover R8-F-4's U+00A0 and non-UTF-8 bytes.
  - Run the check before `retire()` (`:603`) so a refused paste doesn't leave a stray file in `retired/`.
- **Reading with `split('\n')` is incomplete as written.**
  - `open(path)` uses universal newlines (`:84`, `:104`, `:131`, `:144`), which turns a lone `\r` into `\n` before the split. A lone-CR file therefore still reads as `K=1`, `K2=2` in `parse_env` but as one value in `load_env`, so U-6's "makes the two readers agree" is false. The fix needs `open(..., newline='')`.
  - `read_agent_file` (`:74`) needs the same line-splitting helper.
  - `remove_from_file` (`:144`) has to change as well.
  - A plain `split('\n')` in `set_in_file` or `remove_from_file` keeps the trailing empty string and adds a blank line on every write. I measured `'A=1\n\nB=2\n\n\n'` after three writes. The empty last element has to be dropped.
- **With both parts applied,** a hand-edited U+2028 inside a value passes `odd_lines` and reads whole. The next `up` then refuses at `write_env`, naming only the key, which is the right behaviour.
- **Gaps the suggested fix doesn't cover:**
  - `backup all` should back up each stack independently and exit non-zero at the end.
  - The backup unit should alert on failure.
  - The new test should go through `cmd_secret set` with `getpass` patched, not only `line()`.

My verification scripts (all on temporary files, throwaway values, nothing printed):
- <scratchpad>/verify-R8-F-3/e2e.py
- <scratchpad>/verify-R8-F-3/getpass_pty.py
- <scratchpad>/verify-R8-F-3/fixcheck.py

#### R8-F-4: lines the tool accepts and the loader refuses — Verdict: CONFIRMED, Info

**Verdict:** confirmed at Info. Two of the three shapes, (a) and (b), behave as claimed in the documented launch shell. Shape (c) doesn't fit the finding's title: the vault tool never accepts that file, it fails on it.

**Severity: Info, as filed.** No money or key is at risk, and nothing is sent before the refusal. In the documented order, step 1's `load_env` is the first read of `.env.mainnet`. Steps 2 and 3 read the same file in the same shell. `deploy-all.sh:43` loads before any `forge`, and the testnet scripts load first too (`deploy-escrow.sh:17`, `deploy.sh:9`, `fund-testnet-wallets.sh:8`). The rehearsal only deploys the router to the local anvil fork before loading. So a refusal costs one fix and a rerun. There is no time-bound state to lose: the promo end is recomputed on a rerun, and the `--resume` hazard can't arise because nothing was broadcast. If the admin fixes the vault rather than the clone, they also re-copy it and re-append the deployer key. The real cost is usability: the error gives a line number but the fault is an invisible character. Only hand edits can create these shapes, but hand edits are the documented way to change mainnet settings (`docs/secrets.md:58`, `sudoedit`), and a pasted leading U+00A0 is plausible.

**Proof result**
- `bash scripts/pentest/eighth/F8_grammar_divergences.sh` (copy root): PASS, exit 0. The R8-F-4 half printed four `ok` lines and the C-locale control.
  - The U+00A0 and U+2028 cases ran with no locale set, so in the C locale, the same as §3's shell.
  - The two Latin-1 cases ran under `LC_ALL=C.UTF-8`.
  - The proof counts the tool's `UnicodeDecodeError` as "the vault accepts" (`grep "odd_lines \[\]\|UnicodeDecodeError"`, line 92), which is wrong.
- My own matrix (`<scratchpad>/v4/matrix.sh`) ran each shape against the C, C.UTF-8 and en_US.UTF-8 locales:

| Shape | Vault tool | Loader, C | Loader, UTF-8 |
|---|---|---|---|
| Leading U+00A0, U+2007 or U+202F; `export`+U+00A0 | accepts | refused | refused |
| U+2028, U+2029, U+3000 or U+1680 on its own line; leading U+2003 | accepts | refused | loads as blank |
| Latin-1 byte in a comment or value | cannot read the file | loads | refused |
| NEL (U+0085) or 0x1C on its own line; leading 0x1F | accepts | refused | refused |

- `env -i … bash --norc` gives `LANG` unset and the C locale (é is two bytes). I didn't read the admin's `~/.bashrc`; if it exports `LANG`, the UTF-8 columns apply.

**Corrections to the finding**
- **(c) is not "a line the vault tool accepts".**
  - `odd_lines` and `parse_env` (`ops/yescrow-ctl:84,104`) raise on any non-UTF-8 byte, so every `secret` action and `up mainnet` fails.
  - In §3's C-locale shell, `load_env` loads that file. That is the opposite divergence: the loader accepts what the tool can't read.
  - In a UTF-8 shell, both refuse, and the loader at least gives the line number.
  - (c) should be dropped from this finding or restated as a separate tool-side defect.
- **The tool's message for (c) does carry a line number, and it misleads.** `ops/yescrow-ctl:948–953` prints `yescrow-ctl: internal error UnicodeDecodeError at line 325`. Line 325 belongs to Python's frozen `codecs` module, but reads like a line of the vault file. "Checked and found sound" says the message gives a "byte offset": it doesn't print one either.
- **(b) holds only in the C locale.** In any UTF-8 locale, glibc counts U+2028, U+2029, U+2000–U+2006, U+2008–U+200A, U+1680, U+205F and U+3000 as `[[:space:]]`, so those lines load as blank. In practice:
  - §3's shell refuses (a) and (b).
  - A login shell refuses (a) and (c).
  - Only the no-break spaces (U+00A0, U+2007, U+202F) and the control characters refuse in every locale.
- **Root cause of (b), on the tool side.** For U+2028, U+2029, NEL and FS/GS/RS, the tool accepts the line because `str.splitlines()` at `ops/yescrow-ctl:85,105` treats it as a line break. `\s` at `:97` is not the cause. `\s` is the cause for U+3000, U+00A0 and the like.
- **Missed shapes.** These are refused by the loader in every locale and accepted by the tool:
  - Python's `\s` matches 0x1C–0x1F, so a line starting with 0x1F passes.
  - `splitlines` breaks on NEL and 0x1C–0x1E, so those characters on their own line pass.
- **The tool's own writes can't create these shapes, and they cure two of them.**
  - Every writer goes through `line()` and `set_in_file`/`remove_from_file` (`:108–153`, `:381–398`), which write `NAME=…` lines. The strict UTF-8 encoder can't write a non-UTF-8 byte.
  - Any write rewrites the whole file through `splitlines()` plus `'\n'.join`. That turns a U+2028- or NEL-only line into blank lines (tested).
  - `secret set NAME` replaces a U+00A0-led `NAME=` line, because its pattern's `\s` matches U+00A0. Other names' lines are kept.
  - So these shapes reach the launch only through `sudoedit` of the vault, or a hand edit of the clone's `.env.mainnet`, which the tool never sees. The "tool said it was fine" premise also needs a `secret` command to have been run after that edit.
- **Cited lines are real:** `scripts/lib/env.sh:12–13`, `ops/yescrow-ctl:97`, `:84`, `:104`, and `\s` in `parse_env` at `:86`. The comment on `:97`, "what scripts/lib/env.sh load_env takes", is false in both directions.

**Fix assessment**
- **`local LC_ALL=C` inside `load_env` works.** Prototype: `<scratchpad>/v4/env_fixed.sh`.
  - It makes the result the same in every caller locale.
  - It restores the caller's locale on return; no child process sees `LC_ALL=C`.
- **It needs R8-F-1's refusal of `LC_*`/`LANG` names in the env file.** Without that, a file line `LC_ALL=en_US.UTF-8` switches the loop's locale for the lines after it (a U+2028 line then loads). The file's `LC_ALL` is also silently dropped when the local goes away.
- **`[ \t]` in `ENV_LINE`/`parse_env` alone is incomplete.** Prototype: `<scratchpad>/v4/fixcheck.py`.
  - With `splitlines()` kept, U+2028-, NEL- and FS-only lines are still accepted by the tool and refused by the loader.
  - With R8-F-3's `split('\n')` (and a trailing `\r` stripped) as well, no tested shape is accepted by the tool and refused by the loader. R8-F-3's and R8-F-4's fixes have to land together.
- **Exact grammar.** Under `LC_ALL=C` the loader's `[[:space:]]` is space, `\t`, `\v`, `\f` and `\r`. With `[ \t]`, the tool would refuse lines starting with `\v`, `\f` or `\r` that the loader takes. That is the safe direction, but not one grammar. Use the same five characters on both sides.
- **Use "valid UTF-8", not "ASCII".** The "ASCII is all the vault needs" alternative would break the real file:
  - `.env.mainnet.example` has 15 `§` (U+00A7) in comment lines; `.env.example` has 12 `§` and one `×`.
  - `set_in_file` keeps comments, so a vault file built from the template would be refused by every `secret` action.
  - If values are limited to ASCII, `line()` must refuse non-ASCII values too, or the tool writes a file it then refuses (R8-F-3's class).
- **Not covered by the suggested fix:**
  - `parse_env`'s `.strip()` (`:89`) strips Unicode spaces, so values still differ (`A=1` with a trailing U+00A0: the tool reads `1`, the C-locale loader keeps the U+00A0). Use `.strip(' \t\v\f\r')`.
  - The patterns in `set_in_file` and `remove_from_file` (`:132`, `:145`) still use `\s`. That is harmless once `odd_lines` refuses first, but should match.
- Reading the file as bytes and refusing by line number also replaces the misleading "at line 325" message.
- **Suggested regression test:** every shape above through both `odd_lines` and `load_env`, under C and en_US.UTF-8 callers, asserting that whatever the tool accepts the loader loads, with equal values.

#### R8-F-5: step 3's provider check — Verdict: CONFIRMED, Info

**Verdict:** Confirmed, at Info.

**Severity: Info, as claimed.** Info is already the lowest level, so it can't be lowered, and the proof passes, so it can't be dropped. Five things keep it at Info:
- **No attacker.** A lying or compromised RPC provider is outside the attacker list (third run U-2, fourth run U-1).
- **The template's value is unaffected.** `ARBITRUM_ONE_RPC_URL=https://arb1.arbitrum.io/rpc` (`.env.mainnet.example:11`) still gets two independent runs. Nothing in the docs tells the admin to change it.
- **Only working spellings reach step 3.** Step 2's `cast chain-id` check (`scripts/deploy-all.sh:96`) must pass on the same URL first.
- **One independent provider always remains.** In every alias case the other template provider still runs the full gate.
- **Code and constructor arguments are checked outside the RPC.** Step 2 already ran the gate before recording (`deploy-all.sh:171-172`). The Arbiscan and Sourcify verification (`:174-196`) checks the code and constructor arguments against the explorers' own view of the chain.

What is lost is the second 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. That matters only if step 2's provider and the one remaining independent provider both lie the same way. The real defect is that this happens silently: the output reads as two independent checks.

**Proof result:**
- `bash scripts/pentest/eighth/F8_step3_provider_aliases.sh`, run from the copy's root: exit 0, `PASS`.
  - The two template spellings are refused: PublicNode after 0 runs, dRPC after 1.
  - The five aliases (`:443`, upper case, `lb.drpc.org` keyed, userinfo, `arbitrum-one.publicnode.com`) are not refused, and the gate runs twice.
- My own check: `scripts/pentest/verify/V8_F-5_step3_aliases.sh`, added to the copy (`<scratchpad>/pentest8/F/scripts/pentest/verify/V8_F-5_step3_aliases.sh`).
  - It takes `docs/mainnet.md:154-165` by line number, which is identical to the proof's awk extraction. It runs that block in `env -i bash --norc -i`, writes the URL quoted as the vault tool's `line()` would, and has stub `forge` log each URL.
  - Template arb1, and `RPC_URL`'s list copied in: 2 runs, 0 on step 2's provider.
  - Exact dRPC, `/`-suffixed, `http://`: refused after 1 run (PublicNode).
  - Exact PublicNode: refused after 0 runs.
  - Every alias: 2 runs, exit 0, exactly 1 on step 2's provider. The aliases tested: the single-quoted keyed `lb.drpc.org` (alone and first in a list), `lb.drpc.live/arbitrum/K`, `?x=1` with no path, `:443`, upper case, userinfo, `arbitrum-one.publicnode.com`.

**Corrections to the finding:**
- **Two root-cause line numbers are off by one.** `host_of` is `docs/mainnet.md:155`, not `:156` (`:156` is `step2=`). The string inequality is `:159`, not `:160` (`:160` is the `echo`). The range `:155–160` in Steps is right.
- **§1 doesn't invite a paid endpoint here.** "A paid endpoint can go first" (`docs/mainnet.md:73-74`) is about the service's `RPC_URL`, `RPC_URL_8453` and `RPC_URL_1` lists, "later if public rate limits bite". It says nothing about `ARBITRUM_ONE_RPC_URL` or the deployment, and no doc tells the admin to change that value from the template.
- **Only one alias is plausible.** dRPC's keyed endpoint is a real shape, if the admin buys dRPC and also puts it in `ARBITRUM_ONE_RPC_URL`. `:443`, upper case and userinfo are synthetic: dRPC authenticates with `dkey`, not userinfo. Whether `arbitrum-one.publicnode.com` exists couldn't be checked offline. If it doesn't resolve, step 2 stops at `deploy-all.sh:96` and step 3 is never reached.
- **The finding misses an alias shape.** A query with no path (`https://arbitrum.drpc.org?x=1`) also passes, because `${h%%/*}` keeps `?…`.
- **The finding misses that the exact-match path is no better.** An exact PublicNode match refuses after 0 runs. An exact dRPC match refuses after PublicNode has already passed. Either way the message (`:159`) says "check against another provider" but the doc gives no command for a third provider. So the PublicNode alias (1 independent run, silent) gets more independent coverage than the exact PublicNode match (0 runs, loud). The harm is the misleading output, not a loss compared to the refusal path.
- **The proof counts runs but doesn't log URLs.** That's harmless because step 3's URLs are constants. My check logs them and confirms one run in each alias case went to step 2's operator.
- **Cited lines are real.** `.env.mainnet.example:11` and `deploy-all.sh:70,89` are as described. The history matches: fourth run I-3 (`reports/AI pentest, fourth run (2026-11-02).md:365,383,430`) and R7-F-2, whose fix chose the host comparison.

**Fix assessment:**
- **Normalising hosts (lower case, drop userinfo, port, query) and comparing the registrable domain** closes all the proof's aliases, plus the `?`-without-path shape. My check's "fix 1" shows that. It is not complete:
  - It misses any other domain the same operator serves keyed endpoints from (I recall dRPC also uses `lb.drpc.live`; not checked offline, but the shape gets past fix 1 in my run).
  - It misses a reseller or proxy in front of the same nodes.
  - It keeps the refusal path that leaves step 3 undone with no command for a third provider.
  - "Contains `drpc` or `publicnode` anywhere" catches more but is still a string guess.
- **Comparing by chain data doesn't work.** Honest providers return the same data, and one operator returns the same data under every hostname, so no read can tell whether two URLs are one provider. It doesn't address this finding.
- **Requiring `${ARBITRUM_ONE_RPC_URL%%,*}` to be exactly `https://arb1.arbitrum.io/rpc` is complete by construction, and the simplest.** My check's "fix 2" refuses every non-template shape and runs PublicNode and dRPC for the template.
  - It should go in `deploy-all.sh` for arbitrum-one, beside the chain-id check at `:96`, so a wrong URL is caught before anything is sent rather than after the deploy.
  - Its cost is that step 2 can't use a paid endpoint. That isn't needed for five transactions, and a mid-deploy RPC failure already has the `RESUME=1` path.
  - It also refuses harmless variants such as a trailing `/`. That fails loudly, so it's acceptable.
- **The finding's "require at least one run on a provider other than step 2's" adds nothing for aliases.** That already holds in every alias case. The useful part is printing which provider was skipped.
- **If the user keeps the host comparison:** build the list as {arb1, PublicNode, dRPC} minus step 2's operator, and run the first two. That gives two independent runs instead of a refusal with no next command.
