What it is

Claude Code runs in your terminal with your permissions. Whether it can read your SSH keys, push code, or be steered by a malicious README depends entirely on how it's configured. The audit checks that configuration against the Security for Claude Code Development guide and tells you exactly what to change.

Claude Code security audit v1.3.0 (read-only, offline)

FAIL  [2] Dangerous commands are auto-allowed
              Bash(docker run *)
              Bash(git push *)
WARN  [2] Sandbox does not block ~/.ssh
              The default read policy allows it. Add it under sandbox.credentials.files with mode deny.
PASS  [2] Sandbox is enabled
PASS  [2] Strict sandbox mode is on (allowUnsandboxedCommands: false)
PASS  [3] Installed hooks pass the test suite
...
Summary: 14 pass, 3 warn, 1 fail, 1 info

Save a report with how-to-fix steps? [h]tml, [t]ext, [c]sv, [j]son, [n]o (default n):

Every finding shows, in brackets, the guide step that explains the fix.

Why you can trust it

Small and knownOfficial Alpine Linux 3.24.2 pinned by digest, plus only bash and jq from Alpine's official repository, pinned to exact versions. About 18 MB.
OpenEverything that runs is two short files: the Dockerfile and audit.sh. You build it yourself from source.
OfflineRuns with --network none: it cannot send anything anywhere.
Read-onlyYour files are mounted read-only and the container's own filesystem is read-only. It changes nothing.
What it can readYour Claude Code settings and hooks, ~/.claude.json (which lists your MCP servers), your shell startup files, and the whole project folder you run it from, including any secrets kept in that project. It doesn't mount ~/.ssh, cloud credential folders, transcripts or the rest of your home folder. Its output names settings, files and commands; it never prints the contents of .env or credential files.
UnprivilegedRuns as you, with every Linux capability dropped.
Leaves nothing behindThe container is removed when it exits; one command removes the image.
Verifiable releasesThe repository includes a GitHub Actions workflow that builds release images with an SBOM, provenance and a signed attestation. No images are published yet, so for now you build it yourself from the open source.

Run it

You need Docker Desktop, Docker Engine or Podman. Nothing else is installed on your machine.

macOS, Linux and WSL2

git clone https://github.com/aisecuritylabs-org/coding-agent-security.git ~/coding-agent-security
cd ~/code/my-app            # the project you want to audit
bash ~/coding-agent-security/claude-code/audit/run.sh

Windows: Command Prompt

git clone https://github.com/aisecuritylabs-org/coding-agent-security.git "%USERPROFILE%\coding-agent-security"
cd /d C:\Projects\my-app
powershell -NoProfile -ExecutionPolicy Bypass -File "%USERPROFILE%\coding-agent-security\claude-code\audit\run.ps1"

Windows: PowerShell

git clone https://github.com/aisecuritylabs-org/coding-agent-security.git "$HOME\coding-agent-security"
Set-Location C:\Projects\my-app
powershell -NoProfile -ExecutionPolicy Bypass -File "$HOME\coding-agent-security\claude-code\audit\run.ps1"

Replace the project folder with the one you want to audit. The first run builds the image from the open Dockerfile in a few seconds, and it rebuilds automatically whenever you update the repository.

What it checks

AreaExamples
SandboxEnabled; strict mode; strict network allowlist; no exfiltration-prone domains; ~/.ssh and ~/.aws blocked; no Docker socket
Permissions.env reads denied; curl/wget restricted; git push needs approval; no Bash(*) or other dangerous allow rules; no bypass mode
HooksGuards on Bash and on Edit/Write; installed hooks pass a 41-case test suite; no hook that downloads or runs remote code
RepositoriesHooks that run the moment a session starts; MCP servers that launch when you trust the folder; symlinks disguised as ordinary files; servers that read outsider-written content; non-GitHub plugin marketplaces; project hooks you didn't write; auto-enabled or unpinned MCP servers; a CLAUDE.md that tells the agent to fetch or install things; .env files not gitignored
HygieneSecrets in the settings env block; transcript retention; --dangerously-skip-permissions aliases; Claude Code version

