@fanzhen/agent-audit 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -0
- package/dist/agents.js +197 -0
- package/dist/cli.js +330 -0
- package/dist/demo.js +54 -0
- package/dist/discovery.js +68 -0
- package/dist/domains.js +87 -0
- package/dist/engine.js +83 -0
- package/dist/events.js +82 -0
- package/dist/footprint.js +482 -0
- package/dist/parsers/claude-code.js +184 -0
- package/dist/parsers/codex.js +349 -0
- package/dist/parsers/kimi.js +221 -0
- package/dist/parsers/zcode.js +333 -0
- package/dist/report.js +176 -0
- package/dist/rules/base.js +68 -0
- package/dist/rules/bypass.js +231 -0
- package/dist/rules/credentials.js +70 -0
- package/dist/rules/destructive.js +70 -0
- package/dist/rules/exfiltration.js +101 -0
- package/dist/rules/index.js +21 -0
- package/dist/rules/unsafe.js +85 -0
- package/dist/tty-gate.js +18 -0
- package/dist/watch-poller.js +105 -0
- package/dist/watch.js +280 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# agentaudit
|
|
2
|
+
|
|
3
|
+
**npm audit for your AI coding agents.**
|
|
4
|
+
|
|
5
|
+
One command scans your local Claude Code session history and reports every
|
|
6
|
+
dangerous action your agents ever took — destructive commands, credential
|
|
7
|
+
access, data exfiltration, persistence installs, unsafe downloads.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx @fanzhen/agent-audit # audit ~/.claude/projects immediately
|
|
11
|
+
agent-audit --demo # no Claude Code? try the built-in demo
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
───────────────────── agentaudit ─────────────────────
|
|
16
|
+
files 42 · sessions 87 · events 12,340 · findings 23
|
|
17
|
+
4 CRITICAL 9 HIGH 6 MEDIUM 4 LOW
|
|
18
|
+
──────────────────────────────────────────────────────
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What it detects (28 rules)
|
|
22
|
+
|
|
23
|
+
| Category | Examples |
|
|
24
|
+
|---|---|
|
|
25
|
+
| 🟥 Destructive | `rm -rf`, `git reset --hard`, force push, disk erase |
|
|
26
|
+
| 🔑 Credential access | reading `.env`, `id_rsa`, `~/.aws`, keychain queries |
|
|
27
|
+
| 📤 Exfiltration | `cat .env \| curl`, uploads to paste sites/webhooks |
|
|
28
|
+
| 🚪 Bypass & persistence | loosened `settings.json`, `.bashrc` edits, cron, `authorized_keys` |
|
|
29
|
+
| ⚠️ Unsafe execution | `curl \| sh`, base64 payloads, cloud metadata endpoints, reverse shells |
|
|
30
|
+
|
|
31
|
+
`agentaudit --list-rules` shows all of them with severities.
|
|
32
|
+
|
|
33
|
+
## Why
|
|
34
|
+
|
|
35
|
+
Agents run shell commands all day. In April 2026, Claude Code's deny rules
|
|
36
|
+
were shown to be silently bypassable and multiple command-injection flaws
|
|
37
|
+
were disclosed. Nobody reviews what their agent already did — until now.
|
|
38
|
+
|
|
39
|
+
## Install & usage
|
|
40
|
+
|
|
41
|
+
Requires Node 18+.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx @fanzhen/agent-audit # run without installing, or: npm i -g @fanzhen/agent-audit
|
|
45
|
+
agent-audit # audit default location
|
|
46
|
+
agent-audit ~/somewhere # audit a custom projects dir / .jsonl file
|
|
47
|
+
agent-audit --json # machine-readable output
|
|
48
|
+
agent-audit --severity high --rules E,C
|
|
49
|
+
agent-audit --session <id> # one session only
|
|
50
|
+
agent-audit --share # print a shareable summary card
|
|
51
|
+
agent-audit --watch # LIVE egress monitor (Windows; see below)
|
|
52
|
+
agent-audit --footprint # what Qoder indexed locally (see below)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Python 3.10+ alternative: `uvx agent-audit` (no install) or
|
|
56
|
+
`pipx install agent-audit` (command name: `agentaudit`).
|
|
57
|
+
|
|
58
|
+
- 100% local parsing. No network calls, no telemetry, ever.
|
|
59
|
+
- Works on Windows, macOS and Linux.
|
|
60
|
+
|
|
61
|
+
## Watch mode (v0.2.x, Windows only)
|
|
62
|
+
|
|
63
|
+
Besides auditing history, agent-audit can also watch what your AI coding
|
|
64
|
+
tools are connecting to RIGHT NOW: it polls the TCP table (each poll spawns a PowerShell query — effective cadence is a few seconds) for the
|
|
65
|
+
watched processes' established connections, labels each target against a
|
|
66
|
+
built-in registry of known-agent domains (`model-api` / `telemetry` /
|
|
67
|
+
`update` / `captcha` / `community`), and alerts on anything outside it.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
agent-audit --watch # watch all known AI tools, 60s
|
|
71
|
+
agent-audit --watch --proc ZCode,QoderCN # specific processes (no .exe)
|
|
72
|
+
agent-audit --watch --seconds 300 --csv out.csv # record to CSV
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
[10:13:37] claude(11852) → 160.79.104.10:443 api.anthropic.com (model-api)
|
|
77
|
+
[!] [10:13:37] claude(11852) → 47.96.134.91:443 (unknown — no DNS mapping observed)
|
|
78
|
+
──── agent-audit watch ────
|
|
79
|
+
watched 14s · procs 3 · polls 5 · new connections 3 · dns entries 11
|
|
80
|
+
by category: model-api 1 · unknown 2
|
|
81
|
+
[!] unknown targets: 47.96.134.91:443 (claude)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Hostnames come from the Windows DNS cache sampled during the watch — an IP
|
|
85
|
+
that never resolves there is reported as unknown, never guessed. Ctrl+C stops
|
|
86
|
+
early and still prints the summary. On non-Windows platforms `--watch` exits
|
|
87
|
+
with code 2 (`watch: Windows-only in v0.2.x`).
|
|
88
|
+
|
|
89
|
+
Exit codes: `0` on success (findings do NOT change the exit code yet — a
|
|
90
|
+
`--fail-on` flag is planned), `2` on bad options or missing data dir.
|
|
91
|
+
|
|
92
|
+
## Footprint mode (v0.2.x): what has Qoder collected?
|
|
93
|
+
|
|
94
|
+
`--footprint` answers a different question than the audit: not "what did an
|
|
95
|
+
agent DO" but "what does a tool hold FROM you". For Qoder CN it inventories
|
|
96
|
+
the local index stores under `~/.qoder-cn/shared_client/`: per repo it reads
|
|
97
|
+
the vector index's chunk table (absolute file paths — metadata
|
|
98
|
+
only), counts completion-index `.zap` segments (which hold recoverable source
|
|
99
|
+
text — reported as files + bytes, never opened), git/graph index sizes,
|
|
100
|
+
project-memory file names, and workspace memory notes.
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
agent-audit --footprint # inventory ~/.qoder-cn
|
|
104
|
+
agent-audit --footprint --json # machine-readable (includes the file list)
|
|
105
|
+
agent-audit --footprint --agent qoder # explicit (only qoder in v0.2.x)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Privacy: the report LISTS what was collected (repos, file paths, chunk
|
|
109
|
+
counts, index timestamps) — it never reads or prints file CONTENT. Exits 2
|
|
110
|
+
for any other `--agent`.
|
|
111
|
+
|
|
112
|
+
## Non-goals (v0.2.x, stated plainly)
|
|
113
|
+
|
|
114
|
+
- **A tool's own background network traffic at content level**: TLS-encrypted
|
|
115
|
+
on the wire; `--watch` reports WHO it talks to, not WHAT it sends. For
|
|
116
|
+
content-level proof use the canary method (unique marker strings planted in
|
|
117
|
+
a throwaway repo, then searched in the tool's local stores and captured
|
|
118
|
+
traffic).
|
|
119
|
+
- **Trae chat history**: stored locally in an encrypted database — not
|
|
120
|
+
auditable until the format opens up.
|
|
121
|
+
- **Qoder chat history**: lives server-side; only the local data footprint
|
|
122
|
+
(see `--footprint`) is visible.
|
|
123
|
+
|
|
124
|
+
## Implementation note
|
|
125
|
+
|
|
126
|
+
v0.2 rewrote agent-audit in TypeScript as the canonical implementation
|
|
127
|
+
(`npm/`) — output-equivalent to the Python original on the v0.1 surface,
|
|
128
|
+
verified by an automated equivalence harness on the demo, boundary corpora,
|
|
129
|
+
and real session data (v0.2.x adds TS-only summary fields such as `by_agent`,
|
|
130
|
+
which the harness normalizes out of the parity comparison). The Python
|
|
131
|
+
implementation (`src/agentaudit`) is frozen at v0.1.1 as the porting
|
|
132
|
+
reference and spec.
|
|
133
|
+
|
|
134
|
+
## Roadmap
|
|
135
|
+
|
|
136
|
+
- v0.2: done — TypeScript/npm canonical port (byte-for-byte equivalent to the Python original)
|
|
137
|
+
- v0.2.x: Codex CLI parser (done in 0.3.0) · Gemini CLI (pending one verification session) · SARIF export
|
|
138
|
+
- v0.3: guard mode — block dangerous actions before they run (PreToolUse hooks)
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT
|
package/dist/agents.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Agent registry: per-agent session discovery + parser (v0.2.x multi-agent
|
|
2
|
+
// plan, M1). TS canonical — the Python side is frozen at v0.1.1.
|
|
3
|
+
//
|
|
4
|
+
// Error policy (binding plan decision):
|
|
5
|
+
// - claude-code.find() THROWS DataDirNotFoundError when its root is missing
|
|
6
|
+
// (back-compat with the v0.1 CLI error path and its tests);
|
|
7
|
+
// - kimi.find() silently returns [] — an optional install must never fail
|
|
8
|
+
// an audit. The CLI demotes a missing DEFAULT root to a stderr hint when
|
|
9
|
+
// several agents are selected, and keeps failing loudly for an explicit
|
|
10
|
+
// path the user pointed at.
|
|
11
|
+
// - zcode.find() follows the kimi policy (missing store -> []) plus a
|
|
12
|
+
// node:sqlite runtime gate: without the builtin it returns [] with a
|
|
13
|
+
// single stderr hint (parsers/zcode.ts rejects loudly if called anyway).
|
|
14
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { comparePaths, findSessionFiles } from "./discovery.js";
|
|
18
|
+
import { iterEvents as iterClaudeEvents, } from "./parsers/claude-code.js";
|
|
19
|
+
import { iterEvents as iterCodexEvents } from "./parsers/codex.js";
|
|
20
|
+
import { iterEvents as iterKimiEvents } from "./parsers/kimi.js";
|
|
21
|
+
import { iterEvents as iterZcodeEvents, loadNodeSqlite } from "./parsers/zcode.js";
|
|
22
|
+
// Kimi-Code session store (real-data verified 2026-09-20):
|
|
23
|
+
// ~/.kimi-code/sessions/wd_<dirname>_<hash>/session_<uuid>/agents/main/wire.jsonl
|
|
24
|
+
export function defaultKimiSessionsDir() {
|
|
25
|
+
// Python parity with discovery.default_claude_projects_dir: Path.home()/...
|
|
26
|
+
return join(homedir(), ".kimi-code", "sessions");
|
|
27
|
+
}
|
|
28
|
+
function listDirDirs(dir) {
|
|
29
|
+
try {
|
|
30
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
31
|
+
.filter((e) => e.isDirectory() && !e.isSymbolicLink())
|
|
32
|
+
.map((e) => e.name);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// racy removal / permission error: treat as absent (kimi discovery is
|
|
36
|
+
// best-effort by policy)
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Exact-shape walk (NOT rglob): only wd_*/session_*/agents/main/wire.jsonl
|
|
41
|
+
// counts. Subagent transcripts (agents/<other>/) are out of scope for v0.2.x.
|
|
42
|
+
// Missing root -> empty list (silent skip), NOT an error.
|
|
43
|
+
function findKimiSessionFiles(root) {
|
|
44
|
+
const base = root || defaultKimiSessionsDir();
|
|
45
|
+
if (!existsSync(base)) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const wd of listDirDirs(base)) {
|
|
50
|
+
if (!wd.startsWith("wd_")) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const wdPath = join(base, wd);
|
|
54
|
+
for (const sess of listDirDirs(wdPath)) {
|
|
55
|
+
if (!sess.startsWith("session_")) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const wire = join(wdPath, sess, "agents", "main", "wire.jsonl");
|
|
59
|
+
if (existsSync(wire)) {
|
|
60
|
+
out.push(wire);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out.sort(comparePaths);
|
|
65
|
+
}
|
|
66
|
+
// Codex CLI session store (format ground truth:
|
|
67
|
+
// docs/superpowers/research/2026-09-20-codex-format.md):
|
|
68
|
+
// ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<compact-ts>-<thread-uuid>.jsonl
|
|
69
|
+
// + fork names rollout-<ts>-<thread-uuid>_<rollout-id>.jsonl
|
|
70
|
+
// Newer builds may compress cold rollouts to .jsonl.zst; v0.2.x does not
|
|
71
|
+
// decompress zstd, so discovery EXCLUDES them (the parser would only see
|
|
72
|
+
// binary garbage — better than counting unparseable files).
|
|
73
|
+
export function defaultCodexSessionsDir() {
|
|
74
|
+
// Python parity with discovery.default_claude_projects_dir: Path.home()/...
|
|
75
|
+
return join(homedir(), ".codex", "sessions");
|
|
76
|
+
}
|
|
77
|
+
// rglob("*.jsonl") over the sessions tree — same case-sensitive suffix rule
|
|
78
|
+
// and no-symlink-follow as discovery.walk (the YYYY/MM/DD nesting makes an
|
|
79
|
+
// exact-shape walk pointless; rollout files only live under sessions/).
|
|
80
|
+
// Missing root -> empty list (silent skip), NOT an error (optional agent).
|
|
81
|
+
function findCodexSessionFiles(root) {
|
|
82
|
+
const base = root || defaultCodexSessionsDir();
|
|
83
|
+
if (!existsSync(base)) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const out = [];
|
|
87
|
+
const walk = (dir) => {
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// racy removal / permission error: best-effort, like kimi discovery
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const full = join(dir, entry.name);
|
|
98
|
+
if (entry.isDirectory()) {
|
|
99
|
+
if (!entry.isSymbolicLink()) {
|
|
100
|
+
walk(full);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
104
|
+
out.push(full); // .jsonl.zst does NOT end in .jsonl — excluded here
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
walk(base);
|
|
109
|
+
return out.sort(comparePaths);
|
|
110
|
+
}
|
|
111
|
+
// ZCode (Z.ai desktop IDE) session store — ONE sqlite db file, not a tree:
|
|
112
|
+
// ~/.zcode/cli/db/db.sqlite (+ live -wal/-shm siblings; the parser copies
|
|
113
|
+
// them before reading, see parsers/zcode.ts). Explicit roots: the db file
|
|
114
|
+
// itself, or a directory containing db.sqlite.
|
|
115
|
+
export function defaultZcodeDbPath() {
|
|
116
|
+
// Python parity with discovery.default_claude_projects_dir: Path.home()/...
|
|
117
|
+
return join(homedir(), ".zcode", "cli", "db", "db.sqlite");
|
|
118
|
+
}
|
|
119
|
+
// node:sqlite availability hint must print at most once per process even if
|
|
120
|
+
// find() runs several times (--list-agents, multi-agent discovery).
|
|
121
|
+
let zcodeGateHinted = false;
|
|
122
|
+
function findZcodeSessionFiles(root) {
|
|
123
|
+
let target;
|
|
124
|
+
if (root) {
|
|
125
|
+
let isFile = false;
|
|
126
|
+
try {
|
|
127
|
+
isFile = statSync(root).isFile();
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// not stat-able -> treat as a directory and look for db.sqlite inside
|
|
131
|
+
}
|
|
132
|
+
target = isFile ? root : join(root, "db.sqlite");
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
target = defaultZcodeDbPath();
|
|
136
|
+
}
|
|
137
|
+
if (!existsSync(target)) {
|
|
138
|
+
return []; // optional agent: silent skip, NOT an error
|
|
139
|
+
}
|
|
140
|
+
// Runtime gate: the store is sqlite, so without the node:sqlite builtin
|
|
141
|
+
// (Node >= 22.5) there is no way to read it. Skip for `all` runs with a
|
|
142
|
+
// single stderr hint (stderr keeps --json stdout pure); an explicitly
|
|
143
|
+
// pointed-at path still yields [] here — the parser itself rejects loudly
|
|
144
|
+
// when called directly without the builtin.
|
|
145
|
+
if (loadNodeSqlite() === null) {
|
|
146
|
+
if (!zcodeGateHinted) {
|
|
147
|
+
zcodeGateHinted = true;
|
|
148
|
+
process.stderr.write("zcode: node:sqlite unavailable on this Node runtime (>= 22.5 required); skipping the ZCode store\n");
|
|
149
|
+
}
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
return [target];
|
|
153
|
+
}
|
|
154
|
+
export const AGENTS = {
|
|
155
|
+
"claude-code": {
|
|
156
|
+
id: "claude-code",
|
|
157
|
+
displayName: "Claude Code",
|
|
158
|
+
find: (root) => findSessionFiles(root),
|
|
159
|
+
parser: { iterEvents: (path, stats) => iterClaudeEvents(path, stats) },
|
|
160
|
+
},
|
|
161
|
+
kimi: {
|
|
162
|
+
id: "kimi",
|
|
163
|
+
displayName: "Kimi Code",
|
|
164
|
+
find: (root) => findKimiSessionFiles(root),
|
|
165
|
+
parser: { iterEvents: (path, stats) => iterKimiEvents(path, stats) },
|
|
166
|
+
},
|
|
167
|
+
codex: {
|
|
168
|
+
id: "codex",
|
|
169
|
+
displayName: "Codex CLI",
|
|
170
|
+
find: (root) => findCodexSessionFiles(root),
|
|
171
|
+
parser: { iterEvents: (path, stats) => iterCodexEvents(path, stats) },
|
|
172
|
+
},
|
|
173
|
+
zcode: {
|
|
174
|
+
id: "zcode",
|
|
175
|
+
displayName: "ZCode",
|
|
176
|
+
find: (root) => findZcodeSessionFiles(root),
|
|
177
|
+
parser: { iterEvents: (path, stats) => iterZcodeEvents(path, stats) },
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
// Discovery over the selected agents' DEFAULT roots (explicit per-agent roots
|
|
181
|
+
// are a CLI concern). Entries are sorted with the same pathlib-style compare
|
|
182
|
+
// as discovery so multi-agent runs are deterministic. Per-descriptor error
|
|
183
|
+
// policy applies: a missing kimi root contributes nothing, a missing claude
|
|
184
|
+
// root throws.
|
|
185
|
+
export function findAgentFiles(ids) {
|
|
186
|
+
const entries = [];
|
|
187
|
+
for (const id of ids) {
|
|
188
|
+
const agent = AGENTS[id];
|
|
189
|
+
if (!agent) {
|
|
190
|
+
throw new Error(`unknown agent id: ${id} (known: ${Object.keys(AGENTS).join(", ")})`);
|
|
191
|
+
}
|
|
192
|
+
for (const path of agent.find()) {
|
|
193
|
+
entries.push({ agent: id, path });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return entries.sort((a, b) => comparePaths(a.path, b.path));
|
|
197
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agentaudit CLI entry point. Faithful port of src/agentaudit/cli.py
|
|
3
|
+
// (typer -> commander; Python implementation is the spec).
|
|
4
|
+
//
|
|
5
|
+
// MUST keep `import "./tty-gate.js"` as the first import: ESM evaluates
|
|
6
|
+
// imports in declaration order, and picocolors (via report.js) decides color
|
|
7
|
+
// support ONCE at import time — the gate needs to set NO_COLOR before that
|
|
8
|
+
// for piped (non-TTY) runs (ledger M).
|
|
9
|
+
import "./tty-gate.js";
|
|
10
|
+
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { Command, CommanderError } from "commander";
|
|
15
|
+
import Table from "cli-table3";
|
|
16
|
+
import { AGENTS } from "./agents.js";
|
|
17
|
+
import { writeDemoSession } from "./demo.js";
|
|
18
|
+
import { DataDirNotFoundError, comparePaths } from "./discovery.js";
|
|
19
|
+
import { runAudit } from "./engine.js";
|
|
20
|
+
import { SEVERITY_ORDER } from "./events.js";
|
|
21
|
+
import { qoderFootprint, renderFootprint } from "./footprint.js";
|
|
22
|
+
import { SEV_LABEL, renderTerminal, shareCard, toDict } from "./report.js";
|
|
23
|
+
import { CATEGORY_TITLES, allRules } from "./rules/index.js";
|
|
24
|
+
import { defaultWatchDeps } from "./watch-poller.js";
|
|
25
|
+
import { DEFAULT_WATCH_PROCS, WATCH_DEFAULT_SECONDS, WatchUnsupportedError, parseWatchProcs, renderWatchSummary, runWatch, } from "./watch.js";
|
|
26
|
+
// Keep in sync with npm/package.json "version" (importing package.json would
|
|
27
|
+
// need JSON import attributes, which Node 18 does not support).
|
|
28
|
+
export const VERSION = "0.3.0";
|
|
29
|
+
const description = "npm audit for your AI coding agents - audit dangerous actions in agent history.";
|
|
30
|
+
export async function main(argv, io = {}) {
|
|
31
|
+
const stdout = io.stdout ?? ((chunk) => process.stdout.write(chunk));
|
|
32
|
+
const stderr = io.stderr ?? ((chunk) => process.stderr.write(chunk));
|
|
33
|
+
let exitCode;
|
|
34
|
+
const program = new Command();
|
|
35
|
+
program
|
|
36
|
+
.name("agent-audit")
|
|
37
|
+
.description(description)
|
|
38
|
+
// main() never calls process.exit; commander's exit paths (help, usage
|
|
39
|
+
// errors) are surfaced as CommanderError instead
|
|
40
|
+
.exitOverride()
|
|
41
|
+
.configureOutput({ writeOut: stdout, writeErr: stderr })
|
|
42
|
+
.argument("[path]", "Claude Code projects dir (default ~/.claude/projects) or a .jsonl file")
|
|
43
|
+
.option("--json", "JSON output for scripts/CI")
|
|
44
|
+
.option("--severity <level>", "Minimum severity to show: critical|high|medium|low|info", "low")
|
|
45
|
+
.option("--session <id>", "Audit one session id")
|
|
46
|
+
.option("--rules <prefixes>", "Rule category prefixes, comma separated (D,C,E,B,U)")
|
|
47
|
+
.option("--list-rules", "List all rules and exit")
|
|
48
|
+
.option("--agent <ids>", "Agents to audit, comma separated: claude-code|kimi|codex|zcode|all (--footprint: qoder only)", "all")
|
|
49
|
+
.option("--list-agents", "List known agents and exit")
|
|
50
|
+
.option("--share", "Print a shareable summary card")
|
|
51
|
+
.option("--demo", "Run on built-in demo data")
|
|
52
|
+
// M5 watch mode: live per-process TCP egress monitoring (Windows only
|
|
53
|
+
// in v0.2.x; watch is a MODE on the single-command CLI, not a
|
|
54
|
+
// subcommand). Audit flags are ignored in watch mode and vice versa.
|
|
55
|
+
.option("--watch", "Watch AI-tool processes' live TCP egress (Windows only)")
|
|
56
|
+
.option("--proc <names>", "Watch: process names, comma separated (.exe stripped); default: all known AI tools")
|
|
57
|
+
.option("--seconds <n>", "Watch: how long to poll, seconds", String(WATCH_DEFAULT_SECONDS))
|
|
58
|
+
.option("--csv <path>", "Watch: append one CSV row per new connection")
|
|
59
|
+
// M6 footprint mode: local-data inventory report (Qoder CN only in
|
|
60
|
+
// v0.2.x). Like --watch, a MODE on the single-command CLI: audit flags
|
|
61
|
+
// do not apply. The positional [path] overrides the Qoder data root.
|
|
62
|
+
.option("--footprint", "Inventory what a tool collected locally (Qoder index stores)")
|
|
63
|
+
.option("--version", "Show version")
|
|
64
|
+
.action(async (pathArg, opts) => {
|
|
65
|
+
exitCode = await auditCommand(pathArg, opts, stdout, stderr, io.watchDeps);
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
// argv is user-typed args only (bin wrapper slices process.argv)
|
|
69
|
+
await program.parseAsync(argv, { from: "user" });
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
if (err instanceof CommanderError) {
|
|
73
|
+
// help/version paths exit 0; click/typer (the spec) uses exit code 2
|
|
74
|
+
// for every usage error (unknown option, bad value), commander uses 1
|
|
75
|
+
return err.exitCode === 0 ? 0 : 2;
|
|
76
|
+
}
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
return exitCode ?? 0;
|
|
80
|
+
}
|
|
81
|
+
// Python: audit(...) — the single typer command body
|
|
82
|
+
async function auditCommand(pathArg, opts, stdout, stderr, watchInject) {
|
|
83
|
+
if (opts.version) {
|
|
84
|
+
stdout(`agent-audit ${VERSION}\n`);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
// M5: watch is a mode on this command; audit flags below do not apply.
|
|
88
|
+
if (opts.watch) {
|
|
89
|
+
return watchCommand(opts, stdout, stderr, watchInject);
|
|
90
|
+
}
|
|
91
|
+
// M6: footprint is a mode too (checked before the watch-flag hint below so
|
|
92
|
+
// `--footprint` never falls through into an audit).
|
|
93
|
+
if (opts.footprint) {
|
|
94
|
+
return footprintCommand(opts, pathArg, stdout, stderr);
|
|
95
|
+
}
|
|
96
|
+
if (opts.proc !== undefined || opts.csv !== undefined) {
|
|
97
|
+
// common typo guard: --proc/--csv silently doing nothing would confuse
|
|
98
|
+
stderr("note: --proc/--seconds/--csv apply to --watch mode; ignored\n");
|
|
99
|
+
}
|
|
100
|
+
if (opts.listRules) {
|
|
101
|
+
// Python: rich Table(title=f"agentaudit rules ({len(all_rules())})")
|
|
102
|
+
const rules = allRules();
|
|
103
|
+
stdout(`agentaudit rules (${rules.length})\n`);
|
|
104
|
+
const table = new Table({
|
|
105
|
+
head: ["ID", "SEVERITY", "CATEGORY", "TITLE"],
|
|
106
|
+
style: { head: [], border: [] },
|
|
107
|
+
});
|
|
108
|
+
for (const rule of rules) {
|
|
109
|
+
table.push([
|
|
110
|
+
rule.id,
|
|
111
|
+
SEV_LABEL[rule.severity],
|
|
112
|
+
CATEGORY_TITLES.get(rule.id[0]) ?? "",
|
|
113
|
+
rule.title,
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
stdout(`${table.toString()}\n`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
if (opts.listAgents) {
|
|
120
|
+
// v0.2.x (TS-only flag, no Python parity): informational listing of the
|
|
121
|
+
// agent registry with per-agent default-root file counts. A missing data
|
|
122
|
+
// root just shows 0 — this is a listing, not an audit.
|
|
123
|
+
const agents = Object.values(AGENTS);
|
|
124
|
+
stdout(`agentaudit agents (${agents.length})\n`);
|
|
125
|
+
const table = new Table({
|
|
126
|
+
head: ["ID", "NAME", "SESSION FILES"],
|
|
127
|
+
style: { head: [], border: [] },
|
|
128
|
+
});
|
|
129
|
+
for (const agent of agents) {
|
|
130
|
+
let count = 0;
|
|
131
|
+
try {
|
|
132
|
+
count = agent.find().length;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
count = 0;
|
|
136
|
+
}
|
|
137
|
+
table.push([agent.id, agent.displayName, String(count)]);
|
|
138
|
+
}
|
|
139
|
+
stdout(`${table.toString()}\n`);
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
// Python: _parse_severity — typer.BadParameter exits 2 with this message
|
|
143
|
+
const severityValue = opts.severity.toLowerCase();
|
|
144
|
+
if (!SEVERITY_ORDER.includes(severityValue)) {
|
|
145
|
+
stderr(`error: must be one of: ${SEVERITY_ORDER.join("|")}\n`);
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
|
148
|
+
const floor = severityValue;
|
|
149
|
+
// Python: {c.strip().upper() for c in rules.split(",") if c.strip()} or None
|
|
150
|
+
const prefixes = opts.rules
|
|
151
|
+
? new Set(opts.rules
|
|
152
|
+
.split(",")
|
|
153
|
+
.map((c) => c.trim())
|
|
154
|
+
.filter((c) => c)
|
|
155
|
+
.map((c) => c.toUpperCase()))
|
|
156
|
+
: undefined;
|
|
157
|
+
// v0.2.x: --agent id[,id...]|all (default all = every registered agent).
|
|
158
|
+
// "all" expands to registry order; unknown ids exit 2 (typer usage-error code).
|
|
159
|
+
const agentNames = [...new Set(opts.agent.split(",").map((s) => s.trim()).filter(Boolean))];
|
|
160
|
+
const ids = agentNames.includes("all") ? Object.keys(AGENTS) : agentNames;
|
|
161
|
+
if (ids.length === 0) {
|
|
162
|
+
stderr(`error: no agent ids given (known: ${Object.keys(AGENTS).join(", ")})\n`);
|
|
163
|
+
return 2;
|
|
164
|
+
}
|
|
165
|
+
const unknownId = ids.find((id) => !AGENTS[id]);
|
|
166
|
+
if (unknownId) {
|
|
167
|
+
stderr(`error: unknown agent "${unknownId}" (known: ${Object.keys(AGENTS).join(", ")})\n`);
|
|
168
|
+
return 2;
|
|
169
|
+
}
|
|
170
|
+
let result;
|
|
171
|
+
if (opts.demo) {
|
|
172
|
+
// Python: tempfile.TemporaryDirectory() context manager
|
|
173
|
+
const tmp = mkdtempSync(join(tmpdir(), "agentaudit-demo-"));
|
|
174
|
+
try {
|
|
175
|
+
const files = [writeDemoSession(tmp)];
|
|
176
|
+
result = await runAudit(files, prefixes, opts.session);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
// Python: if path is not None and path.is_file() -> [path], else
|
|
184
|
+
// find_session_files(path); DataDirNotFound -> "error: ..." + exit 2.
|
|
185
|
+
// v0.2.x: discovery runs per selected agent over {agent, path} entries so
|
|
186
|
+
// the engine can route each file to the right parser.
|
|
187
|
+
let entries = null;
|
|
188
|
+
if (pathArg !== undefined) {
|
|
189
|
+
try {
|
|
190
|
+
if (statSync(pathArg).isFile()) {
|
|
191
|
+
// explicit file: route through the FIRST selected agent (default
|
|
192
|
+
// "all" -> claude-code, preserving v0.1 behavior for file args)
|
|
193
|
+
entries = [{ agent: ids[0], path: pathArg }];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// not stat-able == Python's is_file() False -> fall through to discovery
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (entries === null) {
|
|
201
|
+
entries = [];
|
|
202
|
+
try {
|
|
203
|
+
for (const id of ids) {
|
|
204
|
+
try {
|
|
205
|
+
for (const path of AGENTS[id].find(pathArg)) {
|
|
206
|
+
entries.push({ agent: id, path });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
// Error policy: an explicitly given path that cannot be scanned
|
|
211
|
+
// still fails loudly (v0.1 behavior, asserted by tests). Only a
|
|
212
|
+
// MISSING DEFAULT root (no path argument) demotes a single agent
|
|
213
|
+
// to a stderr hint when several were selected.
|
|
214
|
+
if (err instanceof DataDirNotFoundError && pathArg === undefined && ids.length > 1) {
|
|
215
|
+
stderr(`skipping ${id}: data directory not found\n`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
if (err instanceof DataDirNotFoundError) {
|
|
224
|
+
// Python: DataDirNotFound -> "error: ..." + exit 2
|
|
225
|
+
stderr(`error: ${err.message}\n`);
|
|
226
|
+
return 2;
|
|
227
|
+
}
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
// deterministic interleaving of per-agent discoveries (pathlib compare)
|
|
231
|
+
entries.sort((a, b) => comparePaths(a.path, b.path));
|
|
232
|
+
}
|
|
233
|
+
// stderr keeps --json stdout pure; real dirs can take ~10s before output
|
|
234
|
+
stderr(`scanning ${entries.length} session file(s)...\n`);
|
|
235
|
+
result = await runAudit(entries, prefixes, opts.session);
|
|
236
|
+
}
|
|
237
|
+
// (ADJUSTMENT B) severity floor applies to BOTH terminal and JSON modes
|
|
238
|
+
result.findings = result.findings.filter((f) => SEVERITY_ORDER.indexOf(f.severity) >= SEVERITY_ORDER.indexOf(floor));
|
|
239
|
+
if (opts.json) {
|
|
240
|
+
// plain stdout, no color; JSON.stringify is natively unicode, matching
|
|
241
|
+
// Python's json.dumps(ensure_ascii=False)
|
|
242
|
+
stdout(`${JSON.stringify(toDict(result), null, 2)}\n`);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
renderTerminal(result, floor, stdout);
|
|
246
|
+
}
|
|
247
|
+
if (opts.share && !opts.json) {
|
|
248
|
+
// card would corrupt the machine-readable JSON stream on stdout
|
|
249
|
+
stdout("\n");
|
|
250
|
+
stdout(`${shareCard(result)}\n`);
|
|
251
|
+
}
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
// M5 watch mode: poll Get-NetTCPConnection via a PowerShell child once per
|
|
255
|
+
// ~700ms, label targets against the domain registry, print a live line per
|
|
256
|
+
// NEW connection, a summary at the end (and on Ctrl+C), append CSV rows.
|
|
257
|
+
// Windows-only in v0.2.x — anything else exits 2 with the reason.
|
|
258
|
+
async function watchCommand(opts, stdout, stderr, inject) {
|
|
259
|
+
// default list = every known AI-tool process name; --proc replaces it
|
|
260
|
+
const procs = opts.proc !== undefined ? parseWatchProcs(opts.proc) : [...DEFAULT_WATCH_PROCS];
|
|
261
|
+
if (procs.length === 0) {
|
|
262
|
+
stderr("error: --proc must name at least one process (comma separated)\n");
|
|
263
|
+
return 2;
|
|
264
|
+
}
|
|
265
|
+
const seconds = Number(opts.seconds);
|
|
266
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
267
|
+
stderr(`error: --seconds must be a positive number (got "${opts.seconds}")\n`);
|
|
268
|
+
return 2;
|
|
269
|
+
}
|
|
270
|
+
const deps = {
|
|
271
|
+
...defaultWatchDeps(),
|
|
272
|
+
// live lines go through the SAME injected writer as everything else
|
|
273
|
+
writeLine: (line) => stdout(`${line}\n`),
|
|
274
|
+
...(inject ?? {}),
|
|
275
|
+
};
|
|
276
|
+
// Ctrl+C: abort the watch, runWatch returns the partial result and the
|
|
277
|
+
// summary below still prints (same output as a natural end).
|
|
278
|
+
const controller = new AbortController();
|
|
279
|
+
const onSigint = () => controller.abort();
|
|
280
|
+
process.on("SIGINT", onSigint);
|
|
281
|
+
try {
|
|
282
|
+
stdout(`watching ${procs.join(", ")} for ${seconds}s (Ctrl+C to stop) — ` +
|
|
283
|
+
"unknown targets are flagged [!]\n");
|
|
284
|
+
const result = await runWatch({ procs, seconds, csvPath: opts.csv, signal: controller.signal }, deps);
|
|
285
|
+
stdout(renderWatchSummary(result, opts.csv));
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
if (err instanceof WatchUnsupportedError) {
|
|
290
|
+
stderr(`${err.message}\n`);
|
|
291
|
+
return 2;
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
process.removeListener("SIGINT", onSigint);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// M6 footprint mode: inventory the local index stores of one tool (Qoder CN
|
|
300
|
+
// only in v0.2.x — anything else exits 2 with the reason). NOT an audit: the
|
|
301
|
+
// report lists what the tool collected (repos, file paths, counts, times);
|
|
302
|
+
// see src/footprint.ts for the privacy posture. The positional [path]
|
|
303
|
+
// overrides the tool's default data root (also how tests point at fixtures).
|
|
304
|
+
function footprintCommand(opts, pathArg, stdout, stderr) {
|
|
305
|
+
const ids = opts.agent
|
|
306
|
+
.split(",")
|
|
307
|
+
.map((s) => s.trim())
|
|
308
|
+
.filter(Boolean);
|
|
309
|
+
// "all" is the commander default, i.e. literally the bare `--footprint`
|
|
310
|
+
// form — it maps to the only supported tool, qoder.
|
|
311
|
+
const unsupported = ids.filter((id) => id !== "qoder" && id !== "all");
|
|
312
|
+
if (unsupported.length > 0) {
|
|
313
|
+
stderr(`error: footprint only supports --agent qoder in v0.2.x (got: ${unsupported.join(", ")})\n`);
|
|
314
|
+
return 2;
|
|
315
|
+
}
|
|
316
|
+
const report = qoderFootprint(pathArg);
|
|
317
|
+
if (opts.json) {
|
|
318
|
+
stdout(`${JSON.stringify(report, null, 2)}\n`);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
stdout(renderFootprint(report));
|
|
322
|
+
}
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
// Bin wiring: run only when invoked directly as `node dist/cli.js` (or via the
|
|
326
|
+
// npm bin shim), never when imported (tests call main() with injected writers).
|
|
327
|
+
const entry = process.argv[1];
|
|
328
|
+
if (entry && import.meta.url === pathToFileURL(entry).href) {
|
|
329
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
330
|
+
}
|