Claude Code Permissions Hardening: Block Writes Outside Your Repo, Deny Instead of Prompt, and Audit settings.local.json
Updated on

The short version: put this in .claude/settings.json at the root of the repository, commit it, and restart Claude Code. It needs Claude Code 2.1.319 or later (claude --version, then claude update).
{
"permissions": {
"blockWritesOutsideWorkingDirectories": true,
"denyInsteadOfPrompting": true,
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Bash(curl *)",
"Bash(wget *)"
]
}
}What each line buys you:
| Line | What it does | Added in |
|---|---|---|
blockWritesOutsideWorkingDirectories | The Write, Edit, and NotebookEdit tools refuse any path outside your working directories, in every permission mode including bypassPermissions | 2.1.314 (Oct 30, 2026) |
denyInsteadOfPrompting | A tool call that would wait on a permission prompt is denied and the turn continues, in terminal sessions, subagents, and teammates | 2.1.319 (Nov 6, 2026) |
Read(...) deny rules | .env and secrets/ stay unreadable for the file tools and for shell commands such as cat, head, and sed that name the file | long-standing |
Bash(curl *) / Bash(wget *) | Network fetches from the shell are refused instead of asked about | long-standing |
I tested the write block, the deny rules, and the unattended-run behavior on macOS with Claude Code 2.1.319 on November 8, 2026, as claude -p --output-format json runs with the permission mode set per test. The exact refusal messages are quoted below. The changelog describes denyInsteadOfPrompting for terminal sessions, subagents, and teammates, which a -p run already denies, so its description comes from the changelog (opens in a new tab), not from a test. The rest of this guide explains why these settings matter right now, what each one does and does not cover, and three checks you should run once.
Why now: three incidents in nine days
Claude Code shipped seven releases between October 26 and November 6, 2026, and three security stories landed in the same window. Each one maps to a concrete check.
| Date | What happened | Your one-time check |
|---|---|---|
| Oct 30 | Phorion found a backdoored build of the official Claude Code for VS Code extension on the Cursor Marketplace, versions 2.1.312 and 2.1.313. It appends an attacker SSH key to ~/.ssh/authorized_keys and installs persistence (advisory (opens in a new tab), HN 1,011 points (opens in a new tab)) | List your extension version and grep authorized_keys, see Step 5 |
| Nov 5 | GitHub issue #107757 (opens in a new tab): Claude Code put the account email address in the User-Agent header of web requests. Nearly 1,000 reactions, still open on November 8 (HN 1,597 points (opens in a new tab)) | Update to 2.1.317 or later, which asks before a tool sends your email, see Step 6 |
| Nov 7 | Apiiro found 1,122 public GitHub files where a "Yes, and don't ask again" approval had saved a shell command containing a password or token into .claude/settings.local.json (report (opens in a new tab), HN 500+ points (opens in a new tab)) | Run the audit script in Step 4 |
None of these is fixed by a permission mode. They need a settings file, an audit, and a version check.
- Claude Code Permissions Hardening: Block Writes Outside Your Repo, Deny Instead of Prompt, and Audit settings.local.json
- How to Convert Python to EXE: Runcell, PyInstaller, Nuitka, and cx_Freeze
- pandas 3.1 Upgrade Guide: Fix the inplace Pandas4Warning, Use df.select() and pd.col(), and Read CSVs Faster
- How to Upgrade to OpenCode 2.0: Migrate Your v1 Config, Plugins, and Sessions
- Qwen3.8 27B: Specs, Memory Requirements, and How to Run It
- Haiku pricing: Haiku 5.5 vs 4.5 vs GPT-6 Luna
- Python 3.15 Upgrade Guide: Install with uv, Use Lazy Imports, and Check Your Data Stack
- Claude Code Mods Tutorial: Build and Test Your First Mod, Plus 15 Community Examples
- How to Use Pi Coding Agent 1.0: Install, Connect a Model, and Run Your First Task
- How to Use Laya: Classify Text in a pandas DataFrame with the Open-Source Jev Alternative
- Claude Code Reads AGENTS.md Now: Setup, Loading Modes, and Sharing One File with Codex, OpenCode, and Cursor
- How to View Deleted Reddit Posts (2026 Guide): 5 Ways That Still Work
- GPT Image 2.5: How to Use It, Flare vs Sunburst, and API Pricing
- How to Use DeepSeek Harness: Install, Set Up, and Run Your First Agent
- Runcell Science: An Open Source Alternative to Claude Science for Research Workflows
- How to Make Mac Not Sleep: Keep Codex, Claude Code, and AI Agents Running
- OpenClaw vs ZeroClaw vs Pi Agent vs Nanobot: Which AI Agent Stack Should You Choose in 2026?
- Can Claude Code Analyze Jupyter Notebooks for Data Science? What It Actually Does
- Claude Code Routines: Why AI Agent Cron Jobs Matter
- Claude Code Desktop Bypass Permissions: How to Enable It
- How to Build Two Python Agents with Google’s A2A Protocol - Step by Step Tutorial
- Top 10 growing data visualization libraries in Python in 2025
Step 1: Block file writes outside the repository
permissions.blockWritesOutsideWorkingDirectories makes the three file-editing tools refuse any path that is not inside a working directory. The working directories are the folder you started Claude Code in plus anything you added with /add-dir, --add-dir, or permissions.additionalDirectories in your user or managed settings. An additionalDirectories entry in the repository's own .claude/settings.json does not lift the block. The block holds in every permission mode, including bypassPermissions, and a true in any settings file wins over a false elsewhere, so a repository can turn it on for everyone who clones it.
I set the key in a scratch project and asked Claude Code, in acceptEdits mode, to create a file in my home directory with the Write tool. The call was refused with this message:
/Users/me/kanaries-block-test.txt is outside /path/to/proj; the
permissions.blockWritesOutsideWorkingDirectories setting blocks writes
outside the working directories. Ask the user to add the directory with
/add-dir, or to remove that setting.The file was not created, and the JSON result listed the call under permission_denials with tool_name: "Write".
Four limits to know before you rely on it:
- It does not limit shell commands, MCP tools, or hook programs.
echo x > ~/filefrom the Bash tool is not aWritecall. To fence shell writes, turn on the sandbox. - Some of Claude Code's own files stay writable: plan files, the session scratchpad, a background session's
tmpdirectory, and the auto-memory.mdfiles for the project. That is by design. - Claude can no longer edit any Claude Code settings file, even inside the repository, because a settings file could lift the block. Edit
.claude/settings.jsonyourself. - The worktree tools go away. With the key on, Claude Code removes
EnterWorktreeandExitWorktreeor refuses calls to them. If subagents need worktrees, add.claude/worktreesat the repository root as a working directory.
Its sibling, permissions.blockReadsOutsideWorkingDirectories (2.1.257 or later), does the same for Read, Grep, Glob, and LSP calls. Turn it on when the repository should not see the rest of your disk.
Step 2: Keep secrets unreadable with deny rules
A Read(path) deny rule blocks the built-in file tools and, per the docs, the file commands Claude Code recognizes in Bash (cat, head, tail, sed, tee) plus shell redirections that name the file. It also blocks Edit and Write on the same path, so Claude cannot overwrite your .env either.
Tested: with "deny": ["Read(./.env)"], asking Claude to read .env with the Read tool returned:
File is in a directory that is denied by your permission settings.The rule reaches the shell too. In an acceptEdits run I asked for cat README.md and then cat .env: the first printed the file, the second was refused with Permission to use Bash with command cat .env has been denied. A separate default-mode claude -p run refused curl -sI https://example.com because of the Bash(curl *) rule. Every denial was recorded in permission_denials, and each run finished instead of hanging.
Three rule-syntax traps from the permissions reference (opens in a new tab):
- Use
Read(...)andEdit(...).Write(docs/**),Grep(docs/**), andGlob(...)rules with a path are accepted but never matched. Bash(git diff *)with a space before the*matches any command starting withgit diff.Bash(git diff*)would also matchgit diff-index.- A deny rule does not catch a command that reads a file without naming it, such as
grep -r pattern .run from the folder that holds the secret. That is what the sandbox is for.
A .claudeignore file has no effect. Move its entries into Read deny rules.
Step 3: Never hang on a permission prompt
There are now four ways to make an unattended Claude Code run deny instead of wait. Pick by where the run happens.
| Situation | Use | Behavior |
|---|---|---|
| Interactive terminal session you walk away from, including its subagents and teammates | "denyInsteadOfPrompting": true in settings (2.1.319) | A call that would wait on a prompt is denied and the turn continues (changelog wording; I could not test this in -p mode because -p already denies) |
claude -p or Agent SDK run with no permission mode set | Nothing extra | Since 2.1.302, -p starts in Manual mode and denies any call that needs approval. Tested above |
| Locked-down CI job | --permission-mode dontAsk | Every call that would prompt is denied (a mod you installed can still approve some calls). Read-only commands, file reads inside the working directories, and your allow rules still run |
A run with a permission host, such as an SDK app with canUseTool or --permission-prompt-tool | --permission-prompts none (2.1.259 or later) | The run does not wait on the host; Claude is told nobody can approve and not to retry |
Tested with --permission-mode dontAsk --permission-prompts none: python3 -c 'print(2+2)' was denied with a message that starts Permission to use Bash has been denied because Claude Code is running in don't ask mode, while git log --oneline -1 ran because it is on the built-in read-only command list. The result JSON carried one entry in permission_denials.
Things no mode and no allow rule can approve, even bypassPermissions: explicit ask rules, the AskUserQuestion tool, MCP tools marked requiresUserInteraction, and recognized critical-path rm commands such as rm -rf ~. dontAsk denies all of them outright, and also denies reads from network paths even when an allow rule matches.
If a repository should never be run in bypass mode, add "disableBypassPermissionsMode": "disable" under permissions. Claude Code then rejects --dangerously-skip-permissions and ignores a subagent definition that asks for bypass.
Step 4: Audit settings.local.json for leaked allow rules
Here is the mechanism behind the Apiiro report. When Claude asks to run a shell command and you choose Yes, and don't ask again, Claude Code saves an allow rule with that command prefix into .claude/settings.local.json at the repository root. If the command was psql postgres://app:Sup3rSecret@db/prod -c ..., the password is now in the rule.
Claude Code adds **/.claude/settings.local.json to your global git excludes file the first time it writes the file in a repository that does not already ignore it. On my Mac that file is ~/.config/git/ignore, and the pattern is on line 1. That is why git add .claude/settings.local.json fails here with "ignored by one of your .gitignore files". But the protection is per machine, not per repository. A file you created by hand before Claude Code ever wrote to it, a repository copied to another machine as a directory rather than cloned, or a sandbox without your global excludes all commit the file like any other. Apiiro's author said on HN that in 539 of 1,105 traced repositories a commit co-authored by Claude changed the file.
Run this from the repository root. It finds every .claude/settings*.json, reports whether git tracks it, and flags allow rules that carry credentials or approve whole tool families.
cat > /tmp/claude-settings-audit.sh <<'EOF'
#!/usr/bin/env bash
set -u
root="${1:-.}"
found=0
while IFS= read -r -d '' f; do
found=1
echo "== $f"
if git -C "$(dirname "$f")" ls-files --error-unmatch "$(basename "$f")" >/dev/null 2>&1; then
echo " TRACKED BY GIT <- settings.local.json should never be"
fi
grep -nE '"(Bash|PowerShell)\([^"]*(password|passwd|token|secret|api[_-]?key|Authorization|Bearer |PGPASSWORD|AWS_SECRET|--password|://[^"/]*:[^"@]*@|[A-Z_]{3,}=[^[:space:]"]{8,})' "$f" \
| sed 's/^/ SECRET-LIKE: /'
grep -nE '"(Bash|PowerShell)(\(\*\))?"|"Bash\((sudo|rm|curl|wget|ssh|scp|git push|npm publish|docker)[^"]*\*\)"' "$f" \
| sed 's/^/ BROAD: /'
done < <(find "$root" -path '*/node_modules' -prune -o -type f \
\( -name 'settings.local.json' -o -name 'settings.json' \) -path '*/.claude/*' -print0 2>/dev/null)
[ "$found" = 0 ] && echo "No .claude/settings*.json under $root"
EOF
bash /tmp/claude-settings-audit.sh .On a synthetic file with six rules it flagged the three that embedded a database password, a bearer token, and a DATABASE_URL=... assignment, plus Bash(*), Bash(rm -rf *), and the curl rule a second time under BROAD. When the file was committed in a test repository it also printed the TRACKED BY GIT line. It is a grep, so expect to read the hits yourself.
What to do with a hit:
- Rotate the credential first. If the file was ever pushed, treat the secret as public. Rewriting history is not enough once a fork or a scraper has it.
- Delete the rule, or replace it with one that does not carry the secret: approve
psql *and pass the password throughPGPASSWORDfrom your shell environment, not inside the command. - If git tracks the file, run
git rm --cached .claude/settings.local.jsonand add.claude/settings.local.jsonto the repository's.gitignore, so the protection travels with the repository instead of depending on each machine's global excludes. - Check
.claude/settings.jsontoo. It is meant to be committed, so an allow rule with a secret there is a leak by design, not by accident.
For the same reason, prefer deny and ask rules in the committed file and keep personal allow rules local. Deny and ask rules apply right away; allow rules from a repository apply only after each teammate trusts the folder.
Step 5: Check your IDE extension and SSH keys
The backdoored builds were versions 2.1.312 and 2.1.313 of the Claude Code for VS Code extension distributed through the Cursor Marketplace, reported by Phorion on October 30. Cursor removed them. Per Phorion, the payload runs as soon as the extension module loads, appends an Ed25519 key with the comment svc-telemetry to ~/.ssh/authorized_keys, tries to enable Remote Login, and beacons host information. On macOS the SSH-enable step fails for a non-admin user because of a bug in the implant, but the key and the persistence are still dropped.
List installed versions:
cursor --list-extensions --show-versions | grep -i claude
code --list-extensions --show-versions | grep -i claude
ls ~/.cursor/extensions ~/.vscode/extensions 2>/dev/null | grep -i claudeOn my machine this returned anthropic.claude-code@2.1.302, which is outside the affected range. Then check the indicator Phorion published:
grep -n "svc-telemetry" ~/.ssh/authorized_keys 2>/dev/null || echo "no match"Phorion notes the comment string is attacker-controlled, so a clean grep is not proof. If you ran 2.1.312 or 2.1.313 from the Cursor Marketplace, remove every key you do not recognize from authorized_keys, review login items and launch agents, and treat the machine as compromised until an incident response has looked at it. Install the extension from the VS Code Marketplace or let the claude CLI install it via /ide; since 2.1.318 Claude Code no longer installs or updates the extension from inside an IDE terminal on its own.
Step 6: The email-in-User-Agent fix
Issue #107757 reports that Claude Code sent the account's email address in the User-Agent header of web requests made during searches and fetches, so sites and trackers could harvest it. The 2.1.317 changelog entry, dated November 3, reads: "Added a check that asks before a web or shell tool call sends your account email address to the network." The reporter filed on 2.1.318 and the issue was still open with nearly 1,000 reactions on November 8, so watch it for the final resolution.
What you can do today: update (claude update), and if you run unattended, know that with denyInsteadOfPrompting or dontAsk that new "asks before" check becomes a deny, which is the behavior you want.
What each guard stops: tested vs. documented
| Attempt | blockWrites… | Read deny rule | Bash(curl *) deny | dontAsk / -p default | Sandbox |
|---|---|---|---|---|---|
Write tool creates ~/x.txt | Refused (tested) | — | — | Prompt → denied | Allowed by sandbox, file tools are outside it |
Read tool reads .env | — | Refused (tested) | — | Allowed, reads need no approval | — |
cat .env in Bash | — | Refused (tested) | — | Denied | — |
echo x > ~/x.txt in Bash | Not covered | — | — | Prompt → denied | Blocked (docs: Operation not permitted on macOS) |
curl https://… in Bash | — | — | Refused (tested) | Denied | Blocked unless host is allowed |
python3 -c … in Bash | — | — | — | Denied (tested) | Runs inside the sandbox |
git log, ls, other read-only commands | — | — | — | Runs (tested) | Runs |
rm -rf ~ | — | — | — | Denied, no rule can approve it | Blocked, home is outside the writable set (docs) |
"Tested" means I ran it on 2.1.319 on November 8, 2026. Everything else is from the official docs linked in this guide.
Optional: sandbox the shell too
The settings above fence Claude's own file tools. Shell commands are fenced by the Bash sandbox (opens in a new tab), which is off by default. It uses macOS Seatbelt, and on Linux or WSL2 it needs bubblewrap and socat. Turn it on for one project with /sandbox inside a session, or for every project by adding to ~/.claude/settings.json:
{
"sandbox": {
"enabled": true
}
}To confirm it is working, ask Claude to run touch ~/sandbox-probe. Inside the sandbox it fails with Operation not permitted on macOS or Read-only file system on Linux. If Claude offers to retry the command outside the sandbox, decline.
One combined setup covers the gap in the matrix above: file tools fenced by blockWritesOutsideWorkingDirectories, shell fenced by the sandbox, secrets fenced by Read deny rules, and unattended runs fenced by denyInsteadOfPrompting. Bypass mode then only speeds up the prompts that remain. If you still need bypass for a contained task, the bypass permissions guide covers the toggle and its error messages.
FAQ
Related Guides
- Claude Code Bypass Permissions: Desktop Toggle, CLI Flags, and Error Fixes
- Claude Code Mods Tutorial: Build and Test Your First Mod
- Claude Code Reads AGENTS.md Now: Setup and Loading Modes
- Claude Code Routines: Why AI Agent Cron Jobs Matter
- Parallel Code Agents Explained: Worktrees, Sandboxes, and Alternatives