@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/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ export * from "./shared.js";
2
+ export { auditKindForToolV1, type AuditClassificationV1 } from "./classify.js";
3
+ export { auditArgumentDigestV1, auditPreviewV1 } from "./redact.js";
4
+ export {
5
+ AuditOutboxV1,
6
+ AUDIT_OUTBOX_KEY_V1,
7
+ auditEntriesFromStoredRunV1,
8
+ isSettledAuditRunV1,
9
+ type AuditOutboxStateV1,
10
+ type AuditOutboxStorageV1,
11
+ type AuditProjectableRunV1,
12
+ type AuditSinkV1,
13
+ } from "./bot.js";
14
+ export {
15
+ AuditStoreV1,
16
+ AUDIT_REBUILD_PAGE_V1,
17
+ decodeAuditOffsetV1,
18
+ type AuditEntrySourceV1,
19
+ type AuditRebuildOutcomeV1,
20
+ type AuditSqlCursorV1,
21
+ type AuditSqlV1,
22
+ type AuditSqlValueV1,
23
+ } from "./store.js";
24
+ export { default as manifest } from "./manifest.js";
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { auditArgumentDigestV1, auditPreviewV1 } from "./redact.ts";
3
+ import { AUDIT_MAX_PREVIEW_LENGTH_V1 } from "./shared.ts";
4
+
5
+ describe("the audit preview", () => {
6
+ test("redacts a bearer token out of a shell command", () => {
7
+ const preview = auditPreviewV1("shell", "computer_exec", {
8
+ command:
9
+ "curl -H 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345' https://api.example",
10
+ });
11
+ expect(preview).not.toContain("abcdefghijklmnopqrstuvwxyz012345");
12
+ expect(preview).toContain("[redacted:bearer-token]");
13
+ // The rest of the command survives, which is the only reason to keep a
14
+ // preview at all.
15
+ expect(preview).toContain("https://api.example");
16
+ });
17
+
18
+ test("redacts a JWT and an sk- key", () => {
19
+ const jwt =
20
+ "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U";
21
+ expect(
22
+ auditPreviewV1("shell", "computer_exec", { command: `echo ${jwt}` }),
23
+ ).toBe("echo [redacted:jwt]");
24
+ expect(
25
+ auditPreviewV1("shell", "computer_exec", {
26
+ command: "export OPENAI_API_KEY=sk-abcdefghijklmnopqrst",
27
+ }),
28
+ ).toContain("[redacted:");
29
+ });
30
+
31
+ test("never carries env or a credential reference, whatever the input", () => {
32
+ // Structural, not filtered: the preview is built from a per-kind
33
+ // allowlist, so `env` is absent because it was never reachable.
34
+ const preview = auditPreviewV1("shell", "computer_exec", {
35
+ command: "printenv",
36
+ env: { OPENAI_API_KEY: "sk-abcdefghijklmnopqrst" },
37
+ credentialRef: "sprites:user:alice",
38
+ });
39
+ expect(preview).toBe("printenv");
40
+ expect(preview).not.toContain("credentialRef");
41
+ expect(preview).not.toContain("sprites:user:alice");
42
+ });
43
+
44
+ test("shows a browser action and its url, and an MCP call's shape only", () => {
45
+ expect(
46
+ auditPreviewV1("browser", "computer_browser", {
47
+ action: "navigate",
48
+ url: "https://example.test/login",
49
+ }),
50
+ ).toBe("navigate https://example.test/login");
51
+ // A remote server's arguments are somebody else's schema; the preview
52
+ // names the keys and never their values.
53
+ expect(
54
+ auditPreviewV1("mcp", "mcp__example__echo", {
55
+ message: "Bearer abcdefghijklmnopqrstuvwx",
56
+ chatId: 42,
57
+ }),
58
+ ).toBe("mcp__example__echo (chatId, message)");
59
+ });
60
+
61
+ test("is bounded and deterministic", () => {
62
+ const input = { command: "x".repeat(5_000) };
63
+ const once = auditPreviewV1("shell", "computer_exec", input);
64
+ expect(once.length).toBe(AUDIT_MAX_PREVIEW_LENGTH_V1);
65
+ expect(auditPreviewV1("shell", "computer_exec", input)).toBe(once);
66
+ // Falls back to the tool name rather than an empty cell.
67
+ expect(auditPreviewV1("shell", "computer_exec", {})).toBe("computer_exec");
68
+ expect(auditPreviewV1("file", "memory_write", null)).toBe("memory_write");
69
+ });
70
+ });
71
+
72
+ describe("the argument digest", () => {
73
+ test("is a stable sha-256 of the exact argument JSON", async () => {
74
+ const digest = await auditArgumentDigestV1({ command: "ls -la" });
75
+ expect(digest).toMatch(/^[0-9a-f]{64}$/);
76
+ expect(await auditArgumentDigestV1({ command: "ls -la" })).toBe(digest);
77
+ // The same bytes SHA-256 answers for `{"command":"ls -la"}`.
78
+ expect(digest).toBe(
79
+ "1df8bccaec747dc615b50678f35bf5b51756a45f9b2b77b247c7a617fde58b3e",
80
+ );
81
+ });
82
+
83
+ test("distinguishes two different calls, and is total", async () => {
84
+ const left = await auditArgumentDigestV1({ command: "ls" });
85
+ const right = await auditArgumentDigestV1({ command: "ls " });
86
+ expect(left).not.toBe(right);
87
+ expect(await auditArgumentDigestV1(undefined)).toBe(
88
+ await auditArgumentDigestV1(null),
89
+ );
90
+ });
91
+ });
package/src/redact.ts ADDED
@@ -0,0 +1,110 @@
1
+ // What an audit entry is allowed to carry out of a tool call.
2
+ //
3
+ // The constitution is explicit and this module is where it is enforced:
4
+ // "No secret lives on the Workspace except the User's browser profile … Code
5
+ // running on the Computer receives every other credential only as an opaque,
6
+ // expiring lease" (`AGENTS.md` § Computer and Workspace), and "client bundles
7
+ // and protocols contain no secrets" (§ Architecture checks). An audit table is
8
+ // durable state a person reads, so it gets the digest and a redacted preview,
9
+ // never the arguments.
10
+ //
11
+ // Three refusals, in order of how badly they would fail:
12
+ //
13
+ // 1. `env` is never projected. The Computer host's exec op carries an `env`
14
+ // map (`computer-host-protocol/src/protocol.ts`), which is exactly where a
15
+ // leased credential would be; the preview is built from a per-kind
16
+ // allowlist of fields, so `env` is absent because it was never reachable,
17
+ // not because a filter removed it.
18
+ // 2. `credentialRef` is never projected, for the same reason and by the same
19
+ // mechanism.
20
+ // 3. Whatever survives runs through the shared credential-shape table
21
+ // (`@frockbot/secret-shapes`), matched substrings replaced with
22
+ // `[redacted:<id>]`.
23
+ //
24
+ // HONEST BOUND, stated as `plugin-memory/src/secrets.ts` states it: step 3 is
25
+ // a shape matcher, not a secret scanner, and a determined encoding gets
26
+ // through it. Steps 1 and 2 are not — they are structural, and they are what
27
+ // the rule actually rests on.
28
+ import { redactSecretShapesV1 } from "@frockbot/secret-shapes";
29
+ import { AUDIT_MAX_PREVIEW_LENGTH_V1, type AuditKindV1 } from "./shared.js";
30
+
31
+ function isObject(value: unknown): value is Record<string, unknown> {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ /**
36
+ * The fields each kind may show, in the order they read best.
37
+ *
38
+ * An allowlist rather than a denylist: a Package that adds an argument gets no
39
+ * preview for it until somebody names it here, which is the failure direction
40
+ * worth having.
41
+ */
42
+ const PREVIEW_FIELDS: Record<AuditKindV1, readonly string[]> = {
43
+ shell: ["command", "machineId"],
44
+ browser: ["action", "url", "role", "name", "label", "key"],
45
+ process: ["action", "command", "processId", "machineId"],
46
+ file: ["path", "root", "project", "packageId", "skill", "text"],
47
+ mcp: [],
48
+ };
49
+
50
+ function render(value: unknown): string | undefined {
51
+ if (typeof value === "string") return value;
52
+ if (typeof value === "number" || typeof value === "boolean") {
53
+ return String(value);
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ /**
59
+ * The bounded, redacted, human-readable half of an audit entry.
60
+ *
61
+ * Deterministic: same call, same preview, for ever — which is what lets a
62
+ * rebuild reproduce a row written months earlier.
63
+ */
64
+ export function auditPreviewV1(
65
+ kind: AuditKindV1,
66
+ toolName: string,
67
+ input: unknown,
68
+ ): string {
69
+ const parts: string[] = [];
70
+ if (kind === "mcp") {
71
+ // A remote server's arguments are somebody else's schema; there is no
72
+ // allowlist that could be right for all of them, so the preview names the
73
+ // tool and the *shape* of what it was given and stops there.
74
+ parts.push(toolName);
75
+ if (isObject(input)) {
76
+ const keys = Object.keys(input).slice(0, 12).sort();
77
+ if (keys.length > 0) parts.push(`(${keys.join(", ")})`);
78
+ }
79
+ } else {
80
+ for (const field of PREVIEW_FIELDS[kind]) {
81
+ if (!isObject(input)) break;
82
+ const rendered = render(input[field]);
83
+ if (rendered === undefined || rendered.length === 0) continue;
84
+ parts.push(rendered);
85
+ }
86
+ if (parts.length === 0) parts.push(toolName);
87
+ }
88
+ const joined = parts.join(" ").replace(/\s+/g, " ").trim();
89
+ return redactSecretShapesV1(joined).slice(0, AUDIT_MAX_PREVIEW_LENGTH_V1);
90
+ }
91
+
92
+ /**
93
+ * Lowercase hex sha-256 of the exact argument JSON.
94
+ *
95
+ * "Exact" means the value the durable `tool/call` event holds, serialized
96
+ * once: two Turns that issued the same call share a digest, and a person
97
+ * checking whether a command recurred can do so without the table ever having
98
+ * held the command. `undefined` arguments hash as `null` so the digest is
99
+ * total.
100
+ */
101
+ export async function auditArgumentDigestV1(input: unknown): Promise<string> {
102
+ const canonical = JSON.stringify(input ?? null) ?? "null";
103
+ const digest = await crypto.subtle.digest(
104
+ "SHA-256",
105
+ new TextEncoder().encode(canonical),
106
+ );
107
+ return Array.from(new Uint8Array(digest), (byte) =>
108
+ byte.toString(16).padStart(2, "0"),
109
+ ).join("");
110
+ }