The exit code is 0 with no FAIL results and 1 otherwise, so the audit also works in scripts and CI.

Real attacks it covers

Opening a repository has become an attack in its own right. Run the audit from a repository's folder before you start Claude Code there, and it flags the patterns behind these documented attacks:

AttackWhat it doesCheck
Miasma worm (in the wild, Jun 2026)A SessionStart hook planted in a compromised repository stole credentials the moment a session startedP09
TrustFallTrusting a folder starts its MCP servers as processes with your privileges, outside the sandboxP10
SymJack / GhostApprovalThe approval prompt shows one file; the write lands on anotherP11
AgentjackingFake error reports tell the agent to run the attacker's commandP12, M02
Plugin4ShellA pinned plugin is silently swapped for malicious code on updateP13, U17

Read the story in the Security for Claude Code Development guide, or the full list with sources in THREATS.md. Every report cites the sources behind each finding.

Reports with how-to-fix steps

After the on-screen summary, the audit asks whether to save a report:

FormatBest for
HTMLReading and sharing. Every finding as a card: why it matters, details, how to fix (the exact setting to add), risk mapping, sources and a guide link, plus a framework coverage summary. Self-contained: no scripts, no external resources.
TextEmail, tickets, or any editor.
CSVTracking fixes across a team in a spreadsheet, with a column per framework.
JSONScripts and CI.

Reports are saved to claude-code-audit-reports in your home folder, never inside the project, so they can't be committed by accident. Reports mask values that look like secrets, such as tokens, passwords and credentials in URLs, but review a report before you share it. The container only prints the report; the launcher script saves it on your machine.

bash .../run.sh --report html     # save an HTML report without asking
bash .../run.sh --no-report       # never ask

Framework mapping

Every WARN and FAIL explains the risk the control protects against and maps it to four AI security frameworks:

FrameworkVersionExample
OWASP Top 10 for LLM Applications2025LLM06 Excessive Agency
OWASP Top 10 for Agentic Applications2026ASI05 Unexpected Code Execution
MITRE ATLAS2026.09AML.T0086 Exfiltration via AI Agent Tool Invocation
NIST AI RMF1.0MEASURE 2.7, MANAGE 2.4

Reports end with a framework coverage summary of every OWASP risk, ATLAS technique and NIST subcategory your findings relate to, ready for a risk register. The full table is in FRAMEWORK-MAPPING.md. Mappings are AISecurityLabs.org's interpretation and are not endorsed by the framework owners.

Fix what it finds

Most findings are fixed by two things: a hardened ~/.claude/settings.json and two PreToolUse hooks. Both are below as examples to adapt and test.

Baseline settings.json

Adapt the paths and domains to your stack, then re-run the audit.

{
  "permissions": {
    "deny": [
      "Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)",
      "Read(~/.ssh/**)", "Read(~/.aws/**)", "Read(~/.config/gcloud/**)",
      "Bash(curl *)", "Bash(wget *)", "Bash(git push --force *)", "Bash(rm -rf *)"
    ],
    "ask": [
      "Bash(git push *)", "Bash(npm publish *)",
      "Bash(terraform *)", "Bash(kubectl *)"
    ],
    "allow": [
      "Bash(npm run test *)", "Bash(npm run lint *)",
      "Bash(git status)", "Bash(git diff *)"
    ]
  },
  "sandbox": {
    "enabled": true,
    "allowUnsandboxedCommands": false,
    "network": {
      "allowedDomains": ["registry.npmjs.org", "pypi.org", "files.pythonhosted.org"],
      "strictAllowlist": true
    },
    "credentials": {
      "files": [
        { "path": "~/.aws/credentials", "mode": "deny" },
        { "path": "~/.ssh", "mode": "deny" }
      ],
      "envVars": [
        { "name": "GITHUB_TOKEN", "mode": "deny" },
        { "name": "NPM_TOKEN", "mode": "deny" }
      ]
    }
  },
  "enabledMcpjsonServers": [],
  "cleanupPeriodDays": 14
}
SettingWhat it does
deny / ask / allowDeny wins over ask, and ask over allow. Keep allow to commands that can't cause harm; anything that pushes, publishes or deploys belongs in ask.
allowUnsandboxedCommands: falseStrict sandbox mode: removes the escape hatch that lets a failing command retry outside the sandbox.
strictAllowlist: trueSandboxed commands are denied, not prompted, for hosts outside the allowlist. Only takes effect in user, managed or command-line settings, so a repository can't turn it on for you.
credentialsBlocks reads of credential files and strips secret environment variables from sandboxed commands. A mask mode swaps in the real value only for approved hosts.
enabledMcpjsonServers: []Doesn't auto-enable MCP servers a repository declares. Never set enableAllProjectMcpServers: true.
cleanupPeriodDaysLocal transcripts hold code and anything that entered the context. Keep them short-lived.

