@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/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";
|
package/src/manifest.ts
ADDED
|
@@ -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
|
+
}
|