feat: bound and redact console operation evidence
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user