feat: bound and redact console operation evidence

This commit is contained in:
lda
2026-08-11 19:47:03 +07:00 Verified
parent 32c99cbeb0
commit 3231a1d943
17 changed files with 481 additions and 9 deletions
@@ -75,6 +75,7 @@ export const ConsoleWorkspace = () => {
type: "evidence_recorded",
record: {
id: allocateEvidenceId("workflow.health"),
target: response.connection.target,
operation: "workflow.health",
label: "Health check",
equivalentCli: response.equivalentCli,
@@ -5,6 +5,7 @@ import { EvidenceLedger } from "./EvidenceLedger.js";
const record: EvidenceRecord = {
id: "health-0",
target: "http://console.test/rpc",
operation: "workflow.health",
label: "Health check",
equivalentCli: "uv run wf status",
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import type { EvidenceRecord } from "../../app/state.js";
import {
retainEvidence,
sanitizeEvidenceRecord,
sanitizeEvidenceValue,
} from "./evidence-policy.js";
const jsonByteLength = (value: unknown): number =>
new TextEncoder().encode(JSON.stringify(value)).length;
const makeRecord = (id: string, request: unknown = {}, response: unknown = {}): EvidenceRecord => ({
id,
target: "http://console.test/rpc",
operation: "workflow.capabilities.list",
label: "List capabilities",
equivalentCli: "uv run wf cap list",
request,
response,
durationMs: 4,
});
describe("evidence policy", () => {
it("redacts sensitive keys case-insensitively before traversing their values", () => {
const secret: Record<string, unknown> = {};
secret.self = secret;
const value = {
Authorization: secret,
aUtHoRiZaTiOn: "Bearer another-secret",
value: "ok",
};
expect(sanitizeEvidenceValue(value)).toEqual({
Authorization: "[redacted]",
aUtHoRiZaTiOn: "[redacted]",
value: "ok",
});
expect(value.Authorization).toBe(secret);
});
it("truncates recursive depth with a stable marker", () => {
let value: unknown = { leaf: true };
for (let index = 0; index < 20; index += 1) {
value = { nested: value };
}
const sanitized = sanitizeEvidenceValue(value);
const serialized = JSON.stringify(sanitized);
expect(serialized).toContain("[truncated: depth limit]");
expect(serialized).not.toContain("[object Object]");
});
it("truncates oversized strings with a stable marker", () => {
const sanitized = sanitizeEvidenceValue("x".repeat(20_000));
expect(sanitized).toMatch(/x+\[truncated: evidence limit\]$/);
expect(String(sanitized).length).toBeLessThan(20_000);
});
it("truncates array and object entries deterministically", () => {
const array = sanitizeEvidenceValue(Array.from({ length: 150 }, (_, index) => index));
const object = sanitizeEvidenceValue(
Object.fromEntries(Array.from({ length: 150 }, (_, index) => [`entry-${index}`, index])),
);
expect(Array.isArray(array)).toBe(true);
expect((array as unknown[]).at(-1)).toBe("[truncated: evidence limit]");
expect((object as Record<string, unknown>)["[truncated: evidence limit]"]).toBe(
"[truncated: evidence limit]",
);
});
it("keeps each sanitized value within the 32 KiB UTF-8 budget", () => {
const record = sanitizeEvidenceRecord(
makeRecord(
"large",
{ payload: "request-😀".repeat(20_000) },
{ payload: "response-漢".repeat(20_000) },
),
);
expect(jsonByteLength(record.request)).toBeLessThanOrEqual(32 * 1024);
expect(jsonByteLength(record.response)).toBeLessThanOrEqual(32 * 1024);
});
it("preserves ordinary scalar data", () => {
expect(sanitizeEvidenceValue({ ok: true, count: 3, empty: null, text: "hello" })).toEqual({
ok: true,
count: 3,
empty: null,
text: "hello",
});
});
it("handles cyclic and non-JSON values without throwing", () => {
const value: Record<string, unknown> = {
bigint: 123n,
missing: undefined,
callable: () => "not JSON",
};
value.cycle = value;
expect(() => sanitizeEvidenceValue(value)).not.toThrow();
expect(() => JSON.stringify(sanitizeEvidenceValue(value))).not.toThrow();
});
it("retains only the newest 100 sanitized records", () => {
const records = Array.from({ length: 100 }, (_, index) => makeRecord(`record-${index}`));
const retained = retainEvidence(records, makeRecord("record-100", { token: "secret" }));
expect(retained).toHaveLength(100);
expect(retained[0]?.id).toBe("record-1");
expect(retained.at(-1)).toMatchObject({
id: "record-100",
request: { token: "[redacted]" },
});
expect(retained).not.toBe(records);
});
});
@@ -0,0 +1,247 @@
import type { EvidenceRecord } from "../../app/state.js";
export const EVIDENCE_MAX_BYTES = 32 * 1024;
export const EVIDENCE_MAX_RECORDS = 100;
const MAX_DEPTH = 8;
const MAX_STRING_LENGTH = 4096;
const MAX_ENTRIES = 100;
const REDACTED_MARKER = "[redacted]";
const DEPTH_LIMIT_MARKER = "[truncated: depth limit]";
const EVIDENCE_LIMIT_MARKER = "[truncated: evidence limit]";
const CIRCULAR_MARKER = "[truncated: circular reference]";
const UNSUPPORTED_MARKER = "[unsupported: value]";
const TRUNCATION_KEY = EVIDENCE_LIMIT_MARKER;
const sensitiveKeyPattern =
/authorization|cookie|token|password|secret|credential|api[-_]?key|private[-_]?key/i;
const byteLength = (value: unknown): number => {
const serialized = JSON.stringify(value);
return new TextEncoder().encode(serialized).length;
};
const truncateString = (value: string, maxLength: number): string => {
const characters = Array.from(value);
if (characters.length <= maxLength) return value;
const prefixLength = Math.max(0, maxLength - EVIDENCE_LIMIT_MARKER.length);
return `${characters.slice(0, prefixLength).join("")}${EVIDENCE_LIMIT_MARKER}`;
};
const unsupportedValue = (value: unknown): string => {
if (value === undefined) return "[unsupported: undefined]";
if (typeof value === "function") return "[unsupported: function]";
if (typeof value === "symbol") return "[unsupported: symbol]";
if (typeof value === "bigint") return "[unsupported: bigint]";
return UNSUPPORTED_MARKER;
};
const readProperty = (value: object, key: string): unknown => {
try {
return Reflect.get(value, key);
} catch {
return "[unavailable: evidence value]";
}
};
const projectValue = (
value: unknown,
depth: number,
active: WeakSet<object>,
): unknown => {
if (value === null) return null;
switch (typeof value) {
case "string":
return truncateString(value, MAX_STRING_LENGTH);
case "boolean":
return value;
case "number":
return Number.isFinite(value) ? value : "[unsupported: number]";
case "undefined":
case "function":
case "symbol":
case "bigint":
return unsupportedValue(value);
case "object":
break;
}
if (depth >= MAX_DEPTH) return DEPTH_LIMIT_MARKER;
if (active.has(value)) return CIRCULAR_MARKER;
active.add(value);
try {
if (Array.isArray(value)) {
const result: unknown[] = [];
const length = Math.min(value.length, MAX_ENTRIES);
for (let index = 0; index < length; index += 1) {
result.push(projectValue(readProperty(value, index.toString()), depth + 1, active));
}
if (value.length > MAX_ENTRIES) result.push(EVIDENCE_LIMIT_MARKER);
return result;
}
const result: Record<string, unknown> = {};
let keys: string[];
try {
keys = Object.keys(value);
} catch {
return UNSUPPORTED_MARKER;
}
const length = Math.min(keys.length, MAX_ENTRIES);
for (let index = 0; index < length; index += 1) {
const key = keys[index];
if (key === undefined) continue;
const safeKey = truncateString(key, MAX_STRING_LENGTH);
// Check the key before reading its value so secrets behind getters or cycles are never traversed.
result[safeKey] = sensitiveKeyPattern.test(key)
? REDACTED_MARKER
: projectValue(readProperty(value, key), depth + 1, active);
}
if (keys.length > MAX_ENTRIES) result[TRUNCATION_KEY] = EVIDENCE_LIMIT_MARKER;
return result;
} finally {
active.delete(value);
}
};
const fitString = (value: string, maxBytes: number): string => {
if (byteLength(value) <= maxBytes) return value;
if (byteLength(EVIDENCE_LIMIT_MARKER) > maxBytes) return "";
const characters = Array.from(value);
let low = 0;
let high = characters.length;
let best = EVIDENCE_LIMIT_MARKER;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const candidate = `${characters.slice(0, middle).join("")}${EVIDENCE_LIMIT_MARKER}`;
if (byteLength(candidate) <= maxBytes) {
best = candidate;
low = middle + 1;
} else {
high = middle - 1;
}
}
return best;
};
const fitChild = <T>(
child: T,
maxBytes: number,
accepts: (candidate: T) => boolean,
): T | undefined => {
let low = 0;
let high = maxBytes;
let best: T | undefined;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const candidate = fitValue(child, middle) as T;
if (accepts(candidate)) {
best = candidate;
low = middle + 1;
} else {
high = middle - 1;
}
}
return best;
};
const fitArray = (value: readonly unknown[], maxBytes: number): unknown[] => {
if (byteLength(value) <= maxBytes) return [...value];
const result: unknown[] = [];
let omitted = false;
for (let index = 0; index < value.length; index += 1) {
const child = fitValue(value[index], maxBytes);
if (byteLength([...result, child]) <= maxBytes) {
result.push(child);
continue;
}
const fitted = fitChild(value[index], maxBytes, (candidate) =>
byteLength([...result, candidate]) <= maxBytes,
);
if (fitted !== undefined) result.push(fitted);
omitted = true;
break;
}
if (omitted) {
while (result.length > 0 && byteLength([...result, EVIDENCE_LIMIT_MARKER]) > maxBytes) {
result.pop();
}
if (byteLength([...result, EVIDENCE_LIMIT_MARKER]) <= maxBytes) {
result.push(EVIDENCE_LIMIT_MARKER);
}
}
return result;
};
const fitObject = (value: Record<string, unknown>, maxBytes: number): Record<string, unknown> => {
if (byteLength(value) <= maxBytes) return { ...value };
const result: Record<string, unknown> = {};
let omitted = false;
for (const key of Object.keys(value)) {
const child = fitValue(value[key], maxBytes);
const candidate = { ...result, [key]: child };
if (byteLength(candidate) <= maxBytes) {
result[key] = child;
continue;
}
const fitted = fitChild(value[key], maxBytes, (fittedChild) =>
byteLength({ ...result, [key]: fittedChild }) <= maxBytes,
);
if (fitted !== undefined) result[key] = fitted;
omitted = true;
break;
}
if (omitted) {
while (
Object.keys(result).length > 0 &&
byteLength({ ...result, [TRUNCATION_KEY]: EVIDENCE_LIMIT_MARKER }) > maxBytes
) {
const lastKey = Object.keys(result).at(-1);
if (lastKey === undefined) break;
delete result[lastKey];
}
if (byteLength({ ...result, [TRUNCATION_KEY]: EVIDENCE_LIMIT_MARKER }) <= maxBytes) {
result[TRUNCATION_KEY] = EVIDENCE_LIMIT_MARKER;
}
}
return result;
};
const fitValue = (value: unknown, maxBytes: number): unknown => {
if (byteLength(value) <= maxBytes) {
if (Array.isArray(value)) return [...value];
if (value !== null && typeof value === "object") return { ...(value as Record<string, unknown>) };
return value;
}
if (typeof value === "string") return fitString(value, maxBytes);
if (Array.isArray(value)) return fitArray(value, maxBytes);
if (value !== null && typeof value === "object") {
return fitObject(value as Record<string, unknown>, maxBytes);
}
return EVIDENCE_LIMIT_MARKER;
};
export const sanitizeEvidenceValue = (value: unknown): unknown =>
fitValue(projectValue(value, 0, new WeakSet()), EVIDENCE_MAX_BYTES);
export const sanitizeEvidenceRecord = (record: EvidenceRecord): EvidenceRecord => ({
...record,
// Keep request context visible even when a response is independently oversized.
request: sanitizeEvidenceValue(record.request),
response: sanitizeEvidenceValue(record.response),
});
export const retainEvidence = (
records: readonly EvidenceRecord[],
record: EvidenceRecord,
): readonly EvidenceRecord[] => [
...records,
sanitizeEvidenceRecord(record),
].slice(-EVIDENCE_MAX_RECORDS);
@@ -71,6 +71,7 @@ export const createConsoleExecutor = (
if (options.shouldRecordEvidence?.() === false) return;
options.recordEvidence({
id: allocateEvidenceId(operation),
target: options.target,
operation,
label,
equivalentCli,
@@ -47,6 +47,7 @@ describe("ConsoleReadExecutor", () => {
expect(recordEvidence).toHaveBeenCalledTimes(1);
expect(recordEvidence.mock.calls[0]?.[0]).toMatchObject({
id: "workflow.capabilities.list-0",
target: "http://console.test/rpc",
operation: "workflow.capabilities.list",
request: { sent: true },
response: { status: 200 },
@@ -70,6 +71,7 @@ describe("ConsoleReadExecutor", () => {
expect(recordEvidence).toHaveBeenCalledTimes(1);
expect(recordEvidence.mock.calls[0]?.[0]).toMatchObject({
id: "workflow.capabilities.list-0",
target: "http://console.test/rpc",
label: "workflow.capabilities.list failed",
response: { status: 502 },
});
@@ -112,6 +114,7 @@ describe("ConsoleReadExecutor", () => {
expect(recordEvidence).toHaveBeenCalledTimes(1);
expect(recordEvidence.mock.calls[0]?.[0]).toMatchObject({
operation: "workflow.capabilities.list",
target: "http://console.test/rpc",
label: "workflow.capabilities.list failed",
equivalentCli: "unavailable: response operation mismatch",
});
@@ -132,6 +135,7 @@ describe("ConsoleReadExecutor", () => {
}),
).rejects.toMatchObject({ kind: "decode", cause });
expect(recordEvidence).toHaveBeenCalledTimes(1);
expect(recordEvidence.mock.calls[0]?.[0]?.target).toBe("http://console.test/rpc");
});
it("maps rejected invocations to transport errors", async () => {
@@ -149,6 +153,7 @@ describe("ConsoleReadExecutor", () => {
executor.run("workflow.capabilities.list", {}, (value) => value),
).rejects.toMatchObject({ kind: "transport", cause });
expect(recordEvidence).toHaveBeenCalledTimes(1);
expect(recordEvidence.mock.calls[0]?.[0]?.target).toBe("http://console.test/rpc");
});
it("measures duration for failures before operation metadata exists", async () => {
@@ -212,6 +217,7 @@ describe("ConsoleReadExecutor", () => {
executor.run("workflow.capabilities.list", {}, (value) => value),
).rejects.toMatchObject({ kind: "decode", message });
expect(recordEvidence).toHaveBeenCalledTimes(1);
expect(recordEvidence.mock.calls[0]?.[0]?.target).toBe("http://console.test/rpc");
},
);
@@ -47,6 +47,7 @@ describe("ConsoleWriteExecutor", () => {
expect(recordEvidence).toHaveBeenCalledTimes(1);
expect(recordEvidence.mock.calls[0]?.[0]).toMatchObject({
id: "workflow.draft_workspaces.create_empty-0",
target: "http://console.test/rpc",
operation: "workflow.draft_workspaces.create_empty",
request: { sent: true },
response: { status: 200 },
@@ -71,6 +72,7 @@ describe("ConsoleWriteExecutor", () => {
expect(recordEvidence).toHaveBeenCalledTimes(1);
expect(recordEvidence.mock.calls[0]?.[0]).toMatchObject({
operation: "workflow.draft_workspaces.validate",
target: "http://console.test/rpc",
label: "workflow.draft_workspaces.validate failed",
response: { status: 502 },
});
@@ -100,15 +102,17 @@ describe("ConsoleWriteExecutor", () => {
expect(decode).not.toHaveBeenCalled();
expect(recordEvidence.mock.calls[0]?.[0]).toMatchObject({
operation: "workflow.draft_workspaces.create_empty",
target: "http://console.test/rpc",
equivalentCli: "unavailable: response operation mismatch",
});
});
it("preserves decoder and transport causes", async () => {
const decodeCause = new Error("invalid draft response");
const decodeEvidence = vi.fn();
const decodeExecutor = createConsoleWriteExecutor({
target: "http://console.test/rpc",
recordEvidence: vi.fn(),
recordEvidence: decodeEvidence,
invoke: vi.fn(async () => createSuccess({ malformed: true })),
});
@@ -117,11 +121,13 @@ describe("ConsoleWriteExecutor", () => {
throw decodeCause;
}),
).rejects.toMatchObject({ kind: "decode", cause: decodeCause });
expect(decodeEvidence.mock.calls[0]?.[0]?.target).toBe("http://console.test/rpc");
const transportCause = new Error("fetch failed");
const transportEvidence = vi.fn();
const transportExecutor = createConsoleWriteExecutor({
target: "http://console.test/rpc",
recordEvidence: vi.fn(),
recordEvidence: transportEvidence,
invoke: vi.fn(async () => {
throw transportCause;
}),
@@ -130,10 +136,12 @@ describe("ConsoleWriteExecutor", () => {
await expect(
transportExecutor.run("workflow.draft_workspaces.create_empty", {}, (value) => value),
).rejects.toMatchObject({ kind: "transport", cause: transportCause });
expect(transportEvidence.mock.calls[0]?.[0]?.target).toBe("http://console.test/rpc");
const protocolEvidence = vi.fn();
const protocolExecutor = createConsoleWriteExecutor({
target: "http://console.test/rpc",
recordEvidence: vi.fn(),
recordEvidence: protocolEvidence,
invoke: vi.fn(async () => {
throw new ConsoleApiError("protocol", "malformed JSON response");
}),
@@ -141,6 +149,7 @@ describe("ConsoleWriteExecutor", () => {
await expect(
protocolExecutor.run("workflow.draft_workspaces.create_empty", {}, (value) => value),
).rejects.toMatchObject({ kind: "decode" });
expect(protocolEvidence.mock.calls[0]?.[0]?.target).toBe("http://console.test/rpc");
});
it("records elapsed duration for failures without response metadata", async () => {