Deny rules match what's written, so they're a speed bump, not a wall; the sandbox's network isolation is the real boundary. Setting keys evolve quickly, so check each one against the settings reference for your version.

Two PreToolUse hooks

A hook that exits with code 2 blocks the action deterministically and feeds its message back to Claude. You need both: when Write is blocked from touching .env, a model will often try echo "KEY=value" >> .env through Bash instead. Register them in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh" }]
      },
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-commands.sh" }]
      }
    ]
  }
}

.claude/hooks/protect-files.sh guards Edit and Write. It resolves symlinks first, so a harmless-looking name that points at agent config or dotfiles is judged by its real target, and blocks writes that land outside the project. Requires jq.

#!/usr/bin/env bash
# PreToolUse hook for the Edit and Write tools.
# Blocks edits to secrets, agent configuration, CI definitions, lockfiles and
# shell startup files. Exit code 2 blocks the action and sends the message on
# stderr back to Claude. Requires jq.
#
# Symlinks are resolved first, so a harmless-looking name that points at your
# agent config or dotfiles is judged by its real target (the "SymJack" attack).
# When CLAUDE_PROJECT_DIR is set (it is, inside Claude Code), writes that
# resolve outside the project are blocked too.
#
# It fails closed: if jq is missing, the input can't be parsed, or no file
# path can be found, the edit is blocked rather than allowed.

block() {
  echo "Blocked: $1. Ask the user to change it by hand." >&2
  exit 2
}

command -v jq >/dev/null 2>&1 || block "jq is not installed, so this hook can't check the edit"
input=$(cat)
path=$(printf '%s' "$input" | jq -er '.tool_input.file_path // .tool_input.notebook_path // empty' 2>/dev/null) || block "the hook could not read a file path from the tool input"

