# Security model in Claude Code

Commit a developer baseline in .claude/settings.json, and run the agent-ready job under dontAsk with an allow list, a sandbox limited to two hosts and a tool-call log kept as a CI artifact.

Source: https://ai-sw-factory.mellicci.dev/fundamentals/security-model/claude-code

## At a glance

| | Claude Code |
|---|---|
| Term | Permission rules and permission modes, plus the optional Bash sandbox |
| Configured in | `permissions` and `sandbox` keys in `.claude/settings.json`; `--settings`, `--permission-mode`, `--allowedTools` flags; managed settings |
| Loads / runs | The harness checks every tool call against the rules before it runs, in every mode |
| Scope & precedence | Managed, then command line, then `.claude/settings.local.json`, then `.claude/settings.json`, then `~/.claude/settings.json`. Across all levels, a deny beats an allow |

## Build the scenario

| Step | What you write | Where |
|---|---|---|
| 1 | Developer baseline, committed | `.claude/settings.json`, block 1 |
| 2 | CI baseline, passed with `--settings` | `.claude/ci-settings.json`, block 2 |
| 3 | Tool-call log hook | Same CI file, block 3 |
| 4 | Headless command, log kept as artifact | `agent-ready.yml` (see [headless execution](https://ai-sw-factory.mellicci.dev/fundamentals/headless-execution/claude-code)) |

```json
{
  "permissions": {
    "allow": ["Read", "Bash(pnpm test *)", "Bash(pnpm lint *)"],
    "ask": ["WebFetch", "WebSearch", "Bash(curl *)", "Bash(git push *)"],
    "deny": [
      "Read(./.env*)",
      "Edit(./.env*)",
      "Bash(rm -rf *)",
      "Bash(git push --force *)",
      "Edit(./src/db/migrations/**)"
    ]
  }
}
```

A `Read` deny also blocks Edit and Write on that path; the `Edit` line makes it explicit. Other shell commands already prompt in Manual mode. Don't put a bare `Bash` in `ask`: ask beats allow, and `pnpm test` would prompt too.

```json
{
  "permissions": {
    "defaultMode": "dontAsk",
    "allow": ["Read", "Edit", "Bash(pnpm *)", "Bash(git add *)", "Bash(git commit *)",
              "Bash(git status *)", "Bash(git diff *)"],
    "deny": ["Read(./.env*)", "Edit(./src/db/migrations/**)"]
  },
  "sandbox": {
    "enabled": true, "failIfUnavailable": true,
    "network": { "allowedDomains": ["registry.npmjs.org", "github.com"], "strictAllowlist": true }
  }
}
```

The CI file goes in through `--settings` because, under `claude -p`, a project's `permissions.allow` rules aren't used (see below). Add the log hook as a third key in the same file:

```json
{
  "hooks": { "PostToolUse": [ { "matcher": "", "hooks": [
    { "type": "command", "command": "jq -c '{tool_name, tool_input}' >> \"$RUNNER_TEMP/tool-calls.jsonl\"" }
  ] } ] }
}
```

The run step becomes `claude --bare -p "$PROMPT" --settings .claude/ci-settings.json --max-turns 30`, and the workflow uploads `tool-calls.jsonl` whatever the exit code. The other controls live in the workflow, not in Claude Code: the job holds only `ANTHROPIC_API_KEY`, `contents: write`, `pull-requests: write` and `issues: read`; only maintainers add the `agent-ready` label; the issue reaches the prompt as data through an environment variable.

## Specifics

### Permission modes

| Mode | Runs without asking | Use here |
|---|---|---|
| `default` (Manual) | Reads | Developer baseline |
| `acceptEdits` | Reads, file edits, `mkdir`, `touch`, `mv`, `cp`, `rm`, `sed` in the working directory | Reviewing the diff afterwards |
| `plan` | Reads; edits blocked until you approve a plan | Exploring before changing |
| `auto` | Everything, with a classifier model reviewing actions such as shell commands and network requests | Attended long tasks |
| `dontAsk` | Reads and allow-listed tools; everything else is denied | CI baseline |
| `bypassPermissions` | Everything except a few guarded actions; deny rules still apply | Isolated containers only |

`--permission-mode` or `permissions.defaultMode` sets the start. `claude -p` starts in `default` when feature flags are fetched, but in `auto` when they aren't (third-party provider, telemetry off) on v2.1.285 or later; pass the mode you want. Set `permissions.disableBypassPermissionsMode` or `permissions.disableAutoMode` to `"disable"` to remove a mode.

### Allow, ask and deny rules

Evaluation order is deny, then ask, then allow; the first match wins and specificity doesn't matter. `Tool` or `Tool(specifier)`.

| Rule | Matches |
|---|---|
| `Bash(pnpm test *)` | Commands starting `pnpm test`, including bare `pnpm test`. Put the `*` after the subcommand; `Bash(pnpm*)` with no space also matches `pnpmx` |
| `Read(./.env*)` | `.env`, `.env.local` and so on, under the current directory. Bare filenames match at any depth |
| `Edit(./src/db/migrations/**)` | Edits under that directory. `Edit` rules cover all built-in edit tools; a `Write(...)` path rule is never consulted |
| `//path`, `~/path`, `/path` | Absolute path, home path, and path relative to the settings file's project. `/Users/alice/f` is not absolute |
| `src/**` | As allow: only `<cwd>/src`. As deny or ask: a `src` at any depth |
| `WebFetch(domain:github.com)` | Hostname match; `*.example.com` covers subdomains but not the apex |
| `mcp__github__get_*` | MCP tools; allow globs only after a literal `mcp__<server>__` prefix. Rules with parentheses are skipped |

Bash rules match the command text after splitting `&&`, `;`, `|` and stripping wrappers such as `timeout`. A rule can't stop `/bin/rm -rf` or `bash -c 'rm -rf x'`, and Read/Edit denies don't cover a script that opens files itself. For enforcement that ignores command text, use the sandbox or a `PreToolUse` hook.

### Approvals and headless runs

| Situation | Behavior |
|---|---|
| Interactive prompt | Approve once, or "don't ask again": Bash and WebFetch rules save to `.claude/settings.local.json`; edits last for the session |
| `claude -p` | No trust dialog, no prompt. Project `permissions.allow` rules aren't used until you trust the folder; `deny` and `ask` still apply |
| `-p` without `--bare` | Still runs the project's hooks and connects `.mcp.json` servers, even in a folder you never trusted |
| `--bare` | Reads no hooks, plugins, MCP servers, `CLAUDE.md` or auto memory from the project. `--settings` still loads |
| `--allowedTools` | Allow rules for one run; a deny at any level still wins |

Inside a run under `dontAsk`, a denied call doesn't fail the job. `stream-json` output reports `permission_denied` messages and the final result lists `permission_denials`.

### Managed and project policy

Managed settings sit above every other level, including `--settings` and `--allowedTools`. The file is `/etc/claude-code/managed-settings.json` on Linux, or comes from MDM or the admin console.

| Key | Effect |
|---|---|
| `allowManagedPermissionRulesOnly` | Only managed settings supply permission rules |
| `permissions.disableBypassPermissionsMode` / `disableAutoMode` | Remove those modes for everyone |
| `allowManagedHooksOnly` | Restricts which hooks run |
| `sandbox.network.allowManagedDomainsOnly` | Only managed domains are reachable |

Project `.claude/settings.json` is the shared layer: `allow` rules and `additionalDirectories` there apply only after the person accepts the workspace trust dialog, which lists them first. `deny` and `ask` rules always apply.

### Untrusted input and audit

| Topic | In Claude Code |
|---|---|
| Prompt injection | The model can be steered by issue text, files and tool output; only permission rules, the sandbox and hooks are enforced by the harness. Instructions in `CLAUDE.md` or the prompt don't change what is allowed |
| Sandbox | `/sandbox` or `sandbox.enabled`; restricts Bash and its child processes by filesystem and domain. On Linux it needs `bubblewrap` and `socat` |
| Audit | `PreToolUse` and `PostToolUse` hooks get `tool_name` and `tool_input` as JSON on stdin. OpenTelemetry export is available through `CLAUDE_CODE_ENABLE_TELEMETRY`; tool details are off unless `OTEL_LOG_TOOL_DETAILS=1` |

## Gotchas

- A `PostToolUse` hook sees calls that ran. Denied calls appear only in `stream-json` output, so keep that output next to the hook log.
- If the sandbox can't start, Claude Code by default warns and runs unsandboxed. `failIfUnavailable: true` makes it a hard failure; install `bubblewrap` and `socat` on the runner first.
- `Bash(pnpm *)` allows `pnpm exec` and `pnpm dlx`, which run arbitrary code. Narrow it to `pnpm test *`, `pnpm lint *` and the scripts the agent needs if the job doesn't require more.
- Project `permissions.allow` rules are silently unused under `-p`, so a committed `.claude/settings.json` that works for developers grants nothing in CI. Use `--settings` or `--allowedTools`.
