Skip to content

fix: reliability, Windows launch and IPC findings from the code audit - #93

Open
BIackFIame wants to merge 22 commits into
howdeploy:mainfrom
BIackFIame:fix/audit-reliability-windows-ipc
Open

BIackFIame wants to merge 22 commits into
howdeploy:mainfrom
BIackFIame:fix/audit-reliability-windows-ipc

Conversation

@BIackFIame

Copy link
Copy Markdown
Contributor

Goal

Fix the verified findings of a code audit of 31f287d: failures that leave a feature broken until restart, Windows-only launch bugs, IPC handlers without a sender check, and a few plugin storage and file-write defects. One commit per finding (two pairs), each with a test that fails before the fix.

Two areas from the same audit are not in this PR: base-protection parser bypasses (safety/commandFacts.ts) and the permission gate failing open are handled in a separate PR.

Findings, fixes and tests

Line numbers are at 31f287d.

# Finding File Fix Test
1 One failed browser-state write poisoned writeQueue: every later save rejected; close/new tab/select/close tab/navigate failed half-way; unhandled rejections from did-navigate, popup adoption and setRestoreTabs (not awaited in applyBrowserSettings) browser/BrowserStore.ts:67, BrowserService.ts:684,787,794,998, index.ts:679 Queue continues after a failure (as PluginMediaService.persist), temp file removed; persistRuntime/clearSavedTabs log and keep in-memory state; the settings call is caught. Bug fix only: no other Browser card behaviour changes. browser-policy-store.test.mjs: read-only folder, then writes succeed again; callers cannot see the rejection
2 Half of registerIpc.ts handlers had no sender check, among them settings.update, plugin install/set-modules/enable/uninstall, plugin and provider secrets, plugins.openExternal, terminal create/restart/input/dispose, media.read. mediaPath accepted any string, and media.read followed links anywhere ipc/registerIpc.ts:144,357,380-397,675-679, SettingsStore.ts:439 assertMainRenderer on those handlers (terminal.input drops a foreign sender). mediaPath must be an absolute image path; reading follows a link only inside the chosen folder, from one open handle; the picker saves the resolved path browser-ipc-security.test.mjs, home-media.test.mjs
3 .cmd/.bat providers: one caret level and \" quotes. The shim's %* is parsed again by cmd.exe, so && inside the Claude --settings hook JSON ended the argument and the rest ran as a command. Separately, a\\"b lost its quote (lookahead regex doubled only one backslash) providerCliRegistry.ts:278-292 Arguments caret-escaped twice (as cross-spawn does for npm shims); backslash runs before a quote doubled whole provider-cli-registry.test.mjs: round-trip through a cmd.exe parsing model (%VAR%, carets, quotes, both passes, operators, MSVC argv). Real-Windows verification is pending.
4 AgentControlGateway receipts never cleared: after 4096 mutating requests every create/send answered LIMIT_REACHED; a cached BUSY/NOT_READY replayed forever on retry agent-control/AgentControlGateway.ts:85,239-241 Oldest finished receipts dropped at the cap; refusals that wrote nothing are not cached agent-control.test.mjs
5 No error listeners on the Windows pipe host's stdin/stdout/stderr: EPIPE after the host died was an uncaught exception in main agent-browser/WindowsPipeHostTransport.ts:138-195 stdin/stdout errors fail the transport like a FATAL frame; stderr errors ignored windows-pipe-host-transport.test.mjs (injected win32, fake host)
6 RuntimeGateway and AgentControlGateway ignored the transport's fatal: after the pipe host died every launch failed "must be started"; a failed start() kept the dead transport. AgentControlGateway.start() left the socket listening when a later step failed agent-runtime/RuntimeGateway.ts:155-163, AgentControlGateway.ts:94-119 Restart on fatal with AgentGateway's bounded backoff; control republishes connection.json; failed start closes everything (and removes its socket folder) so start() can run again; connection.json is written atomically, so a reader never sees it half written during a republish agent-runtime-gateway.test.mjs, agent-control.test.mjs
7 planned.env.PATH is undefined on Windows (Path), so environment wrappers could not use a bare program name TerminalManager.ts:1460 launchSearchPath(): case-insensitive on Windows session-environments.test.mjs (injected platform)
8 Orchestration cancel/disconnect never reached the handler: a canceled spawn_agent still spawned and the orchestrator got the result instead of CANCELED agent-browser/OrchestrationGateway.ts:330-340 Signal passed to execute; CANCELED sent at once, late result dropped; an agent spawned after cancel is closed orchestration-gateway.test.mjs
9 Plugin service TOCTOU: the entry was hashed on one read and node <entry> read it again after an await PluginServiceSupervisor.ts:302-320 The service starts with a --import data: boot whose module hook reads the entry, checks the trusted SHA-256 and returns exactly those bytes; path, argv[1], __filename, ESM/CJS unchanged. Checked by hand with Electron 43 in run-as-node mode plugin-services.test.mjs: a wrapper swaps the entry right before node reads it; swapped code does not run
10 Runtime hook socket: no deadline before the first message, so 64 idle connections blocked every hook agent-runtime/RuntimeGateway.ts:265-309 5 s to send the message agent-runtime-gateway.test.mjs
11 Temp files left when the rename fails (random names in atomicWrite, so they pile up); session store folder created without a mode agent-runtime/ProviderRuntimeLaunch.ts:1149-1155, TerminalSessionStore.ts:132-137 Temp removed on failure; folder 0700 atomic-write-cleanup.test.mjs
12 One torn audit line (crash/ENOSPC) → AUDIT_UNAVAILABLE for every agent browser mutation until the log is deleted by hand; a failed append also left partial bytes for the next record browser/BrowserAuditStore.ts:83,114,167-181 On open, a last line without newline is completed (whole record) or removed (partial); a failed append truncates back. Other invalid content still fails closed browser-audit-store.test.mjs
13 Audit hash used localeCompare for key order, so it depended on the ICU locale browser/BrowserAuditStore.ts (stableJson) Code-unit order for new records; verification also accepts the previous order, so existing logs verify as before browser-audit-store.test.mjs: append under sv_SE, verify/append under en_US (child processes); legacy record chains on
14 cmd.exe lookup differed: the terminal fallback searched PATH first, batch providers did not terminalLaunch.ts:206, providerCliRegistry.ts:398 One windowsCommandPromptPath(): ComSpec, then %SystemRoot%\System32\cmd.exe, never PATH terminal-launch.test.mjs (injected platform)
15 GitHub device polling ended on the first network error or 15 s timeout; the UI kept showing the code and approval was never picked up GithubAuthService.ts:229 Back off (doubling, max 60 s) and keep polling until the code expires; slow_down +5 s or GitHub's longer interval; cancel still stops github-auth.test.mjs
16 setAvailableProviders wrote a snapshot taken during an in-flight update(), dropping the update from the file SettingsStore.ts:235-276 Filter runs inside the write queue settings-normalizer.test.mjs
17 dispose() while Kimi limits reserved a port left an orphaned kimi web LimitsService.ts:572-588 disposed checked after the await limits-dispose.test.mjs
18 Playlist write followed a link planted at <name>.tmp PluginMediaService.ts:185-187 Random temp name created with wx; removed on failure plugin-media-service.test.mjs
19 Hidden badges of revoked plugins counted toward a card's 4 badge slots PluginCards.ts:82,107 Dropped before counting plugin-tools-events-cards.test.mjs
20 A storage read failure returned {} and the next storageSet wiped all other keys PluginManager.ts:1169-1180 Read error refuses the write; invalid file kept as <id>.json.unreadable-<time>; uninstall removes those plugin-manager.test.mjs

