Published copy of reports/AI pentest, seventh run (2026-11-06).md from yescrow's private repository, unedited apart from local file
paths. File and test references point into that repository; the contracts' source is verified on Arbiscan (see
the contracts). Download as Markdown.
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: indeploy-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_URLline 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 usesARBITRUM_ONE_RPC_URL, notRPC_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
/procline. - 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_unitsignores the generator's exit status.subprocess.run([FSTAB_GENERATOR, d, d, d], …)atops/yescrow-ctl:713checks neitherreturncodenor stderr. A generator that can't run gives two empty lists, the comparison passes, and the/procline is appended with the guard effectively off (F7_fstab_checks.pyrecords it with/bin/falseas the generator). On this server it runs (rc 0, every unit produced), and a missing binary raisesFileNotFoundError, whichmain()turns into a message, not a traceback. No lockout path.returncode != 0 → diewould close it. - U-2. The re-read before the append doesn't recompute
in_fstab.hide_processesdecidesin_fstabat:724before the admin's second login and re-reads at:742only to append. A/procline 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$PATHfirst. bash's.searchesPATHfor 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.mainnetif one existed. Reproduced with two files; no finding, since only root or the admin can write to the admin'sPATHdirectories.. "./$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/procline counts as done.proc_in_fstabacceptsproc /proc proc noauto,hidepid=invisible,gid=proc 0 0; whethersystemd-remount-fsapplies anoautoentry 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_URLgoes oncast/forgecommand lines (the sixth run's U-5, hidden byhidepidnow) and, new here, into forge's cache filecontracts/cache/DeployAll.s.sol/42161/run-latest.json.recovery.json(anrpcfield per transaction), which stays in the admin's clone (contracts/cache/is git-ignored; §3 copies onlybroadcast/anddeployments/). The broadcast records hold norpcfield 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.