@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 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
+ }