fix: apply evidence policy to presentation records
This commit is contained in:
@@ -2,6 +2,8 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testi
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { callOperation } from "../connection/api.js";
|
||||
import type { DemoRecording } from "../demo/timeline/models.js";
|
||||
import { projectRecordingToEvidence } from "./PresentationRoute.js";
|
||||
|
||||
vi.mock("../connection/api.js", () => ({
|
||||
callOperation: vi.fn().mockResolvedValue({
|
||||
@@ -174,6 +176,43 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("PresentationRoute", () => {
|
||||
it("sanitizes and bounds initial replay evidence through the evidence policy", () => {
|
||||
const recording = {
|
||||
schemaVersion: 1,
|
||||
recordingId: "reviewed-recording",
|
||||
title: "Reviewed recording",
|
||||
createdAt: "2026-08-11T00:00:00.000Z",
|
||||
deploymentId: "lda_report_case_study.default",
|
||||
source: "reviewed_live_capture",
|
||||
events: Array.from({ length: 101 }, (_, index) => ({
|
||||
id: `event-${index}`,
|
||||
sequence: index,
|
||||
stage: "trace_read",
|
||||
operation: "workflow.runs.trace",
|
||||
reason: "Read trace",
|
||||
equivalentCli: "uv run wf run trace run_demo",
|
||||
params: { tokenCount: index, cookieJar: "safe" },
|
||||
rawResponse: {
|
||||
AUTHORIZATION: "Bearer secret",
|
||||
secretary: "safe",
|
||||
},
|
||||
interpreted: null,
|
||||
durationMs: 1,
|
||||
resultingIds: { deploymentId: null, runId: null },
|
||||
recordedAt: "2026-08-11T00:00:00.000Z",
|
||||
})),
|
||||
} as DemoRecording;
|
||||
|
||||
const evidence = projectRecordingToEvidence(recording);
|
||||
|
||||
expect(evidence).toHaveLength(100);
|
||||
expect(evidence[0]?.id).toBe("event-1");
|
||||
expect(evidence.at(-1)?.response).toEqual({
|
||||
AUTHORIZATION: "[redacted]",
|
||||
secretary: "safe",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the uniform audience pairing panel on /present", { timeout: 15000 }, async () => {
|
||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||
render(<PresentationRoute />);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import type { EvidenceRecord } from "../app/state.js";
|
||||
import { retainEvidence } from "../workspace/domain/evidence-policy.js";
|
||||
import { resolvePresentationTarget } from "./live-target.js";
|
||||
import { usePresentationTargetStatus } from "./usePresentationTargetStatus.js";
|
||||
import { useTimelineAgent } from "../demo/agent/timelineAgent.js";
|
||||
@@ -20,7 +21,7 @@ import { usePresentationSync } from "./sync/usePresentationSync.js";
|
||||
import "./presentation.css";
|
||||
import "./styles/demo-workflow.css";
|
||||
|
||||
const projectRecordingToEvidence = (
|
||||
export const projectRecordingToEvidence = (
|
||||
recording: import("../demo/timeline/models.js").DemoRecording,
|
||||
): readonly EvidenceRecord[] =>
|
||||
recording.events
|
||||
@@ -34,7 +35,11 @@ const projectRecordingToEvidence = (
|
||||
request: event.params,
|
||||
response: event.rawResponse,
|
||||
durationMs: event.durationMs,
|
||||
}));
|
||||
}))
|
||||
.reduce<readonly EvidenceRecord[]>(
|
||||
(records, record) => retainEvidence(records, record),
|
||||
[],
|
||||
);
|
||||
|
||||
export const PresentationRoute = () => {
|
||||
const [state, dispatch] = useReducer(
|
||||
@@ -80,7 +85,7 @@ export const PresentationRoute = () => {
|
||||
|
||||
const [evidence, setEvidence] = useState<readonly EvidenceRecord[]>(replayEvidence);
|
||||
const recordEvidence = useCallback((record: EvidenceRecord) => {
|
||||
setEvidence((records) => [...records, record]);
|
||||
setEvidence((records) => retainEvidence(records, record));
|
||||
}, []);
|
||||
|
||||
const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
|
||||
|
||||
@@ -38,6 +38,44 @@ describe("evidence policy", () => {
|
||||
expect(value.Authorization).toBe(secret);
|
||||
});
|
||||
|
||||
it("redacts only the approved sensitive keys with case-insensitive matching", () => {
|
||||
const sanitized = sanitizeEvidenceValue({
|
||||
AUTHORIZATION: "authorization-secret",
|
||||
Cookie: "cookie-secret",
|
||||
"SET-COOKIE": "set-cookie-secret",
|
||||
ToKeN: "token-secret",
|
||||
Access_Token: "access-token-secret",
|
||||
REFRESH_TOKEN: "refresh-token-secret",
|
||||
Secret: "secret-value",
|
||||
PASSWORD: "password-secret",
|
||||
Api_Key: "api-key-secret",
|
||||
"API-KEY": "api-key-secret",
|
||||
tokenCount: 3,
|
||||
authorizationStatus: "ok",
|
||||
cookieJar: "safe",
|
||||
secretary: "safe",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
for (const key of [
|
||||
"AUTHORIZATION",
|
||||
"Cookie",
|
||||
"SET-COOKIE",
|
||||
"ToKeN",
|
||||
"Access_Token",
|
||||
"REFRESH_TOKEN",
|
||||
"Secret",
|
||||
"PASSWORD",
|
||||
"Api_Key",
|
||||
"API-KEY",
|
||||
]) {
|
||||
expect(sanitized[key]).toBe("[redacted]");
|
||||
}
|
||||
expect(sanitized.tokenCount).toBe(3);
|
||||
expect(sanitized.authorizationStatus).toBe("ok");
|
||||
expect(sanitized.cookieJar).toBe("safe");
|
||||
expect(sanitized.secretary).toBe("safe");
|
||||
});
|
||||
|
||||
it("truncates recursive depth with a stable marker", () => {
|
||||
let value: unknown = { leaf: true };
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
|
||||
@@ -13,8 +13,20 @@ 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 sensitiveKeys = new Set([
|
||||
"authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"token",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"secret",
|
||||
"password",
|
||||
"api_key",
|
||||
"api-key",
|
||||
]);
|
||||
|
||||
const isSensitiveKey = (key: string): boolean => sensitiveKeys.has(key.toLowerCase());
|
||||
|
||||
const byteLength = (value: unknown): number => {
|
||||
const serialized = JSON.stringify(value);
|
||||
@@ -95,7 +107,7 @@ const projectValue = (
|
||||
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)
|
||||
result[safeKey] = isSensitiveKey(key)
|
||||
? REDACTED_MARKER
|
||||
: projectValue(readProperty(value, key), depth + 1, active);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user