Not changed, and why

  • Orchestration pre-auth timeout (listed with feat(plugins): add modular capabilities, secure settings, and canvas APIs #10): OrchestrationGateway already closes an unauthenticated connection on its heartbeat sweep (15 s; heartbeats need authentication). AgentControlGateway has a 10 s per-connection timer.
  • Old audit records written under another locale: they still verify only under a locale with the same order, as before this change; new records do not depend on the locale.
  • Plugin tool calls on cancel: the plugin service protocol has no cancel, so the call runs on; the orchestrator gets CANCELED and the late result is dropped.
  • pwsh is still found on PATH (where its installer puts it); only the cmd.exe lookup changed.

Verification

  • Merges cleanly with perf(runtime): send Claude Code lifecycle hooks over loopback HTTP, keep decisions on the helper #90 (perf/claude-http-hooks); the merged tree passes typecheck and the full suite (1056/1056).
  • npm run typecheck, full suite (1043/1043), electron-vite build, npm run audit:secrets, npm run test:even (47/47): all pass. Every test run used a throw-away HOME/XDG_*/provider homes.
  • Each new test was run against the code before its fix and failed.
  • Windows logic is tested with an injected platform and environment; nothing here ran on real Windows. The cmd.exe escaping is checked against a model of cmd.exe parsing, not against cmd.exe; it needs a run on Windows with a .cmd provider (Claude with lifecycle hooks) before release.
  • The plugin service guard was also checked by hand with the bundled Electron 43 (ELECTRON_RUN_AS_NODE=1) for .cjs, .js and .mjs entries.

BrowserStore chained every save onto writeQueue with no catch. After one
failed write (full disk, permissions, antivirus lock on the temp file) the
queue stayed rejected, so every later save rejected with the same error
until restart. BrowserService awaits persistRuntime() before updating the
view in close/newTab/selectTab/closeTab/navigate, so those actions failed
half-way; did-navigate and popup adoption left unhandled rejections, and
applyBrowserSettings called setRestoreTabs() without awaiting it.

- BrowserStore.persist() continues after an earlier failure (same pattern
  as PluginMediaService.persist) and removes its temp file when the write
  or rename fails. Each save still reports its own error.
- BrowserService.persistRuntime() and a new clearSavedTabs() log a failed
  save and keep the in-memory state, so the tabs on screen are unchanged;
  only the copy restored at the next start is stale.
- index.ts catches the setRestoreTabs() promise.

No other Browser card behaviour changes.

Test: tests/browser-policy-store.test.mjs "BrowserStore recovers after one
failed write ..." (read-only data folder, then writable again) and a source
check that BrowserService callers cannot see the rejection.
…m the main renderer

About half of the handlers in registerIpc.ts checked the sender with
assertMainRenderer(); the rest trusted any sender. Among them were
settings.update, plugin preview/install/set-modules/enable/uninstall,
plugin and provider secrets, plugins.openExternal, terminal
create/restart/input/dispose and media.read. Today only the main window
loads the preload that exposes them, so this is defence in depth, but a
second renderer that gains ipcRenderer (a new window, a preload change)
would reach terminal input and secrets without any check.

- Those handlers now call assertMainRenderer(). terminal.input is a
  fire-and-forget send, so a foreign sender is dropped (isMainRenderer)
  instead of throwing inside the IPC layer.
- Home media: settings keep mediaPath only when it is an absolute path of
  at most 4096 characters, without NUL, with an image extension Home can
  show; anything else keeps the previous value. media.read (new
  homeMedia.ts) follows a symbolic link only while its target stays in
  the folder the file was chosen from, requires the target to be a
  supported image, and reads size and content from one open handle.
  The picker saves the resolved path of the chosen file.

Tests: tests/browser-ipc-security.test.mjs (every listed channel checks
its sender), tests/home-media.test.mjs (link inside the folder is read,
links to another folder or to a non-image are refused; settings reject a
relative path, a non-image path and a crafted settings file).
…md.exe pass

A .cmd/.bat provider (the npm shims for claude, codex, ...) is started as
`cmd /d /s /c "<exe> <args>"`. cmd.exe removes one level of carets when it
reads that line, and the shim then runs `node cli.js %*`, which cmd.exe
parses again. Arguments carried only one level of carets and used \" for
inner quotes, which cmd.exe does not treat as an escape. In the second pass
the quote state flipped at every \", so && and | inside an argument were
outside quotes: the Claude --settings JSON with its hook command
(`set "ELECTRON_RUN_AS_NODE=1" && "...CanvasTTY.exe" ...`) was cut at the
first && and the rest ran as a separate command.

- Arguments are caret-escaped twice (as cross-spawn does for npm shims);
  every quote is escaped at both levels, so cmd.exe never enters a quoted
  region and every operator stays escaped. The executable is unchanged.
- The MSVC quoting step doubled only one of two or more backslashes before
  a quote (lookahead regex), so `a\\"b` lost its quote. Backslash runs are
  now doubled whole.

Test: tests/provider-cli-registry.test.mjs "Windows batch arguments survive
cmd.exe and the shim's %* re-parse unchanged (cmd.exe model)": a model of
%VAR% expansion, caret/quote handling (both passes), operators outside
quotes and MSVC argv splitting; the settings JSON, operators, %PATH%, ^, !,
quotes, trailing and doubled backslashes round-trip exactly, and no
operator is outside quotes in either pass. This is a model; verification
on real Windows is still pending.
…llers

AgentControlGateway kept a receipt for every mutating request (create,
send, interrupt, choose, dismiss) so a retry with the same request id
replays the first answer. Receipts were never removed: after 4096 such
requests, including failed ones, every mutating request answered
LIMIT_REACHED until the app restarted. A refusal that wrote nothing
(BUSY, NOT_READY) was cached too, so retrying that request id replayed
BUSY forever.

- At the cap the oldest finished receipts are dropped; LIMIT_REACHED
  remains only when every kept receipt is still running.
- BUSY, NOT_READY, LIMIT_REACHED, LIFECYCLE_DISABLED and CLOSED are
  refusals before any write; their receipt is removed so the same id is
  performed again. Other results, successful or not, still replay.
- maxReceipts option (default 4096) so the cap can be tested.

Test: tests/agent-control.test.mjs "request receipts are bounded without
locking the gateway, and a refused request can be retried": with a cap of
3, five failed requests do not block a create, a recent receipt still
replays, and a BUSY send is performed on retry with the same id.
…ain process

WindowsPipeHostTransport had no 'error' listener on the host's stdin,
stdout or stderr. A relay write or the shutdown end() after the host
process died raises EPIPE on stdin; with no listener Node throws it as an
uncaught exception in the main process.

stdin and stdout errors now fail the transport the same way a FATAL frame
does (virtual sockets closed with the error, 'fatal' emitted, child
killed); stderr errors are ignored because stderr only feeds diagnostics.
Errors from a previous host process are ignored after a restart.

Test: tests/windows-pipe-host-transport.test.mjs "... host pipe errors into
a transport failure instead of an uncaught exception" (fake host; EPIPE on
each stream). Runs on any platform with platform: "win32" injected.
… failed start

