diff --git a/docs/electron-observation.md b/docs/electron-observation.md index de5ac0327b..272c4e6511 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -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. diff --git a/scripts/electron-active-hook-boundaries.cjs b/scripts/electron-active-hook-boundaries.cjs index 7f34758a2e..cb0d529674 100644 --- a/scripts/electron-active-hook-boundaries.cjs +++ b/scripts/electron-active-hook-boundaries.cjs @@ -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. diff --git a/tests/boundary/browser/electronActiveHook.test.ts b/tests/boundary/browser/electronActiveHook.test.ts index b8acf13efb..538544687a 100644 --- a/tests/boundary/browser/electronActiveHook.test.ts +++ b/tests/boundary/browser/electronActiveHook.test.ts @@ -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-",