@frockbot/plugin-audit 0.0.0 → 0.1.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/src/bot.ts ADDED
@@ -0,0 +1,312 @@
1
+ // The Bot half of the Audit Package: the projection, and the bounded outbox
2
+ // that carries its entries across the Bot/User seam.
3
+ //
4
+ // The kernel imports no Package, so nothing here is called from `kernel-do`.
5
+ // The Bot Durable Object projects a *settled* run — one that has already
6
+ // reached a durable terminal state — and hands the entries to a narrow
7
+ // `AuditSinkV1` its host constructs, the same shape the Memory Package reaches
8
+ // the User Durable Object through.
9
+ //
10
+ // WHY AN OUTBOX, when the transcript index is fire-and-forget. Because the
11
+ // parity item is completeness: GrokBot's `agents/audit-outbox.json` is a
12
+ // durable queue of 1028 audited actions (`grokbot-computer.md:193`, `:547`),
13
+ // and an audit surface with silent gaps answers no question anybody asks of
14
+ // it. So entries are appended to a bounded durable key in the Bot Durable
15
+ // Object first and drained after; a drain that fails leaves them pending for
16
+ // the next settlement or the alarm the Bot already has. Overflow drops the
17
+ // oldest and sets a durable `outbox-truncated` marker the UI shows and a
18
+ // rebuild clears — "Failures are observable through durable state" rather than
19
+ // a queue that quietly forgets.
20
+ //
21
+ // It reads the *stored* run rather than the client projection, because the
22
+ // client projection drops `call.input` and the argument digest needs the exact
23
+ // arguments.
24
+ import { auditKindForToolV1 } from "./classify.js";
25
+ import { auditArgumentDigestV1, auditPreviewV1 } from "./redact.js";
26
+ import {
27
+ AUDIT_MAX_OUTBOX_V1,
28
+ decodeAuditOccurrenceIdV1,
29
+ type AuditEntryV1,
30
+ type AuditOutcomeV1,
31
+ } from "./shared.js";
32
+
33
+ /** The User-scoped audit table, as a Bot Durable Object calls it. */
34
+ export interface AuditSinkV1 {
35
+ /** Idempotent on `(botId, runId, occurrenceId)`. */
36
+ indexEntries(entries: readonly AuditEntryV1[]): Promise<void>;
37
+ }
38
+
39
+ /**
40
+ * The decoded run this Package reads: the durable events, and nothing else.
41
+ *
42
+ * Structural on purpose, so the Shell Package's stored-run page and a
43
+ * settlement-time lookup satisfy it without either naming this Package.
44
+ */
45
+ export interface AuditProjectableRunV1 {
46
+ runId: string;
47
+ status: string;
48
+ events: readonly {
49
+ type: string;
50
+ timestamp?: string;
51
+ occurrenceId?: string;
52
+ name?: string;
53
+ input?: unknown;
54
+ content?: string;
55
+ isError?: boolean;
56
+ status?: string;
57
+ }[];
58
+ /** Used only when an event carries no timestamp of its own. */
59
+ acceptedAt?: string;
60
+ }
61
+
62
+ /** A run is projected once it can no longer change. */
63
+ export function isSettledAuditRunV1(run: { status: string }): boolean {
64
+ return (
65
+ run.status === "completed" ||
66
+ run.status === "failed" ||
67
+ run.status === "cancelled"
68
+ );
69
+ }
70
+
71
+ function outcomeFor(
72
+ result: { isError?: boolean; status?: string; content?: string } | undefined,
73
+ ): AuditOutcomeV1 {
74
+ // No result at all is `unknown`, never `error`. The durable log does not
75
+ // know how the effect ended, and inventing an answer here would be the
76
+ // silent classification the constitution's reconciliation rule forbids.
77
+ if (!result) return "unknown";
78
+ if (result.status === "interrupted") return "interrupted";
79
+ if (result.isError !== true) return "ok";
80
+ // A tool that declined before doing anything is a refusal, which is a
81
+ // materially different fact from an effect that ran and failed.
82
+ return /\brefus|not allowed|denied|blocked while\b/i.test(
83
+ result.content ?? "",
84
+ )
85
+ ? "refused"
86
+ : "error";
87
+ }
88
+
89
+ /**
90
+ * The audit entries one settled run contributes, in a deterministic order.
91
+ *
92
+ * Determinism is the whole contract, exactly as it is for the transcript
93
+ * index: every field is derived from the run's own durable events and from
94
+ * nothing else, so the entries a Turn writes on settlement and the entries a
95
+ * rebuild writes months later are byte-for-byte identical, and re-projecting a
96
+ * run is a no-op rather than a duplicate.
97
+ */
98
+ export async function auditEntriesFromStoredRunV1(
99
+ botId: string,
100
+ run: AuditProjectableRunV1,
101
+ ): Promise<AuditEntryV1[]> {
102
+ if (!isSettledAuditRunV1(run)) return [];
103
+ const results = new Map<
104
+ string,
105
+ { isError?: boolean; status?: string; content?: string }
106
+ >();
107
+ for (const event of run.events) {
108
+ if (event.type !== "tool/result" || !event.occurrenceId) continue;
109
+ results.set(event.occurrenceId, {
110
+ ...(event.isError === undefined ? {} : { isError: event.isError }),
111
+ ...(event.status === undefined ? {} : { status: event.status }),
112
+ ...(event.content === undefined ? {} : { content: event.content }),
113
+ });
114
+ }
115
+ const entries: AuditEntryV1[] = [];
116
+ for (const event of run.events) {
117
+ if (event.type !== "tool/call") continue;
118
+ const { occurrenceId, name } = event;
119
+ if (!occurrenceId || !name) continue;
120
+ const classification = auditKindForToolV1(name, event.input);
121
+ if (!classification) continue;
122
+ let coordinates: { turn: number; step: number; ordinal: number };
123
+ try {
124
+ coordinates = decodeAuditOccurrenceIdV1(occurrenceId);
125
+ } catch {
126
+ // An occurrence id this schema cannot place is an entry with no
127
+ // coordinates; the run is still readable, and a row that lied about
128
+ // where it came from would be worse than its absence.
129
+ continue;
130
+ }
131
+ const result = results.get(occurrenceId);
132
+ const at = event.timestamp ?? run.acceptedAt;
133
+ if (!at) continue;
134
+ entries.push({
135
+ schemaVersion: 1,
136
+ botId,
137
+ runId: run.runId,
138
+ occurrenceId,
139
+ ...coordinates,
140
+ // `plugin-shell` writes `occurrenceId: context.effectId`, so the
141
+ // Computer envelope's `effectId` and this string are the same key.
142
+ effectId: occurrenceId,
143
+ at,
144
+ kind: classification.kind,
145
+ target: classification.target,
146
+ toolName: name,
147
+ argumentDigest: await auditArgumentDigestV1(event.input),
148
+ preview: auditPreviewV1(classification.kind, name, event.input),
149
+ outcome: outcomeFor(result),
150
+ ...(result?.content === undefined
151
+ ? {}
152
+ : { bytesOut: new TextEncoder().encode(result.content).byteLength }),
153
+ });
154
+ }
155
+ return entries;
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // The outbox.
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /** The durable key the Bot Durable Object's outbox lives under. */
163
+ export const AUDIT_OUTBOX_KEY_V1 = "audit:outbox";
164
+
165
+ /** Exactly the Durable Object storage surface the outbox uses. */
166
+ export interface AuditOutboxStorageV1 {
167
+ get<T>(key: string): Promise<T | undefined>;
168
+ put<T>(key: string, value: T): Promise<void>;
169
+ delete(key: string): Promise<boolean>;
170
+ }
171
+
172
+ export interface AuditOutboxStateV1 {
173
+ pending: number;
174
+ /** Entries were dropped to stay inside the bound; a rebuild clears it. */
175
+ truncated: boolean;
176
+ }
177
+
178
+ interface StoredOutbox {
179
+ schemaVersion: 1;
180
+ entries: AuditEntryV1[];
181
+ truncated: boolean;
182
+ }
183
+
184
+ function decodeStoredOutbox(value: unknown): StoredOutbox {
185
+ if (
186
+ typeof value !== "object" ||
187
+ value === null ||
188
+ (value as StoredOutbox).schemaVersion !== 1 ||
189
+ !Array.isArray((value as StoredOutbox).entries)
190
+ ) {
191
+ return { schemaVersion: 1, entries: [], truncated: false };
192
+ }
193
+ const stored = value as StoredOutbox;
194
+ return {
195
+ schemaVersion: 1,
196
+ entries: stored.entries.slice(0, AUDIT_MAX_OUTBOX_V1),
197
+ truncated: stored.truncated === true,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * A bounded, durable, at-least-once queue of audit entries.
203
+ *
204
+ * At-least-once, never at-most-once: an entry stays in the outbox until the
205
+ * User Durable Object has accepted it, and acceptance is idempotent on
206
+ * `(botId, runId, occurrenceId)`, so a redelivery costs a no-op insert. That
207
+ * pairing is the whole reason a queue is safe here.
208
+ */
209
+ export class AuditOutboxV1 {
210
+ private readonly maximum: number;
211
+
212
+ constructor(
213
+ private readonly storage: AuditOutboxStorageV1,
214
+ options: { maximum?: number } = {},
215
+ ) {
216
+ this.maximum = options.maximum ?? AUDIT_MAX_OUTBOX_V1;
217
+ }
218
+
219
+ private async read(): Promise<StoredOutbox> {
220
+ return decodeStoredOutbox(
221
+ await this.storage.get<unknown>(AUDIT_OUTBOX_KEY_V1),
222
+ );
223
+ }
224
+
225
+ private async write(outbox: StoredOutbox): Promise<void> {
226
+ if (outbox.entries.length === 0 && !outbox.truncated) {
227
+ await this.storage.delete(AUDIT_OUTBOX_KEY_V1);
228
+ return;
229
+ }
230
+ await this.storage.put(AUDIT_OUTBOX_KEY_V1, outbox);
231
+ }
232
+
233
+ async state(): Promise<AuditOutboxStateV1> {
234
+ const outbox = await this.read();
235
+ return { pending: outbox.entries.length, truncated: outbox.truncated };
236
+ }
237
+
238
+ /**
239
+ * Appends entries, dropping the oldest when the bound is reached.
240
+ *
241
+ * Dropping the oldest rather than refusing the newest: an audit surface that
242
+ * stopped recording because it was full would go quiet exactly when a Bot
243
+ * was busiest. The loss is durable and named instead.
244
+ */
245
+ async append(entries: readonly AuditEntryV1[]): Promise<AuditOutboxStateV1> {
246
+ if (entries.length === 0) return this.state();
247
+ const outbox = await this.read();
248
+ const seen = new Set(
249
+ outbox.entries.map((entry) => `${entry.runId}${entry.occurrenceId}`),
250
+ );
251
+ for (const entry of entries) {
252
+ const key = `${entry.runId}${entry.occurrenceId}`;
253
+ if (seen.has(key)) continue;
254
+ seen.add(key);
255
+ outbox.entries.push(entry);
256
+ }
257
+ if (outbox.entries.length > this.maximum) {
258
+ outbox.entries = outbox.entries.slice(
259
+ outbox.entries.length - this.maximum,
260
+ );
261
+ outbox.truncated = true;
262
+ }
263
+ await this.write(outbox);
264
+ return { pending: outbox.entries.length, truncated: outbox.truncated };
265
+ }
266
+
267
+ /**
268
+ * Hands everything pending to the sink and clears what it accepted.
269
+ *
270
+ * A throwing sink leaves the outbox exactly as it was: nothing is cleared
271
+ * that was not delivered, which is the only property that makes the queue
272
+ * worth having. The truncation marker survives a drain — it describes what
273
+ * was lost, not what is pending — and only a rebuild clears it.
274
+ */
275
+ async drain(
276
+ sink: AuditSinkV1,
277
+ ): Promise<{ delivered: number; state: AuditOutboxStateV1 }> {
278
+ const outbox = await this.read();
279
+ if (outbox.entries.length === 0) {
280
+ return {
281
+ delivered: 0,
282
+ state: { pending: 0, truncated: outbox.truncated },
283
+ };
284
+ }
285
+ await sink.indexEntries(outbox.entries);
286
+ const delivered = outbox.entries.length;
287
+ const remaining = await this.read();
288
+ // Anything appended while the sink was in flight stays pending.
289
+ const deliveredKeys = new Set(
290
+ outbox.entries.map((entry) => `${entry.runId}${entry.occurrenceId}`),
291
+ );
292
+ const next: StoredOutbox = {
293
+ schemaVersion: 1,
294
+ entries: remaining.entries.filter(
295
+ (entry) => !deliveredKeys.has(`${entry.runId}${entry.occurrenceId}`),
296
+ ),
297
+ truncated: remaining.truncated || outbox.truncated,
298
+ };
299
+ await this.write(next);
300
+ return {
301
+ delivered,
302
+ state: { pending: next.entries.length, truncated: next.truncated },
303
+ };
304
+ }
305
+
306
+ /** Clears the truncation marker. Only a completed rebuild may do this. */
307
+ async clearTruncation(): Promise<void> {
308
+ const outbox = await this.read();
309
+ if (!outbox.truncated) return;
310
+ await this.write({ ...outbox, truncated: false });
311
+ }
312
+ }
@@ -0,0 +1,174 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { auditKindForToolV1 } from "./classify.ts";
3
+ import {
4
+ decodeAuditOccurrenceIdV1,
5
+ isAuditTargetV1,
6
+ type AuditKindV1,
7
+ } from "./shared.ts";
8
+
9
+ describe("the classifier table", () => {
10
+ const table: Array<[string, unknown, AuditKindV1 | undefined, string?]> = [
11
+ ["computer_exec", { command: "ls" }, "shell", "computer"],
12
+ [
13
+ "computer_exec",
14
+ { command: "npm run dev", background: true },
15
+ "process",
16
+ "computer",
17
+ ],
18
+ ["computer_screenshot", {}, undefined],
19
+ ["computer_process_check", { processId: "p1" }, "process", "computer"],
20
+ ["computer_process_logs", { processId: "p1" }, "process", "computer"],
21
+ ["computer_process_stop", { processId: "p1" }, "process", "computer"],
22
+ ["computer_browser", { action: "navigate" }, "browser", "computer"],
23
+ [
24
+ "computer_process_start",
25
+ { command: "npm run dev" },
26
+ "process",
27
+ "computer",
28
+ ],
29
+ ["computer_process_poll", { processId: "p1" }, "process", "computer"],
30
+ ["memory_write", { text: "a fact" }, "file", "computer"],
31
+ ["skill_write", { path: "a.md" }, "file", "computer"],
32
+ ["package_author", { packageId: "x" }, "file", "computer"],
33
+ ["mcp__example__echo", { message: "hi" }, "mcp", "remote:example"],
34
+ ["mcp__beeper__send_message", {}, "mcp", "remote:beeper"],
35
+ // Read-only and product tools perform no audited effect at all. An audit
36
+ // surface that logged them would be a transcript.
37
+ ["current_time", {}, undefined],
38
+ ["memory_search", { query: "gym" }, undefined],
39
+ ["skill_load", { skill: "a" }, undefined],
40
+ ["send_to_user", { text: "hi" }, undefined],
41
+ ["mcp__", {}, undefined],
42
+ ];
43
+
44
+ for (const [name, input, kind, target] of table) {
45
+ test(`${name} → ${kind ?? "not audited"}`, () => {
46
+ const classification = auditKindForToolV1(name, input);
47
+ if (kind === undefined) {
48
+ expect(classification).toBeUndefined();
49
+ return;
50
+ }
51
+ expect(classification?.kind).toBe(kind);
52
+ expect(classification?.target).toBe(target!);
53
+ expect(isAuditTargetV1(classification!.target)).toBe(true);
54
+ });
55
+ }
56
+
57
+ test("the registered machine's tools audit against the machine they named", () => {
58
+ // Register rows 48 and 49. Every `machine_*` tool carries `machineId` on
59
+ // its input verbatim, which is what lets the table answer `machine:<id>`
60
+ // without knowing anything about the Package that produced the call.
61
+ expect(
62
+ auditKindForToolV1("machine_exec", {
63
+ machineId: "994dc2ee-1",
64
+ command: "git status",
65
+ }),
66
+ ).toEqual({ kind: "shell", target: "machine:994dc2ee-1" });
67
+ for (const name of [
68
+ "machine_read",
69
+ "machine_copy_to_computer",
70
+ "machine_copy_from_computer",
71
+ ]) {
72
+ expect(
73
+ auditKindForToolV1(name, { machineId: "994dc2ee-1", path: "/tmp/x" }),
74
+ ).toEqual({ kind: "file", target: "machine:994dc2ee-1" });
75
+ }
76
+ // Reading the registry and reading a command's own result perform no
77
+ // external effect, so they are not audited at all: an audit surface that
78
+ // logged every tool call would be a transcript.
79
+ for (const name of ["machine_list", "machine_command_check"]) {
80
+ expect(auditKindForToolV1(name, { commandId: "c1" })).toBeUndefined();
81
+ }
82
+ // Row 57g. Every one of the seven Messages verbs is an `mcp` row against
83
+ // the Mac it named — reaching a service on somebody's laptop, which is the
84
+ // shape §4.2 gives them — and the send is audited exactly like the reads.
85
+ for (const name of [
86
+ "machine_messages_check_permissions",
87
+ "machine_messages_find_chats",
88
+ "machine_messages_chat_items",
89
+ "machine_messages_search",
90
+ "machine_messages_activity",
91
+ "machine_messages_fetch_attachment",
92
+ "machine_messages_send",
93
+ ]) {
94
+ expect(auditKindForToolV1(name, { machineId: "994dc2ee-1" })).toEqual({
95
+ kind: "mcp",
96
+ target: "machine:994dc2ee-1",
97
+ });
98
+ }
99
+ // A machine tool that named no machine could not have run; the row says
100
+ // so by falling back to the target it did name.
101
+ expect(auditKindForToolV1("machine_exec", { command: "ls" })).toEqual({
102
+ kind: "shell",
103
+ target: "computer",
104
+ });
105
+ });
106
+
107
+ test("a call naming a registered machine is audited against that machine", () => {
108
+ expect(
109
+ auditKindForToolV1("computer_exec", {
110
+ command: "ls",
111
+ machineId: "994dc2ee-1",
112
+ }),
113
+ ).toEqual({ kind: "shell", target: "machine:994dc2ee-1" });
114
+ // A malformed machine id is the Bot's own Computer, not a target the row
115
+ // would be lying about.
116
+ expect(
117
+ auditKindForToolV1("computer_exec", { command: "ls", machineId: "../x" }),
118
+ ).toEqual({ kind: "shell", target: "computer" });
119
+ });
120
+
121
+ test("is pure: the same call always classifies the same way", () => {
122
+ const first = auditKindForToolV1("computer_exec", { command: "ls" });
123
+ const second = auditKindForToolV1("computer_exec", { command: "ls" });
124
+ expect(first).toEqual(second!);
125
+ });
126
+
127
+ test("is total: no input throws", () => {
128
+ for (const input of [undefined, null, 1, "text", [], { machineId: 7 }]) {
129
+ expect(() => auditKindForToolV1("computer_exec", input)).not.toThrow();
130
+ }
131
+ });
132
+ });
133
+
134
+ describe("occurrence-id decoding", () => {
135
+ test("names the turn, step and ordinal already in the durable event", () => {
136
+ expect(decodeAuditOccurrenceIdV1("tool:3:2:0")).toEqual({
137
+ turn: 3,
138
+ step: 2,
139
+ ordinal: 0,
140
+ });
141
+ expect(decodeAuditOccurrenceIdV1("tool:1:1:11")).toEqual({
142
+ turn: 1,
143
+ step: 1,
144
+ ordinal: 11,
145
+ });
146
+ });
147
+
148
+ test("refuses anything the kernel would not have written", () => {
149
+ for (const value of [
150
+ "tool:0:1:0",
151
+ "tool:1:0:0",
152
+ "tool:1:1",
153
+ "tool:1:1:0:0",
154
+ "call:1:1:0",
155
+ "tool:-1:1:0",
156
+ "",
157
+ 42,
158
+ ]) {
159
+ expect(() => decodeAuditOccurrenceIdV1(value)).toThrow();
160
+ }
161
+ });
162
+ });
163
+
164
+ describe("target shapes", () => {
165
+ test("accepts exactly the three the schema declares", () => {
166
+ expect(isAuditTargetV1("computer")).toBe(true);
167
+ expect(isAuditTargetV1("machine:Tims-M5")).toBe(true);
168
+ expect(isAuditTargetV1("remote:mcp.example.test")).toBe(true);
169
+ expect(isAuditTargetV1("remote:mcp.example.test:8443")).toBe(true);
170
+ expect(isAuditTargetV1("box")).toBe(false);
171
+ expect(isAuditTargetV1("machine:")).toBe(false);
172
+ expect(isAuditTargetV1("remote:/etc/passwd")).toBe(false);
173
+ });
174
+ });
@@ -0,0 +1,151 @@
1
+ // The classifier: one pure table from a tool call to an audit kind and target.
2
+ //
3
+ // PURITY IS THE CONTRACT. A settlement-time projection and a rebuild months
4
+ // later must produce byte-identical rows, or the index stops being a
5
+ // projection and starts being a second authority that can disagree with the
6
+ // first. This function therefore reads only its arguments — never the clock,
7
+ // never a registry, never the Bot's mounted Composition — exactly as
8
+ // `searchRowsFromClientRunV1` does for the transcript index.
9
+ //
10
+ // The MCP target is the one thing a tool name cannot answer on its own: the
11
+ // name carries the Connection's *slug* (`mcp__<slug>__<tool>`,
12
+ // `plugin-mcp/src/agent.ts`), and the host lives in the Connection's settings,
13
+ // which the User Durable Object owns. So this answers `remote:<slug>` and the
14
+ // User object — the authority for Connections — resolves it to `remote:<host>`
15
+ // on the one code path both projection and rebuild go through
16
+ // (`resolveAuditTargetV1` in `user.ts`). One resolution point, in the object
17
+ // that holds the registry, is the only arrangement where the two cannot drift.
18
+ import {
19
+ AUDIT_TARGET_COMPUTER_V1,
20
+ AUDIT_TARGET_MACHINE_PREFIX_V1,
21
+ AUDIT_TARGET_REMOTE_PREFIX_V1,
22
+ type AuditKindV1,
23
+ } from "./shared.js";
24
+
25
+ /** What one tool call is, for audit. */
26
+ export interface AuditClassificationV1 {
27
+ kind: AuditKindV1;
28
+ /**
29
+ * `computer`, `machine:<id>`, or the provisional `remote:<slug>` an MCP call
30
+ * carries until the Connection registry resolves its host.
31
+ */
32
+ target: string;
33
+ }
34
+
35
+ /**
36
+ * Tools whose effect is a write to the Workspace rather than a command.
37
+ *
38
+ * `AGENTS.md` § Memory notes the parity gap this closes: GrokBot audits
39
+ * neither Memory writes nor Routine edits (`grokbot-computer.md:194-195`).
40
+ * They already carry their own intent and result events here, so auditing them
41
+ * costs one table row and no new authority.
42
+ */
43
+ const FILE_TOOLS = new Set([
44
+ "memory_write",
45
+ "memory_forget",
46
+ "skill_write",
47
+ "package_author",
48
+ // The registered machine's file verbs (register rows 48, 49). They are file
49
+ // effects on "a separate filesystem" — the User's own laptop — which is why
50
+ // their target is `machine:<id>` and never `computer`.
51
+ "machine_read",
52
+ "machine_copy_to_computer",
53
+ "machine_copy_from_computer",
54
+ ]);
55
+
56
+ /**
57
+ * The registered machine's shell verb.
58
+ *
59
+ * It is a `shell` row for the same reason `computer_exec` is: §2.16 says the
60
+ * machine runs `Shell`, row 30 audits "every shell command … with turn id and
61
+ * target", and the target is what tells the two apart. It is never
62
+ * `background`: a machine command outlives its Turn by construction — the
63
+ * approval ends the Turn before anything runs — so there is no foreground case
64
+ * for a `process` row to be the exception to.
65
+ */
66
+ const MACHINE_SHELL_TOOL = "machine_exec";
67
+
68
+ /**
69
+ * The registered Mac's Messages verbs (register row 57g).
70
+ *
71
+ * They are `mcp` rows and not `file` ones: reading somebody's Messages history
72
+ * or sending as them is reaching a *service* on that machine — the shape §4.2
73
+ * itself gives them, beside the connector tools — and the target says which
74
+ * machine it was. Prefix-matched rather than listed one by one because the
75
+ * seven names all belong to one Package and one classification, so a Package
76
+ * that adds an eighth cannot accidentally add an unaudited one.
77
+ */
78
+ const MACHINE_MESSAGES_PREFIX = "machine_messages_";
79
+
80
+ const MCP_TOOL = /^mcp__([a-zA-Z0-9_]{1,64})__(.{1,96})$/;
81
+ const MACHINE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
82
+
83
+ function isObject(value: unknown): value is Record<string, unknown> {
84
+ return typeof value === "object" && value !== null && !Array.isArray(value);
85
+ }
86
+
87
+ /**
88
+ * The registered Mac, when the call named one.
89
+ *
90
+ * GrokBot reaches Tim's laptop by passing `machineId` to `Shell`, and its
91
+ * `audit.jsonl` records which target the command ran on
92
+ * (`grokbot-computer.md:361`). `plugin-user-machine` carries `machineId` on
93
+ * every one of its tool inputs verbatim, for exactly this reason: the target a
94
+ * command ran on is read off the call, and this function did not have to learn
95
+ * anything about the Package to say so.
96
+ */
97
+ function machineTarget(input: unknown): string | undefined {
98
+ if (!isObject(input)) return undefined;
99
+ const machineId = input.machineId;
100
+ if (typeof machineId !== "string" || !MACHINE_ID.test(machineId)) {
101
+ return undefined;
102
+ }
103
+ return `${AUDIT_TARGET_MACHINE_PREFIX_V1}${machineId}`;
104
+ }
105
+
106
+ /**
107
+ * What kind of audited effect a tool call is, or `undefined` when it is none.
108
+ *
109
+ * `undefined` is the common answer and deliberately so: a Turn that asks the
110
+ * time or searches Memory performs no external effect, and an audit surface
111
+ * that logged every tool call would be a transcript, not an audit.
112
+ */
113
+ export function auditKindForToolV1(
114
+ name: string,
115
+ input: unknown,
116
+ ): AuditClassificationV1 | undefined {
117
+ const onComputer = machineTarget(input) ?? AUDIT_TARGET_COMPUTER_V1;
118
+ if (name === "computer_exec") {
119
+ // A background command outlives the Turn that launched it and is acted on
120
+ // afterwards by the three `computer_process_*` tools, so it is a process
121
+ // rather than a command that ended with the call. GrokBot draws the same
122
+ // line, as `shellKind: foreground | background` on its own audit line
123
+ // (`docs/research/grokbot-computer.md:189`).
124
+ const background =
125
+ isObject(input) && input.background === true ? "process" : "shell";
126
+ return { kind: background, target: onComputer };
127
+ }
128
+ if (name === MACHINE_SHELL_TOOL) {
129
+ // No `machineId` on the input is not a machine command; it is a call that
130
+ // could not have run, and it is audited against the target it named.
131
+ return { kind: "shell", target: onComputer };
132
+ }
133
+ if (name === "computer_browser") {
134
+ return { kind: "browser", target: onComputer };
135
+ }
136
+ if (name.startsWith("computer_process_")) {
137
+ return { kind: "process", target: onComputer };
138
+ }
139
+ if (name.startsWith(MACHINE_MESSAGES_PREFIX)) {
140
+ return { kind: "mcp", target: onComputer };
141
+ }
142
+ if (FILE_TOOLS.has(name)) return { kind: "file", target: onComputer };
143
+ const mcp = MCP_TOOL.exec(name);
144
+ if (mcp) {
145
+ return {
146
+ kind: "mcp",
147
+ target: `${AUDIT_TARGET_REMOTE_PREFIX_V1}${mcp[1]}`,
148
+ };
149
+ }
150
+ return undefined;
151
+ }