@frockbot/plugin-audit 0.0.0 → 0.1.1
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/frockbot.json +37 -0
- package/package.json +43 -6
- package/src/backend.test.ts +193 -0
- package/src/backend.ts +148 -0
- package/src/bot.test.ts +250 -0
- package/src/bot.ts +312 -0
- package/src/classify.test.ts +174 -0
- package/src/classify.ts +151 -0
- package/src/client/AuditSection.vue +363 -0
- package/src/client/index.test.ts +160 -0
- package/src/client/index.ts +125 -0
- package/src/client/state.ts +35 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +3 -0
- package/src/redact.test.ts +91 -0
- package/src/redact.ts +110 -0
- package/src/shared.ts +623 -0
- package/src/store.test.ts +168 -0
- package/src/store.ts +432 -0
- package/src/testing.ts +197 -0
- package/src/user.test.ts +139 -0
- package/src/user.ts +215 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
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
|
+
});
|
package/src/classify.ts
ADDED
|
@@ -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
|
+
}
|