RuntimeGateway and AgentControlGateway did not listen for the pipe host
transport's 'fatal' event (AgentGateway does). After the host died,
RuntimeGateway kept its endpoint and dead transport, so every later agent
launch failed with "must be started" until restart; agent control kept
publishing an endpoint nobody served. A rejected RuntimeGateway start()
also left the dead transport in place.

AgentControlGateway.start() did not clean up when a step after listen()
failed (for example writing the token file): the socket kept listening
and a second start() answered "already started".

- RuntimeGateway: on 'fatal' the transport and its sockets are dropped
  and the host is started again with the same bounded backoff as
  AgentGateway (3 attempts, 500 ms doubling); a failed start closes and
  forgets its transport; close() cancels a pending restart.
- AgentControlGateway: start() is split into openEndpoint /
  writeDiscovery / closeEndpoint. A failure closes the server or
  transport and removes the temporary socket folder, so start() can be
  called again. On 'fatal' the pipe host is restarted the same way and
  connection.json is rewritten with the new endpoint (the token file is
  written once). close() also removes the socket folder.
  connection.json is written to a temp file and renamed, so a controller
  reading it during that republish never sees it empty or half written;
  onTransportRestarted() reports the republished record.

Tests: tests/agent-runtime-gateway.test.mjs (fatal -> restart, launches
refused only while down, no restart after close; failed start drops the
transport) and tests/agent-control.test.mjs (failed start then a
successful start on the same gateway; Windows host restart republishes
the endpoint, awaited through onTransportRestarted; 20 isolated runs
pass). Windows cases use injected platform and a fake transport.
TerminalManager handed environments.wrap() `planned.env.PATH`. planned.env
is a plain-object copy of process.env; on Windows process.env is
case-insensitive but the copy keeps the key as Windows spells it, usually
"Path", so the value was undefined. EnvironmentRegistry.resolveCommand()
then found no bare program name on PATH and refused every wrapper that
answered with one (for example `wsl` or `docker`).