# resolve <path>: follow symlinks to the real location, even when the file
# (or some of its parent directories) doesn't exist yet. Plain POSIX tools
# only, so it behaves the same on Linux, macOS, WSL2 and BusyBox.
resolve() {
  local p="$1" target dir base rest="" i=0
  while [ -L "$p" ] && [ "$i" -lt 40 ]; do           # follow links on the final component
    target=$(readlink "$p")
    case "$target" in /*) p="$target" ;; *) p="$(dirname "$p")/$target" ;; esac
    i=$((i + 1))
  done
  dir=$(dirname "$p"); base=$(basename "$p")
  while [ ! -d "$dir" ] && [ "$dir" != "/" ] && [ "$dir" != "." ]; do   # walk up to an existing directory
    rest="/$(basename "$dir")$rest"; dir=$(dirname "$dir")
  done
  dir=$(cd -P "$dir" 2>/dev/null && pwd) || { printf '%s\n' "$1"; return; }
  printf '%s%s/%s\n' "${dir%/}" "$rest" "$base"
}

protected() {
  case "$1" in
    *.env|*.env.*|*/secrets/*|*.pem|*.key|\
    */.claude/*|*/.mcp.json|*/.claude.json|\
    */.github/workflows/*|*/.gitlab-ci.yml|\
    *lock.json|*.lock|*/go.sum|\
    */.bashrc|*/.zshrc|*/.profile|*/.bash_profile|*/.zprofile|*/.ssh/*|*/.aws/*)
      return 0 ;;
  esac
  return 1
}

real=$(resolve "$path")

protected "$path" && block "$path is a protected file"
[ "$real" != "$path" ] && protected "$real" && block "$path resolves to protected file $real"

if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
  project=$(resolve "$CLAUDE_PROJECT_DIR")
  case "$real" in
    "$project"|"$project"/*) ;;
    *) block "$path resolves to $real, outside the project" ;;
  esac
fi

exit 0

.claude/hooks/validate-commands.sh guards Bash:

#!/usr/bin/env bash
# PreToolUse hook for the Bash tool.
# Blocks recursive deletes, pipe-to-shell, destructive SQL, permission bypass,
# world-writable chmod, reads/writes of .env through the shell, and network
# commands that mention a secret. Exit code 2 blocks the action and sends the
# message on stderr back to Claude. Requires jq.
#
# These are pattern matches on command text: guardrails, not walls. Keep the
# sandbox on underneath them.
#
# It fails closed: if jq is missing or the input can't be parsed, the command
# is blocked rather than allowed.

block() {
  echo "Blocked: $1. Ask the user to run this by hand if it is really needed." >&2
  exit 2
}

command -v jq >/dev/null 2>&1 || block "jq is not installed, so this hook can't check the command"
input=$(cat)
cmd=$(printf '%s' "$input" | jq -er '.tool_input.command | strings' 2>/dev/null) || block "the hook could not read a command from the tool input"

grep -Eq 'rm +-[a-zA-Z]*r[a-zA-Z]*f|rm +-[a-zA-Z]*f[a-zA-Z]*r' <<<"$cmd" && block "recursive delete"
grep -Eq '(curl|wget)[^|]*\|[[:space:]]*(ba|z)?sh'              <<<"$cmd" && block "pipe to shell"
grep -Eiq 'drop +table|drop +database|delete +from|truncate +table' <<<"$cmd" && block "destructive SQL"
grep -Eq -- '--dangerously-skip-permissions'                    <<<"$cmd" && block "permission bypass"
grep -Eq 'chmod +(-R +)?777'                                    <<<"$cmd" && block "world-writable chmod"
grep -Eq '(>|>>|cat|less|more|head|tail|cp|mv)[^;|&]*\.env'     <<<"$cmd" && block "reading or writing .env"
if grep -Eq '(curl|wget|nc)\b' <<<"$cmd" && grep -Eiq 'key|token|secret|password' <<<"$cmd"; then
  block "network command that mentions a secret"
fi

exit 0

Make both executable (chmod +x .claude/hooks/*.sh). They are pattern matching: guardrails, not walls. Keep the sandbox on underneath. The audit's H03 check runs your installed hooks against a 41-case test suite.

Step-by-step guide

Each finding's step number points to the step-by-step hardening guide:

StepCovers
1. Before you startVersion, account, OS user, sandbox dependencies
2. Sandbox and permissionsThe baseline settings.json, explained setting by setting
3. HooksTwo PreToolUse hooks that block dangerous actions deterministically
4. MCP servers, plugins and repositoriesVetting extensions; inspecting a cloned repository before trusting it
5. Working habitsCLAUDE.md rules and the habits settings can’t enforce
6. Self-testProving the controls work in a live session
7. ChecklistOne-page summary to re-check after every upgrade

The audit reads configuration. To prove the controls actually block things in a live session, run the ten-minute self-test as well.

Delete it when you are done

The container is already gone. Remove the image, and the reports folder if you no longer need your reports:

docker image rm claude-code-audit

Informational only; no guarantee of a vulnerability-free system. The audit reads configuration and cannot detect every weakness. AISecurityLabs.org is independent and not affiliated with Anthropic. Claude Code is a trademark of its owner, used only to identify the product.