@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.
@@ -0,0 +1,105 @@
1
+ // M5: the REAL watch deps for Windows — one PowerShell child process per
2
+ // poll (design decision: Node has no native per-process TCP table). Port of
3
+ // D:/zcode-test/watch_egress.ps1:
4
+ // - Get-NetTCPConnection -State Established, OwningProcess -> process name
5
+ // - Get-DnsClientCache A/AAAA entries piped in the SAME poll for ip->host
6
+ // NOT imported by unit tests (they inject fake deps into runWatch); only
7
+ // cli.ts wires this in.
8
+ //
9
+ // Robustness: the script is passed via -EncodedCommand (base64 UTF-16LE) so
10
+ // process names with spaces/quotes need no shell quoting; malformed JSON
11
+ // rows are skipped; a failed poll rejects and runWatch counts it (never
12
+ // aborts the watch).
13
+ import { execFile } from "node:child_process";
14
+ import { appendCsvLine } from "./watch.js";
15
+ function psQuote(name) {
16
+ // inside PowerShell single-quoted string, ' is escaped by doubling
17
+ return `'${name.replace(/'/g, "''")}'`;
18
+ }
19
+ export function buildPollScript(procs) {
20
+ const want = procs.map(psQuote).join(",");
21
+ return [
22
+ "$ErrorActionPreference='SilentlyContinue'",
23
+ `$want=@(${want})`,
24
+ "$pn=@{}",
25
+ "Get-Process | ForEach-Object { $pn[[uint32]$_.Id]=$_.ProcessName }",
26
+ "$rows=@(" +
27
+ "Get-NetTCPConnection -State Established | ForEach-Object { " +
28
+ "$n=$pn[[uint32]$_.OwningProcess]; " +
29
+ "if($n -and ($want -contains $n)){ " +
30
+ "[pscustomobject]@{t='c';p=$n;i=$_.OwningProcess;ip=$_.RemoteAddress;pt=$_.RemotePort}" +
31
+ " } }" +
32
+ ") + @(" +
33
+ "Get-DnsClientCache | ForEach-Object { " +
34
+ "if($_.Entry -match '\\.' -and ($_.Type -eq 1 -or $_.Type -eq 28)){ " +
35
+ "[pscustomobject]@{t='d';h=$_.Entry;ip=([string]$_.Data)}" +
36
+ " } }" +
37
+ ")",
38
+ "if($rows.Count -gt 0){ $rows | ConvertTo-Json -Compress }",
39
+ ].join(";");
40
+ }
41
+ function isRecord(v) {
42
+ return typeof v === "object" && v !== null;
43
+ }
44
+ export function parsePollOutput(stdout) {
45
+ const conns = [];
46
+ const dns = [];
47
+ const text = stdout.trim();
48
+ if (!text) {
49
+ return { conns, dns };
50
+ }
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(text);
54
+ }
55
+ catch {
56
+ return { conns, dns }; // never let a garbled poll crash the watch
57
+ }
58
+ const rows = Array.isArray(parsed) ? parsed : [parsed];
59
+ for (const row of rows) {
60
+ if (!isRecord(row)) {
61
+ continue;
62
+ }
63
+ const port = Number(row.pt);
64
+ const pid = Number(row.i);
65
+ if (row.t === "c" && typeof row.p === "string" && typeof row.ip === "string") {
66
+ if (Number.isInteger(port) && port > 0 && port < 65536 && Number.isFinite(pid)) {
67
+ conns.push({ proc: row.p, pid, ip: row.ip, port });
68
+ }
69
+ }
70
+ else if (row.t === "d" && typeof row.h === "string" && typeof row.ip === "string") {
71
+ dns.push({ host: row.h, ip: row.ip });
72
+ }
73
+ }
74
+ return { conns, dns };
75
+ }
76
+ export function powershellPoll(procs) {
77
+ const script = buildPollScript(procs);
78
+ return new Promise((resolve, reject) => {
79
+ execFile("powershell.exe", [
80
+ "-NoProfile",
81
+ "-NonInteractive",
82
+ "-EncodedCommand",
83
+ Buffer.from(script, "utf16le").toString("base64"),
84
+ ], {
85
+ timeout: 15000,
86
+ windowsHide: true,
87
+ maxBuffer: 32 * 1024 * 1024,
88
+ encoding: "utf8",
89
+ }, (err, stdout) => {
90
+ if (err) {
91
+ reject(err);
92
+ return;
93
+ }
94
+ resolve(parsePollOutput(stdout));
95
+ });
96
+ });
97
+ }
98
+ // The CLI's default dep set (tests override any part of it via MainIo.watchDeps).
99
+ export function defaultWatchDeps() {
100
+ return {
101
+ platform: process.platform,
102
+ poll: powershellPoll,
103
+ appendCsv: appendCsvLine,
104
+ };
105
+ }
package/dist/watch.js ADDED
@@ -0,0 +1,280 @@
1
+ // M5: watch mode core — live per-process TCP egress monitoring with domain
2
+ // classification. TS port of the manual D:/zcode-test/watch_egress.ps1
3
+ // polling design (Get-NetTCPConnection + Get-DnsClientCache, ~700ms cadence,
4
+ // dedupe on proc|ip|port), productized per the multi-agent plan.
5
+ //
6
+ // Testability contract: runWatch(opts, deps) takes EVERYTHING it touches as
7
+ // an injectable dep (poll/now/sleep/writeLine/appendCsv/platform). Unit
8
+ // tests NEVER spawn powershell — the real poller lives in watch-poller.ts
9
+ // and is wired only by the CLI (cli.ts -> defaultWatchDeps()).
10
+ //
11
+ // Honesty rules (binding design decisions):
12
+ // - connections are remote IPs; hostname labels only come from the DNS
13
+ // cache sampled during the watch window;
14
+ // - an IP with no DNS mapping is category "unknown" with a note, never a
15
+ // guessed IP-range label (domain registries' ranges are too broad);
16
+ // - a DNS-resolved hostname outside DOMAIN_RULES is also "unknown" and is
17
+ // alerted ([!]) as a whitelist-external domain.
18
+ import { existsSync, appendFileSync, statSync } from "node:fs";
19
+ import { CATEGORY_ORDER, classify } from "./domains.js";
20
+ export const WATCH_INTERVAL_MS = 700;
21
+ export const WATCH_DEFAULT_SECONDS = 60;
22
+ export const WATCH_WINDOWS_MESSAGE = "watch: Windows-only in v0.2.x (needs Get-NetTCPConnection/Get-DnsClientCache via PowerShell)";
23
+ export class WatchUnsupportedError extends Error {
24
+ constructor() {
25
+ super(WATCH_WINDOWS_MESSAGE);
26
+ this.name = "WatchUnsupportedError";
27
+ }
28
+ }
29
+ // Default watch list: AI coding tool process names observed on the dev
30
+ // machine / known installs (Get-Process names, no .exe suffix). Users add to
31
+ // it with --proc (replaces the default; comma separated).
32
+ export const DEFAULT_WATCH_PROCS = [
33
+ "ZCode",
34
+ "QoderCN",
35
+ "Qoder",
36
+ "Trae",
37
+ "Trae CN",
38
+ "kimi",
39
+ "kimi-code",
40
+ "codex",
41
+ "claude",
42
+ "gemini",
43
+ ];
44
+ export const CSV_HEADER = "ts,proc,pid,remote,host,category,note";
45
+ // CSV field escaping: quote when the value contains comma/quote/newline.
46
+ function csvField(value) {
47
+ const s = value === null ? "" : String(value);
48
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
49
+ }
50
+ export function csvRow(conn) {
51
+ return [
52
+ conn.at.toISOString(),
53
+ conn.proc,
54
+ conn.pid,
55
+ `${conn.ip}:${conn.port}`,
56
+ conn.host,
57
+ conn.category,
58
+ conn.note,
59
+ ]
60
+ .map(csvField)
61
+ .join(",");
62
+ }
63
+ // Real CSV writer (CLI default dep): header is prepended on file creation
64
+ // only, rows are appended (crash-safe, matches the ps1 prototype).
65
+ export function appendCsvLine(path, line) {
66
+ let needsHeader = true;
67
+ try {
68
+ needsHeader = !existsSync(path) || statSync(path).size === 0;
69
+ }
70
+ catch {
71
+ needsHeader = true;
72
+ }
73
+ appendFileSync(path, (needsHeader ? `${CSV_HEADER}\n` : "") + line + "\n", "utf8");
74
+ }
75
+ function pad2(n) {
76
+ return String(n).padStart(2, "0");
77
+ }
78
+ // Local wall-clock stamp for live lines: [HH:MM:SS]
79
+ function formatClock(at) {
80
+ return `${pad2(at.getHours())}:${pad2(at.getMinutes())}:${pad2(at.getSeconds())}`;
81
+ }
82
+ export function formatLiveLine(conn) {
83
+ const stamp = `[${formatClock(conn.at)}]`;
84
+ const target = `${conn.ip}:${conn.port}`;
85
+ const dest = conn.host ? `${target} ${conn.host}` : target;
86
+ if (conn.category === "unknown") {
87
+ const why = conn.note ?? "not in known-agent registry";
88
+ return `[!] ${stamp} ${conn.proc}(${conn.pid}) → ${dest} (unknown — ${why})`;
89
+ }
90
+ return `${stamp} ${conn.proc}(${conn.pid}) → ${dest} (${conn.category})`;
91
+ }
92
+ export function renderWatchSummary(result, csvPath) {
93
+ const lines = [];
94
+ lines.push("──── agent-audit watch ────");
95
+ const head = `watched ${Math.round(result.elapsedMs / 1000)}s · procs ${result.procs.length}` +
96
+ ` · polls ${result.polls} · new connections ${result.connections.length}` +
97
+ ` · dns entries ${result.dnsEntries}`;
98
+ lines.push(result.pollErrors > 0 ? `${head} · poll errors ${result.pollErrors}` : head);
99
+ if (result.connections.length > 0) {
100
+ const cats = CATEGORY_ORDER.filter((c) => (result.byCategory[c] ?? 0) > 0).map((c) => `${c} ${result.byCategory[c]}`);
101
+ if (cats.length > 0) {
102
+ lines.push(`by category: ${cats.join(" · ")}`);
103
+ }
104
+ lines.push(`by process: ${Object.entries(result.byProc)
105
+ .map(([p, n]) => `${p} ${n}`)
106
+ .join(" · ")}`);
107
+ if (result.unknownTargets.length > 0) {
108
+ lines.push(`[!] unknown targets: ${result.unknownTargets.join(" · ")}`);
109
+ }
110
+ }
111
+ if (csvPath) {
112
+ lines.push(`csv: ${csvPath}`);
113
+ }
114
+ return lines.map((l) => `${l}\n`).join("");
115
+ }
116
+ // CLI parsing for --proc: trim, strip .exe (users paste Task-Manager names),
117
+ // drop empties, dedupe preserving order.
118
+ export function parseWatchProcs(input) {
119
+ const out = [];
120
+ for (const raw of input.split(",")) {
121
+ let name = raw.trim();
122
+ if (name.toLowerCase().endsWith(".exe")) {
123
+ name = name.slice(0, -4);
124
+ }
125
+ if (name && !out.includes(name)) {
126
+ out.push(name);
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+ // Default sleep: real timer, wakes early on abort so Ctrl+C is snappy.
132
+ function defaultSleep(ms, signal) {
133
+ return new Promise((resolve) => {
134
+ if (signal?.aborted) {
135
+ resolve();
136
+ return;
137
+ }
138
+ const done = () => {
139
+ clearTimeout(timer);
140
+ signal?.removeEventListener("abort", done);
141
+ resolve();
142
+ };
143
+ const timer = setTimeout(done, ms);
144
+ signal?.addEventListener("abort", done, { once: true });
145
+ });
146
+ }
147
+ function buildConn(raw, dnsMap, at) {
148
+ const host = dnsMap.get(raw.ip) ?? null;
149
+ const hit = host !== null ? classify(host) : null;
150
+ const note = host === null
151
+ ? "no DNS mapping observed"
152
+ : hit === null
153
+ ? "domain not in known-agent registry"
154
+ : (hit.note ?? null);
155
+ return {
156
+ at,
157
+ proc: raw.proc,
158
+ pid: raw.pid,
159
+ ip: raw.ip,
160
+ port: raw.port,
161
+ host,
162
+ category: hit !== null ? hit.category : "unknown",
163
+ note,
164
+ };
165
+ }
166
+ export async function runWatch(opts, deps) {
167
+ const platform = deps.platform ?? process.platform;
168
+ if (platform !== "win32") {
169
+ throw new WatchUnsupportedError();
170
+ }
171
+ if (opts.procs.length === 0) {
172
+ throw new Error("watch: no process names given");
173
+ }
174
+ if (!(opts.seconds > 0) || !Number.isFinite(opts.seconds)) {
175
+ throw new Error(`watch: seconds must be a positive number (got ${opts.seconds})`);
176
+ }
177
+ const poll = deps.poll;
178
+ const now = deps.now ?? (() => new Date());
179
+ const sleep = deps.sleep ?? defaultSleep;
180
+ const writeLine = deps.writeLine ?? ((line) => process.stdout.write(`${line}\n`));
181
+ const appendCsv = deps.appendCsv ?? appendCsvLine;
182
+ const intervalMs = opts.intervalMs ?? WATCH_INTERVAL_MS;
183
+ const signal = opts.signal;
184
+ const empty = {
185
+ procs: opts.procs,
186
+ seconds: opts.seconds,
187
+ elapsedMs: 0,
188
+ polls: 0,
189
+ pollErrors: 0,
190
+ dnsEntries: 0,
191
+ connections: [],
192
+ byCategory: {},
193
+ byProc: {},
194
+ unknownTargets: [],
195
+ };
196
+ if (signal?.aborted) {
197
+ return empty;
198
+ }
199
+ const dnsMap = new Map(); // ip -> latest observed hostname
200
+ const dnsHosts = new Set();
201
+ const seen = new Set(); // dedupe key: proc|ip|port (prototype parity)
202
+ const connections = [];
203
+ const byCategory = {};
204
+ const byProc = {};
205
+ const unknownTargets = [];
206
+ const start = now().getTime();
207
+ let polls = 0;
208
+ let pollErrors = 0;
209
+ let attempts = 0;
210
+ let warnedPollFailure = false;
211
+ // do-at-least-one semantics for a healthy run, but a pre-aborted signal
212
+ // (checked above) and abort mid-run stop immediately. The loop guard uses
213
+ // ATTEMPTS, not successful polls, so a persistently failing poller still
214
+ // terminates.
215
+ while (!signal?.aborted &&
216
+ (attempts === 0 || now().getTime() - start < opts.seconds * 1000)) {
217
+ attempts += 1;
218
+ let sample = null;
219
+ try {
220
+ sample = await poll(opts.procs);
221
+ }
222
+ catch (err) {
223
+ pollErrors += 1;
224
+ if (!warnedPollFailure) {
225
+ warnedPollFailure = true;
226
+ const msg = err instanceof Error ? err.message : String(err);
227
+ writeLine(`[!] watch: poll failed (will retry): ${msg}`);
228
+ }
229
+ }
230
+ if (sample !== null) {
231
+ polls += 1;
232
+ for (const d of sample.dns) {
233
+ if (d && d.host && d.ip) {
234
+ dnsMap.set(d.ip, d.host);
235
+ dnsHosts.add(d.host);
236
+ }
237
+ }
238
+ for (const c of sample.conns) {
239
+ if (!c || !c.ip || !c.port || !c.proc) {
240
+ continue; // malformed row from the poller: skip silently
241
+ }
242
+ if (!opts.procs.includes(c.proc)) {
243
+ continue; // defensive: the real poller filters already
244
+ }
245
+ // dedupe on pid (prototype parity): two same-named processes (IDE
246
+ // main + extension host, node helpers) hitting the same endpoint are
247
+ // distinct connections and must both be reported
248
+ const key = `${c.pid}|${c.ip}|${c.port}`;
249
+ if (seen.has(key)) {
250
+ continue;
251
+ }
252
+ seen.add(key);
253
+ const conn = buildConn(c, dnsMap, now());
254
+ connections.push(conn);
255
+ byCategory[conn.category] = (byCategory[conn.category] ?? 0) + 1;
256
+ byProc[conn.proc] = (byProc[conn.proc] ?? 0) + 1;
257
+ if (conn.category === "unknown") {
258
+ unknownTargets.push(`${conn.host ?? conn.ip}:${conn.port} (${conn.proc})`);
259
+ }
260
+ writeLine(formatLiveLine(conn));
261
+ if (opts.csvPath) {
262
+ appendCsv(opts.csvPath, csvRow(conn));
263
+ }
264
+ }
265
+ }
266
+ await sleep(intervalMs, signal);
267
+ }
268
+ return {
269
+ procs: opts.procs,
270
+ seconds: opts.seconds,
271
+ elapsedMs: now().getTime() - start,
272
+ polls,
273
+ pollErrors,
274
+ dnsEntries: dnsHosts.size,
275
+ connections,
276
+ byCategory,
277
+ byProc,
278
+ unknownTargets,
279
+ };
280
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@fanzhen/agent-audit",
3
+ "version": "0.3.0",
4
+ "description": "npm audit for your AI coding agents - audit dangerous actions in agent session history",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "agent-audit": "dist/cli.js"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "scripts": {
17
+ "test": "vitest run",
18
+ "build": "tsc"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "devDependencies": {
24
+ "@types/node": "^22",
25
+ "typescript": "~5.9.2",
26
+ "vitest": "^2.1.9"
27
+ },
28
+ "dependencies": {
29
+ "cli-table3": "^0.6.5",
30
+ "commander": "^15.0.0",
31
+ "picocolors": "^1.1.1"
32
+ }
33
+ }