launchSearchPath(env, platform) returns PATH, and on Windows falls back to
the first key that matches PATH case-insensitively. POSIX is unchanged.

Test: tests/session-environments.test.mjs "the environment wrapper gets
the launch's search path even when Windows spells it Path" (injected
platform, plus the call site).
…call

OrchestrationGateway created an AbortController per request and aborted it
on `cancel` or disconnect, but never passed its signal to the handler.
The call ran to completion: a canceled spawn_agent still created and
prompted the subagent, a plugin tool kept the request open, and when the
call finished the orchestrator got the successful result instead of
CANCELED.

- OrchestrationCommandHandler.execute() takes the signal. The gateway
  answers CANCELED as soon as the signal aborts and drops a late result.
- ScopedOrchestrationHandler refuses a call that is already canceled; a
  spawn_agent canceled while its agent was starting closes that agent,
  since nobody will receive its id. Plugin tool calls have no cancel in
  the service protocol, so they are no longer waited for.

Tests: tests/orchestration-gateway.test.mjs "cancel reaches the running
command and the answer is CANCELED, not the late result" and "a
spawn_agent canceled while it was starting closes the agent it created".
… hash

PluginServiceSupervisor hashed the entry with one read and then started
`node <entry>`, which read the file again; an await (mkdir of the data
folder) sat between the two. A file replaced in that window ran as
trusted native code.

