Merge pull request #1263 from morluto/fix/process-normalization-fidelity

fix(process): preserve numeric output and original PTY evidence
This commit is contained in:
Ysopwnable 2026-10-09 18:07:22 +08:00 committed by GitHub
commit c5c75c38c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 168 additions and 7 deletions

View File

@ -94,6 +94,23 @@ deleted, modified, or unchanged. These are bounded snapshots, not a syscall
trace; short-lived changes between snapshots may be missed. With no selected
paths, filesystem effects remain unknown.
Terminal frame `data` is the comparison text after the selected normalization.
New captures also preserve `raw_data` when normalization changes a PTY chunk;
otherwise `data` is already the original text. Older captures may lack the
original text for changed chunks. Both values participate in Evidence identity,
while comparisons and trace assertions use the normalized text. Retaining the
original adds at most `limits.output_bytes` of text, because it comes from the
same admitted PTY chunks rather than a second unbounded stream.
Port normalization recognizes explicit `port`, `tcp_port`, `udp_port`, and
`listen` fields, URL authorities, IP endpoints, and `localhost` endpoints, with
ports from 0 through 65535. It preserves ordinary counters, dimensions, file
line numbers, and ambiguous bare host labels. Use explicit literal `patterns`
for a different application-specific spelling. Set `ports: false` to preserve
all endpoint numbers in comparison text. Captures commit the port-normalization
version so older broad numeric normalization cannot silently compare as the
same contract.
Output, file count, file size, process sampling, filesystem depth, total
runtime, idle time, and post-exit settlement are bounded by the scenario's
limits. The result marks truncated observations and residual unknowns rather

View File

@ -5,6 +5,28 @@ import {
emptyProcessCapture as emptyCapture,
} from "./processCapture.fixture.js";
it("compares normalized terminal facts while retaining different source text for audit", () => {
const capture = emptyCapture();
const frame = { sequence: 0, at_ms: 0, data: "listen:<port> score=20" };
const left = {
...capture,
frames: [{ ...frame, raw_data: "listen:8080 score=20" }],
};
const right = {
...capture,
frames: [{ ...frame, raw_data: "listen:9090 score=20" }],
};
expect(compareProcessCaptures(left, right)).toMatchObject({
terminal: "unchanged",
});
expect(
compareProcessCaptures(left, {
...right,
frames: [{ ...frame, data: "listen:<port> score=21" }],
}),
).toMatchObject({ terminal: "changed" });
});
it("distinguishes added evidence from unknown observations", () => {
const capture = emptyCapture();
const added = compareProcessCaptures(capture, {

View File

@ -11,6 +11,8 @@ export interface TerminalFrame {
readonly sequence: number;
readonly at_ms: number;
readonly data: string;
/** Original PTY text when normalization changed it; older captures may omit it. */
readonly raw_data?: string | undefined;
}
/** Serialized terminal state after interpreting control and resize sequences. */
@ -402,6 +404,7 @@ const processCaptureShapeSchema = z.strictObject({
sequence: z.number().int().nonnegative(),
at_ms: z.number().int().nonnegative(),
data: z.string(),
raw_data: z.string().optional(),
}),
),
rendered_frames: z.array(

View File

@ -2,6 +2,7 @@ import canonicalize from "canonicalize";
import { z } from "zod";
import type { ProcessCapture } from "./processCapture.js";
import { comparableTerminalFrame } from "./processObservation.js";
import { AnalysisInputError } from "../analysisErrorCore.js";
import { jsonValueSchema } from "../jsonValue.js";
import {
@ -100,7 +101,10 @@ export type ProcessCaptureComparison = z.infer<
const terminalObservations = (capture: ProcessCapture): readonly unknown[] =>
[
...capture.frames.map((frame) => ({ kind: "raw" as const, ...frame })),
...capture.frames.map((frame) => ({
kind: "raw" as const,
...comparableTerminalFrame(frame),
})),
...capture.rendered_frames.map((frame) => ({
kind: "rendered" as const,
...frame,

View File

@ -1,7 +1,17 @@
import { z } from "zod";
import { jsonValueSchema, type JsonValue } from "../jsonValue.js";
import type { UnverifiedProcessCapture } from "./processCapture.js";
import type {
TerminalFrame,
UnverifiedProcessCapture,
} from "./processCapture.js";
/** Project normalized PTY facts without source text used only for audit. */
export const comparableTerminalFrame = (frame: TerminalFrame) => ({
sequence: frame.sequence,
at_ms: frame.at_ms,
data: frame.data,
});
/** Process-capture observation families shared by live drivers and trace assertions. */
export const processObservationSourceSchema = z.enum([
@ -126,11 +136,14 @@ const projectedRecord = (
): ProjectedRecord | null => {
const projected = (() => {
switch (location.collection) {
case "frames":
case "frames": {
const frame = capture.frames[location.index];
return {
source: "terminal_raw" as const,
value: capture.frames[location.index],
value:
frame === undefined ? undefined : comparableTerminalFrame(frame),
};
}
case "rendered_frames":
return {
source: "terminal_rendered" as const,

View File

@ -84,6 +84,9 @@ export const processComparisonContract = (
settle_ms: scenario.settle_ms,
limits: scenario.limits,
normalization: scenario.normalization,
...(scenario.normalization.ports
? { port_normalization_version: "contextual-endpoints-v1" }
: {}),
});
/**

View File

@ -828,6 +828,7 @@ export const captureTerminalFrames = (options: {
sequence,
at_ms: atMs,
data: normalized,
...(normalized === data ? {} : { raw_data: data }),
});
options.renderer.write(data, atMs);
options.recordEvent("frames", sequence);

View File

@ -1,6 +1,9 @@
import { expect, it } from "vitest";
import { normalizeProcessSamples } from "./ProcessNormalization.js";
import {
normalizeProcessSamples,
normalizeProcessText,
} from "./ProcessNormalization.js";
import { parseProcessScenario } from "../../domain/process/processCapture.js";
const rootPid = 41_010;
@ -68,6 +71,34 @@ const scenario = (normalization: {
},
});
it("preserves compact JSON, counters, line numbers, and ambiguous endpoint spellings", () => {
const input = [
'{"pid":32,"size":108,"height":480,"width":640,"score":20}',
"score=20 size:108 elapsed=3600 file.js:480 server:8080",
"port=65536 port:8080ms localhost:70000 999.1.2.3:8080",
"https://user:32@example.test/path http://user:20@localhost/path",
].join("\n");
expect(normalizeProcessText(input, scenario({}), "/temporary", rootPid)).toBe(
input,
);
});
it("normalizes only contextual ports before overlapping PID values and honors disabled rules", () => {
const input =
'http://example.test:8080/x https://user:password@example.test:443/x 127.0.0.1:9 [::1]:65535 localhost:41010 "port":80 tcp_port=123 udp_port:0 listen:8080';
expect(normalizeProcessText(input, scenario({}), "/temporary", rootPid)).toBe(
'http://example.test:<port>/x https://user:password@example.test:<port>/x 127.0.0.1:<port> [::1]:<port> localhost:<port> "port":<port> tcp_port=<port> udp_port:<port> listen:<port>',
);
expect(
normalizeProcessText(
input,
scenario({ ports: false, pids: false }),
"/temporary",
rootPid,
),
).toBe(input);
});
it("normalizes representative process identities and preserves null, zero, and unrelated numbers", () => {
const before = JSON.stringify(samples);
const normalized = normalizeProcessSamples(samples, scenario({}), rootPid);

View File

@ -1,3 +1,5 @@
import { isIP } from "node:net";
import type {
ProcessSample,
ProcessScenario,
@ -30,15 +32,34 @@ export const normalizeProcessText = (
`<filesystem-root-${String(index)}>`,
);
}
if (scenario.normalization.ports)
normalized = normalizePortTokens(normalized);
if (scenario.normalization.pids)
normalized = normalizePidTokens(normalized, [pid]);
if (scenario.normalization.ports)
normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, "<port>");
for (const pattern of scenario.normalization.patterns)
normalized = normalized.replaceAll(pattern.pattern, pattern.replacement);
return normalized;
};
const normalizePortTokens = (value: string): string => {
const endpointNormalized = value.replaceAll(
/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/?#"'<>]+|\blocalhost|\b(?:\d{1,3}\.){3}\d{1,3}|\[[\da-f:.%]+\]):(\d{1,5})(?![\w.@:])/giu,
(match: string, endpoint: string, port: string) => {
if (Number(port) > 65_535) return match;
const isEndpoint = endpoint.includes("://")
? URL.canParse(`${endpoint}:${port}`)
: endpoint.toLowerCase() === "localhost" ||
isIP(endpoint.replace(/^\[|\]$/gu, "")) !== 0;
return isEndpoint ? `${endpoint}:<port>` : match;
},
);
return endpointNormalized.replaceAll(
/(\b(?:port|tcp_port|udp_port|listen)\b["']?\s*[:=]\s*)(\d{1,5})(?![\w.])/giu,
(match: string, prefix: string, port: string) =>
Number(port) <= 65_535 ? `${prefix}<port>` : match,
);
};
/** Project sampled observations under the caller-selected normalization rules. */
export const normalizeProcessSamples = (
samples: readonly ProcessSample[],

View File

@ -27,6 +27,52 @@ const identitySchema = z.object({
const itWithLinuxCaptureCapability =
process.platform === "linux" ? itWithCaptureCapability : it.skip;
itWithCaptureCapability(
"preserves numeric reports and original normalized PTY text in Evidence",
async () => {
const root = await createTestTempDirectory("rea-process-numeric-report-");
const report = JSON.stringify({
pid: 32,
size: 108,
height: 480,
width: 640,
score: 20,
});
const original = `${report}\nlisten:8080\n`;
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: root,
arguments: ["-e", `process.stdout.write(${JSON.stringify(original)})`],
settle_ms: 100,
});
const result = await captureProcessScenario(scenario);
if (!result.ok) throw result.error;
const capture = parseProcessCapture(
createProcessCaptureEvidence(scenario, result.value).normalized_result,
);
expect(capture.frames.map(({ data }) => data).join("")).toContain(report);
expect(capture.frames.map(({ data }) => data).join("")).toContain(
"listen:<port>",
);
expect(
capture.frames.map(({ data, raw_data }) => raw_data ?? data).join(""),
).toBe(original.replaceAll("\n", "\r\n"));
expect(
capture.rendered_frames.some(({ serialized_state }) =>
serialized_state.includes(report),
),
).toBe(true);
expect(capture.manifest.comparison_contract).toMatchObject({
port_normalization_version: "contextual-endpoints-v1",
});
expect(capture.exit).toMatchObject({ code: 0, reason: "exited" });
expect(capture.cleanup).toMatchObject({
owned_process_group: "verified",
temporary_root: "removed",
});
},
);
itWithLinuxCaptureCapability.each([false, true])(
"honors pids=%s in owned Linux capture samples and committed Evidence",
async (pids) => {