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