The service process now starts with `--import` of a small data: URL boot
that registers module load hooks (module.register). For the entry URL the
hook reads the file itself, checks its SHA-256 against the trusted hash
and returns exactly those bytes as the module source; a mismatch stops the
load and the process exits. The host's own check stays, so a changed file
still fails at once with "changed after it was trusted" and is not
retried. The entry keeps its path: argv[1], __filename, import.meta.url
and the format (ESM or CommonJS) are as before.

Checked by hand with the bundled Electron 43 in ELECTRON_RUN_AS_NODE mode
(.cjs, .js and .mjs entries run with the right hash and stop with a wrong
one); packaged-build fuses do not affect --import.

Tests: tests/plugin-services.test.mjs "an entry swapped after the host
checked it is not run ..." (the started "node" swaps the entry before the
real node reads it; the swapped code must not run) and "a verified entry
still runs from its own location with the guard in place" (ESM and
CommonJS, __filename and argv[1] unchanged).
RuntimeGateway accepts up to 64 sockets and only started a timer after
the first message. A connection that sent nothing stayed open for good,
so a process of the same user could open 64 idle connections and every
lifecycle and decision hook was refused until restart.

A connection now has 5 s (firstMessageTimeoutMs) to send its one message;
hook helpers write it right after connecting. The timer is cleared once
the message arrives, so a decision request still waits for its answer as
before.

OrchestrationGateway was listed with the same problem, but it already
closes an unauthenticated connection on its heartbeat sweep (15 s,
heartbeats need authentication); unchanged. AgentControlGateway has a
10 s per-connection timer; unchanged.

Test: tests/agent-runtime-gateway.test.mjs "RuntimeGateway closes a
connection that sends no message ...".
ProviderRuntimeLaunch.atomicWrite() and TerminalSessionStore.persist()
write a temp file and rename it over the target. When the rename failed
(EPERM from a locked file or antivirus on Windows, a full disk) the temp
file stayed. atomicWrite() names it with a random UUID, so every failed
launch left one more *.tmp next to the provider's settings for good.
TerminalSessionStore also created its folder without a mode.

Both now delete the temp file when the write or rename fails and rethrow;
TerminalSessionStore creates its folder with mode 0700 (an existing
folder is left as it is).

Test: tests/atomic-write-cleanup.test.mjs (a non-empty folder in place of
the target makes the rename fail; no *.tmp remains; a new store folder is
private).
The browser audit log is a hash chain that is verified when the store
opens; any line that does not parse sets integrityError, and every agent
browser mutation then fails with AUDIT_UNAVAILABLE ("retryable") until
someone deletes the log by hand. A crash or a full disk during an append
leaves exactly such a line: half a record with no newline. A failed
append in a running app also left its partial bytes, so the next record
was written onto the same line.

- On open, an active file that does not end with a newline was cut
  during a write: a whole last record only gets its newline back, a
  partial one is removed (with a warning). The chain before it is
  untouched, and anything else that does not verify still fails closed,
  as the tamper test requires.
- A failed append truncates the file back to its size before the write.

