Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 6 min read

What your coding agent saves when you click "Don't ask again": a checklist for Foundry and Hardhat repos

Claude Code asks before it runs a shell command, and one of the answers is "Yes, and don't ask again". Pick it and Claude Code writes the command into .claude/settings.local.json as an allow rule. Word for word. If the c

Claude Code asks before it runs a shell command, and one of the answers is "Yes, and don't ask again". Pick it and Claude Code writes the command into .claude/settings.local.json as an allow rule. Word for word. If the command had a key in it, the file has the key now.

Last week Apiiro went looking for that file on public GitHub. Of 104,185 copies, 1,122 held at least one real secret: database passwords, SSH logins, GitHub and npm tokens, AWS keys. Counting git history, the shared .claude/settings.json and agent transcripts, they found 2,539 secrets in 1,994 repositories. gitleaks and TruffleHog both missed 215 of the 1,122 files, mostly passwords written as command flags like PGPASSWORD= or sshpass -p (Apiiro, with the numbers broken down in the CSA research note).

No wallet keys in that list. But look at the commands an agent runs in a smart contract repo:

forge script script/Deploy.s.sol --rpc-url https://eth-mainnet.g.alchemy.com/v2/<key> --broadcast --private-key 0x<64 hex>
cast send 0xToken "transfer(address,uint256)" 0xTreasury 1000 --private-key 0x<64 hex>
forge verify-contract 0xDeployed src/Vault.sol:Vault --etherscan-api-key <key>

The deployer key, the RPC key and the Etherscan key, and every one of those lines is a command you approve. A leaked database password gets changed. A leaked deployer key doesn't, because the key is the address.

How a key gets into the file

--private-key $PRIVATE_KEY is safe in that file. The rule saves the text $PRIVATE_KEY, not the value. The value lands there when it's in the command itself: you pasted it, or the agent read .env and wrote the value in. RPC URLs are the easy one to miss. Alchemy and Infura put the API key in the URL path, so a pasted --rpc-url is a pasted key.

Claude Code's protection is one line it adds to your global git excludes the first time it writes the file, **/.claude/settings.local.json. I checked, it's there on my machine. It covers commits from this laptop. It doesn't cover a teammate who creates the file by hand (the docs say to add it to .gitignore yourself then), and it doesn't cover anything that isn't git.

The VS Code extension adds one more path: its approval card lets you pick the destination file, and one of the options is the shared .claude/settings.json, the one meant to be committed.

The checklist

Find the file, and its history. In every repo:

git ls-files | grep -E '\.claude/settings(\.local)?\.json'
git log --all --oneline -- .claude/settings.local.json .claude/settings.json
git check-ignore -v .claude/settings.local.json

If the last one prints nothing, the file isn't ignored for this clone. Add .claude/settings.local.json to the project's own .gitignore, so it holds for everyone who clones the repo, not only for you.

Grep for what matters in a contracts repo. A raw private key is 64 hex characters, the same shape as a transaction hash, so I wouldn't count on a generic scanner here. Search for the flags instead, in the files and in their history:

pattern='private-key|PRIVATE_KEY=|mnemonic|--password|alchemy\.com/v2/|infura\.io/v3/|etherscan-api-key'
grep -nE -- "$pattern" .claude/settings*.json
git log --all -p -- .claude/ | grep -nE -- "$pattern"

Anything that shows up in history is public for as long as the repo, its forks and their clones exist. Deleting the file doesn't change that.

Keep the key out of the command. Foundry has had encrypted keystores for a while:

cast wallet import deployer --interactive
forge script script/Deploy.s.sol --rpc-url mainnet --account deployer --broadcast

--account asks for the keystore password when the script runs. Don't add --password to the command to skip the prompt. That's the same leak with one more step, since the keystore file plus its password is the key. For a mainnet deploy, --ledger keeps the key off the machine completely.

If your deploy script does vm.startBroadcast(vm.envUint("PRIVATE_KEY")), the key sits in .env in plain text, one cat away from the agent. vm.startBroadcast() with no argument uses the signer you pass on the command line, so the script never needs to see the key.

In Hardhat 3, npx hardhat keystore set DEPLOYER_PRIVATE_KEY stores it encrypted in your home directory, and configVariable("DEPLOYER_PRIVATE_KEY") in the config asks for the password when a task needs it (docs). Hardhat 2's npx hardhat vars set keeps it out of the repo, but its docs say the values are stored in plain text on your disk.

Give RPC and Etherscan keys a name. In foundry.toml:

[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"

[etherscan]
mainnet = { key = "${ETHERSCAN_API_KEY}" }

Then the command says --rpc-url mainnet --verify, and the approved rule holds a name. In Hardhat the network URL can come from configVariable too.

Watch what a pattern approves. A wildcard rule looks safer than a single command with a key in it, but it approves more. Bash(forge script *) also approves the same script with --broadcast on mainnet. Bash(cast send *) approves any transaction from any wallet the shell can reach. And if foundry.toml has ffi = true, an allowed forge test runs whatever shell commands the tests call.

What I'd do in a contracts repo: allow forge build and forge test, and put anything that broadcasts under ask in the committed .claude/settings.json. An allow rule in someone's local file doesn't outrank an ask rule from the project file, so one teammate's "don't ask again" can't turn broadcasting into a silent step.

{
  "permissions": {
    "ask": ["Bash(forge script *)", "Bash(cast send *)", "Bash(cast wallet *)"],
    "deny": ["Read(./.env)", "Read(./.env.*)"]
  }
}

cast wallet is on the list because cast wallet decrypt-keystore prints the key. The deny rules on .env help, but a .env with nothing worth reading helps more.

Check what ships outside git. npm pack builds the tarball from your files list, .npmignore or the project's .gitignore. It doesn't read your global git excludes. Check Point found the file in 428 of about 46,500 npm packages in April, with credentials in 33 files across 30 of them. If you publish contracts, ABIs or a deploy helper to npm, run npm pack --dry-run and look for .claude/. A Docker build context is the same story, so add it to .dockerignore too.

If a key was in there

Rotating an API token is a click. There's no rotating an EOA key. You make a new deployer, ideally on a hardware wallet, and move everything the old address holds: owner, DEFAULT_ADMIN_ROLE, the proxy admin, timelock proposer and executor roles, every allowlist that names it, then the funds. Assume someone is already watching the old address, and move the roles that can upgrade or mint before you move its ETH. A contract with an immutable owner can't be moved at all, so that one means a redeploy.

The key can also sign an EIP-7702 authorization, so check eth_getCode on the old address afterwards. Empty means no delegation. 0xef0100 followed by an address means someone delegated it, and I went through what that hands over in the EIP-7702 post.

Two older posts cover the agent side of the same problem: your repo is not trusted context and the MCP security checklist.

I wrote this with help from AI for drafting and editing. The opinions and the audit experience are mine.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.