@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/src/testing.ts ADDED
@@ -0,0 +1,197 @@
1
+ // A fake `AuditSqlV1`, and a fake outbox storage, for unit tests.
2
+ //
3
+ // The SQL fake is not an SQL engine. It recognises exactly the statements
4
+ // `AuditStoreV1` issues and answers them from JavaScript arrays. That is
5
+ // enough to hold the module's real logic to account — idempotency on
6
+ // `(botId, runId, occurrenceId)`, both eviction bounds and their durable
7
+ // marker, purge, filters and paging — while `audit.workerd.ts` proves the same
8
+ // module against a real table on `ctx.storage.sql`.
9
+ import type { AuditSqlCursorV1, AuditSqlV1, AuditSqlValueV1 } from "./store.js";
10
+ import type { AuditOutboxStorageV1 } from "./bot.js";
11
+
12
+ interface FakeRow extends Record<string, AuditSqlValueV1> {
13
+ bot_id: string;
14
+ run_id: string;
15
+ occurrence_id: string;
16
+ turn: number;
17
+ step: number;
18
+ ordinal: number;
19
+ effect_id: string;
20
+ at: string;
21
+ kind: string;
22
+ target: string;
23
+ tool_name: string;
24
+ argument_digest: string;
25
+ preview: string;
26
+ outcome: string;
27
+ exit_code: number | null;
28
+ duration_ms: number | null;
29
+ bytes_out: number | null;
30
+ }
31
+
32
+ const COLUMNS = [
33
+ "bot_id",
34
+ "run_id",
35
+ "occurrence_id",
36
+ "turn",
37
+ "step",
38
+ "ordinal",
39
+ "effect_id",
40
+ "at",
41
+ "kind",
42
+ "target",
43
+ "tool_name",
44
+ "argument_digest",
45
+ "preview",
46
+ "outcome",
47
+ "exit_code",
48
+ "duration_ms",
49
+ "bytes_out",
50
+ ] as const;
51
+
52
+ function cursor<Row extends Record<string, AuditSqlValueV1>>(
53
+ rows: Row[],
54
+ ): AuditSqlCursorV1<Row> {
55
+ return { toArray: () => rows };
56
+ }
57
+
58
+ function order(left: FakeRow, right: FakeRow): number {
59
+ return (
60
+ left.at.localeCompare(right.at) ||
61
+ left.bot_id.localeCompare(right.bot_id) ||
62
+ left.run_id.localeCompare(right.run_id) ||
63
+ left.occurrence_id.localeCompare(right.occurrence_id)
64
+ );
65
+ }
66
+
67
+ export class FakeAuditSql implements AuditSqlV1 {
68
+ private rows: FakeRow[] = [];
69
+ private meta = new Map<string, string>();
70
+ /** Every statement the module issued, for tests that assert on shape. */
71
+ readonly statements: string[] = [];
72
+
73
+ exec<Row extends Record<string, AuditSqlValueV1>>(
74
+ query: string,
75
+ ...bindings: unknown[]
76
+ ): AuditSqlCursorV1<Row> {
77
+ this.statements.push(query);
78
+ const sql = query.replace(/\s+/g, " ").trim();
79
+ const answer = (rows: unknown[]) => cursor(rows as Row[]);
80
+
81
+ if (sql.startsWith("CREATE") || sql.startsWith("DROP")) return answer([]);
82
+
83
+ if (sql.startsWith("SELECT value FROM audit_meta")) {
84
+ const value = this.meta.get(String(bindings[0]));
85
+ return answer(value === undefined ? [] : [{ value }]);
86
+ }
87
+ if (sql.startsWith("INSERT INTO audit_meta")) {
88
+ this.meta.set(String(bindings[0]), String(bindings[1]));
89
+ return answer([]);
90
+ }
91
+ if (sql.startsWith("DELETE FROM audit_meta")) {
92
+ this.meta.delete(String(bindings[0]));
93
+ return answer([]);
94
+ }
95
+ if (sql.startsWith("INSERT INTO audit_entries")) {
96
+ const row = Object.fromEntries(
97
+ COLUMNS.map((column, index) => [column, bindings[index] ?? null]),
98
+ ) as FakeRow;
99
+ this.rows.push(row);
100
+ return answer([]);
101
+ }
102
+ if (sql.startsWith("SELECT count(*) AS n FROM audit_entries")) {
103
+ return answer([{ n: this.filtered(sql, bindings).length }]);
104
+ }
105
+ if (sql.startsWith("SELECT bot_id, run_id, occurrence_id FROM")) {
106
+ const limit = Number(bindings[0]);
107
+ return answer(
108
+ [...this.rows]
109
+ .sort(order)
110
+ .slice(0, limit)
111
+ .map((row) => ({
112
+ bot_id: row.bot_id,
113
+ run_id: row.run_id,
114
+ occurrence_id: row.occurrence_id,
115
+ })),
116
+ );
117
+ }
118
+ if (sql.startsWith("DELETE FROM audit_entries WHERE at <")) {
119
+ this.rows = this.rows.filter((row) => row.at >= String(bindings[0]));
120
+ return answer([]);
121
+ }
122
+ if (
123
+ sql.startsWith(
124
+ "DELETE FROM audit_entries WHERE bot_id = ? AND run_id = ? AND occurrence_id = ?",
125
+ )
126
+ ) {
127
+ this.rows = this.rows.filter(
128
+ (row) =>
129
+ !(
130
+ row.bot_id === bindings[0] &&
131
+ row.run_id === bindings[1] &&
132
+ row.occurrence_id === bindings[2]
133
+ ),
134
+ );
135
+ return answer([]);
136
+ }
137
+ if (sql.startsWith("DELETE FROM audit_entries WHERE bot_id = ?")) {
138
+ this.rows = this.rows.filter((row) => row.bot_id !== bindings[0]);
139
+ return answer([]);
140
+ }
141
+ if (sql === "DELETE FROM audit_entries") {
142
+ this.rows = [];
143
+ return answer([]);
144
+ }
145
+ if (sql.startsWith("SELECT * FROM audit_entries")) {
146
+ const descending = [...this.filtered(sql, bindings)].sort(
147
+ (left, right) => -order(left, right),
148
+ );
149
+ if (!sql.includes("LIMIT")) return answer(descending);
150
+ const limit = Number(bindings[bindings.length - 2]);
151
+ const offset = Number(bindings[bindings.length - 1]);
152
+ return answer(descending.slice(offset, offset + limit));
153
+ }
154
+ throw new Error(`FakeAuditSql does not recognise: ${sql}`);
155
+ }
156
+
157
+ /** Applies the `WHERE bot_id/kind/target` clauses the store builds. */
158
+ private filtered(sql: string, bindings: unknown[]): FakeRow[] {
159
+ const values = [...bindings];
160
+ const where = /WHERE (.+?)(?: ORDER BY| LIMIT|$)/.exec(sql)?.[1] ?? "";
161
+ if (where.startsWith("at <")) {
162
+ return this.rows.filter((row) => row.at < String(values[0]));
163
+ }
164
+ const predicates: Array<(row: FakeRow) => boolean> = [];
165
+ for (const clause of where.split(" AND ").filter(Boolean)) {
166
+ const column = clause.split(" ")[0] as keyof FakeRow;
167
+ if (!COLUMNS.includes(column as (typeof COLUMNS)[number])) continue;
168
+ const expected = String(values.shift());
169
+ predicates.push((row) => String(row[column]) === expected);
170
+ }
171
+ return this.rows.filter((row) =>
172
+ predicates.every((predicate) => predicate(row)),
173
+ );
174
+ }
175
+ }
176
+
177
+ /** An in-memory `AuditOutboxStorageV1`. */
178
+ export class FakeAuditOutboxStorage implements AuditOutboxStorageV1 {
179
+ private readonly values = new Map<string, unknown>();
180
+
181
+ async get<T>(key: string): Promise<T | undefined> {
182
+ const value = this.values.get(key);
183
+ // Durable Object storage round-trips through structured clone, so a test
184
+ // that shared an object reference would prove less than production does.
185
+ return value === undefined
186
+ ? undefined
187
+ : (JSON.parse(JSON.stringify(value)) as T);
188
+ }
189
+
190
+ async put<T>(key: string, value: T): Promise<void> {
191
+ this.values.set(key, JSON.parse(JSON.stringify(value)));
192
+ }
193
+
194
+ async delete(key: string): Promise<boolean> {
195
+ return this.values.delete(key);
196
+ }
197
+ }
@@ -0,0 +1,139 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { AuditUserBackendContribution, resolveAuditTargetV1 } from "./user.ts";
3
+ import { FakeAuditSql } from "./testing.ts";
4
+ import type { AuditEntryV1 } from "./shared.ts";
5
+
6
+ function entry(overrides: Partial<AuditEntryV1> = {}): AuditEntryV1 {
7
+ const occurrenceId = overrides.occurrenceId ?? "tool:1:1:0";
8
+ const [, turn, step, ordinal] = /^tool:(\d+):(\d+):(\d+)$/.exec(
9
+ occurrenceId,
10
+ )!;
11
+ return {
12
+ schemaVersion: 1,
13
+ botId: "foreman",
14
+ runId: "run-1",
15
+ occurrenceId,
16
+ turn: Number(turn),
17
+ step: Number(step),
18
+ ordinal: Number(ordinal),
19
+ effectId: occurrenceId,
20
+ at: "2026-08-31T00:00:00.000Z",
21
+ kind: "shell",
22
+ target: "computer",
23
+ toolName: "computer_exec",
24
+ argumentDigest: "a".repeat(64),
25
+ preview: "ls -la",
26
+ outcome: "ok",
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function contribution(
32
+ options: {
33
+ entries?: AuditEntryV1[];
34
+ hosts?: Map<string, string>;
35
+ journal?: string[];
36
+ } = {},
37
+ ) {
38
+ return new AuditUserBackendContribution({
39
+ sql: new FakeAuditSql(),
40
+ readDirectory: async () => ({ botIds: ["foreman"] }),
41
+ projectBotEntries: async (botId, cursor) => ({
42
+ schemaVersion: 1,
43
+ botId,
44
+ entries: cursor ? [] : (options.entries ?? []),
45
+ }),
46
+ ...(options.hosts ? { readMcpHosts: async () => options.hosts! } : {}),
47
+ ...(options.journal
48
+ ? { readHostJournalEffectIds: async () => options.journal! }
49
+ : {}),
50
+ });
51
+ }
52
+
53
+ describe("resolving an MCP target against the Connection registry", () => {
54
+ test("turns the Connection slug into the server's host", () => {
55
+ const hosts = new Map([["example", "mcp.example.test"]]);
56
+ expect(resolveAuditTargetV1("remote:example", hosts)).toBe(
57
+ "remote:mcp.example.test",
58
+ );
59
+ // A slug the registry does not know stays a slug. That is a less specific
60
+ // row, not a wrong one — better than claiming a host nobody can vouch for.
61
+ expect(resolveAuditTargetV1("remote:beeper", hosts)).toBe("remote:beeper");
62
+ // Everything else is complete as the classifier wrote it.
63
+ expect(resolveAuditTargetV1("computer", hosts)).toBe("computer");
64
+ expect(resolveAuditTargetV1("machine:mac-1", hosts)).toBe("machine:mac-1");
65
+ });
66
+
67
+ test("is applied on the projection path and the rebuild path alike", async () => {
68
+ const hosts = new Map([["example", "mcp.example.test"]]);
69
+ const mcp = entry({
70
+ occurrenceId: "tool:1:1:1",
71
+ kind: "mcp",
72
+ target: "remote:example",
73
+ toolName: "mcp__example__echo",
74
+ });
75
+ const audit = contribution({ entries: [mcp], hosts });
76
+ await audit.indexAuditEntries([mcp]);
77
+ expect(audit.query({}).entries.map((row) => row.target)).toEqual([
78
+ "remote:mcp.example.test",
79
+ ]);
80
+ // The rebuild reads the same unresolved rows back out of the Bot and must
81
+ // land on the identical target, or the table would change under a repair.
82
+ await audit.rebuildAuditIndex();
83
+ expect(audit.query({}).entries.map((row) => row.target)).toEqual([
84
+ "remote:mcp.example.test",
85
+ ]);
86
+ });
87
+ });
88
+
89
+ describe("the User Contribution", () => {
90
+ test("refuses anything that is not a bounded array of entries", async () => {
91
+ const audit = contribution();
92
+ await expect(audit.indexAuditEntries({})).rejects.toThrow("an array");
93
+ await expect(
94
+ audit.indexAuditEntries(Array.from({ length: 513 }, () => entry())),
95
+ ).rejects.toThrow("bound");
96
+ await expect(
97
+ audit.indexAuditEntries([{ ...entry(), turn: 7 }]),
98
+ ).rejects.toThrow("disagree");
99
+ });
100
+
101
+ test("counts host-journal discrepancies without writing them in", async () => {
102
+ const known = entry();
103
+ const audit = contribution({
104
+ entries: [known],
105
+ // The host claims an effect no session event accounts for. The host is
106
+ // non-authoritative, so this is a number a person is shown — never a row.
107
+ journal: [known.effectId, "tool:9:9:9"],
108
+ });
109
+ await audit.indexAuditEntries([known]);
110
+ const receipt = await audit.rebuildAuditIndex();
111
+ expect(receipt).toMatchObject({
112
+ status: "rebuilt",
113
+ entries: 1,
114
+ hostJournalDiscrepancies: 1,
115
+ unknownOutcomes: 0,
116
+ });
117
+ expect(audit.query({}).entries.map((row) => row.effectId)).toEqual([
118
+ known.effectId,
119
+ ]);
120
+ });
121
+
122
+ test("counts outcomes the durable log cannot explain", async () => {
123
+ const unknown = entry({ occurrenceId: "tool:1:1:2", outcome: "unknown" });
124
+ const audit = contribution({ entries: [entry(), unknown] });
125
+ const receipt = await audit.rebuildAuditIndex();
126
+ expect(receipt.entries).toBe(2);
127
+ expect(receipt.unknownOutcomes).toBe(1);
128
+ });
129
+
130
+ test("purges one Bot, and answers its own state", async () => {
131
+ const audit = contribution();
132
+ await audit.indexAuditEntries([entry(), entry({ botId: "scheduler" })]);
133
+ expect(audit.state()).toBe("ready");
134
+ expect(audit.purgeAuditForBot("foreman")).toEqual({ removed: 1 });
135
+ expect(audit.query({}).entries.map((row) => row.botId)).toEqual([
136
+ "scheduler",
137
+ ]);
138
+ });
139
+ });
package/src/user.ts ADDED
@@ -0,0 +1,215 @@
1
+ // The User backend Contribution: the one place a User's audit table lives.
2
+ //
3
+ // It is mounted into the User Durable Object's Cordis root beside Settings,
4
+ // Credentials, Flock and the transcript index, and it owns exactly one thing —
5
+ // an `AuditStoreV1` over that object's own SQL storage.
6
+ //
7
+ // Three seams it does not own, and takes as host functions instead:
8
+ //
9
+ // * the Bot directory, because Flock is the authority for which Bots exist;
10
+ // * the entry source, because entries are projections of runs the *Bot*
11
+ // Durable Object holds, and a rebuild must read them from that authority;
12
+ // * the MCP host map, because Connections are User-scoped state this object
13
+ // holds elsewhere — and resolving `remote:<slug>` to `remote:<host>` here,
14
+ // on the one path both projection and rebuild take, is what stops the two
15
+ // disagreeing about what a row says.
16
+ import type { Plugin } from "cordis";
17
+ import {
18
+ AUDIT_MAX_ENTRY_PAGE_V1,
19
+ AuditDecodeError,
20
+ AUDIT_TARGET_REMOTE_PREFIX_V1,
21
+ decodeAuditEntryPageV1,
22
+ decodeAuditEntryV1,
23
+ type AuditEntryV1,
24
+ type AuditIndexStateV1,
25
+ type AuditRebuildReceiptV1,
26
+ } from "./shared.js";
27
+ import {
28
+ AuditStoreV1,
29
+ type AuditEntrySourceV1,
30
+ type AuditSqlV1,
31
+ } from "./store.js";
32
+
33
+ export interface AuditUserBackendHost {
34
+ /** The User Durable Object's own SQL storage. */
35
+ sql: AuditSqlV1;
36
+ /** Every Bot this User has. */
37
+ readDirectory(): Promise<{ botIds: readonly string[] }>;
38
+ /**
39
+ * One page of a Bot's projected entries, read from that Bot's Durable
40
+ * Object. The answer is decoded here: it is inbound from another runtime.
41
+ */
42
+ projectBotEntries(botId: string, cursor?: string): Promise<unknown>;
43
+ /**
44
+ * `<mcp server slug> → <host>`, from this User's Connection registry.
45
+ *
46
+ * Absent or incomplete is not a failure: an unresolved slug stays
47
+ * `remote:<slug>`, which is still a true statement about where the call
48
+ * went, rather than a row that claims a host nobody can vouch for.
49
+ */
50
+ readMcpHosts?(): Promise<ReadonlyMap<string, string>>;
51
+ /**
52
+ * The Computer host's own per-effect journal, when the deployment exposes
53
+ * one. It is non-authoritative (`AGENTS.md` § Computer and Workspace), so it
54
+ * is only ever *compared* against the table — never inserted into it.
55
+ */
56
+ readHostJournalEffectIds?(): Promise<readonly string[]>;
57
+ /** Overridable so a test can drive eviction. */
58
+ maxRows?: number;
59
+ /** Overridable so a test can drive age eviction. */
60
+ maxAgeMs?: number;
61
+ now?: () => number;
62
+ }
63
+
64
+ /**
65
+ * `remote:<slug>` resolved against the Connection registry.
66
+ *
67
+ * Every other target passes through untouched: `computer` and `machine:<id>`
68
+ * are complete as the classifier wrote them.
69
+ */
70
+ export function resolveAuditTargetV1(
71
+ target: string,
72
+ hosts: ReadonlyMap<string, string>,
73
+ ): string {
74
+ if (!target.startsWith(AUDIT_TARGET_REMOTE_PREFIX_V1)) return target;
75
+ const slug = target.slice(AUDIT_TARGET_REMOTE_PREFIX_V1.length);
76
+ const host = hosts.get(slug);
77
+ return host ? `${AUDIT_TARGET_REMOTE_PREFIX_V1}${host}` : target;
78
+ }
79
+
80
+ export class AuditUserBackendContribution {
81
+ readonly packageId = "audit";
82
+ private readonly store: AuditStoreV1;
83
+
84
+ constructor(private readonly host: AuditUserBackendHost) {
85
+ this.store = new AuditStoreV1({
86
+ sql: host.sql,
87
+ ...(host.maxRows === undefined ? {} : { maxRows: host.maxRows }),
88
+ ...(host.maxAgeMs === undefined ? {} : { maxAgeMs: host.maxAgeMs }),
89
+ ...(host.now === undefined ? {} : { now: host.now }),
90
+ });
91
+ }
92
+
93
+ private async hosts(): Promise<ReadonlyMap<string, string>> {
94
+ if (!this.host.readMcpHosts) return new Map();
95
+ try {
96
+ return await this.host.readMcpHosts();
97
+ } catch {
98
+ // A registry this object could not read leaves slugs unresolved, which
99
+ // is a less specific row and not a wrong one.
100
+ return new Map();
101
+ }
102
+ }
103
+
104
+ private resolve(
105
+ entries: readonly AuditEntryV1[],
106
+ hosts: ReadonlyMap<string, string>,
107
+ ): AuditEntryV1[] {
108
+ return entries.map((entry) => ({
109
+ ...entry,
110
+ target: resolveAuditTargetV1(entry.target, hosts),
111
+ }));
112
+ }
113
+
114
+ /**
115
+ * Idempotent on `(botId, runId, occurrenceId)`; a redelivered outbox page
116
+ * inserts nothing the second time.
117
+ */
118
+ async indexAuditEntries(input: unknown): Promise<{ indexed: number }> {
119
+ if (!Array.isArray(input)) {
120
+ throw new AuditDecodeError("audit entries must be an array");
121
+ }
122
+ if (input.length > AUDIT_MAX_ENTRY_PAGE_V1) {
123
+ throw new AuditDecodeError("audit entries exceed their bound");
124
+ }
125
+ const entries = input.map(decodeAuditEntryV1);
126
+ return {
127
+ indexed: this.store.insert(this.resolve(entries, await this.hosts())),
128
+ };
129
+ }
130
+
131
+ /** Every entry of one Bot leaves the table. The archive saga calls this. */
132
+ purgeAuditForBot(botId: string): { removed: number } {
133
+ return { removed: this.store.purge(botId) };
134
+ }
135
+
136
+ state(): AuditIndexStateV1 {
137
+ return this.store.state();
138
+ }
139
+
140
+ query(request: {
141
+ botId?: string;
142
+ kind?: string;
143
+ target?: string;
144
+ before?: string;
145
+ limit?: number;
146
+ }): { entries: AuditEntryV1[]; nextCursor?: string; total: number } {
147
+ return this.store.query(request);
148
+ }
149
+
150
+ /**
151
+ * Reconstructs the whole table from the Bots' own stored runs.
152
+ *
153
+ * This is what makes the table disposable rather than authoritative. The
154
+ * receipt names how many entries it wrote and how many effects the Computer
155
+ * host's journal reported that no durable event accounts for — an `unknown`
156
+ * the User is told about rather than a row invented to cover it.
157
+ */
158
+ async rebuildAuditIndex(): Promise<AuditRebuildReceiptV1> {
159
+ const directory = await this.host.readDirectory();
160
+ const hosts = await this.hosts();
161
+ const sources: AuditEntrySourceV1[] = directory.botIds.map((botId) => ({
162
+ botId,
163
+ page: async (cursor) => {
164
+ const page = decodeAuditEntryPageV1(
165
+ await this.host.projectBotEntries(botId, cursor),
166
+ );
167
+ return {
168
+ entries: this.resolve(page.entries, hosts),
169
+ ...(page.nextCursor === undefined
170
+ ? {}
171
+ : { nextCursor: page.nextCursor }),
172
+ };
173
+ },
174
+ }));
175
+ const outcome = await this.store.rebuild(sources);
176
+ return {
177
+ schemaVersion: 1,
178
+ status: "rebuilt",
179
+ entries: outcome.entries,
180
+ bots: outcome.bots,
181
+ indexState: outcome.indexState,
182
+ unknownOutcomes: this.store
183
+ .all()
184
+ .filter((entry) => entry.outcome === "unknown").length,
185
+ hostJournalDiscrepancies: await this.countHostJournalDiscrepancies(),
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Effects the host journal claims that the durable events do not.
191
+ *
192
+ * Counted, never written. The host is non-authoritative, so an effect it
193
+ * reports with no matching session event is a discrepancy for a person to
194
+ * look at — not evidence a Turn did something.
195
+ */
196
+ private async countHostJournalDiscrepancies(): Promise<number> {
197
+ if (!this.host.readHostJournalEffectIds) return 0;
198
+ let journal: readonly string[];
199
+ try {
200
+ journal = await this.host.readHostJournalEffectIds();
201
+ } catch {
202
+ return 0;
203
+ }
204
+ if (journal.length === 0) return 0;
205
+ const known = new Set(this.store.all().map((entry) => entry.effectId));
206
+ return journal.filter((effectId) => !known.has(effectId)).length;
207
+ }
208
+ }
209
+
210
+ export function createAuditUserBackendPlugin(
211
+ host: AuditUserBackendHost,
212
+ lifecycle: { mount(value: AuditUserBackendContribution): () => void },
213
+ ): Plugin {
214
+ return () => lifecycle.mount(new AuditUserBackendContribution(host));
215
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "resolveJsonModule": true,
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
12
+ "types": ["bun", "vite/client"]
13
+ },
14
+ "include": ["src/**/*.ts", "src/**/*.vue"]
15
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @frockbot/plugin-audit
2
-
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.