Test: tests/browser-audit-store.test.mjs "BrowserAuditStore repairs a
line torn by a crash instead of refusing every later action" (partial
record, then a whole record missing only its newline).
The audit chain hashes a canonical JSON of each record whose keys were
sorted with localeCompare. That order follows the ICU locale of the
process: with LC_ALL=sv_SE "ä" sorts after "z", with en_US before it,
and upper case sorts differently from code-unit order. A log written
under one locale could fail verification under another, which blocks
every agent browser mutation (AUDIT_UNAVAILABLE). The other canonical
JSON implementations in the repo sort by code unit.

- New records sort keys by UTF-16 code unit.
- Verification accepts a record whose hash matches either the code-unit
  or the previous localeCompare order, so existing logs keep verifying
  exactly as before and new records chain onto them. (An old record
  written under a different locale still depends on that locale, as it
  did before; new records no longer do.)
- Rotated audit files are ordered by code unit too (their names are
  ASCII, so the order is unchanged).

Tests: tests/browser-audit-store.test.mjs "the audit hash does not depend
on the system locale" (append under sv_SE, verify and append under
en_US, verify under sv_SE again, in child processes) and "records hashed
with the earlier locale-ordered keys still verify and extend the chain".
…oviders

Two lookups of cmd.exe disagreed. providerCliRegistry (batch provider
launches) takes ComSpec, then %SystemRoot%\System32\cmd.exe.
terminalLaunch (the plain terminal when no PowerShell exists) searched
PATH before SystemRoot, so a cmd.exe in a project folder or any earlier
PATH entry started as the terminal shell.

Both now use windowsCommandPromptPath(): ComSpec, then
%SystemRoot%\System32\cmd.exe, never PATH. pwsh is still found on PATH,
where its installer puts it; that is unchanged.

Test: tests/terminal-launch.test.mjs "the Windows terminal falls back to
the system cmd.exe, never one found on PATH, like provider launches"
(injected platform, environment and file checks).
pollDeviceCode() awaited fetch without a catch. One dropped connection or
the 15 s request timeout (an AbortError from the per-request controller)
left the loop; startDeviceFlow() swallowed the error, so the UI kept
showing the code until it expired and an approval on GitHub was never
picked up.

- A network error, a request timeout, a non-OK response or an unreadable
  body now backs off (interval doubled, at most 60 s, RFC 8628 3.5) and
  polls again until the code's lifetime ends. Cancelling the flow (new
  flow, sign-out) still stops it at once.
- slow_down adds 5 s for this and later polls and honours a longer
  `interval` GitHub sends with it. access_denied, expired_token and the
  flow lifetime end the poll as before.

Test: tests/github-auth.test.mjs "device polling survives network errors
and timeouts, backs off, and honours slow_down" (fake fetch and clock:
fetch failure, timeout, 503, slow_down, pending, then approval).
setAvailableProviders() filtered a snapshot of this.value when it was
called and queued a write of that snapshot. update() sets this.value only
after its own write, so a CLI recheck (index.ts recheckProviderClis)
during a settings update took the value without the patch and wrote it
after the update: the file lost the change while memory kept it, until
the next write or restart.

The filter now runs inside the write queue, on the value left by the
writes before it, and memory is updated after its own write, like
update().

Test: tests/settings-normalizer.test.mjs "a provider recheck during a
settings update does not write the settings without the update".
…posed

KimiWebUsageClient.startChild() awaits reserveLoopbackPort() and then
spawns `kimi web`. dispose() (app quit, or providerClisRefreshed after a
CLI recheck) during that await found no child to stop, and the spawn
went ahead: an orphaned local Kimi web server with its token in the URL,
outside any cleanup.

startChild() now checks `disposed` after the await and refuses to spawn.

Test: tests/limits-dispose.test.mjs (a stand-in kimi records whether it
ran; dispose() right after get()).
writePlaylist() checked that the library's Playlists folder is a real
folder inside the library, then wrote `<name>.tmp` there with writeFile,
which follows a symbolic link. A link already sitting at that name (the
library is a user folder; anything with access to it can plant one) made
the write land outside the library, with the plugin's content.

The temp file now has a random name and is created with O_EXCL ("wx"),
which fails on any existing entry, link or not; it is removed if the
write or rename fails.

Test: tests/plugin-media-service.test.mjs "a playlist write does not
follow a link planted at its temporary name".
A card shows at most 4 plugin badges. When a plugin loses trust its
badges are hidden from decorations() but stay in the card's map, and
setBadge() counted the map. Four hidden badges kept every trusted plugin
off that card ("already shows the most plugin badges") while the card
showed none.

