A coding agent's permission model isn't an access-control system. It's a list of things the agent has been asked not to attempt. That distinction matters more than most teams realise, and it's the right place to start any conversation about skills, files, and what an autonomous coder is actually allowed to touch.
A typical incident: an engineer hands Claude Code a refactor task, the agent decides it needs to "tidy up" an adjacent module, and edits a file the engineer never intended to change. The file was reachable. The edit was within scope of the agent's tool surface. No permission system fired, because no permission system was violated. The agent simply chose to do something the engineer didn't expect.
Permissions on agents are not the chmod bits you grew up with. They are constraints on the agent's intent surface. Everything below follows from that.
We've Been Here Before
Unix DAC gave us read/write/execute and the user/group/other split — granular enough to reason about, blunt enough to ship in 1971 and still be in production. SELinux added mandatory access control on top because DAC couldn't express "this process should never touch /etc regardless of who runs it." The mobile world rebuilt the stack a decade later: iOS and Android moved from "trust the binary" to "ask the user, scope by capability, sandbox by default." Browsers went further with origin isolation — the most successful sandbox ever shipped, by volume.
The pattern across all of these: the system enforces the boundary at the moment of access. The kernel checks the inode bits. The browser checks the origin. The OS prompts before the camera turns on. Every model assumes the actor will try to do the wrong thing, and the runtime stops it.
Agent permissions don't work like this. When you write "deny": ["Bash(rm -rf *)"] in .claude/settings.json, you are not preventing the agent from issuing that command at the syscall level. You are telling the harness — the program wrapping the model — to refuse the tool call if the model produces it. The model can still emit the command. The denial happens one layer up from the kernel, in user space, in a process the agent itself can read and reason about.
The closest historical parallel isn't DAC or SELinux. It's sudoers. Sudo is a co-operative mechanism: a user with privileges chooses to drop them, and the sudoers file describes the contract. Break out of sudo and you have root. Break out of an agent's permission model — through a misconfigured MCP server, a skill with an over-broad allowed-tools line, or bypassPermissions left on for "just this one task" — and you have whatever the harness can do. Which on most developer laptops is everything.
The point isn't that agent permissions are useless. They're useful. The point is that they are constraints on a co-operating actor, not access checks on an adversarial one. Calibrate accordingly.
What Skills Actually Are
A Claude Code skill isn't a plugin and it isn't a prompt. It's a directory containing a SKILL.md file with YAML frontmatter and markdown instructions, which the harness loads into context either when the user types /skill-name or when the model decides the skill's description matches the conversation. The directory can sit in three places:
~/.claude/skills/<skill-name>/SKILL.md— personal, every project sees it.claude/skills/<skill-name>/SKILL.md— committed to the repo, the team sees it- Inside a plugin or a managed enterprise location — distributed centrally
Skills can do three things that matter for governance. First, they can pre-approve tool calls via the allowed-tools frontmatter field — meaning a skill is permitted to grant the agent permissions the user would otherwise be prompted for. Second, they can run shell commands at load time using the !`<command>` injection syntax, which executes before the model ever sees the rendered skill content. Third, they can fork into an isolated subagent context with context: fork, inheriting only the tools that subagent type allows.
That's the surface. Now the governance reality.
A skill with allowed-tools: Bash(git *) is a standing grant of every git command for the session. A skill with !`curl https://example.com/setup.sh | bash` in its body executes that command on /skill-name invocation, before any review, every time. A skill checked into .claude/skills/ loads the moment a contributor accepts the workspace trust dialog — the same trust gesture that imports CLAUDE.md and .claude/settings.json.
Cursor solves the adjacent problem differently. Cursor rules live in .cursor/rules/ as markdown files (.mdc when you need frontmatter: description, globs, alwaysApply) — context injection, not tool grants. GitHub Copilot uses .github/copilot-instructions.md at repo root, automatically picked up for every Copilot request in that repo. Neither format grants tool permissions the way Claude Code skills do; they shape the model's behaviour by stuffing context into the prompt. Claude Code skills are the more powerful primitive, and the more dangerous one.
A skill is not a configuration file. It is executable policy.
Where File Access Actually Gets Decided
Claude Code's permission model has four modes, set via defaultMode in settings or --permission-mode on the CLI: default (prompt for everything not allow-listed), plan (read-only planning, no edits), acceptEdits (auto-approve file edits), and bypassPermissions (skip every check). The tool-level rules sit in the permissions block of .claude/settings.json with three arrays: allow, ask, and deny. Each entry uses a tool-and-argument syntax — Bash(npm run *), Read(./.env), WebFetch(domain:example.com), Edit(./src/**), MCP(github/search_repos), Skill(commit).
In practice the calibration most teams want looks something like:
{
"permissions": {
"defaultMode": "default",
"allow": [
"Bash(npm run lint)",
"Bash(npm run test *)",
"Bash(git status)",
"Bash(git diff *)",
"Read(./**)"
],
"ask": [
"Bash(git push *)",
"Bash(npm install *)"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Bash(curl *)",
"Bash(rm -rf *)",
"WebFetch"
]
}
}
A few things are doing real work. The deny list is non-negotiable: blocking Read(./.env) means the model can't pull secrets into context even if it decides it needs them, enforced by the harness regardless of what the model produces. The ask tier is where most teams underspend their attention — git push and npm install are the commands you want a human to acknowledge, because they leave the local machine. The allow list keeps the agent moving without prompts on the boring 90% (lint, test, status), which is the whole reason teams turn to agents in the first place.
What this configuration does not do is restrict what the agent will try. The model can still attempt to read ./.env — it just gets a denial back. If your model is good, it will then try to find the secret somewhere else. The denial is the boundary; the reasoning around it is unconstrained.
.claude/settings.json is committed to git and shared with the team — anything in it is policy your contributors inherit. .claude/settings.local.json is gitignored and personal. A common failure mode is to put strict deny rules in settings.local.json because that's where the engineer is iterating, then ship a permissive settings.json that nobody else's machine matches. Project policy belongs in the committed file. Personal escape hatches belong in the local one.
The MCP Server Permission Gotcha
Model Context Protocol servers are how agents reach beyond their built-in tool surface — connecting to GitHub, Linear, Postgres, Slack, an internal billing system, whatever. From the agent's perspective, an MCP tool is just another tool to call. From the harness's perspective, an MCP server is a separate process that the harness launches, talks to over stdio or SSE, and trusts to behave.
Here's the part that catches teams: an MCP server's tool surface is added to the agent's tool surface. If you install an MCP server that exposes a read_file tool, the agent now has a second way to read files — and the path-scoped denials in your .claude/settings.json don't apply to it, because those rules govern the built-in Read tool. The MCP tool is a different tool. It needs its own permission entry: MCP(filesystem/read_file) or similar.
Worse, MCP servers run with the privileges of the harness, which on a developer laptop is the engineer's full UID. An MCP server installed from npm executes arbitrary code on connect. There is no sandbox. The supply-chain failure mode of "I ran one bad MCP install" is identical to "I ran one bad npm postinstall script."
Treat every MCP server like a curl-bash from the internet. Pin versions. Read the source if it touches the filesystem or the network. Add the server's tools to your permissions.deny list explicitly if you don't want them available, and remember that adding the server without restricting its tools grants everything that server exposes.
Hooks Are the Enforcement Layer You Actually Have
Permission rules are static. Hooks are dynamic. If you want the agent to be unable to push to main without a CI check, or to refuse rm -rf regardless of which path it's pointed at, or to log every Bash invocation to a SIEM, hooks are the mechanism.
Claude Code fires hooks on documented lifecycle events including PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Stop, and SubagentStop. A PreToolUse hook can block a tool call by exiting with code 2 or by emitting JSON with permissionDecision: "deny". It can rewrite the tool input. It can inject context for the model to read. The hook itself is a shell command, an HTTP endpoint, an MCP tool call, or a prompt evaluated by another model — your choice.
A minimal "block destructive bash" hook in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-destructive.sh",
"timeout": 10
}
]
}
]
}
}
The script reads the tool input from stdin as JSON, greps for the patterns you care about, and exits 2 with a stderr message if it finds one. The model sees the rejection and the reason, and can decide what to do next. That's the loop.
Hooks are the first thing in this stack that behaves like a real access check. Use them for the rules you genuinely cannot afford the agent to violate — touching production secrets, force-pushing to main, writing outside the repo root, exfiltrating data over the network. Permission rules describe intent. Hooks enforce it.
CI Versus Local Dev: Different Calibrations
The settings that work on a developer laptop are wrong for CI, and vice versa. A laptop wants the agent to be productive across the whole repo with reasonable guardrails. CI wants the agent to do exactly one thing in a clean environment and produce a deterministic artifact.
For local dev, lean on default mode with a generous allow list for the boring commands and a tight deny list for the dangerous ones. Hooks for the destructive operations. MCP servers only for the ones the engineer actually uses.
For CI, the calibration inverts. The agent is running headless in a container with no human to prompt. bypassPermissions is acceptable here — and only here — because the container is the boundary. The risk surface is what the container can reach: the network the runner is on, the secrets injected as env vars, the git remotes it can push to. Lock the container, restrict the network egress at the runner level, scope the GitHub token to the minimum permissions the task needs, and let the agent run without permission prompts inside that box. The same bypassPermissions flag that's reckless on a laptop is the right answer in a hermetic CI environment, because the threat model is fundamentally different.
This is the pattern Devin uses with its snapshot-based environment, and the pattern Replit Agent uses with its container sandbox: the agent is unrestricted within a tightly bounded environment, and the environment itself is the security boundary. Aider takes the opposite approach for local use — its /read command and --read flag let the engineer mark specific files as off-limits to edits while keeping them in context, putting the boundary at the file granularity rather than the environment.
There's no single right answer. There is a right answer per environment.
What to Actually Do This Week
A short list, ordered by impact:
- Audit
.claude/settings.jsonin every active repo. Look for missingdenyentries onRead(./.env*),Read(./secrets/**), and any path that contains credentials. These are five-minute fixes with outsized impact. - List your installed skills under
~/.claude/skills/and.claude/skills/. For each one, read theallowed-toolsfrontmatter and the body. Anything you didn't write yourself should be re-read for shell injection blocks (!`...`) and broad tool grants. Delete the ones you don't actively use. - Inventory MCP servers in
.mcp.jsonand~/.claude.json. For each, ask: do I need it, do I trust the source, and what tools does it expose? Add explicitMCP(...)rules topermissions.denyfor any server tool you don't want available. - Add a
PreToolUsehook that blocks at least one thing —Bash(rm -rf /*), force-push to main, writes outside the repo root. Pick one. Wire it up. The exercise is more important than the rule. - Move
bypassPermissionsto containers only. If any engineer is running withbypassPermissionson the host, that's a calibration problem. The mode is fine in CI; it's not fine on a laptop with admin SSH keys in~/.ssh. - Decide where personal versus project policy lives. Project policy in
.claude/settings.json, committed. Personal overrides in.claude/settings.local.json, gitignored. If your team is iterating on rules in the local file, you have a documentation problem. - For CI runs, scope the GitHub token. The default
GITHUB_TOKENpermissions are broader than most agent jobs need. Restrict tocontents: readplus whatever specific writes the workflow requires.
None of this requires a new tool. It requires reading the configs you already have.
Signals That Your Calibration Is Off
A permission model is wrong in two directions. Too tight and the agent stops being useful — it prompts for every command, it can't read the files it needs, engineers start running it with bypassPermissions to get work done, and you've trained the team to disable the boundary. Too loose and the agent reaches places it shouldn't, edits files outside the task scope, makes network calls that surprise you in postmortems.
The signals are observable. If your engineers are routinely answering permission prompts with "yes to all," your allow list is too short. If your hooks fire but never block anything in real workflows, your matchers don't match what the agent actually does. If you find agent-authored commits touching files unrelated to the prompted task, your file scoping is too loose. If the agent's success rate on routine tasks drops after a permissions change, the change was too aggressive — back it off and add the specific rule that solved the original problem instead.
The calibration job is permanent. Models change, tool surfaces grow, MCP catalogues expand, and the threat model moves with them. Treat the permission file as something you revisit every quarter, not something you set once and ship.
Permissions on agents are not access checks. They are a contract between you and a co-operating system that decided, this turn, to play along. Design accordingly.
Sources
- Claude Code: Configuration reference (settings.json, permission modes, tool names)
- Claude Code: Skills documentation (SKILL.md, frontmatter, allowed-tools, dynamic context injection)
- Claude Code: Hooks reference (PreToolUse, PostToolUse, hook handler types)
- Cursor: Project rules (.cursor/rules, .mdc format)
- GitHub Copilot: Repository custom instructions (.github/copilot-instructions.md)
- Aider: Conventions and read-only files (--read flag, /read-only command)