fix(electron): block synchronous shell effects during capture

Block showItemInFolder and writeShortcutLink using the shared active shell boundary, preserving undefined/false synchronous refusal contracts and shape-only timeline evidence.

Retain Nitin13-git implementation and add base-red/shared-contract coverage plus a reproducible unchanged Joplin welcome-attachment reveal. Local87cases, static and docs pass. The explicit test argv shim verifies the operation phase; standard startup and provider cleanup remain unsuccessful and are recorded without a full-workflow performance claim.

Fixes #4393

Co-authored-by: Nitin13-git <internshipsvc01@gmail.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-16 21:03:12 +00:00
1 parent 9a95db6819
commit 709b5cbdc1
6 files changed
+2290 -3

No files matched your search

+5
View File
@@ -114,6 +114,11 @@ 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:
`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.
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.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,91 @@
# Electron synchronous shell boundary verification — 2026-10-16
Fix #4393 reuses Nitin13-git's existing PR #4562. The boundary owns a fixed
method/return table: existing asynchronous blocks still reject, while
`showItemInFolder` returns `undefined` and `writeShortcutLink` returns `false`.
Both new blocks use the existing shape-only event recorder, shared by CLI/MCP.
No tool, schema, capture authority, analysis cache or launch behavior is added.
## Source and controlled regression
The fresh main base is `f26973a52918a938631e90fdc9116f7b85a62a52`;
implementation integration is `0386b60fb` and frozen verifier source is
`3f231735c3fbc28918590ddc61461871c8a2aa4d`.
The adjacent JSON records frozen final script/runtime/input hashes and all attempts;
unpinned exploratory helper revisions are explicitly identified.
Original contributor attribution remains in Git ancestry.
Using the candidate test with the base boundary produces **73 passes / 1 failure**:
the fake native shortcut implementation executes and returns true. With the
candidate boundary, **87 cases pass** (74 staged-hook, 5 projection, 8 MCP).
These test native dispatch suppression, synchronous results, shape-only
retention and the shared event schema. The fake Electron cases and controlled
MCP provider do not prove a real Windows shell implementation was exercised.
`npm run check:fast` passes both uncached tasks. Build-cache differences and
different case counts make the red/green timings unsuitable as a speed comparison.
## Real Joplin workflow and limits
Inputs: unchanged Joplin Desktop **3.7.21** ASAR (233,575,014 bytes), and the
explicit generic Electron **44.7.0** runtime. Acquisition/checksum references
are in `javascript-native-handoff-identity-2026-10-15.json` and
`electron-cancellation-2026-10-16.json`; this is not vendor-executable compatibility.
No executable fuse is changed. Large downloadable inputs and generated profiles
are not committed or retained as an experiment bundle.
The verifier opens Joplin's shipped **1. Welcome to Joplin!** note and real PNG
attachment. It selects Joplin's actual editor layout and resource context-menu
callbacks; Joplin's existing bridge calls the real patched shell method. The
native popup presentation is captured for automation; its application callbacks
are not replaced. Before any app actions, the verifier requires the active hook.
It requires zero earlier reveal events, one new blocked shape-only reveal event,
a still-rendered attachment and an identical event in the shared retained capture.
**Standard launch remains unsuccessful.** The earlier standard probe reports
`Unknown flag: -r` in Joplin's start-flag parser; the frozen standard verifier
times out waiting for the application search input. This is relevant to #2974,
not fixed here. A discarded NODE_OPTIONS experiment was invalid: Playwright
removes that variable, so no hook was installed. Those attempts are never used
as interception or performance evidence.
The explicit `--argv-shim-transport` test adapter loads the provider's staged
canonical hook and removes only its own exact `-r`/shim pair from app argv before
Joplin starts. It does not modify the ASAR or production provider. Its successful
operation phase is not a successful standard public CLI/MCP workflow. Provider
cleanup still fails (#4282); the verifier exits nonzero, preserving the distinction.
External owned-scope teardown does not establish provider cleanup.
The first valid shim probe reaches one blocked reveal, visible 1075×666 PNG,
no page errors and retained capture evidence. The final frozen rerun additionally
checks event causality and exact capture equality. Detailed measured results and
whole-family observation status are in the JSON. The final controlled pipeline
takes16.989s wall /16.055812 family CPU s /1,376,382,976 peak cgroup bytes
with verified payload-exit observation; the overall workflow still fails cleanup. Each final trial has a new
private config mount; earlier exploratory shared-config trials are not independent
cold runs. Filesystem/runtime/build caches are warm; no cold-system or warm-repeat
speedup is claimed. Agent/billed tokens are unmeasured; there is no token benefit claim.
## Reproduction
Use the pinned Node/npm toolchain. Install dependencies and build with
`npm ci` and `npm run build:cached`. Create an owned writable packet directory
with `tmp`, `profiles`, and `config/joplin-desktop`. On Linux, bind that private
config over the actual user config directory in a disposable `bwrap` mount
namespace (do not change HOME), keep the application resources read-only and
run under Xvfb. Set TMPDIR to the owned writable packet `tmp` directory. Run:
```sh
node experiments/analysis-efficiency/joplin-shell-boundary.mjs \
--runtime-root /absolute/rea-checkout \
--executable /absolute/generic-electron \
--application /absolute/Joplin/resources/app.asar \
--profile-root /absolute/owned-packet/profiles \
--argv-shim-transport
```
Omit the last flag for the failing standard launch. Run heavy work serially under
`resource-run.py --memory-gib 8 --observe-payload-exit`, one CPU, no swap, the
6 GiB early-stop margin and 192-task ceiling. CPU/peak memory receipts are at
payload exit with the observer alive; memory includes file cache, not just RSS.
Unverified complete-family observations are explicitly marked. No Windows host,
macOS host, vendor packaged runtime or full `check:pr` was executed here.
@@ -0,0 +1,291 @@
#!/usr/bin/env node
// Unchanged Joplin ASAR and its shipped welcome note/attachment. The actual
// resource context menu, bridge and shell call execute; menu automation is explicit.
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import { _electron } from "playwright-core";
const { values } = parseArgs({
options: {
"runtime-root": { type: "string" },
executable: { type: "string" },
application: { type: "string" },
"profile-root": { type: "string" },
"argv-shim-transport": { type: "boolean", default: false },
},
});
for (const key of ["runtime-root", "executable", "application", "profile-root"])
assert.ok(values[key], `Missing --${key}`);
const runtime = resolve(values["runtime-root"]);
const { PlaywrightElectronActiveProvider } = await import(
pathToFileURL(
join(runtime, "dist/browser/PlaywrightElectronActiveProvider.js"),
)
);
const { captureElectronScenario } = await import(
pathToFileURL(
join(
runtime,
"dist/application/javascript/ElectronActiveObservationService.js",
),
)
);
const profile = await mkdtemp(
join(resolve(values["profile-root"]), "joplin-shell-"),
);
let application;
let child;
let probe;
const started = performance.now();
const controller = new AbortController();
const deadline = setTimeout(
() => controller.abort(new Error("Verifier deadline")),
45_000,
);
const launch = async (options) => {
let launchOptions = options;
if (values["argv-shim-transport"]) {
// Explicit test adapter for #2974, not the public provider launch path.
const args = [...options.args];
const index = args.indexOf("-r");
assert.notEqual(index, -1, "Expected the provider's staged require pair");
const hook = args[index + 1];
const shim = join(profile, "launch-shim.cjs");
await writeFile(
shim,
`require(${JSON.stringify(hook)});\nconst index = process.argv.indexOf("-r");\nif (index < 0 || process.argv[index + 1] !== __filename) throw new Error("Unexpected staged argv");\nprocess.argv.splice(index, 2);\n`,
);
args[index + 1] = shim;
launchOptions = { ...options, args };
}
application = await _electron.launch(launchOptions);
child = application.process();
assert.equal(
await application.evaluate(
() => typeof globalThis.__reaElectronActiveSnapshot,
),
"function",
"The staged active hook must be installed before app actions",
);
const page = await application.firstWindow({ timeout: 15_000 });
const pageErrors = [];
page.on("pageerror", (error) => {
if (pageErrors.length < 5) pageErrors.push(String(error).slice(0, 600));
});
await page.getByPlaceholder("Search...").waitFor({ timeout: 15_000 });
await page
.getByText("1. Welcome to Joplin!", { exact: true })
.first()
.click({ timeout: 20_000 });
const viewer = page.locator("iframe.noteTextViewer");
for (
let attempt = 0;
attempt < 3 && !(await viewer.isVisible());
attempt += 1
) {
await application.evaluate(({ Menu }) => {
const item =
Menu.getApplicationMenu()?.getMenuItemById("toggleVisiblePanes");
if (!item) throw new Error("Missing Joplin editor layout menu");
item.click();
});
await viewer
.waitFor({ state: "visible", timeout: 3_000 })
.catch(() => undefined);
}
const image = page
.frameLocator("iframe.noteTextViewer")
.locator("img")
.first();
try {
await image.waitFor({ timeout: 15_000 });
} catch (cause) {
console.log(
JSON.stringify({
viewer_visible: await viewer.isVisible(),
frames: await Promise.all(
page
.frames()
.slice(0, 5)
.map(async (frame) => ({
url: frame.url(),
state: await frame.evaluate(() => ({
text: document.body?.innerText.slice(0, 500),
images: document.images.length,
})),
})),
),
}),
);
throw cause;
}
const attachment = await image.evaluate((element) => ({
src: element.getAttribute("src"),
alt: element.getAttribute("alt"),
width: element.naturalWidth,
height: element.naturalHeight,
}));
assert.ok(
attachment.width > 0 && attachment.height > 0,
"The shipped welcome attachment must render",
);
const beforeShellCount = await application.evaluate(
() =>
globalThis
.__reaElectronActiveSnapshot()
.events.filter(
(event) =>
event.kind === "shell-attempt" &&
event.event === "showItemInFolder",
).length,
);
assert.equal(
beforeShellCount,
0,
"A startup event cannot prove the requested reveal",
);
// Capture the app's actual context menu instead of opening a native popup.
await application.evaluate(({ Menu }) => {
const original = Menu.prototype.popup;
globalThis.__reaShellMenuProbe = { original, menu: null };
Menu.prototype.popup = function captureMenu() {
globalThis.__reaShellMenuProbe.menu = this;
};
});
await image.click({ button: "right" });
const revealed = await application.evaluate(async ({ Menu }) => {
const state = globalThis.__reaShellMenuProbe;
const deadline = Date.now() + 5_000;
while (state.menu === null && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
Menu.prototype.popup = state.original;
delete globalThis.__reaShellMenuProbe;
const item = state.menu?.items.find(
(entry) => entry.label === "Reveal file in folder",
);
if (!item) throw new Error("Missing actual Joplin resource reveal menu");
await item.click();
return item.label;
});
const shell = await application.evaluate(async () => {
const read = () =>
globalThis
.__reaElectronActiveSnapshot()
.events.filter(
(event) =>
event.kind === "shell-attempt" &&
event.event === "showItemInFolder",
);
const deadline = Date.now() + 5_000;
let events = read();
while (events.length === 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50));
events = read();
}
return events;
});
assert.equal(shell.length, 1);
assert.equal(shell[0].phase, "blocked");
assert.deepEqual(shell[0].argument_shapes, ["string"]);
probe = {
before_shell_count: beforeShellCount,
note: "1. Welcome to Joplin!",
attachment,
revealed,
image_visible: await image.isVisible(),
shell,
page_errors: pageErrors,
};
return application;
};
try {
const result = await captureElectronScenario(
new PlaywrightElectronActiveProvider(process.env, launch),
{
executable_path: resolve(values.executable),
application_path: resolve(values.application),
args: [
"--no-sandbox",
"--disable-gpu",
`--user-data-dir=${profile}`,
"--profile",
profile,
],
actions: [
{
step_id: "settle-after-reveal",
kind: "wait",
window_index: 0,
duration_ms: 100,
},
],
},
{ signal: controller.signal },
);
await writeFile(join(profile, "result.json"), JSON.stringify(result));
const capture = result.ok
? result.value.normalized_result
: result.error.partialObservation?.capture;
const retainedShell = capture?.timeline.events.filter(
(event) =>
event.kind === "shell-attempt" && event.event === "showItemInFolder",
);
if (probe)
assert.deepEqual(
retainedShell,
probe.shell,
"The shared capture must retain the observed shell event",
);
console.log(
JSON.stringify({
profile,
runtime,
elapsed_ms: performance.now() - started,
launch_transport: values["argv-shim-transport"]
? "controlled-argv-shim"
: "standard-staged-argv",
probe: probe ?? null,
provider_ok: result.ok,
error: result.ok
? null
: {
tag: result.error._tag,
reason: result.error.reason,
diagnostics: result.error.diagnostics,
},
coverage: capture?.coverage,
retention: capture?.retention,
actions: capture?.actions,
deadline_triggered: controller.signal.aborted,
workflow_status:
result.ok && probe !== undefined ? "verified" : "partial-or-failed",
}),
);
assert.ok(probe, "Real Joplin resource reveal was not verified");
assert.ok(result.ok, "Provider cleanup remains unverified");
} catch (cause) {
console.log(
JSON.stringify({ verifier_failure: String(cause), probe: probe ?? null }),
);
process.exitCode = 1;
} finally {
clearTimeout(deadline);
if (application && child?.exitCode === null && child?.signalCode === null) {
let timer;
await Promise.race([
application.close().catch(() => undefined),
new Promise((resolve) => {
timer = setTimeout(resolve, 5_000);
}),
]);
clearTimeout(timer);
}
}
// The external owned scope recovers leftovers; that is not provider cleanup.
process.exit(process.exitCode ?? 0);
+15 -2
View File
@@ -335,9 +335,22 @@ const createElectronActiveBoundaryPatches = ({
};
};
// Each blocked method keeps the return contract of the Electron API it
// replaces, so a target that checks the result sees an ordinary refusal.
const blockedShellResults = {
openExternal: () =>
Promise.reject(new Error("External shell effects are blocked")),
openPath: () =>
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.
writeShortcutLink: () => false,
};
const patchShell = (shell) => {
if (shell === null || shell === undefined) return;
for (const method of ["openExternal", "openPath"]) {
for (const [method, blockedResult] of Object.entries(blockedShellResults)) {
const original = shell[method];
if (typeof original !== "function") continue;
shell[method] = function blockedShellCall(...args) {
@@ -345,7 +358,7 @@ const createElectronActiveBoundaryPatches = ({
argument_shape_values: args,
error: true,
});
return Promise.reject(new Error("External shell effects are blocked"));
return blockedResult();
};
}
};
@@ -56,7 +56,13 @@ const utilityProcess = {
return child;
},
};
const shell = { openExternal: () => Promise.resolve(), openPath: () => Promise.resolve() };
globalThis.__nativeShellCalls = [];
const shell = {
openExternal: () => Promise.resolve(),
openPath: () => Promise.resolve(),
showItemInFolder: (path) => { globalThis.__nativeShellCalls.push(["showItemInFolder", path]); },
writeShortcutLink: (path) => { globalThis.__nativeShellCalls.push(["writeShortcutLink", path]); return true; },
};
const createSession = () => {
const value = new EventEmitter();
value.setPermissionRequestHandler = () => undefined;
@@ -219,6 +225,60 @@ it("retains active hook evidence within its event-retention budget", async () =>
);
});
it("blocks synchronous shell effects with their native return contracts and retains argument shapes only", async () => {
const root = await createTestTempDirectory("rea-electron-hook-shell-");
temporary.push(root);
await writeElectronFixture(root);
const script = join(root, "shell.cjs");
await writeFile(
script,
`const { shell } = require("electron");
const revealed = shell.showItemInFolder("/host/secret-folder/file.txt");
const written = shell.writeShortcutLink("C:\\\\Users\\\\host\\\\app.lnk", "create", { target: "C:\\\\payload.exe" });
const snapshot = globalThis.__reaElectronActiveSnapshot();
process.stdout.write(JSON.stringify({
revealed: revealed === undefined ? "undefined" : revealed,
written,
native_calls: globalThis.__nativeShellCalls,
snapshot,
}) + "\\n");
`,
);
const execution = await runNode(script, root);
expect(execution.code, execution.stderr).toBe(0);
const result = JSON.parse(execution.stdout.trim()) as {
readonly revealed: string;
readonly written: boolean;
readonly native_calls: readonly unknown[];
readonly snapshot: {
readonly hook_error: boolean;
readonly events: readonly Record<string, unknown>[];
};
};
expect(result.revealed).toBe("undefined");
expect(result.written).toBe(false);
expect(result.native_calls).toEqual([]);
expect(result.snapshot.hook_error).toBe(false);
const attempts = result.snapshot.events.filter(
(event) => event.kind === "shell-attempt",
);
expect(attempts).toEqual([
expect.objectContaining({
event: "showItemInFolder",
phase: "blocked",
argument_shapes: ["string"],
}),
expect.objectContaining({
event: "writeShortcutLink",
phase: "blocked",
argument_shapes: ["string", "string", "object"],
}),
]);
expect(JSON.stringify(result.snapshot)).not.toContain("secret-folder");
expect(JSON.stringify(result.snapshot)).not.toContain("payload.exe");
electronActiveTimelineEventSchema.array().parse(result.snapshot.events);
});
it("classifies a dropped browser preload as preload configuration at capture", async () => {
const root = await createTestTempDirectory(
"rea-electron-hook-browser-preload-",