@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,333 @@
1
+ // Parser: ZCode (Z.ai desktop IDE) local sqlite session store -> unified
2
+ // events. v0.2.x multi-agent plan (M4); TS-only (no Python parity). Ground
3
+ // truth: docs/superpowers/research/2026-09-20-zcode-forensics.md + live schema
4
+ // introspection (PRAGMA table_info over a copy of ~/.zcode/cli/db/db.sqlite,
5
+ // 2026-09-20 — schema complete, every DATA table 0 rows on this machine, so
6
+ // fixtures are synthetic db files built with the real schema:
7
+ // test/zcode-fixtures.ts).
8
+ //
9
+ // PRIVACY RED LINE (binding M4 decision): this parser touches ONLY the store
10
+ // given to it plus its "-wal"/"-shm" siblings (copied, see below). It must
11
+ // NEVER open ~/.zcode/v2/credentials.json, v2/tasks-index.sqlite, or any
12
+ // JWT/token/cookie data. Columns read are exactly: session(id, directory,
13
+ // slug), part(data: tool parts only), tool_usage(identity/timing). Message
14
+ // text, input_history and permission rows are never read.
15
+ //
16
+ // SQLite access:
17
+ // - Needs the node:sqlite builtin (Node >= 22.5, flag-free on current
18
+ // builds). Discovery (agents.ts) gates on loadNodeSqlite() and skips the
19
+ // agent with a single stderr hint where it is missing; calling iterEvents
20
+ // directly without it REJECTS.
21
+ // - WAL copy strategy: ZCode keeps its db in journal_mode=wal and may be
22
+ // running while we read; a plain mode=ro open can miss -wal content or
23
+ // hit locks. Each parse run therefore copies db.sqlite (+ -wal/-shm when
24
+ // present) into a fresh os.tmpdir() directory (TMP/TEMP respected) and
25
+ // opens THE COPY read-write — a private file whose WAL is replayed there;
26
+ // the original is never written. Copies are removed in a finally block.
27
+ // A torn copy (store written between the copyFileSync calls) yields a
28
+ // stale-but-consistent snapshot at worst; SQLite guarantees the original
29
+ // cannot be corrupted by this.
30
+ //
31
+ // Schema -> events:
32
+ // The driver is tool_usage — ZCode's per-execution ledger (tool_call_id,
33
+ // tool_name, session_id, started_at, exit/bytes telemetry). It carries NO
34
+ // payload columns; tool INPUT lives in part.data JSON as a tool part
35
+ // (shape grep-verified in the zcode.cjs CLI bundle):
36
+ // {"type":"tool","callID":...,"tool":...,"state":{"status":...,
37
+ // "input":{...},"output":...,"title":...,"time":{...}}}
38
+ // so the parser preloads a callID -> input map from part (rows prefiltered
39
+ // with LIKE '%"type":"tool"%'; last row wins for retried/streamed parts)
40
+ // and joins on tool_usage.tool_call_id. message.data holds only role/time
41
+ // scaffolding (opencode lineage) — never read.
42
+ //
43
+ // sessionId : tool_usage.session_id (the FK itself; a missing session row
44
+ // degrades project resolution, not the event).
45
+ // project : session.directory -> session.slug -> session_id.
46
+ // ShellCommand.cwd: input.workdir -> input.cwd -> session.directory.
47
+ // timestamp : tool_usage.started_at (action start). INTEGER time columns
48
+ // are epoch values; >= 10^12 reads as milliseconds, else as
49
+ // seconds (the unit is not observable on the 0-row live db;
50
+ // opencode lineage is ms and the heuristic keeps both
51
+ // parseable). NULL/0/garbage -> null timestamp, as elsewhere.
52
+ // ordering : ORDER BY started_at ASC, rowid ASC (deterministic; SQLite
53
+ // sorts NULL first on ASC).
54
+ // Reads are paged (LIMIT ? OFFSET ?) to bound memory; the generator stays
55
+ // async for the shared parser signature although sqlite reads are sync.
56
+ //
57
+ // Tool mapping (tool_name lowercased; payload from the joined part):
58
+ // shell-class (bash, shell, code_execution, run_command, execute_command)
59
+ // -> ShellCommand(input.command; string or string[] joined)
60
+ // file-class (read, view, write, edit, multiedit, notebookedit, patch,
61
+ // apply_patch, str_replace_editor,
62
+ // str_replace_based_edit_tool)
63
+ // -> FileWrite(filePath|path|file_path|notebook_path,
64
+ // content = content|file_text|new_string).
65
+ // READS are audited as content-less FileWrites on purpose
66
+ // (kimi M1 precedent: the event model has no FileRead and
67
+ // sensitive-config reads gate the same rules via isConfigPath).
68
+ // todowrite is deliberately NOT file-class: todo lists are
69
+ // not files.
70
+ // network-class(web_fetch, webfetch, fetchurl, fetch, web_search, websearch)
71
+ // -> NetworkRequest(input.url | input.query)
72
+ // "mcp__<server>__<tool>" / "<server>__<tool>" -> McpToolCall (same split
73
+ // as the codex parser); MCP identity lives entirely in the
74
+ // tool name, so its event does not need the joined part —
75
+ // argsHint is empty when the payload is missing.
76
+ // everything else (grep, glob, list, skill, task, agent, computer, ...)
77
+ // -> skipped SILENTLY, counted as nothing (codex parser's
78
+ // unknown-record policy: benign/navigational tools are most
79
+ // of the traffic and are not "skipped data").
80
+ //
81
+ // Stats semantics (sqlite is not jsonl, so the line_* fields are redefined
82
+ // here and ONLY here):
83
+ // linesTotal = tool_usage rows read (candidate actions).
84
+ // linesSkipped = tool_usage rows that are corrupt/unmappable: a missing
85
+ // session_id or tool_name, or an AUDITABLE-CLASS row whose
86
+ // joined payload cannot yield its required field (part row
87
+ // missing / part.data unparseable / input field mistyped).
88
+ // events = events emitted (same aggregate as every other parser).
89
+ import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
90
+ import { createRequire } from "node:module";
91
+ import { tmpdir } from "node:os";
92
+ import { join } from "node:path";
93
+ import { FileWrite, McpToolCall, NetworkRequest, ShellCommand, } from "../events.js";
94
+ import { ParseStats, pyJsonDumps } from "./claude-code.js";
95
+ // createRequire works in ESM and require()s the builtin synchronously — the
96
+ // agent registry's find() is sync, so import() is not an option here.
97
+ export const defaultSqliteLoader = (id) => createRequire(import.meta.url)(id);
98
+ let sqliteLoader = defaultSqliteLoader;
99
+ // Tests stub this to simulate a Node without the builtin.
100
+ export function setSqliteLoaderForTests(loader) {
101
+ sqliteLoader = loader;
102
+ }
103
+ export function loadNodeSqlite() {
104
+ try {
105
+ const mod = sqliteLoader("node:sqlite");
106
+ return mod && typeof mod.DatabaseSync === "function" ? mod : null;
107
+ }
108
+ catch {
109
+ return null; // ERR_UNKNOWN_BUILTIN_MODULE on Node < 22.5, odd embedded builds
110
+ }
111
+ }
112
+ // --- tool classification -------------------------------------------------------
113
+ // Exact lowercase sets (kimi/codex precedent: names are matched exactly, so a
114
+ // future unrelated tool whose name happens to contain "read" can't mis-map).
115
+ const SHELL_TOOLS = new Set([
116
+ "bash", "shell", "code_execution", "run_command", "execute_command",
117
+ ]);
118
+ const FILE_TOOLS = new Set([
119
+ "read", "view", "write", "edit", "multiedit", "notebookedit", "patch",
120
+ "apply_patch", "str_replace_editor", "str_replace_based_edit_tool",
121
+ ]);
122
+ const NETWORK_TOOLS = new Set([
123
+ "web_fetch", "webfetch", "fetchurl", "fetch", "web_search", "websearch",
124
+ ]);
125
+ function isRecord(value) {
126
+ return typeof value === "object" && value !== null && !Array.isArray(value);
127
+ }
128
+ function str(value) {
129
+ return typeof value === "string" && value ? value : null;
130
+ }
131
+ // ShellCommand.raw: a string passes through, an array of strings joins with
132
+ // spaces (codex parity); anything else -> null (unmappable).
133
+ function joinCmd(raw) {
134
+ if (typeof raw === "string") {
135
+ return raw ? raw : null;
136
+ }
137
+ if (Array.isArray(raw)) {
138
+ const parts = raw.filter((p) => typeof p === "string");
139
+ if (parts.length === 0 || parts.length !== raw.length) {
140
+ return null;
141
+ }
142
+ return parts.join(" ");
143
+ }
144
+ return null;
145
+ }
146
+ // INTEGER epoch columns: >= 10^12 -> ms, else seconds (see header). Note the
147
+ // seconds branch must come out at the SAME wall clock for pre-2001 values;
148
+ // audit data is contemporary, so the split point is safe in practice.
149
+ function parseEpochMs(raw) {
150
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
151
+ return null;
152
+ }
153
+ return new Date(raw >= 1e12 ? raw : raw * 1000);
154
+ }
155
+ // One mapped event or the verdict "unmappable" (counted) vs null (benign,
156
+ // silent). `name` is the lowercased tool_name; `input` the joined part payload
157
+ // (null when no part row parsed for this call).
158
+ function toZcodeEvent(name, input, sessionId, project, ts, sessionDir) {
159
+ if (SHELL_TOOLS.has(name)) {
160
+ const cmd = joinCmd(input ? input["command"] : undefined);
161
+ if (cmd === null) {
162
+ return { skip: true }; // shell execution whose command never landed
163
+ }
164
+ const cwd = str(input?.["workdir"]) ?? str(input?.["cwd"]) ?? sessionDir;
165
+ return { event: new ShellCommand(sessionId, project, ts, cmd, cwd) };
166
+ }
167
+ if (FILE_TOOLS.has(name)) {
168
+ const path = str(input?.["filePath"]) ?? str(input?.["path"]) ??
169
+ str(input?.["file_path"]) ?? str(input?.["notebook_path"]);
170
+ if (path === null) {
171
+ return { skip: true };
172
+ }
173
+ // "" is a legitimate (empty) write; only non-strings fall back to null.
174
+ const raw = input?.["content"] ?? input?.["file_text"] ?? input?.["new_string"];
175
+ const content = typeof raw === "string" ? raw : null;
176
+ return { event: new FileWrite(sessionId, project, ts, path, null, content) };
177
+ }
178
+ if (NETWORK_TOOLS.has(name)) {
179
+ const url = str(input?.["url"]) ?? str(input?.["query"]);
180
+ if (url === null) {
181
+ return { skip: true };
182
+ }
183
+ return { event: new NetworkRequest(sessionId, project, ts, url) };
184
+ }
185
+ if (name.startsWith("mcp__") || name.includes("__")) {
186
+ const body = name.startsWith("mcp__") ? name.slice("mcp__".length) : name;
187
+ const parts = body.split("__");
188
+ if (parts.length < 2 || !parts[0]) {
189
+ return { skip: true }; // not splittable into server__tool
190
+ }
191
+ const hint = input
192
+ ? pyJsonDumps(input).slice(0, 200)
193
+ : ""; // payload never landed; the name still identifies the call
194
+ return { event: new McpToolCall(sessionId, project, ts, parts[0], parts.slice(1).join("__"), hint) };
195
+ }
196
+ return null; // benign/navigational tool — silent, uncounted
197
+ }
198
+ // --- paged reads ----------------------------------------------------------------
199
+ const PAGE_SIZE = 500;
200
+ function eachRow(db, sql, visit) {
201
+ const stmt = db.prepare(sql);
202
+ let offset = 0;
203
+ for (;;) {
204
+ const rows = stmt.all(PAGE_SIZE, offset);
205
+ if (!Array.isArray(rows) || rows.length === 0) {
206
+ return;
207
+ }
208
+ for (const row of rows) {
209
+ if (isRecord(row)) {
210
+ visit(row);
211
+ }
212
+ }
213
+ if (rows.length < PAGE_SIZE) {
214
+ return;
215
+ }
216
+ offset += PAGE_SIZE;
217
+ }
218
+ }
219
+ // Loads callID -> state.input from tool parts (prefiltered in SQL; the JSON
220
+ // parse is the real check). Corrupt/untool part rows are ignored: they are not
221
+ // tool_usage rows, and the join simply misses — an auditable usage row whose
222
+ // payload is corrupt is counted by the caller instead.
223
+ function loadToolInputs(db) {
224
+ const inputs = new Map();
225
+ eachRow(db, `SELECT data FROM part WHERE data LIKE '%"type":"tool"%' ORDER BY rowid ASC LIMIT ? OFFSET ?`, (row) => {
226
+ let parsed;
227
+ try {
228
+ parsed = JSON.parse(typeof row["data"] === "string" ? row["data"] : "");
229
+ }
230
+ catch {
231
+ return;
232
+ }
233
+ if (!isRecord(parsed) || parsed["type"] !== "tool") {
234
+ return; // prefilter false positive (text quoting a tool part)
235
+ }
236
+ const callId = str(parsed["callID"]);
237
+ if (!callId) {
238
+ return;
239
+ }
240
+ const state = isRecord(parsed["state"]) ? parsed["state"] : {};
241
+ // Last row wins: retried/streamed parts append newer versions.
242
+ inputs.set(callId, isRecord(state["input"]) ? state["input"] : {});
243
+ });
244
+ return inputs;
245
+ }
246
+ function loadSessions(db) {
247
+ const sessions = new Map();
248
+ eachRow(db, `SELECT id, directory, slug FROM session ORDER BY rowid ASC LIMIT ? OFFSET ?`, (row) => {
249
+ const id = str(row["id"]);
250
+ if (id) {
251
+ sessions.set(id, { directory: str(row["directory"]), slug: str(row["slug"]) });
252
+ }
253
+ });
254
+ return sessions;
255
+ }
256
+ function* readStore(db, stats) {
257
+ const inputs = loadToolInputs(db);
258
+ const sessions = loadSessions(db);
259
+ const stmt = db.prepare(`SELECT session_id AS sessionId, tool_call_id AS callId, tool_name AS toolName,
260
+ started_at AS startedAt
261
+ FROM tool_usage ORDER BY started_at ASC, rowid ASC LIMIT ? OFFSET ?`);
262
+ let offset = 0;
263
+ for (;;) {
264
+ const rows = stmt.all(PAGE_SIZE, offset);
265
+ if (!Array.isArray(rows) || rows.length === 0) {
266
+ return;
267
+ }
268
+ for (const row of rows) {
269
+ if (!isRecord(row)) {
270
+ stats.linesSkipped += 1;
271
+ continue;
272
+ }
273
+ stats.linesTotal += 1;
274
+ const sessionId = str(row["sessionId"]);
275
+ const name = str(row["toolName"]);
276
+ if (sessionId === null || name === null) {
277
+ stats.linesSkipped += 1; // identity/classification impossible
278
+ continue;
279
+ }
280
+ const sess = sessions.get(sessionId);
281
+ const project = str(sess?.directory) ?? str(sess?.slug) ?? sessionId;
282
+ const ts = parseEpochMs(row["startedAt"]);
283
+ const input = str(row["callId"]) ? inputs.get(row["callId"]) ?? null : null;
284
+ const mapped = toZcodeEvent(name.trim().toLowerCase(), input, sessionId, project, ts, sess?.directory ?? null);
285
+ if (mapped === null) {
286
+ continue; // benign tool: silent (see header skip policy)
287
+ }
288
+ if ("skip" in mapped) {
289
+ stats.linesSkipped += 1; // auditable class, unusable payload
290
+ continue;
291
+ }
292
+ stats.events += 1;
293
+ yield mapped.event;
294
+ }
295
+ if (rows.length < PAGE_SIZE) {
296
+ return;
297
+ }
298
+ offset += PAGE_SIZE;
299
+ }
300
+ }
301
+ // --- entry point -----------------------------------------------------------------
302
+ export async function* iterEvents(path, stats = new ParseStats()) {
303
+ const mod = loadNodeSqlite();
304
+ if (mod === null) {
305
+ throw new Error("node:sqlite is required to parse the ZCode session store (Node >= 22.5)");
306
+ }
307
+ const work = mkdtempSync(join(tmpdir(), "agentaudit-zcode-"));
308
+ const copyPath = join(work, "db.sqlite");
309
+ let db = null;
310
+ try {
311
+ copyFileSync(path, copyPath);
312
+ for (const suffix of ["-wal", "-shm"]) {
313
+ if (existsSync(path + suffix)) {
314
+ copyFileSync(path + suffix, copyPath + suffix);
315
+ }
316
+ }
317
+ // Open the COPY read-write on purpose: replaying its WAL may write (and
318
+ // close checkpoints); the original store is never touched.
319
+ db = new mod.DatabaseSync(copyPath);
320
+ yield* readStore(db, stats);
321
+ }
322
+ finally {
323
+ if (db !== null) {
324
+ try {
325
+ db.close();
326
+ }
327
+ catch {
328
+ // already closed / failed open — the rmSync below is what matters
329
+ }
330
+ }
331
+ rmSync(work, { recursive: true, force: true });
332
+ }
333
+ }
package/dist/report.js ADDED
@@ -0,0 +1,176 @@
1
+ // Report rendering: terminal (picocolors + cli-table3), JSON dict, share card.
2
+ // Faithful port of src/agentaudit/report.py (Python is the spec).
3
+ import Table from "cli-table3";
4
+ import pc from "picocolors";
5
+ import { SEVERITY_ORDER } from "./events.js";
6
+ // Python: SEV_LABEL (also consumed by the CLI's --list-rules table)
7
+ export const SEV_LABEL = {
8
+ critical: "CRITICAL",
9
+ high: "HIGH",
10
+ medium: "MEDIUM",
11
+ low: "LOW",
12
+ info: "INFO",
13
+ };
14
+ // Python: SEV_STYLE via picocolors — "bold white on red" -> white text on red
15
+ // background, bolded. NOTE: picocolors colors UNCONDITIONALLY on win32 (even
16
+ // off-TTY); the CLI layer must set NO_COLOR when stdout is not a TTY to match
17
+ // rich's isatty gating.
18
+ function colorSev(text, sev) {
19
+ switch (sev) {
20
+ case "critical":
21
+ return pc.bgRed(pc.bold(pc.white(text)));
22
+ case "high":
23
+ return pc.bold(pc.red(text));
24
+ case "medium":
25
+ return pc.yellow(text);
26
+ case "low":
27
+ return pc.cyan(text);
28
+ case "info":
29
+ return pc.dim(text);
30
+ }
31
+ }
32
+ // Python: _SEV_ORDERED = list(reversed(SEVERITY_ORDER)) # critical -> info
33
+ const SEV_ORDERED = [...SEVERITY_ORDER].reverse();
34
+ // Python datetime.isoformat() for tz-aware UTC values: "+00:00" offset,
35
+ // microseconds included only when nonzero (6 digits). Parsed timestamps are
36
+ // always UTC-normalized (Z inputs), so a UTC formatter is exact.
37
+ function pyIso(d) {
38
+ const p = (n, w = 2) => String(n).padStart(w, "0");
39
+ const base = `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}` +
40
+ `T${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
41
+ const us = d.getUTCMilliseconds() * 1000;
42
+ return us ? `${base}.${p(us, 6)}+00:00` : `${base}+00:00`;
43
+ }
44
+ export function severityCounts(findings) {
45
+ // zero-fill all five severities in critical->info order so the object is
46
+ // directly JSON-serializable as Python's by_severity (insertion order kept)
47
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
48
+ for (const f of findings) {
49
+ counts[f.severity] += 1;
50
+ }
51
+ return counts;
52
+ }
53
+ export function filterBySeverity(findings, floor) {
54
+ const floorIdx = SEVERITY_ORDER.indexOf(floor);
55
+ return findings.filter((f) => SEVERITY_ORDER.indexOf(f.severity) >= floorIdx);
56
+ }
57
+ // Python: re.split(r"[\\/]", project.replace("\\\\", "\\"))[-1] or project
58
+ // (collapse doubled backslashes first, then take the last /-or-\-separated
59
+ // segment; a trailing separator leaves an empty last segment -> whole string)
60
+ function shortProject(project) {
61
+ const parts = project.replace(/\\\\/g, "\\").split(/[\\/]/);
62
+ return parts[parts.length - 1] || project;
63
+ }
64
+ // Python: ts.strftime("%m-%d %H:%M") if ts else "-"
65
+ // UTC getters on purpose: Python stores tz-aware UTC datetimes (real data is
66
+ // always Z-suffixed) and strftime prints the stored fields, which getUTC*
67
+ // recovers exactly. Local getters would shift them outside UTC timezones.
68
+ function shortTs(ts) {
69
+ if (!ts) {
70
+ return "-";
71
+ }
72
+ const p2 = (n) => String(n).padStart(2, "0");
73
+ return (`${p2(ts.getUTCMonth() + 1)}-${p2(ts.getUTCDate())} ` +
74
+ `${p2(ts.getUTCHours())}:${p2(ts.getUTCMinutes())}`);
75
+ }
76
+ // SGR sequences wrap whole segments, so border/padding math must measure the
77
+ // visible (code-stripped) width while emitting colored content verbatim.
78
+ const ANSI_SGR = /\u001B\[[0-9;]*m/g;
79
+ function padEndVisible(text, width) {
80
+ return text + " ".repeat(Math.max(0, width - text.replace(ANSI_SGR, "").length));
81
+ }
82
+ // Python render_terminal(result, floor, console=None) -> the injected `write`
83
+ // replaces Console(file=buf) for tests; default writes to process.stdout.
84
+ export function renderTerminal(result, floor = "low", write = (chunk) => process.stdout.write(chunk)) {
85
+ const out = (line) => write(`${line}\n`);
86
+ const findings = filterBySeverity(result.findings, floor);
87
+ const counts = severityCounts(findings);
88
+ // summary block (Python: rich Panel titled "agentaudit", expand=False)
89
+ const summaryLine = `files ${result.filesScanned} · sessions ${result.sessions.size} · ` +
90
+ `events ${result.events} · findings ${findings.length}`;
91
+ const sevLine = SEV_ORDERED.map((sev) => `${counts[sev]} ${colorSev(SEV_LABEL[sev], sev)}`).join(" ");
92
+ const width = Math.max(summaryLine.length, sevLine.replace(ANSI_SGR, "").length);
93
+ out(`┌─ agentaudit ${"─".repeat(Math.max(0, width - 11))}┐`);
94
+ out(`│ ${padEndVisible(summaryLine, width)} │`);
95
+ out(`│ ${padEndVisible(sevLine, width)} │`);
96
+ out(`└${"─".repeat(width + 2)}┘`);
97
+ // 7-col table. rich ratios SEV8/RULE6/FINDING30/PROJECT16/SESSION10/WHEN11/
98
+ // EVIDENCE40 are usable text width, but cli-table3 colWidths INCLUDE the
99
+ // 1+1 padding, so each is widened by 2. wordWrap + wrapOnWordBoundary:false
100
+ // reproduces rich's overflow="fold": hard character fold, never the
101
+ // ellipsis truncation cli-table3 defaults to on unbroken strings.
102
+ // Evidence cells stay RAW text: cli-table3 interprets nothing, so the rich
103
+ // markup-injection pitfall (evidence "del /s [/etc] /q") has no TS
104
+ // equivalent to defend against.
105
+ const table = new Table({
106
+ head: ["SEV", "RULE", "FINDING", "PROJECT", "SESSION", "WHEN", "EVIDENCE"],
107
+ colWidths: [10, 8, 32, 18, 12, 13, 42],
108
+ wordWrap: true,
109
+ wrapOnWordBoundary: false,
110
+ style: { head: [], border: [] },
111
+ });
112
+ for (const f of findings.slice(0, 200)) {
113
+ table.push([
114
+ // plain label: Python passes SEV_LABEL as a plain str too — SEV_STYLE
115
+ // colors only the summary panel's severity line (and a colored cell
116
+ // would be split mid-escape-sequence by cli-table3's hard fold)
117
+ SEV_LABEL[f.severity],
118
+ f.ruleId,
119
+ f.title,
120
+ shortProject(f.event.project),
121
+ f.event.sessionId.slice(0, 8),
122
+ shortTs(f.event.timestamp),
123
+ f.evidence,
124
+ ]);
125
+ }
126
+ out(table.toString());
127
+ if (result.linesSkipped) {
128
+ out(pc.dim(`skipped ${result.linesSkipped} malformed lines`));
129
+ }
130
+ if (result.filesFailed) {
131
+ out(pc.yellow(`failed to read ${result.filesFailed} file(s)`));
132
+ }
133
+ if (findings.length > 200) {
134
+ out(pc.dim(`showing first 200 of ${findings.length} findings`));
135
+ }
136
+ }
137
+ export function toDict(result) {
138
+ const counts = severityCounts(result.findings);
139
+ return {
140
+ summary: {
141
+ files: result.filesScanned,
142
+ files_failed: result.filesFailed,
143
+ sessions: result.sessions.size,
144
+ events: result.events,
145
+ lines_skipped: result.linesSkipped,
146
+ total: result.findings.length,
147
+ // severityCounts zero-fills in critical->info order, matching Python's
148
+ // {sev.value: counts[sev] for sev in _SEV_ORDERED}
149
+ by_severity: { ...counts },
150
+ by_agent: { ...result.byAgent },
151
+ },
152
+ findings: result.findings.map((f) => ({
153
+ rule_id: f.ruleId,
154
+ severity: f.severity,
155
+ title: f.title,
156
+ evidence: f.evidence,
157
+ project: f.event.project,
158
+ session_id: f.event.sessionId,
159
+ // Python: datetime.isoformat() emits "+00:00" offsets and omits
160
+ // microseconds when zero — NOT what toISOString() produces ("Z",
161
+ // always ".000"). pyIso mirrors it for byte-identical JSON output.
162
+ timestamp: f.event.timestamp ? pyIso(f.event.timestamp) : null,
163
+ explanation: f.explanation,
164
+ recommendation: f.recommendation,
165
+ })),
166
+ };
167
+ }
168
+ export function shareCard(result) {
169
+ const counts = severityCounts(result.findings);
170
+ const stats = SEV_ORDERED.filter((sev) => counts[sev] > 0)
171
+ .map((sev) => `${SEV_LABEL[sev]} ${counts[sev]}`)
172
+ .join(" · ") || "no findings";
173
+ return ("──── agent-audit · AI agent safety report ────\n" +
174
+ `Sessions: ${result.sessions.size} ${stats}\n` +
175
+ "Audit your own agents → npx @fanzhen/agent-audit");
176
+ }
@@ -0,0 +1,68 @@
1
+ // Ported 1:1 from src/agentaudit/rules/base.py (Python implementation is the spec).
2
+ import { FileWrite, McpToolCall, NetworkRequest, ShellCommand, } from "../events.js";
3
+ export function evidenceOf(event) {
4
+ if (event instanceof ShellCommand) {
5
+ return event.raw;
6
+ }
7
+ if (event instanceof FileWrite) {
8
+ // path only — to match written content, write a custom Rule.check()
9
+ return event.path;
10
+ }
11
+ if (event instanceof NetworkRequest) {
12
+ return event.url;
13
+ }
14
+ if (event instanceof McpToolCall) {
15
+ return `${event.server}::${event.tool} ${event.argsHint}`;
16
+ }
17
+ return "";
18
+ }
19
+ export class Rule {
20
+ id = "?";
21
+ severity = "low";
22
+ title = "";
23
+ explanation = "";
24
+ recommendation = "";
25
+ appliesTo = [ShellCommand];
26
+ check(event) {
27
+ throw new Error("NotImplementedError");
28
+ }
29
+ }
30
+ export class RegexRule extends Rule {
31
+ // JS porting note: in Python, `pattern` is a class attribute that subclasses
32
+ // override, visible to the base __init__ via attribute lookup. JS instance
33
+ // fields are NOT visible to the base constructor (subclass field initializers
34
+ // only run after super() returns), so subclasses override the *static*
35
+ // `pattern` instead, and the constructor reads it via this.constructor.
36
+ static pattern = "";
37
+ re;
38
+ constructor() {
39
+ super();
40
+ const ctor = this.constructor;
41
+ if (!ctor.pattern) {
42
+ throw new Error(`${ctor.name}.pattern is empty`);
43
+ }
44
+ this.re = new RegExp(ctor.pattern, "i");
45
+ }
46
+ check(event) {
47
+ if (!this.appliesTo.some((ctor) => event instanceof ctor)) {
48
+ return null;
49
+ }
50
+ const text = evidenceOf(event);
51
+ if (!text) {
52
+ return null;
53
+ }
54
+ const m = this.re.exec(text);
55
+ if (!m) {
56
+ return null;
57
+ }
58
+ return {
59
+ ruleId: this.id,
60
+ severity: this.severity,
61
+ title: this.title,
62
+ event,
63
+ evidence: m[0].slice(0, 200),
64
+ explanation: this.explanation,
65
+ recommendation: this.recommendation,
66
+ };
67
+ }
68
+ }