@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,349 @@
|
|
|
1
|
+
// Streaming parser: Codex CLI rollout-*.jsonl -> unified events.
|
|
2
|
+
// v0.2.x multi-agent plan (M2); format ground truth:
|
|
3
|
+
// docs/superpowers/research/2026-09-20-codex-format.md (codex-rs source,
|
|
4
|
+
// cross-checked against the 5 local rollout files). Error/stats semantics
|
|
5
|
+
// mirror parsers/claude-code.ts:
|
|
6
|
+
// - blank lines are never counted; unparseable / non-record lines -> linesSkipped
|
|
7
|
+
// - unknown record `type`s (envelope level) and unknown response_item
|
|
8
|
+
// payload types are skipped SILENTLY (counted as nothing): Codex adds
|
|
9
|
+
// variants across CLI versions (world_state appeared mid-2026,
|
|
10
|
+
// task_started <-> turn_started renamed, ...), and `event_msg` — the UI
|
|
11
|
+
// channel — deliberately does NOT persist tool activity
|
|
12
|
+
// (codex-rs rollout/src/policy.rs::should_persist_event_msg). Tool calls
|
|
13
|
+
// live ONLY in `response_item` records, which is all this parser maps.
|
|
14
|
+
// - a missing file REJECTS with the fs error (the engine counts files_failed)
|
|
15
|
+
//
|
|
16
|
+
// Envelope (codex-rs history/src/lib.rs RolloutLine):
|
|
17
|
+
// {"timestamp":"<RFC3339 UTC>","ordinal":N,"type":"<item-type>","payload":{...}}
|
|
18
|
+
// Line order == append order == causal order; ordinals are per-file, 0..n.
|
|
19
|
+
// Event timestamps come from the ENVELOPE, never the payload.
|
|
20
|
+
//
|
|
21
|
+
// Identity (research doc §8):
|
|
22
|
+
// session id : session_meta.session_id (first session_meta wins; `id` holds
|
|
23
|
+
// the same thread uuid) -> filename uuid
|
|
24
|
+
// (rollout-<compact-ts>-<uuid>; fork names append
|
|
25
|
+
// _<rollout-id> AFTER the thread uuid, so the FIRST uuid-shaped
|
|
26
|
+
// group is the thread id) -> plain file stem (claude parity).
|
|
27
|
+
// project : session_meta.cwd -> parent-dir name (claude/kimi parity).
|
|
28
|
+
// Stable for the whole file — turn_context cwd feeds only the
|
|
29
|
+
// ShellCommand.cwd chain below.
|
|
30
|
+
// ShellCommand.cwd: args workdir/working_directory -> most recent
|
|
31
|
+
// turn_context.cwd at or before the line -> session_meta.cwd.
|
|
32
|
+
//
|
|
33
|
+
// Tool mapping (response_item payloads only; shapes source-verified — the
|
|
34
|
+
// local corpus has ZERO tool-call records, so fixtures are synthetic):
|
|
35
|
+
// function_call name "exec_command" -> ShellCommand. `arguments` is a
|
|
36
|
+
// JSON-ENCODED STRING: {"cmd": "...", "workdir"?} — decode the line,
|
|
37
|
+
// then the arguments string (double decode).
|
|
38
|
+
// function_call name "shell" (legacy) -> ShellCommand from
|
|
39
|
+
// {"command": [...], "workdir"?} — array form joined with spaces.
|
|
40
|
+
// local_shell_call -> ShellCommand from
|
|
41
|
+
// action.command (a PARSED array) + action.working_directory.
|
|
42
|
+
// custom_tool_call name "apply_patch" -> one FileWrite per V4A patch entry
|
|
43
|
+
// (Add = full content; Update = diff only, content null; Delete = path
|
|
44
|
+
// only; Move to: = destination path). See parseApplyPatch below.
|
|
45
|
+
// web_search_call -> NetworkRequest(action.query | action.url).
|
|
46
|
+
// function_call with payload.namespace (MCP server), or a "__" in the name
|
|
47
|
+
// (flat "<server>__<tool>"; legacy "mcp__<server>__<tool>") -> McpToolCall.
|
|
48
|
+
// every other function_call / custom_tool_call name (view_image, custom
|
|
49
|
+
// containers, ...) -> skipped for now.
|
|
50
|
+
//
|
|
51
|
+
// .jsonl.zst rollouts (newer builds compress cold sessions; codex decompresses
|
|
52
|
+
// transparently for itself) are NOT supported in v0.2.x — discovery EXCLUDES
|
|
53
|
+
// them (agents.ts), so they cost nothing; a .zst path handed here directly
|
|
54
|
+
// would only produce linesSkipped noise from binary garbage.
|
|
55
|
+
import { createReadStream } from "node:fs";
|
|
56
|
+
import { basename, dirname } from "node:path";
|
|
57
|
+
import { createInterface } from "node:readline";
|
|
58
|
+
import { FileWrite, McpToolCall, NetworkRequest, ShellCommand, } from "../events.js";
|
|
59
|
+
import { ParseStats, parseTs, pyJsonDumps } from "./claude-code.js";
|
|
60
|
+
function isRecord(value) {
|
|
61
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
// Python Path.stem parity (same helper as claude-code.ts, kept local).
|
|
64
|
+
function stem(path) {
|
|
65
|
+
const name = basename(path);
|
|
66
|
+
const dot = name.lastIndexOf(".");
|
|
67
|
+
const suffix = dot > 0 && dot < name.length - 1 ? name.slice(dot) : "";
|
|
68
|
+
return suffix ? name.slice(0, name.length - suffix.length) : name;
|
|
69
|
+
}
|
|
70
|
+
const UUID_RE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/;
|
|
71
|
+
// Filename fallback for the session id: rollout-<compact-ts>-<thread-uuid>
|
|
72
|
+
// (forks: ...-<thread-uuid>_<rollout-id> — the thread id comes FIRST, and the
|
|
73
|
+
// compact timestamp's 4-2-2 digit groups are not uuid-shaped). Anything else
|
|
74
|
+
// falls back to the plain stem, exactly like claude-code.ts.
|
|
75
|
+
function sessionFromFilename(path) {
|
|
76
|
+
const s = stem(path);
|
|
77
|
+
const m = s.match(UUID_RE);
|
|
78
|
+
return m ? m[0] : s;
|
|
79
|
+
}
|
|
80
|
+
// ShellCommand.raw derivation: a string passes through, an array joins with
|
|
81
|
+
// spaces (shell/local_shell_call legacy forms), anything else -> null (skip).
|
|
82
|
+
function joinCmd(raw) {
|
|
83
|
+
if (typeof raw === "string") {
|
|
84
|
+
return raw ? raw : null;
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(raw)) {
|
|
87
|
+
const parts = raw.filter((p) => typeof p === "string");
|
|
88
|
+
if (parts.length === 0 || parts.length !== raw.length) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return parts.join(" ");
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
function strField(payload, key) {
|
|
96
|
+
const v = payload[key];
|
|
97
|
+
return typeof v === "string" && v ? v : null;
|
|
98
|
+
}
|
|
99
|
+
// McpToolCall.argsHint: pyJsonDumps of the PARSED arguments (claude/kimi
|
|
100
|
+
// parity), sliced to the 200-char hint budget. An arguments value that is not
|
|
101
|
+
// parseable JSON falls back to the raw string (still sliced) — evidence beats
|
|
102
|
+
// formatting when a tool call is malformed.
|
|
103
|
+
function argsHintOf(rawArgs) {
|
|
104
|
+
if (typeof rawArgs === "string") {
|
|
105
|
+
try {
|
|
106
|
+
return pyJsonDumps(JSON.parse(rawArgs)).slice(0, 200);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return rawArgs.slice(0, 200);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return pyJsonDumps(isRecord(rawArgs) ? rawArgs : {}).slice(0, 200);
|
|
113
|
+
}
|
|
114
|
+
// Line-scan parser for the apply_patch grammar (codex-rs "V4A"). Sections:
|
|
115
|
+
// *** Add File: <path> body lines are "+"-prefixed, content = full file
|
|
116
|
+
// *** Update File: <path> diff hunks (@@ / context / - / +), no full file
|
|
117
|
+
// *** Move to: <path> inside an Update section -> rename destination
|
|
118
|
+
// *** Delete File: <path> no body
|
|
119
|
+
// framed by *** Begin Patch / *** End Patch. Tolerant by design: a missing
|
|
120
|
+
// End Patch still flushes the last section, CRLF is accepted, unknown "*** "
|
|
121
|
+
// markers are ignored, paths are taken verbatim (Windows backslashes and
|
|
122
|
+
// spaces survive), and text with no sections yields [].
|
|
123
|
+
export function parseApplyPatch(patch) {
|
|
124
|
+
const entries = [];
|
|
125
|
+
let kind = null;
|
|
126
|
+
let path = "";
|
|
127
|
+
let moveTo = null;
|
|
128
|
+
const added = [];
|
|
129
|
+
const flush = () => {
|
|
130
|
+
if (kind === null) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (kind === "update" && moveTo !== null) {
|
|
134
|
+
entries.push({ kind: "move", path: moveTo, content: null });
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
entries.push({
|
|
138
|
+
kind,
|
|
139
|
+
path,
|
|
140
|
+
content: kind === "add" ? added.join("\n") : null,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
kind = null;
|
|
144
|
+
moveTo = null;
|
|
145
|
+
added.length = 0;
|
|
146
|
+
};
|
|
147
|
+
for (const line of patch.split(/\r?\n/)) {
|
|
148
|
+
if (line.startsWith("*** ")) {
|
|
149
|
+
const m = line.match(/^\*\*\* (Add File|Update File|Delete File|Move to): ?(.*)$/);
|
|
150
|
+
if (m) {
|
|
151
|
+
const tag = m[1];
|
|
152
|
+
const target = m[2];
|
|
153
|
+
if (tag === "Move to") {
|
|
154
|
+
if (kind === "update") {
|
|
155
|
+
moveTo = target;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
flush();
|
|
160
|
+
kind = tag === "Add File" ? "add" : tag === "Update File" ? "update" : "delete";
|
|
161
|
+
path = target;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else if (line.trim() === "*** End Patch") {
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
// "*** Begin Patch" and unknown markers: framing only, keep scanning
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (kind === "add" && line.startsWith("+")) {
|
|
171
|
+
added.push(line.slice(1));
|
|
172
|
+
}
|
|
173
|
+
// Update/Delete bodies carry no full file content — path-only events.
|
|
174
|
+
}
|
|
175
|
+
flush();
|
|
176
|
+
return entries;
|
|
177
|
+
}
|
|
178
|
+
// --- iterEvents --------------------------------------------------------------
|
|
179
|
+
export async function* iterEvents(path, stats = new ParseStats()) {
|
|
180
|
+
// claude-code parity fallbacks, upgraded in-place by session_meta
|
|
181
|
+
let sessionId = sessionFromFilename(path);
|
|
182
|
+
let project = basename(dirname(path));
|
|
183
|
+
let cwdSession = null; // session_meta.cwd
|
|
184
|
+
let cwdRolling = null; // most recent turn_context.cwd
|
|
185
|
+
let sawMeta = false; // first session_meta wins
|
|
186
|
+
const shellCwd = (workdir) => workdir ?? cwdRolling ?? cwdSession;
|
|
187
|
+
const input = createReadStream(path, { encoding: "utf8" });
|
|
188
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
189
|
+
let failure = null;
|
|
190
|
+
input.on("error", (err) => {
|
|
191
|
+
if (failure === null) {
|
|
192
|
+
failure = err;
|
|
193
|
+
}
|
|
194
|
+
rl.close();
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
for await (const line of rl) {
|
|
198
|
+
if (failure !== null) {
|
|
199
|
+
throw failure;
|
|
200
|
+
}
|
|
201
|
+
// blank lines never counted, parse failures counted — claude-code order
|
|
202
|
+
const trimmed = line.trim();
|
|
203
|
+
if (!trimmed) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
stats.linesTotal += 1;
|
|
207
|
+
let rec;
|
|
208
|
+
try {
|
|
209
|
+
rec = JSON.parse(trimmed);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
stats.linesSkipped += 1;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (!isRecord(rec)) {
|
|
216
|
+
stats.linesSkipped += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const type = rec["type"];
|
|
220
|
+
if (type === "session_meta") {
|
|
221
|
+
if (!sawMeta) {
|
|
222
|
+
sawMeta = true;
|
|
223
|
+
const payload = isRecord(rec["payload"]) ? rec["payload"] : {};
|
|
224
|
+
// Python `or` chain: session_id preferred, `id` is the same thread
|
|
225
|
+
// uuid (alias drift across CLI versions)
|
|
226
|
+
const sid = payload["session_id"] || payload["id"];
|
|
227
|
+
if (typeof sid === "string" && sid) {
|
|
228
|
+
sessionId = sid;
|
|
229
|
+
}
|
|
230
|
+
cwdSession = strField(payload, "cwd");
|
|
231
|
+
if (cwdSession) {
|
|
232
|
+
project = cwdSession;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (type === "turn_context") {
|
|
238
|
+
const payload = isRecord(rec["payload"]) ? rec["payload"] : {};
|
|
239
|
+
const cwd = strField(payload, "cwd");
|
|
240
|
+
if (cwd) {
|
|
241
|
+
cwdRolling = cwd;
|
|
242
|
+
}
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (type !== "response_item") {
|
|
246
|
+
// event_msg / world_state / token_usage_record / unknown future
|
|
247
|
+
// variants: no audited actions — skipped silently, never counted
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const payload = isRecord(rec["payload"]) ? rec["payload"] : {};
|
|
251
|
+
const itemType = payload["type"];
|
|
252
|
+
const ts = parseTs(rec["timestamp"]);
|
|
253
|
+
if (itemType === "function_call") {
|
|
254
|
+
const name = payload["name"];
|
|
255
|
+
if (typeof name !== "string" || !name) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const namespace = strField(payload, "namespace");
|
|
259
|
+
if (namespace) {
|
|
260
|
+
// MCP via the newer explicit namespace field; `name` is the tool
|
|
261
|
+
stats.events += 1;
|
|
262
|
+
yield new McpToolCall(sessionId, project, ts, namespace, name, argsHintOf(payload["arguments"]));
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (name.includes("__")) {
|
|
266
|
+
// MCP via the flat name: "<server>__<tool>", legacy
|
|
267
|
+
// "mcp__<server>__<tool>" — same split as the claude/kimi mcp__
|
|
268
|
+
// handling (split fully, rejoin the remainder).
|
|
269
|
+
const body = name.startsWith("mcp__") ? name.slice("mcp__".length) : name;
|
|
270
|
+
const parts = body.split("__");
|
|
271
|
+
if (parts.length >= 2 && parts[0]) {
|
|
272
|
+
stats.events += 1;
|
|
273
|
+
yield new McpToolCall(sessionId, project, ts, parts[0], parts.slice(1).join("__"), argsHintOf(payload["arguments"]));
|
|
274
|
+
}
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (name === "exec_command" || name === "shell") {
|
|
278
|
+
// `arguments` is a JSON-encoded STRING — second decode
|
|
279
|
+
let args;
|
|
280
|
+
try {
|
|
281
|
+
args = JSON.parse(typeof payload["arguments"] === "string" ? payload["arguments"] : "");
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
continue; // malformed args: no event, the LINE itself was fine
|
|
285
|
+
}
|
|
286
|
+
if (!isRecord(args)) {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const cmd = joinCmd(name === "exec_command" ? args["cmd"] : args["command"]);
|
|
290
|
+
if (cmd === null) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
stats.events += 1;
|
|
294
|
+
yield new ShellCommand(sessionId, project, ts, cmd, shellCwd(strField(args, "workdir")));
|
|
295
|
+
}
|
|
296
|
+
// every other tool name (view_image, custom tools, ...): skipped
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (itemType === "local_shell_call") {
|
|
300
|
+
const action = isRecord(payload["action"]) ? payload["action"] : null;
|
|
301
|
+
const cmd = action ? joinCmd(action["command"]) : null;
|
|
302
|
+
if (!action || cmd === null) {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
stats.events += 1;
|
|
306
|
+
yield new ShellCommand(sessionId, project, ts, cmd, shellCwd(strField(action, "working_directory")));
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (itemType === "custom_tool_call") {
|
|
310
|
+
if (payload["name"] === "apply_patch" && typeof payload["input"] === "string") {
|
|
311
|
+
for (const entry of parseApplyPatch(payload["input"])) {
|
|
312
|
+
if (!entry.path) {
|
|
313
|
+
continue; // malformed section header — nothing to audit
|
|
314
|
+
}
|
|
315
|
+
stats.events += 1;
|
|
316
|
+
yield new FileWrite(sessionId, project, ts, entry.path, null, entry.content);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
continue; // other custom tools: skipped
|
|
320
|
+
}
|
|
321
|
+
if (itemType === "web_search_call") {
|
|
322
|
+
const action = isRecord(payload["action"]) ? payload["action"] : {};
|
|
323
|
+
// search -> query, open_page/find_in_page -> url (Python `or` chain);
|
|
324
|
+
// newer builds may carry queries: [str] instead of singular query
|
|
325
|
+
const queries = action["queries"];
|
|
326
|
+
const url = action["query"] ||
|
|
327
|
+
action["url"] ||
|
|
328
|
+
(Array.isArray(queries) && typeof queries[0] === "string"
|
|
329
|
+
? queries[0]
|
|
330
|
+
: undefined);
|
|
331
|
+
if (typeof url === "string" && url) {
|
|
332
|
+
stats.events += 1;
|
|
333
|
+
yield new NetworkRequest(sessionId, project, ts, url);
|
|
334
|
+
}
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
// message / reasoning / function_call_output / unknown payload types:
|
|
338
|
+
// skipped silently (developer messages are injected scaffolding, outputs
|
|
339
|
+
// join their calls only via call_id — v0.2.x keeps evidence call-side)
|
|
340
|
+
}
|
|
341
|
+
if (failure !== null) {
|
|
342
|
+
throw failure;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
rl.close();
|
|
347
|
+
input.destroy();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Streaming parser: Kimi-Code wire.jsonl -> unified events.
|
|
2
|
+
// v0.2.x multi-agent plan (M1): TS canonical, no Python parity (Python is
|
|
3
|
+
// frozen at v0.1.1). Error/stats semantics mirror parsers/claude-code.ts:
|
|
4
|
+
// - blank lines are never counted; unparseable lines -> linesSkipped
|
|
5
|
+
// - unknown record types are skipped silently (counted as nothing)
|
|
6
|
+
// - a missing file REJECTS with the fs error (the engine counts files_failed)
|
|
7
|
+
//
|
|
8
|
+
// Wire layout (protocol 1.5, verified against real data 2026-09-20):
|
|
9
|
+
// <root>/wd_<dirname>_<hash>/session_<uuid>/agents/main/wire.jsonl
|
|
10
|
+
// + sibling state.json: {"id":"session_<uuid>","version":2,"cwd":"...",...}
|
|
11
|
+
// Every record has `type`; `context.append_loop_event` wraps an inner event
|
|
12
|
+
// whose `tool.call` members carry {uuid, toolCallId, name, args}. Timestamps
|
|
13
|
+
// are epoch MILLISECONDS on the OUTER record (`time`), not ISO strings.
|
|
14
|
+
//
|
|
15
|
+
// Identity resolution (the wire itself carries NO sessionId/workDir — checked
|
|
16
|
+
// across all record types on 14 real sessions):
|
|
17
|
+
// 1. metadata record sessionId / workDir|cwd (forward compat; not on the
|
|
18
|
+
// wire today, but the first metadata record wins if a future build adds it)
|
|
19
|
+
// 2. sibling state.json id / cwd (the real source on current data)
|
|
20
|
+
// 3. path fallbacks — inside the kimi layout the session-dir name (the
|
|
21
|
+
// "session_<uuid>" dir) and the wd-dir name ARE the identity (a file-stem
|
|
22
|
+
// fallback would collapse every session to "wire"); outside the layout,
|
|
23
|
+
// file stem / parent dir name, exactly like claude-code.ts.
|
|
24
|
+
//
|
|
25
|
+
// Tool mapping (v0.2.x scope — the four event types of the fixed model):
|
|
26
|
+
// Bash -> ShellCommand(args.command)
|
|
27
|
+
// Read/Write/Edit/NotebookEdit -> FileWrite(args.file_path|path|notebook_path,
|
|
28
|
+
// content = args.content|new_string, isConfigPath gate). Kimi's Read uses
|
|
29
|
+
// `args.path` and carries no content; the event model has no FileRead and
|
|
30
|
+
// sensitive-config READS matter as much as writes to the audit (D/C rules
|
|
31
|
+
// gate on isConfigPath), so Read joins the FileWrite mapping per the M1 plan.
|
|
32
|
+
// FetchURL/WebFetch/WebSearch -> NetworkRequest(args.url|query)
|
|
33
|
+
// mcp__* -> McpToolCall (same name convention as claude-code)
|
|
34
|
+
// everything else (Grep/Glob/Skill/WaitFor/TaskStop, ...) -> skipped for now
|
|
35
|
+
import { createReadStream, readFileSync } from "node:fs";
|
|
36
|
+
import { basename, dirname, join } from "node:path";
|
|
37
|
+
import { createInterface } from "node:readline";
|
|
38
|
+
import { FileWrite, McpToolCall, NetworkRequest, ShellCommand, isConfigPath, } from "../events.js";
|
|
39
|
+
import { ParseStats, parseTs, pyJsonDumps } from "./claude-code.js";
|
|
40
|
+
const WRITE_TOOLS = new Set(["Read", "Write", "Edit", "NotebookEdit"]);
|
|
41
|
+
const NETWORK_TOOLS = new Set(["FetchURL", "WebFetch", "WebSearch"]);
|
|
42
|
+
function isRecord(value) {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
// Python Path.stem parity (same helper as claude-code.ts, kept local).
|
|
46
|
+
function stem(path) {
|
|
47
|
+
const name = basename(path);
|
|
48
|
+
const dot = name.lastIndexOf(".");
|
|
49
|
+
const suffix = dot > 0 && dot < name.length - 1 ? name.slice(dot) : "";
|
|
50
|
+
return suffix ? name.slice(0, name.length - suffix.length) : name;
|
|
51
|
+
}
|
|
52
|
+
// The wire's native `time` is epoch ms (number). Strings keep the identical
|
|
53
|
+
// ISO-shape guard as claude-code's parseTs (JS new Date() alone is far too
|
|
54
|
+
// loose); anything else -> null so report output shows "-".
|
|
55
|
+
function parseKimiTs(raw) {
|
|
56
|
+
if (typeof raw === "number") {
|
|
57
|
+
return Number.isFinite(raw) && raw > 0 ? new Date(raw) : null;
|
|
58
|
+
}
|
|
59
|
+
return parseTs(raw);
|
|
60
|
+
}
|
|
61
|
+
// Sibling state.json {"id","cwd"} — the real per-session identity source.
|
|
62
|
+
// Missing/unreadable/corrupt -> null (silent fallback, never an error).
|
|
63
|
+
function readStateJson(path) {
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
66
|
+
if (!isRecord(parsed)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const id = parsed["id"];
|
|
70
|
+
const cwd = parsed["cwd"];
|
|
71
|
+
return {
|
|
72
|
+
id: typeof id === "string" && id ? id : null,
|
|
73
|
+
cwd: typeof cwd === "string" && cwd ? cwd : null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Python json.dumps parity for McpToolCall.argsHint comes from claude-code.ts
|
|
81
|
+
// (pyJsonDumps); the tool->event mapping below mirrors its toEvent().
|
|
82
|
+
function toKimiEvent(name, rawArgs, sid, project, ts, cwd) {
|
|
83
|
+
const args = isRecord(rawArgs) ? rawArgs : {};
|
|
84
|
+
if (name === "Bash") {
|
|
85
|
+
const cmd = args["command"];
|
|
86
|
+
if (typeof cmd === "string" && cmd) {
|
|
87
|
+
return new ShellCommand(sid, project, ts, cmd, cwd);
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (WRITE_TOOLS.has(name)) {
|
|
92
|
+
// Kimi Read uses `path`; claude-style records use `file_path`/`notebook_path`.
|
|
93
|
+
const p = (args["file_path"] || args["path"] || args["notebook_path"]);
|
|
94
|
+
if (typeof p !== "string" || !p) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
let content = typeof args["content"] === "string" ? args["content"] : null;
|
|
98
|
+
if (content === null && typeof args["new_string"] === "string") {
|
|
99
|
+
content = args["new_string"];
|
|
100
|
+
}
|
|
101
|
+
return new FileWrite(sid, project, ts, p, isConfigPath(p), content);
|
|
102
|
+
}
|
|
103
|
+
if (NETWORK_TOOLS.has(name)) {
|
|
104
|
+
const url = (args["url"] || args["query"]);
|
|
105
|
+
if (typeof url === "string" && url) {
|
|
106
|
+
return new NetworkRequest(sid, project, ts, url);
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (name.startsWith("mcp__")) {
|
|
111
|
+
const parts = name.split("__");
|
|
112
|
+
let server;
|
|
113
|
+
let tool;
|
|
114
|
+
if (parts.length >= 3) {
|
|
115
|
+
server = parts[1];
|
|
116
|
+
tool = parts.slice(2).join("__");
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
server = "?";
|
|
120
|
+
tool = name;
|
|
121
|
+
}
|
|
122
|
+
return new McpToolCall(sid, project, ts, server, tool, pyJsonDumps(args).slice(0, 200));
|
|
123
|
+
}
|
|
124
|
+
return null; // Grep/Glob/Skill/WaitFor/... — see mapping note in the header
|
|
125
|
+
}
|
|
126
|
+
export async function* iterEvents(path, stats = new ParseStats()) {
|
|
127
|
+
// <sessionDir>/agents/<agentDir>/wire.jsonl — only trust the kimi layout
|
|
128
|
+
// (and its sibling state.json) when the path actually has that shape.
|
|
129
|
+
const agentsDir = dirname(dirname(path));
|
|
130
|
+
const inKimiLayout = basename(agentsDir) === "agents";
|
|
131
|
+
const sessionDir = inKimiLayout ? dirname(agentsDir) : null;
|
|
132
|
+
const state = sessionDir ? readStateJson(join(sessionDir, "state.json")) : null;
|
|
133
|
+
// fallback identity (claude-code parity, kimi-layout aware — see header)
|
|
134
|
+
let sessionId = inKimiLayout && sessionDir ? basename(sessionDir) : stem(path);
|
|
135
|
+
let project = inKimiLayout && sessionDir ? basename(dirname(sessionDir)) : basename(dirname(path));
|
|
136
|
+
let workDir = null; // ShellCommand.cwd — the session workDir
|
|
137
|
+
if (state?.id) {
|
|
138
|
+
sessionId = state.id;
|
|
139
|
+
}
|
|
140
|
+
if (state?.cwd) {
|
|
141
|
+
workDir = state.cwd;
|
|
142
|
+
project = state.cwd;
|
|
143
|
+
}
|
|
144
|
+
const input = createReadStream(path, { encoding: "utf8" });
|
|
145
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
146
|
+
let failure = null;
|
|
147
|
+
let sawMetadata = false; // first metadata record wins
|
|
148
|
+
input.on("error", (err) => {
|
|
149
|
+
if (failure === null) {
|
|
150
|
+
failure = err;
|
|
151
|
+
}
|
|
152
|
+
rl.close();
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
for await (const line of rl) {
|
|
156
|
+
if (failure !== null) {
|
|
157
|
+
throw failure;
|
|
158
|
+
}
|
|
159
|
+
// blank lines never counted, parse failures counted — claude-code order
|
|
160
|
+
const trimmed = line.trim();
|
|
161
|
+
if (!trimmed) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
stats.linesTotal += 1;
|
|
165
|
+
let rec;
|
|
166
|
+
try {
|
|
167
|
+
rec = JSON.parse(trimmed);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
stats.linesSkipped += 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!isRecord(rec)) {
|
|
174
|
+
stats.linesSkipped += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const type = rec["type"];
|
|
178
|
+
if (type === "metadata") {
|
|
179
|
+
if (!sawMetadata) {
|
|
180
|
+
sawMetadata = true;
|
|
181
|
+
const metaSid = rec["sessionId"];
|
|
182
|
+
if (typeof metaSid === "string" && metaSid) {
|
|
183
|
+
sessionId = metaSid;
|
|
184
|
+
}
|
|
185
|
+
const metaWorkDir = rec["workDir"] || rec["cwd"];
|
|
186
|
+
if (typeof metaWorkDir === "string" && metaWorkDir) {
|
|
187
|
+
workDir = metaWorkDir;
|
|
188
|
+
project = metaWorkDir;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// every other record kind only matters as a loop-event wrapper;
|
|
194
|
+
// turn.prompt / profile.bind / llm.* / agent.* etc. are skipped silently
|
|
195
|
+
if (type !== "context.append_loop_event") {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const inner = rec["event"];
|
|
199
|
+
if (!isRecord(inner) || inner["type"] !== "tool.call") {
|
|
200
|
+
// step.begin/end, content.part and tool.result carry no audited action
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const name = inner["name"];
|
|
204
|
+
if (typeof name !== "string" || !name) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const ev = toKimiEvent(name, inner["args"], sessionId, project, parseKimiTs(rec["time"]), workDir);
|
|
208
|
+
if (ev !== null) {
|
|
209
|
+
stats.events += 1;
|
|
210
|
+
yield ev;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (failure !== null) {
|
|
214
|
+
throw failure;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
rl.close();
|
|
219
|
+
input.destroy();
|
|
220
|
+
}
|
|
221
|
+
}
|