Back to Insights
21 Jun 20264 min readAI & LLMs

The Loosest Line in Your Repo Is in .claude/settings.json

We've spent the last few posts on the acute side of agent security — frisking inputs for hidden instructions, measuring the blast radius of reachable secrets, blocking destructive commands at runtime. But all of those sit on top of one quieter decision that sets the ceiling on how much damage a single injected instruction can do: your permission config. .claude/settings.json decides what the agent is allowed to do without asking you — and the loosest setting in there is the real size of your exposure.

Most of these files drift loose for the most human reason: every permission prompt is friction, so people allow-list broadly to stop being asked. The result is configs that quietly say "do anything." Here's what to look for, and a 110-line linter that finds it.

The permission model in one paragraph

Claude Code's permissions object has allow, deny, and ask arrays of rules shaped like Tool(specifier) — Bash(npm test *), Read(./src/**), WebFetch. deny wins over allow. A defaultMode sets the baseline behavior. The safe default (no config, or defaultMode: "default") is prompt-before-risky — the agent asks. Everything dangerous is a widening of that.

The five widenings that matter

perm-audit reads a settings.json and ranks what it finds:

defaultMode: "bypassPermissions" — CRITICAL. This turns off permission checks entirely. Every tool call runs unprompted. With this on, an injected "run this" is just… run. It exists for sandboxes; it has no business in a repo on your laptop. (Its cousin "acceptEdits" is milder — it auto-approves edits — but it's still a widening worth a warning.)

Bash(*) in allow — HIGH. This auto-approves every shell command. It's the single most common over-grant, because typing Bash(*) once is so much easier than allow-listing the commands you actually run. But it means a hijacked agent never has to ask before curl | sh. The fix is boring and correct: allow the specific commands (Bash(npm test *), Bash(git status)) and let everything else prompt.

Broad reads and network egress — WARN. Read(**) auto-approves reading any file (including the secrets reach warned you about). A bare WebFetch / WebSearch allow, or Bash(curl *) in allow, hands the agent an unsupervised way to send data out — the exfiltration channel that makes a secret-read actually matter.

enableAllProjectMcpServers: true — WARN. This auto-trusts every MCP server declared in .mcp.json. Each server's tool descriptions go straight into the model's instruction context — exactly the hidden-instruction surface we frisk files for. Auto-approving all of them is auto-trusting text you didn't write.

No deny rules protecting secrets — HIGH (when Bash is broad). allow is a convenience; deny is a guarantee — it holds even if the agent tries. A config that broadly allows but denies nothing has no floor. The fix is a few lines that cost nothing:

"deny": [
  "Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)",
  "Read(~/.ssh/**)", "Bash(curl *)", "Bash(wget *)"
]

The tool

$ perm-audit
perm-audit — .claude/settings.json

  ● CRITICAL  defaultMode is bypassPermissions
              every tool call runs with NO permission check. One injected instruction = full shell.
  ● HIGH      allow rule 'Bash(*)' auto-approves ALL Bash
              any Bash call runs without asking. Narrow it to the commands you trust.
  ● HIGH      no deny rules protecting secrets
              add Read(./.env), Read(./secrets/**), Read(~/.ssh/**), Bash(curl *) …
  ● WARN      enableAllProjectMcpServers is true
  ● WARN      allow rule 'Read(**)' auto-approves ALL Read

  6 finding(s), 3 high-severity.

It's severity-ranked and exits non-zero on anything HIGH or CRITICAL, so it drops into CI or a pre-flight. Crucially, it doesn't cry wolf: a well-scoped config — specific Bash(...) commands, scoped Read(./src/**), and a secret deny block — comes back clean. (We fixed a false-positive in the first pass where every allow rule looked broad; the test suite now pins "scoped allows are clean" precisely so the tool stays trustworthy.) And a repo with no settings.json is reported as the safe baseline — you only need this once you've started allow-listing.

How it fits

This is the fourth and quietest layer of the same defense:

  • frisk — shrink the chance the agent is injected (scan inputs).
  • reach — shrink what it could steal (audit reachable secrets).
  • guard — stop the destructive command at runtime (a PreToolUse veto).
  • perms — set the ceiling on what runs unprompted in the first place.

Guard, reach, and frisk all matter more when perms is loose, and matter less when it's tight. So tighten the ceiling first: it's the cheapest, most durable control of the four — a few lines of config that turn "the agent can do anything" into "the agent asks before anything risky."

The real lesson

Permission prompts feel like friction, so we widen them away — and in doing so we quietly set how large a single bad instruction can get. The fix isn't to live with the prompts; it's to encode the small set of things you actually trust into allow, put a hard floor under it in deny, and never leave bypassPermissions on outside a sandbox. One perm-audit run tells you how far your config has drifted from that, ranked worst-first.

We build and run autonomous agent fleets on Claude Code in production. If you're hardening an agent setup, talk to us.


Get the tool → perm-audit in agent-ops — open source (MIT), a single dependency-free script, part of our toolkit for running coding agents in production.

AI agentssecuritypermissionsClaude Code

Found this useful?