fix(electron): block trash calls in active observation hook

This commit is contained in:
morluto committed 2026-10-24 02:36:00 +08:00
1 parent d3700f709b
commit 816a9dc988
3 files changed
+49 -5

No files matched your search

+9 -4
View File
@@ -129,8 +129,10 @@ native-addon, process, and IPC timeline events. IPC channels and argument-shape
metadata retain their observed lengths; payload values are never retained.
Renderer crash/restart and deep-link actions are synthetic
scenario controls; their attempted and observed outcomes remain in the
timeline. The active hook blocks and records external shell/navigation,
permission, download, popup, updater, and OS-integration effects. The timeline
timeline. The active hook blocks and records the shell methods listed below
and selected navigation, permission, download, popup, updater, and
OS-integration APIs. These method patches do not contain direct filesystem,
network, native-addon, or other unpatched target effects. The timeline
is explicitly partial when attachment starts after application activity. The
startup, including the initial state snapshot, has a 60-second deadline.
Actions wait until completion or request cancellation; a click whose target
@@ -139,11 +141,14 @@ window, remains pending until cancelled. After cancellation, the final state
read has 5 seconds before REA keeps the initial snapshot instead; the
cancellation keeps the caller's reason and records that expiry in its
diagnostics.
Blocked synchronous shell APIs keep their return contracts:
The shell policy is owned by `blockedShellResults` in
`scripts/electron-active-hook-boundaries.cjs`. Its synchronous blocks are:
`showItemInFolder` returns `undefined` and `writeShortcutLink` returns `false`.
Their `shell-attempt` events record the blocked phase and argument shapes;
neither result means the requested host operation occurred. The existing
`openExternal` and `openPath` blocks continue to reject their promises.
`openExternal`, `openPath`, and `trashItem` blocks reject their promises.
The `trashItem` block prevents that call from reaching the OS trash operation;
it does not intercept other ways the application can remove files.
CLI SIGINT/SIGTERM requests cancellation and awaits owned-process cleanup.
Window IDs, URLs, and cached titles come from one main-process Electron snapshot,
so a crashed renderer does not require a successful renderer-side title query.
@@ -350,6 +350,8 @@ const createElectronActiveBoundaryPatches = ({
Promise.reject(new Error("External shell effects are blocked")),
openPath: () =>
Promise.reject(new Error("External shell effects are blocked")),
trashItem: () =>
Promise.reject(new Error("External shell effects are blocked")),
// Synchronous `void`: reveals a path in the host file manager.
showItemInFolder: () => undefined,
// Synchronous `boolean`: creates or replaces a Windows shortcut file.
@@ -1,4 +1,4 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
@@ -63,6 +63,7 @@ globalThis.__nativeShellCalls = [];
const shell = {
openExternal: () => Promise.resolve(),
openPath: () => Promise.resolve(),
trashItem: (path) => require("node:fs/promises").unlink(path),
showItemInFolder: (path) => { globalThis.__nativeShellCalls.push(["showItemInFolder", path]); },
writeShortcutLink: (path) => { globalThis.__nativeShellCalls.push(["writeShortcutLink", path]); return true; },
};
@@ -300,6 +301,42 @@ process.stdout.write(JSON.stringify({
electronActiveTimelineEventSchema.array().parse(result.snapshot.events);
});
it("blocks trashItem asynchronously, records the attempt, and preserves the selected file", async () => {
const root = await createTestTempDirectory("rea-electron-hook-trash-");
temporary.push(root);
await writeElectronFixture(root);
const selected = join(root, "retained.txt");
await writeFile(selected, "must remain");
const script = join(root, "trash.cjs");
await writeFile(
script,
`const { shell } = require("electron");
const result = shell.trashItem(${JSON.stringify(selected)});
const promise = result instanceof Promise;
result.then(
() => { throw new Error("trashItem unexpectedly succeeded"); },
error => process.stdout.write(JSON.stringify({ promise, message: error.message, snapshot: globalThis.__reaElectronActiveSnapshot() }) + "\\n"),
);
`,
);
const execution = await runNode(script, root);
expect(execution.code, execution.stderr).toBe(0);
const result = JSON.parse(execution.stdout.trim());
expect(result.promise).toBe(true);
expect(result.message).toBe("External shell effects are blocked");
expect(await readFile(selected, "utf8")).toBe("must remain");
expect(result.snapshot.events).toContainEqual(
expect.objectContaining({
kind: "shell-attempt",
event: "trashItem",
phase: "blocked",
argument_shapes: ["string"],
error: true,
}),
);
expect(JSON.stringify(result.snapshot)).not.toContain(selected);
});
it("classifies a dropped browser preload as preload configuration at capture", async () => {
const root = await createTestTempDirectory(
"rea-electron-hook-browser-preload-",