Compare commits

..
Author SHA1 Message Date
Cursor Agent e60f6b53f3 chore: merge main into codex/audit-browser-scenario 2026-11-11 02:26:31 +00:00
morluto 099a79587a fix(browser-scenario)!: record parsed request actions in evidence
BREAKING CHANGE: New captures with request actions that save nothing include save: [] in the recorded parameters. Their scenario and Evidence digests can differ from older captures of the same scenario. Read retained captures with their original recorded digests and use the newly recorded digest for new captures.
2026-11-10 17:32:19 -05:00
morluto 760dc4b201 fix(browser-scenario): redact saved secrets across wire encodings 2026-11-10 17:32:05 -05:00
morluto e863ee1258 fix(browser-scenario): retain popup events when open metadata exceeds its budget 2026-11-10 17:32:00 -05:00
morluto 0b10a9132b fix(browser-scenario): page captured text on Unicode boundaries 2026-11-10 17:31:55 -05:00
15 changed files with 291 additions and 168 deletions

No files matched your search

+5 -4
View File
@@ -604,10 +604,11 @@ id the application assigns:
Reduce the payload/repetitions, or raise the parent
Node heap and restart; this is a conservative allocation estimate, not a
measurement or guarantee of browser/process-family memory.
- Evidence records a request step's empty `save` list as absent, so a scenario
that saves nothing records the same parameters and `scenario_sha256` as
before saved values existed, and REA versions without saved values read its
captures.
- Evidence records the current parsed request action, including `save: []`
when no values are saved. Its parameters and `scenario_sha256` use that
representation; new observations can have different identities from older
captures that omitted the empty list. Retained Evidence keeps its original
parameters and identity.
## Network evidence
@@ -7,7 +7,8 @@
3. Runtime: read the response body when a step saves, start waiting for a UI
step's response before it acts, keep saved values with their JSON type,
fill placeholders, build Basic credentials, and redact secret saves.
4. Evidence: leave an empty `save` out of the recorded step.
4. Evidence: record the current parsed step, including an empty `save` list;
preserve the representation and identity of retained Evidence.
5. Environment build: warn about credential-named saved values not marked
secret.
6. Skill and docs: write through the UI first and save ids instead of guessing
@@ -47,10 +47,9 @@ values.
- A value saved with `secret: true` is redacted from the selected source response
before retention and from subsequent records. The environment build warns about saved values named like credentials
that are not marked secret (`credential-named-saved-values`).
- A scenario that saves nothing records the same Evidence parameters and
`scenario_sha256` as before: an empty `save` is left out of the recorded
step.
- Earlier REA versions read captures that use saved values.
- New Evidence records the current parsed request step, including an empty
`save` list, in its parameters and `scenario_sha256`. Retained Evidence keeps
its original representation and identity.
- The compact MCP catalog stays under its measured one-megabyte frame.
- The skill records writes the way the application's UI does and falls back to
request steps when the UI cannot reach an operation or coverage withholds the
@@ -36,10 +36,10 @@ const create = (fields: Record<string, unknown> = {}) => ({
});
describe("browser scenario evidence parameters", () => {
it("records a scenario that saves nothing as it was recorded before saved values existed", () => {
it("records the current parsed request action, including an empty saved-value list", () => {
const recorded = parameters([open(), create()]);
const actions = recorded["actions"] as Record<string, unknown>[];
expect(Object.keys(actions[1]!)).not.toContain("save");
expect(actions[1]).toHaveProperty("save", []);
expect(parameters([open(), create({ save: [] })])).toEqual(recorded);
const { scenario_sha256, ...scenario } = recorded;
expect(scenario_sha256).toBe(
+2 -15
View File
@@ -9,25 +9,12 @@ import {
} from "../domain/evidence.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
/** A step as Evidence records it: a request step leaves out an empty `save`, so a scenario that saves nothing records and hashes as it did before saved values existed. */
const recordedAction = (
action: BrowserScenario["actions"][number],
): Record<string, unknown> => {
if (action.action !== "request" || action.save.length > 0) return action;
const { save, ...rest } = action;
return rest;
};
const browserScenarioParameters = (
scenario: BrowserScenario,
): EvidenceObservation["parameters"] => {
const recorded = {
...scenario,
actions: scenario.actions.map(recordedAction),
};
return {
...jsonObjectSchema.parse(recorded),
scenario_sha256: digestCanonicalValue(recorded, "Browser scenario"),
...jsonObjectSchema.parse(scenario),
scenario_sha256: digestCanonicalValue(scenario, "Browser scenario"),
};
};
@@ -551,7 +551,70 @@ describe("browser scenario text and selection boundaries", () => {
reason: "capture budget",
text: null,
},
coverage: { status: "empty", exhausted: true },
coverage: { status: "unavailable", total: null, exhausted: false },
});
});
});
it("keeps scenario text pages on Unicode boundaries and distinguishes unavailable from captured empty text", async () => {
const capture = quizCapture("completed");
capture.steps[1]!.artifacts.accessibility = tree("A💡B");
const original = JSON.stringify(capture);
const request = {
kind: "browser-scenario" as const,
collection: "text" as const,
step_index: 1,
artifact: "accessibility" as const,
offset: 0,
limit: 2,
};
const first = await view(capture, request);
expect(first).toMatchObject({
items: { text: "A", sha256: tree("A💡B").value.sha256 },
coverage: { examined: 1, total: 4, next_offset: 1, exhausted: false },
});
expect(await view(capture, { ...request, offset: 1 })).toMatchObject({
items: { text: "💡" },
coverage: { examined: 2, total: 4, next_offset: 3 },
});
for (const selection of [
{ offset: 2, limit: 2 },
{ offset: 1, limit: 1 },
]) {
const result = await inspectAnalysisView({
source: { kind: "inline", evidence: scenarioCaptureEvidence(capture) },
view: { ...request, ...selection },
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
}
expect(JSON.stringify(capture)).toBe(original);
for (const artifact of [
{ state: "not_requested" as const },
{ state: "missing" as const, reason: "page closed" },
{
state: "truncated" as const,
observed: 100,
retained: 40,
reason: "capture budget",
},
]) {
capture.steps[1]!.artifacts.accessibility = artifact;
expect(await view(capture, request)).toMatchObject({
items: { state: artifact.state, text: null },
coverage: {
status: "unavailable",
total: null,
exhausted: false,
next_offset: null,
},
});
}
capture.steps[1]!.artifacts.accessibility = tree("");
expect(await view(capture, request)).toMatchObject({
items: { state: "captured", text: "" },
coverage: { status: "empty", total: 0, exhausted: true },
});
});
@@ -66,28 +66,6 @@ const report: PythonPyc = {
],
};
it.each([
{
flags: 1,
hash_based: true,
source_hash_hex: "",
source_mtime: null,
source_size: null,
},
{ flags: 1 },
])("refuses inconsistent provider header facts %j", async (header) => {
const result = await new PythonPycService({
close: async () => {},
identity,
inspect: async () =>
ok({ ...report, header: { ...report.header, ...header } }),
}).inspect({ path: report.artifact.path });
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
});
it("rejects a relative path and extra arguments before reading", async () => {
let calls = 0;
const service = new PythonPycService({
@@ -0,0 +1,73 @@
import { expect, it } from "vitest";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import { readResponseValues } from "./BrowserScenarioResponseValues.js";
const scenario = browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: "/fixture/chrome" },
start_url: { url: "https://example.test/" },
actions: [
{
step_id: "save",
action: "request",
method: "GET",
path: "/value",
save: [{ name: "token", json_pointer: "/token", secret: true }],
},
],
});
it.each([
'private-\ud800-"\\token',
'private-\udc00-"\\token',
'private-\ud83d\ude80-"\\token',
])(
"retains exact saved values and redacts their JSON, UTF-8 and URL representations: %j",
(token) => {
const resolved = BrowserScenarioSecrets.resolve(scenario, {});
if (!resolved.ok) throw resolved.error;
const secrets = resolved.value;
const action = scenario.actions[0]!;
if (action.action !== "request") throw new Error("Expected request");
const body = JSON.stringify({ token, other: "visible" });
const selected = readResponseValues(Buffer.from(body), action.save, "fail");
if (!selected.ok) throw new Error(selected.error.message);
expect(selected.value.values[0]!.value).toBe(token);
for (const { entry, value } of selected.value.values)
secrets.save("save", entry.name, value, entry.secret);
expect(secrets.savedValue("token")).toBe(token);
const scalar = new TextDecoder().decode(new TextEncoder().encode(token));
for (const value of [token, scalar]) {
expect(secrets.redact(value)).not.toBe(value);
expect(secrets.redact(JSON.stringify(value))).not.toBe(
JSON.stringify(value),
);
expect(secrets.fingerprint(value)).toEqual({
value_state: "redacted-secret",
value_sha256: null,
});
}
const rawBytes = Buffer.from(token);
expect(secrets.redactBytes(rawBytes).equals(rawBytes)).toBe(false);
const destination = new URL("https://example.test/");
destination.searchParams.set("value", token);
expect(secrets.sanitizeUrl(destination.href)).toMatchObject({
redacted: true,
});
expect(secrets.sanitizeUrl(destination.href).url).not.toBe(
destination.href,
);
const request = {};
secrets.responseProtector("save", action.save)!(request);
const protectedBody = secrets.admitResponseSecrets(
request,
Buffer.from(body),
{ maxBytes: 4096, maxAllocationBytes: 65536 },
);
expect(JSON.parse(protectedBody.toString())).toMatchObject({
other: "visible",
});
expect(JSON.parse(protectedBody.toString()).token).not.toBe(token);
expect(secrets.redact("unrelated value")).toBe("unrelated value");
},
);
+21 -10
View File
@@ -3,6 +3,7 @@ import { redactSensitiveFailure } from "../domain/explicitSensitiveFailure.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../domain/result.js";
import { createHash } from "node:crypto";
import { toUSVString } from "node:util";
import type { BrowserScenario } from "../domain/browserScenario.js";
import type {
@@ -40,13 +41,13 @@ const normalizedSecretValues = (value: string): readonly string[] => [
...new Set([value.replace(/^[\t ]+|[\t ]+$/gu, ""), value]),
];
const encodedSecretValues = (secret: string): ReadonlySet<string> => {
const component = encodeURIComponent(secret);
const encodedSecretValues = (scalar: string): ReadonlySet<string> => {
const component = encodeURIComponent(scalar);
return new Set([
component,
// Special-URL queries escape apostrophes while keeping the component's tilde.
component.replace(/'/gu, "%27"),
new URLSearchParams([["value", secret]]).toString().slice("value=".length),
new URLSearchParams([["value", scalar]]).toString().slice("value=".length),
]);
};
@@ -76,20 +77,30 @@ const secretPatterns = (
spellings: SecretSpellings,
): readonly SecretPattern[] => {
if (secret === "") return [];
// UTF-8 and Web APIs replace unpaired UTF-16 units; retain source identity
// while accounting for those emitted spellings only in redaction patterns.
const scalar = toUSVString(secret);
const literals = [...new Set([secret, scalar])];
const patterns: SecretPattern[] = [
{ text: secret, searchText: secret, percentInsensitive: false },
...[...encodedSecretValues(secret)].map((text) => ({
...literals.map((text) => ({
text,
searchText: text,
percentInsensitive: false,
})),
...[...encodedSecretValues(scalar)].map((text) => ({
text,
searchText: normalizePercentEscapes(text),
percentInsensitive: true,
})),
];
if (spellings === "text")
patterns.push({
text: JSON.stringify(secret).slice(1, -1),
searchText: JSON.stringify(secret).slice(1, -1),
percentInsensitive: false,
});
patterns.push(
...literals.map((text) => ({
text: JSON.stringify(text).slice(1, -1),
searchText: JSON.stringify(text).slice(1, -1),
percentInsensitive: false,
})),
);
const seen = new Set<string>();
return patterns.filter(({ text, percentInsensitive }) => {
if (text === "") return false;
@@ -153,3 +153,56 @@ it("detaches owned-context popup discovery when a scenario finishes", async () =
await events.finish();
expect(context.listenerCount("page")).toBe(0);
});
it.each([false, true])(
"keeps requested popup page errors when popup lifecycle metadata exceeds the event budget (lifecycle selected: %s)",
async (lifecycle) => {
const page = new EventEmitter();
let popupUrl = `https://app.example.test/${"x".repeat(5000)}`;
const popup = Object.assign(new EventEmitter(), {
url: () => popupUrl,
});
const scenario = browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: "/fixture/browser" },
start_url: { url: "https://app.example.test/" },
actions: [
{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 },
],
capture: { limits: { max_event_allocation_bytes: 20_000 } },
});
const events = new PlaywrightScenarioEvents({
page: page as unknown as Page,
enabled: lifecycle
? new Set(["page-errors", "popups"])
: new Set(["page-errors"]),
limits: scenario.capture.limits,
secrets: resolveScenarioSecrets(scenario, {}),
});
page.emit("popup", popup);
const error = new Error("small popup application failure");
error.stack = "";
popup.emit("pageerror", error);
expect(events.result().items).toEqual([
expect.objectContaining({ kind: "page-error", message: error.message }),
]);
expect(events.result().omissions.by_kind).toEqual(
lifecycle
? [
{
kind: "popup-opened",
count: 1,
reasons: ["max-event-allocation-bytes"],
},
]
: [],
);
popupUrl = "https://app.example.test/closed";
popup.emit("close");
expect(events.result().items.map(({ kind }) => kind)).toEqual(
lifecycle ? ["page-error", "popup-closed"] : ["page-error"],
);
expect(popup.listenerCount("pageerror")).toBe(0);
await events.finish();
expect(popup.listenerCount("close")).toBe(0);
},
);
+8 -10
View File
@@ -906,11 +906,7 @@ export class PlaywrightScenarioEvents {
private popup(page: Page): void {
if (this.pages.has(page)) return;
if (
!this.admitOwner("popup-opened") ||
!this.admitText("popup-opened", page.url())
)
return;
if (!this.admitOwner("popup-opened")) return;
this.pages.add(page);
for (const family of [
"frames",
@@ -919,12 +915,14 @@ export class PlaywrightScenarioEvents {
"downloads",
] as const)
if (this.enabled.has(family)) this.incompleteFamilies.add(family);
const opened = {
url: page.url() === "" ? null : this.safeUrl(page.url()),
name: null,
};
if (this.enabled.has("popups")) {
this.push({ kind: "popup-opened", ...opened });
const url = page.url();
if (this.admitText("popup-opened", url))
this.push({
kind: "popup-opened",
url: url === "" ? null : this.safeUrl(url),
name: null,
});
const onClose = () => {
if (!this.admitText("popup-closed", page.url())) return;
this.push({
@@ -1476,6 +1476,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
view: browserScenarioViewRequestSchema,
items: browserScenarioViewItemsSchema,
...viewResultBase,
coverage: nullableCollectionViewCoverageSchema,
...browserScenarioViewContextSchema.shape,
artifact: z.null(),
parent_operation: z.literal("capture_browser_scenario"),
+23 -12
View File
@@ -11,9 +11,11 @@ import { analysisViewInputError } from "./analysisViewErrors.js";
import {
completeWithinViewCoverage,
pageViewCoverage,
unavailableViewCoverage,
type AnalysisViewCoverage,
} from "./analysisViewCoverage.js";
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
import { analysisTextPage } from "./analysisViewText.js";
import type { AnalysisViewParent } from "./analysisViewParent.js";
import {
assertOwnedView,
@@ -211,8 +213,17 @@ const textPage = (
) => {
const artifact = capture.steps[view.step_index]!.artifacts[view.artifact];
const value = artifact.state === "captured" ? artifact.value : null;
const text = value?.text.slice(view.offset, view.offset + view.limit) ?? null;
return {
const page =
value === null
? ok({ item: { text: null }, coverage: unavailableViewCoverage() })
: analysisTextPage(
value.text,
view.offset,
view.limit,
"Scenario artifact text",
);
if (!page.ok) return page;
return ok({
items: {
collection: "text" as const,
step_index: view.step_index,
@@ -223,25 +234,20 @@ const textPage = (
bytes: value?.bytes ?? null,
observed: artifact.state === "truncated" ? artifact.observed : null,
retained: artifact.state === "truncated" ? artifact.retained : null,
text,
text: page.value.item.text,
},
coverage: pageViewCoverage(
view.offset,
text?.length ?? 0,
value?.text.length ?? 0,
),
};
coverage: page.value.coverage,
});
};
/** One stable page of exact recorded facts; no whole-result filtering arrays. */
const pageRecords = (
capture: BrowserScenarioCapture,
view: BrowserScenarioViewRequest,
view: Exclude<BrowserScenarioViewRequest, { collection: "text" }>,
): {
readonly items: z.infer<typeof browserScenarioViewItemsSchema>;
readonly coverage: AnalysisViewCoverage;
} => {
if (view.collection === "text") return textPage(capture, view);
const stepSelected = selected(view.step_indices);
if (view.collection === "steps") {
const artifacts = new Set(view.artifacts);
@@ -376,6 +382,11 @@ export const projectBrowserScenarioView = (
},
]),
);
const page =
view.collection === "text"
? textPage(capture, view)
: ok(pageRecords(capture, view));
if (!page.ok) return page;
return ok({
kind: "browser-scenario" as const,
view,
@@ -389,6 +400,6 @@ export const projectBrowserScenarioView = (
transactions: capture.events.omissions.network_transactions.length,
transaction_range: capture.events.omissions.network_transaction_range,
},
...pageRecords(capture, view),
...page.value,
});
};
-40
View File
@@ -3,49 +3,9 @@ import {
inspectPythonPycInputSchema,
pythonPycMarshalProfile,
PYTHON_PYC_MARSHAL_PROFILES,
pythonPycHeaderSchema,
} from "./pythonPyc.js";
import { pythonBytecodeIdentity } from "./pythonBytecodeIdentity.js";
const hashHeader = {
magic: 3531,
python_version: "3.12",
code_layout: "py311",
flags: 3,
hash_based: true,
check_source: true,
source_hash_hex: "0123456789abcdef",
source_mtime: null,
source_size: null,
};
it.each(["", "ab", "g".repeat(16)])(
"rejects an invalid source hash %j",
(source_hash_hex) => {
expect(
pythonPycHeaderSchema.safeParse({ ...hashHeader, source_hash_hex })
.success,
).toBe(false);
},
);
it.each([
{ hash_based: false },
{ check_source: false },
{ source_hash_hex: null },
{ source_mtime: 0 },
{ source_size: 0 },
])("keeps header facts consistent with the recorded flags: %j", (changed) => {
expect(
pythonPycHeaderSchema.safeParse({ ...hashHeader, ...changed }).success,
).toBe(false);
});
it("preserves unknown raw header flags and valid hash bytes", () => {
const header = { ...hashHeader, flags: 0x80000003 };
expect(pythonPycHeaderSchema.parse(header)).toEqual(header);
});
it("keeps pinned CPython identity separate from standalone marshal coverage", () => {
expect(pythonPycMarshalProfile(3394)).toEqual({
code_layout: "py37",
+34 -47
View File
@@ -205,53 +205,17 @@ const pythonCodeObjectSchema = z.strictObject({
});
export type PythonPycCodeObject = z.infer<typeof pythonCodeObjectSchema>;
export const pythonPycHeaderSchema = z
.strictObject({
magic: z.number().int().min(0).max(0xffff),
python_version: z.string().min(1),
code_layout: z.enum(PYTHON_PYC_CODE_LAYOUTS),
flags: uint,
hash_based: z.boolean(),
check_source: z.boolean(),
source_hash_hex: z
.string()
.regex(/^[0-9a-f]{16}$/u)
.nullable(),
source_mtime: uint.nullable(),
source_size: uint.nullable(),
})
.superRefine((header, context) => {
for (const [field, flag] of [
["hash_based", PYTHON_PYC_HASH_BASED],
["check_source", PYTHON_PYC_CHECK_SOURCE],
] as const)
if (header[field] !== ((header.flags & flag) !== 0))
context.addIssue({
code: "custom",
path: [field],
message: `${field} disagrees with its recorded header flag.`,
});
if (header.hash_based === (header.source_hash_hex === null))
context.addIssue({
code: "custom",
path: ["source_hash_hex"],
message:
"Hash-based pycs require an 8-byte source hash; others must omit it.",
});
if (header.hash_based === (header.source_mtime !== null))
context.addIssue({
code: "custom",
path: ["source_mtime"],
message: "Timestamp pycs require mtime; hash-based pycs must omit it.",
});
if (header.hash_based === (header.source_size !== null))
context.addIssue({
code: "custom",
path: ["source_size"],
message:
"Timestamp pycs require source_size; hash-based pycs must omit it.",
});
});
export const pythonPycHeaderSchema = z.strictObject({
magic: z.number().int().min(0).max(0xffff),
python_version: z.string().min(1),
code_layout: z.enum(PYTHON_PYC_CODE_LAYOUTS),
flags: uint,
hash_based: z.boolean(),
check_source: z.boolean(),
source_hash_hex: z.string().nullable(),
source_mtime: uint.nullable(),
source_size: uint.nullable(),
});
/** Bounded CPython 3.7–3.13 pyc observation; marshal is walked, bytecode is not decompiled. */
export const pythonPycSchema = z
@@ -289,6 +253,29 @@ export const pythonPycSchema = z
message: "Marshal byte count disagrees with the header-stripped file.",
});
}
if (report.header.hash_based === (report.header.source_hash_hex === null)) {
context.addIssue({
code: "custom",
path: ["header", "source_hash_hex"],
message:
"Hash-based pycs require an 8-byte source hash; others must omit it.",
});
}
if (report.header.hash_based === (report.header.source_mtime !== null)) {
context.addIssue({
code: "custom",
path: ["header", "source_mtime"],
message: "Timestamp pycs require mtime; hash-based pycs must omit it.",
});
}
if (report.header.hash_based === (report.header.source_size !== null)) {
context.addIssue({
code: "custom",
path: ["header", "source_size"],
message:
"Timestamp pycs require source_size; hash-based pycs must omit it.",
});
}
if (report.coverage.code_objects !== report.code_objects.length) {
context.addIssue({
code: "custom",