setBadge() now drops the badges of plugins that are no longer trusted
before it counts. Hiding on revoke is unchanged.

Test: tests/plugin-tools-events-cards.test.mjs "badges of plugins whose
trust was revoked do not use up a card's badge slots".
storageSet() read the plugin's storage file, set one key and wrote the
whole object back. readStorage() returned {} on any failure: a read
error (permissions, a file locked by another process on Windows), a
broken JSON file or one over the 64 KB quota. The next write then
replaced every other key with just the new one.

Writes now use readStorageForWrite():
- a missing file starts empty, as before;
- a read error refuses the write ("could not be read; nothing was
  written"), the file stays as it is;
- a file that is not valid storage (bad JSON, not an object, over the
  quota) is renamed to `<id>.json.unreadable-<time>` and a fresh file is
  started, with a warning. Uninstall removes those copies too.
storageGet() is unchanged (unreadable storage reads as empty).

Test: tests/plugin-manager.test.mjs "plugin storage that cannot be read
is not replaced by the next write".
…ommonJS shim

electron-vite places its CommonJS shim (`__dirname`, `require`) after the
last static `import ... from` it finds anywhere in the main bundle, string
literals included. The entry guard hooks are ESM source kept in a string,
and their two static imports pulled the shim into that string: the built
main process had no `__dirname` and could not open its window.

The hooks now load their modules with `await import(...)`. A test decodes
the hooks from the `--import` arguments and checks them against the
shim's own import pattern.
@BIackFIame

Copy link
Copy Markdown
Contributor Author

Added 1c58f2a: the entry-guard hooks from 232a9fd were ESM source kept in a string with two static imports, and electron-vite's CommonJS shim (__dirname, require) is inserted after the last static import … from it finds in the main bundle, string literals included. In the built app the shim ended up inside that string, so the main process had no __dirname and could not open its window. The hooks now use await import(...); a new test decodes the hooks from the --import arguments and checks them against the shim's import pattern (it fails on the previous head). Typecheck, full suite (1044) and build pass.

Copy link
Copy Markdown
Owner

I reviewed the updated head 1c58f2a, including the fix for the bundled CommonJS shim. There appears to be a remaining path-substitution case in the plugin entry guard. This is a static-review concern; I have not reproduced it at runtime.

In PluginServiceSupervisor.ts:344–364, the expected entry URL is computed from realpath(spec.entryPath), but the child is still launched with the original spec.entryPath. The load hook at line 117 delegates without checking the hash whenever url !== entryUrl.

If the entry is replaced with a symlink to another module after the parent checks it but before the child resolves its main module, the resolved URL can change. That appears to route the actual entry through nextLoad() without the trusted-hash check. Rejecting symlinks during package validation would not cover a replacement in this later window.

The new swap test replaces file contents with cp, keeping the resolved URL unchanged, so it exercises a different case. Could you extend that deterministic wrapper test to replace the entry with a symlink to an untrusted module and assert that its marker is never written? The actual main entry should remain subject to hash validation even if path resolution changes.

This requires the ability to modify the plugin files during startup; I am not describing a remote exploit. It is a gap in the specific TOCTOU guarantee this guard is intended to provide.

…the host resolved

The entry guard hashed the module whose URL equalled realpath(spec.entryPath) as the host computed it
before spawning, while the child was started with spec.entryPath and node resolves the main module
itself. An entry, or a folder above it, replaced by a symlink between the two resolutions sent the main
module to another URL, which the load hook passed to nextLoad unchecked: the untrusted module ran.

The hooks now also resolve: the one module resolved without a parent is node's main entry, and its
URL is checked with the same hash as the host's URL, so whatever file node resolves the entry to must
hold the trusted bytes, and those bytes are what runs (CommonJS entries included; node takes the source
the hook returns for them too).

Tests start the service through a "node" that changes the plugin files first and assert the untrusted
module's marker is never written: the entry replaced by a symlink (ES module and CommonJS), the entry's
folder replaced by a symlink, and a CommonJS entry replaced by content.
@BIackFIame

Copy link
Copy Markdown
Contributor Author

Thanks for the review. The symlink substitution reproduced. It is fixed in one new commit on top of 1c58f2a with no history rewrite. The new tests were run against 1c58f2a first, and in three of them the untrusted module wrote its marker.

Final head: 4d616f7ad136350dc1c952b7726b8d36989f3e4b.

Commit: 4d616f7ad136350dc1c952b7726b8d36989f3e4b

Root cause. The guard compared each loaded URL with pathToFileURL(realpath(spec.entryPath)), which the host computed before spawning. The child was then started with spec.entryPath, and node resolves the main module itself, to its real path. Suppose the entry, or any folder above it, is replaced by a symlink between the host's resolution and node's. Node's main-module URL then differs from the expected one, and the load hook passed it to nextLoad() without a hash check, as you described. I checked with node 23.3 and with Electron's node 24.18 (ELECTRON_RUN_AS_NODE): for both ES module and CommonJS mains, the main entry reaches resolve with parentURL === undefined and an already-resolved real-path URL, then reaches load.

Fix. The guard hooks now also export resolve. The one module resolved without a parent is node's main entry. Its resolved URL is recorded, and load applies the trusted-hash check to it as well as to the host's URL. So whatever file node resolves the entry to must hold the trusted bytes, and the bytes the hook hashed are what runs.

I checked that node runs the source the hook returns for a CommonJS main as well: a hook-supplied source ran with require and __filename intact on both runtimes. The host's own check and the "runs from its own location" behaviour (__filename, process.argv[1]) are unchanged.

I did not add a rule that the two URLs must be equal. On Windows, fs.promises.realpath and node's main-module resolution can spell the same file differently (for example drive-letter case). Such a rule would refuse legitimate entries there. Before this change, those entries simply skipped the check.

Tests (tests/plugin-services.test.mjs). They extend the deterministic wrapper approach of the existing swap test. A shared helper, assertSwapNeverRuns, starts the service through a "node" script that first changes the plugin files, then execs the real node. The untrusted module writes a marker file, and each test asserts the marker never appears:

  • "an entry replaced by a symlink to another module after the host checked it is not run" (your case: rm the entry, ln -s the untrusted module in its place). It failed at 1c58f2a.
  • "an entry whose folder is replaced by a symlink after the host checked it is not run" (the plugin folder is moved aside and replaced by a symlink to a folder holding an untrusted module of the same name). It failed at 1c58f2a.
  • "a CommonJS entry replaced by a symlink after the host checked it is not run". It failed at 1c58f2a.
  • "a CommonJS entry swapped after the host checked it is not run, by content". This already passed, and it confirms that CommonJS mains go through the guard.

The existing content-swap test, the "no static import" test for the bundled CommonJS shim, and the "runs from its own location" test pass unchanged. I also ran tests/plugin-services.test.mjs with Electron's node as the service runtime: 19/19 pass.

Also checked. I looked for other places where the verified path is not the path that runs:

  • downloadGithubModuleFiles writes the exact bytes it verified.
  • currentServiceTrust only computes the hash that is trusted.
  • The supervisor's spawn is the only place where a hash-checked plugin file is executed.
  • Runtime hooks are matched by their manifest record, not by a file hash, so this TOCTOU does not apply to them.

Gates on 4d616f7: npm run typecheck, the full suite (node --test --test-concurrency=2, 1048/1048), npm run build and npm run audit:secrets all pass.

BIackFIame added a commit to BIackFIame/CanvasTTY that referenced this pull request Sep 28, 2026
BIackFIame added a commit to BIackFIame/CanvasTTY that referenced this pull request Sep 28, 2026
There were three: the browser catalog's (strict, but it rebuilt objects,
so integer-like keys came out in numeric order), the orchestration
catalog's (no checks at all; an undefined property came out as the bare
word `undefined`, which is not JSON), and the browser audit's
`stableJson`.

`canonicalStringify` in tool-catalog.mjs is now the only one; the
orchestration catalog re-exports it. Keys are sorted by UTF-16 code unit,
strictly as strings. Strict by default, as the browser bridge already
was: cycles, non-finite numbers, non-plain objects and undefined array
entries throw, undefined properties are left out. The browser audit uses
`lenient`, which answers those as JSON.stringify would, and verifies
records written before howdeploy#93 with `compareKeys: localeCompare`, so its
hash chain is unchanged.

A test replays 500 random JSON values through the previous audit
serializer in both key orders and requires the same text.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants