feat(cutter): identify the running plugin's source and refuse a modified one (#10731)

* feat(cutter): identify the running plugin's source and refuse a modified one

The Cutter plugin now publishes the SHA-256 of its own file on its session
descriptor (an additive field). Discovery reports each session's
bridge_source as packaged, modified, or unknown (an older plugin without the
field). cutter_command refuses a modified plugin before sending anything and
names the packaged plugin to copy into Cutter's plugins/python directory.

* fix(cutter): report an unreadable packaged plugin instead of hiding the live session

* test(cutter): give the discovery fixture sessions their bridge source
This commit is contained in:
morluto authored and GitHub committed 2026-11-15 13:45:19 +08:00
1 parent 12dd3d38ac
commit 0c38229dd3
10 files changed
+253 -1

No files matched your search

+13
View File
@@ -130,6 +130,18 @@ MAX_FRAME_BYTES, BRIDGE_MAX_REQUEST_BYTES_SOURCE = (
)
def _bridge_source_sha256() -> str | None:
"""Digest this plugin file so REA can tell its packaged copy from another one."""
try:
with open(__file__, "rb") as source:
return hashlib.sha256(source.read()).hexdigest()
except (NameError, OSError):
return None
BRIDGE_SOURCE_SHA256 = _bridge_source_sha256()
def _bridge_max_request_bytes_recovery():
return (
"The effective REA_BRIDGE_MAX_REQUEST_BYTES value is %d bytes "
@@ -655,6 +667,7 @@ class _BridgeServer(threading.Thread):
"cutter_version": self.dispatcher.cutter_version,
"identity_status": "partial",
"max_request_bytes": MAX_FRAME_BYTES,
"bridge_source_sha256": BRIDGE_SOURCE_SHA256,
}
if os.name == "nt":
_write_windows_descriptor(self.directory, self.descriptor, json.dumps(payload).encode("utf-8"))
+7
View File
@@ -269,6 +269,13 @@ they use `%LOCALAPPDATA%\REA\CutterBridge` on Windows when `LOCALAPPDATA` is set
and `~/.cache/rea/cutter-bridge` otherwise. The two processes must resolve the
same directory for discovery to find the published sessions.
The plugin publishes the SHA-256 of its own file on its session descriptor.
Discovery reports each session's `bridge_source`: `packaged` when it matches
REA's packaged `bridge/cutter/rea_bridge.py`, `modified` when it differs, and
`unknown` when the plugin predates this field. `cutter_command` refuses a
`modified` session before sending anything and names the packaged plugin to
copy into Cutter's `plugins/python` directory. An `unknown` session still runs.
The client and plugin must support the current v2 handshake; older peers are
rejected before dispatch because they cannot preserve cross-caller recovery proof.
The client checks the final encoded request against
+35 -1
View File
@@ -42,6 +42,10 @@ import {
} from "../domain/cutterDiscovery.js";
import { err, ok, type Result } from "../domain/result.js";
import { cutterBridgeMac } from "./cutterBridgeMac.js";
import {
cutterBridgeSource,
packagedCutterPluginPath,
} from "./CutterBridgeSource.js";
import { CutterBridgeResponseFrames } from "./cutterAuthenticatedResponse.js";
import { requireWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
@@ -60,7 +64,12 @@ import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSna
import type { AnalysisSettingsReader } from "../domain/analysisSettings.js";
const descriptorSchema = z.object({
...cutterSessionSchema.shape,
...cutterSessionSchema.omit({ bridge_source: true }).shape,
bridge_source_sha256: z
.string()
.regex(/^[0-9a-f]{64}$/)
.nullable()
.optional(),
host: z.literal("127.0.0.1"),
port: z.int().min(1).max(65535),
token: z.string().min(20),
@@ -95,6 +104,17 @@ const responseSchema = z.object({
.regex(/^[0-9a-f]{16}$/)
.optional(),
});
/** A session runs a plugin whose bytes differ from the packaged copy. */
const modifiedPluginRefusal = (): AnalysisCapabilityUnavailableError => {
const remediation = `Copy ${packagedCutterPluginPath()} into Cutter's plugins/python directory, restart Cutter, and list sessions again. No command was sent.`;
return new AnalysisCapabilityUnavailableError(
"cutter",
"cutter_command",
"The Cutter session runs a REA plugin whose source differs from the packaged plugin, so its protocol cannot be trusted to match this REA release.",
{ remediationAction: remediation, userMessage: remediation },
);
};
const acknowledgementSchema = z.object({
ok: z.literal(true),
acknowledged: z.boolean(),
@@ -383,6 +403,12 @@ export class CutterBridgeClient {
mismatches.push(advertised);
return undefined;
}
// Classify outside the per-peer catch: an unreadable packaged plugin
// is REA's own failure and must not read as an absent session.
const bridgeSource = cutterBridgeSource(
descriptor.bridge_source_sha256,
"list_cutter_sessions",
);
try {
const status = responseSchema.safeParse(
await this.#request(
@@ -414,6 +440,7 @@ export class CutterBridgeClient {
cutter_version:
status.data.cutter_version ?? descriptor.cutter_version,
identity_status: "partial" as const,
bridge_source: bridgeSource,
};
} catch (cause) {
if (cause instanceof AnalysisResourceConstraintError) throw cause;
@@ -468,6 +495,13 @@ export class CutterBridgeClient {
this.#requestLimit();
const descriptor = await this.#find(input.sessionId, input.signal);
assertBridgeActive(input.signal, "cutter_command");
if (
cutterBridgeSource(
descriptor.bridge_source_sha256,
"cutter_command",
) === "modified"
)
throw modifiedPluginRefusal();
const advertised =
descriptor.max_request_bytes ?? DEFAULT_BRIDGE_MAX_REQUEST_BYTES;
if (advertised !== this.#requestLimit().maximumBytes)
+40
View File
@@ -0,0 +1,40 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
import { cutterBridgeSource } from "./CutterBridgeSource.js";
const packaged = new URL("../../bridge/cutter/rea_bridge.py", import.meta.url);
const packagedSha256 = createHash("sha256")
.update(readFileSync(packaged))
.digest("hex");
it("classifies a session's plugin against the packaged plugin bytes", () => {
expect(cutterBridgeSource(packagedSha256, "list_cutter_sessions")).toBe(
"packaged",
);
expect(cutterBridgeSource("0".repeat(64), "list_cutter_sessions")).toBe(
"modified",
);
expect(cutterBridgeSource(undefined, "list_cutter_sessions")).toBe("unknown");
expect(cutterBridgeSource(null, "list_cutter_sessions")).toBe("unknown");
});
it("reports an unreadable packaged plugin as REA's own typed failure", () => {
const missing = new URL("./missing-rea-bridge.py", import.meta.url);
expect(() =>
cutterBridgeSource("0".repeat(64), "list_cutter_sessions", missing),
).toThrow(
expect.objectContaining({
_tag: "AnalysisCapabilityUnavailableError",
operation: "list_cutter_sessions",
remediationAction: expect.stringContaining("Reinstall REA"),
}),
);
expect(
cutterBridgeSource(undefined, "list_cutter_sessions", missing),
"a plugin without a digest needs no packaged comparison",
).toBe("unknown");
});
+60
View File
@@ -0,0 +1,60 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import type { CutterBridgeSource } from "../domain/cutterDiscovery.js";
const PACKAGED_PLUGIN = new URL(
"../../bridge/cutter/rea_bridge.py",
import.meta.url,
);
/** Packaged plugin path a user copies into Cutter's `plugins/python` directory. */
export const packagedCutterPluginPath = (plugin = PACKAGED_PLUGIN): string =>
fileURLToPath(plugin);
const packagedDigests = new Map<string, string>();
/**
* SHA-256 of the packaged Cutter plugin bytes. The user copies the plugin into
* Cutter, so REA compares the digest the running plugin publishes with this
* one instead of trusting its protocol version alone. An unreadable packaged
* plugin is a broken REA installation, reported as such rather than hiding
* the live session that asked for the comparison.
*/
const packagedCutterPluginDigest = (operation: string, plugin: URL): string => {
const cached = packagedDigests.get(plugin.href);
if (cached !== undefined) return cached;
let bytes: Buffer;
try {
bytes = readFileSync(plugin);
} catch (cause: unknown) {
const remediation = `Reinstall REA so ${packagedCutterPluginPath(plugin)} is present and readable, then retry. REA could not compare the running Cutter plugin with it, and no command was sent.`;
throw new AnalysisCapabilityUnavailableError(
"cutter",
operation,
"REA's packaged Cutter plugin could not be read, so the running plugin's source cannot be identified.",
{ cause, remediationAction: remediation, userMessage: remediation },
);
}
const digest = createHash("sha256").update(bytes).digest("hex");
packagedDigests.set(plugin.href, digest);
return digest;
};
/**
* Classify the plugin a session runs. A plugin that publishes no digest
* predates source identity, so its source is unknown rather than modified.
*/
export const cutterBridgeSource = (
advertisedSha256: string | null | undefined,
operation: string,
plugin = PACKAGED_PLUGIN,
): CutterBridgeSource => {
if (advertisedSha256 === undefined || advertisedSha256 === null)
return "unknown";
return advertisedSha256 === packagedCutterPluginDigest(operation, plugin)
? "packaged"
: "modified";
};
+12
View File
@@ -1,5 +1,16 @@
import { z } from "zod";
/**
* Which plugin source a session runs: the packaged copy, a different one, or
* unknown when the plugin predates source identity.
*/
export const cutterBridgeSourceSchema = z.enum([
"packaged",
"modified",
"unknown",
]);
export type CutterBridgeSource = z.infer<typeof cutterBridgeSourceSchema>;
/** Observed Cutter session identity without bridge transport credentials. */
export const cutterSessionSchema = z.strictObject({
session_id: z.string().uuid(),
@@ -8,6 +19,7 @@ export const cutterSessionSchema = z.strictObject({
current_file: z.string().nullable(),
cutter_version: z.string().nullable(),
identity_status: z.literal("partial"),
bridge_source: cutterBridgeSourceSchema,
});
/** Discovery facts retained independently of descriptor cleanup. */
@@ -3,6 +3,7 @@ import {
writeDescriptor,
} from "../../fixtures/cutterBridgeProtocol.js";
import { createServer } from "node:net";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import {
chmod,
@@ -305,6 +306,81 @@ describe("CutterBridgeClient: selected POSIX paths", () => {
);
});
describe("CutterBridgeClient: plugin source identity", () => {
let directory: string | undefined;
let server: ReturnType<typeof createServer> | undefined;
afterEach(async () => {
if (server !== undefined)
await new Promise<void>((resolve) => server?.close(() => resolve()));
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
server = undefined;
directory = undefined;
});
const packagedDigest = createHash("sha256")
.update(readFileSync("bridge/cutter/rea_bridge.py"))
.digest("hex");
it.each([
["packaged", packagedDigest],
["modified", "0".repeat(64)],
["unknown", undefined],
] as const)(
"reports a %s plugin source in discovery",
async (source, digest) => {
directory = await mkdtemp(join(tmpdir(), "rea-cutter-source-"));
const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a";
const token = "synthetic-cutter-bridge-token-for-test";
server = createStatusHarness(token, "/tmp/sample.bin", 4).server;
const port = await listen(server);
await writeDescriptor(
directory,
sessionId,
port,
token,
digest === undefined ? {} : { bridgeSourceSha256: digest },
);
const result = await createClient({
REA_CUTTER_BRIDGE_DIR: directory,
}).listSessions();
expect(result.sessions).toMatchObject([{ bridge_source: source }]);
},
);
it("refuses a command to a modified plugin before sending it", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-cutter-source-"));
const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a";
const token = "synthetic-cutter-bridge-token-for-test";
const requests: unknown[] = [];
server = authenticatedServer(token, sessionId, (request, respond) => {
requests.push(request);
respond({ ok: true, execution_state: "complete" });
});
const port = await listen(server);
await writeDescriptor(directory, sessionId, port, token, {
bridgeSourceSha256: "0".repeat(64),
});
await expect(
createClient({ REA_CUTTER_BRIDGE_DIR: directory }).execute({
sessionId,
expectedGeneration: 0,
command: "ij",
json: true,
}),
).rejects.toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
operation: "cutter_command",
remediationAction: expect.stringContaining("rea_bridge.py"),
});
expect(requests).toEqual([]);
});
});
describe("CutterBridgeClient: scenario group 1", () => {
let directory: string | undefined;
let server: ReturnType<typeof createServer> | undefined;
@@ -111,6 +111,10 @@ it("round-trips authenticated status and command bytes across the Python produce
const { client, session } = bridge;
expect(session.document_generation).toBe(4);
expect(session.current_file).toBe("/tmp/cutter-interop-雪.bin");
expect(
session.bridge_source,
"the real plugin publishes the digest of its packaged bytes",
).toBe("packaged");
for (let index = 0; index <= 64; index += 1) {
await expect(
+4
View File
@@ -119,6 +119,7 @@ export const writeDescriptor = async (
readonly currentFile?: string | null;
readonly documentGeneration?: number;
readonly maxRequestBytes?: number;
readonly bridgeSourceSha256?: string | null;
} = {},
): Promise<void> =>
writeFile(
@@ -136,6 +137,9 @@ export const writeDescriptor = async (
...(options.maxRequestBytes === undefined
? {}
: { max_request_bytes: options.maxRequestBytes }),
...(options.bridgeSourceSha256 === undefined
? {}
: { bridge_source_sha256: options.bridgeSourceSha256 }),
}),
{ mode: 0o600 },
);
@@ -73,6 +73,7 @@ const cutterDiscoveryFixture = () =>
current_file: "C:\\Caller files\\token=literal%2F.bin",
cutter_version: "2.3.4",
identity_status: "partial",
bridge_source: "unknown",
},
{
session_id: "00000000-0000-4000-8000-000000000002",
@@ -81,6 +82,7 @@ const cutterDiscoveryFixture = () =>
current_file: null,
cutter_version: null,
identity_status: "partial",
bridge_source: "unknown",
},
],
bridge_directory: "/caller/Cutter bridge/token=literal%2F/证据",