Skip to content

Audit hardening: persistence durability, Electron security, perf, deps - #65

Open
aagentah wants to merge 27 commits into
developfrom
chore/audit-hardening
Open

Audit hardening: persistence durability, Electron security, perf, deps#65
aagentah wants to merge 27 commits into
developfrom
chore/audit-hardening

Conversation

@aagentah

@aagentah aagentah commented Jun 5, 2026

Copy link
Copy Markdown
Owner

Summary

Hardening from a full codebase audit (security, performance, persistence, dependencies). 11 commits, each verified independently.

Regression-proof set (verified: typecheck:all, 175/175 unit tests, lint, webpack prod build, unsigned --dir packs on macOS + Linux)

  • persistence: atomic writes are now crash-durable (fsync of temp file + containing directory) with a rolling .backup on every write. Previously it did writeFile+rename with no fsync, and the .backup the reader relies on was only written on the Windows EEXIST/EPERM path, so on macOS/Linux it never existed and corrupt-primary recovery had nothing to restore from. (4 new TDD tests)
  • security: dashboard + projector now deny window.open and block navigation away from bundled local content, routing genuine http(s) links through the validated normalizeOpenExternalUrl + shell.openExternal path. (1 new TDD test)
  • perf: debug-log writes no longer trigger a re-render on every input event when the overlay is closed (ring buffer + flush-on-open, observable behaviour preserved); the sequencer-pattern lookup is hoisted out of the 16xN step-render loop.
  • memory: BaseThreeJsModule.destroy() now removes its window resize listener and calls renderer.forceContextLoss() before dispose.
  • deps: @types/node ^25 to ^20, eslint 8 to 9 alignment (+ @eslint/js, globals), electron to 39.8.10, playwright 1.60, overrides.ws ^8.18.1, within-range tooling bumps, non-breaking npm audit fix.

Packaging hardening (committed; REQUIRES a signed + notarized build test before release)

  • Electron fuses: RunAsNode / inspect / NODE_OPTIONS disabled via an afterPack hook (verified flipping on the packaged binary on macOS and Linux --dir).
  • Dropped disable-library-validation from the macOS entitlements (kept allow-jit, allow-unsigned-executable-memory, audio-input).
  • electron-builder 25 to 26 (npm audit 38 to 3; the remaining 3 are dev-only webpack-dev-server transitives).
  • Pinned softprops/action-gh-release to a commit SHA.

Deliberately NOT changed (would have regressed)

  • Sync: develop already sets backgroundThrottling:false on both windows; the audit's Tone.Draw change would make visuals lead audio by the look-ahead.
  • CSP on the file:// windows: script-src 'self' would likely blank them (opaque file origin) and could break Tone's AudioWorklet. Deferred to a custom-protocol migration + smoke test.

Test plan

  • typecheck:all, test:unit (175/175), lint, webpack production build
  • unsigned pack:mac:arm64 --dir and pack:linux --dir (afterPack fuses applied to the correct per-platform binary)
  • adversarial multi-agent review of the full diff (found + fixed 1 regression: Linux fuse-hook used the wrong binary name)
  • npm run dist:mac (signed + notarized): app launches, mic/MIDI input works, ELECTRON_RUN_AS_NODE=1 .../nw_wrld -e "..." is blocked
  • npm run dist:win and npm run dist:linux package end-to-end (electron-builder 26 is a major bump)

Deferred follow-ups (need a product/behaviour decision)

OSC default bind 0.0.0.0 to 127.0.0.1 + rate limit; recordingData growth cap; quit-time write flush; CSP; first-party action SHA pins; asar-integrity fuses.

🤖 Generated with Claude Code

aagentah and others added 27 commits June 5, 2026 22:11
… audit vulns

- @types/node ^25 → ^20 to match node>=20 engine and Electron's bundled Node (ends types/runtime skew)
- eslint ^8 → ^9 (9.39.2 already installed; flat config is already v9); add @eslint/js + globals as explicit devDeps since eslint.config.mjs imports them
- electron ^39.2.7 → ^39.8.10 (Chromium security patches within the same major)
- @playwright/test + playwright 1.57.0 → 1.60.0
- add overrides.ws ^8.18.1: fixes the ws advisory pulled via osc (the only shipped-runtime vuln) without downgrading osc
- npm update on within-range build/lint tooling: @babel/preset-env ≥7.29.4 fixes GHSA-fv7c-fp4j-7gwp; webpack, postcss, ts-eslint, etc.
- npm audit fix (non-breaking): 38 → 12 vulns; remaining are the electron-builder 25.x cluster and dev-only webpack-dev-server transitives, handled/deferred separately

Note: legacy-peer-deps kept intentionally (load-bearing for CI npm ci / lockfile compatibility per release.yml). React @types already realigned to 18.x on develop.
Verified green: typecheck:all, 170/170 unit tests, lint (no new warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ling backup

atomicWrite did writeFile→rename with no fsync, so a power loss within the OS
writeback window could leave a truncated JSON primary. The .backup the reader
(readJsonWithBackup) relies on was only created inside the Windows EEXIST/EPERM
rename fallback, which POSIX rename never triggers, so on macOS/Linux no backup
was ever written and corrupt-primary recovery had nothing to restore from.

Now both async and sync paths:
- write the temp file and fsync it before touching the primary
- copy the current good primary to <file>.backup before the atomic rename
- fsync the containing directory after the rename
Directory fsync and the backup copy are best-effort (never abort the write);
the Windows rename fallback is preserved.

TDD: added 4 tests (rolling backup + fsync, async and sync). Full suite 174/174, typecheck:all + lint green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on privileged windows

The dashboard and projector windows hold the full nwWrldBridge (workspace fs,
JSON store, openExternal) but had no setWindowOpenHandler and no will-navigate
guard, so a stray or injected link/navigation could load remote content into a
filesystem-capable renderer. Both windows now:
- deny window.open, routing genuine http(s) links through the validated
  normalizeOpenExternalUrl + shell.openExternal path (same allow-list as os IPC)
- prevent will-navigate / will-redirect away from bundled local content
  (file:/nw-sandbox:/nw-assets:/devtools:/about:blank), which a file:// SPA
  using hash/history routing never triggers, so no in-app flow regresses.

TDD: extended the windows runtime harness (webContents open-handler + nav
listeners + shell) and added a test asserting deny + external-nav block +
file:// allow on both windows. Full suite 175/175, typecheck:all + lint green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…en overlay is closed

addDebugLog (and the from-projector debug-log path) called setDebugLogs on every
inbound MIDI/OSC event regardless of whether the debug overlay was open, forcing a
re-render of every debugLogs consumer on the live input hot path.

Entries are now always retained in a ring buffer (cheap, no re-render) and a
re-render is only triggered while the overlay is open; the buffer is flushed into
state when the overlay opens. Observable behaviour is unchanged (the overlay still
shows the last 200 entries); only the wasted re-renders while closed are removed.

Verified: typecheck:all + lint green (no new warnings). Dashboard hook is webpack-lane (not node-testable); logic preserves the existing last-200 semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nder loop

getSequencerForTrack (which walks recordingData) was called inside every one of
the 16 step buttons for every channel row, i.e. 16xN times per NoteSelector render
- and NoteSelector re-renders every step during playback (the playhead prop).
The lookup depends only on recordingData + track.id, so it is now memoized once
per render and channelPattern is derived from it. Pure refactor: identical values,
no behaviour change.

Verified: typecheck:all + lint green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hreeBase destroy

BaseThreeJsModule added a window 'resize' listener in its constructor but never
removed it in destroy(), and disposed the renderer without forcing context loss.
Across same-sandbox instance churn (e.g. matrix re-configuration, which destroys
and recreates instances without recycling the sandbox process), each dead instance
stayed pinned by its resize closure and its WebGL context lingered until GC.
destroy() now removes the resize listener and calls renderer.forceContextLoss()
before dispose().

Verified: typecheck:all green; full webpack production build compiles (dashboard,
projector, moduleSandbox bundles all emit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The release-publishing action ran from the mutable @v2 tag while holding the
contents:write token, so a repointed tag (maintainer or attacker) could tamper
with published artifacts or exfiltrate the token. Pinned to the commit SHA the v2
tag currently resolves to (3bb12739...), which is behaviourally identical to @v2.
First-party actions/* remain on @v4 (lower risk); pinning those is a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aged builds

The signed app shipped with Electron's RunAsNode, EnableNodeCliInspectArguments and
EnableNodeOptionsEnvironmentVariable fuses enabled, so a local process could relaunch
the notarized binary as a generic Node runtime (ELECTRON_RUN_AS_NODE), attach a
debugger (--inspect), or inject NODE_OPTIONS, running arbitrary code inside the app's
code-signing identity and entitlements. Added an electron-builder afterPack hook
(scripts/afterPack.js) that flips these three fuses off before signing, via
@electron/fuses. OnlyLoadAppFromAsar / asar-integrity fuses intentionally deferred
(they interact with asar signing and need their own test).

Verified: unsigned pack:mac:arm64 --dir succeeds and afterPack flips the fuses on the
packaged binary. NEEDS a signed+notarized build test before release (confirm launch,
and that ELECTRON_RUN_AS_NODE no longer works).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hardened-runtime entitlements granted com.apple.security.cs.disable-library-validation,
which lets the signed app load unsigned/third-party dylibs and weakens the hardened
runtime. Removed it from both entitlement plists; kept allow-jit and
allow-unsigned-executable-memory (required for V8 JIT) and device.audio-input (the mic
capture feature). No dependency appears to load external unsigned dylibs.

Verified: plists remain valid (packagingConfig test green); unsigned --dir pack succeeds.
NEEDS a signed+notarized build launch test (confirm the app starts and audio capture
works) before release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… build CVEs

electron-builder 25.x pulled ~9 high-severity advisories via app-builder-lib,
@electron/rebuild, node-gyp, tar, etc. (build-time only, not shipped, but flagged by
npm audit). Bumped electron-builder, dmg-builder and electron-builder-squirrel-windows
together to 26.8.1. npm audit: 12 to 3 (the remaining 3 are dev-only webpack-dev-server
transitives needing a breaking major).

Verified: typecheck:all + 175 unit tests + lint green; unsigned pack:mac:arm64 --dir
packages successfully on eb 26 with the afterPack fuses applied. NEEDS a full
signed+notarized release build (dist:mac/win/linux) before release: eb 26 changed parts
of the signing/notarization path that --dir does not exercise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nal rejection

Adversarial review of the branch surfaced two issues:
- afterPack used productFilename (nw_wrld) for the Linux binary, but electron-builder
  names the Linux executable from the package name (nw-wrld), so flipFuses hit ENOENT
  and would abort every Linux build and the Linux release CI job. The Linux branch now
  uses packager.executableName (sanitizedName.toLowerCase()). macOS/Windows paths were
  already correct and verified.
- the window-open handler caught only synchronous throws from shell.openExternal; its
  promise rejection is now swallowed too (mirrors registerOsBridge).

Verified: typecheck:all + 175 unit tests + lint green; unsigned --dir packs now succeed
on macOS AND Linux, afterPack flipping fuses on the correct binary (release/linux-unpacked/nw-wrld).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
eslint.config.mjs already runs on eslint 8 with both packages resolved transitively, so the 8->9 bump was currency-only and added two avoidable direct deps. Revert to eslint ^8 and remove the companions; lint stays green (0 errors).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codebase is near-zero comments (sandbox.ts: 633 lines, 0; all untouched test files: 0). Reduce the multi-line explanatory blocks to terse one-liners, keeping only genuine gotchas (Windows rename, Linux exec name, dir fsync). threeBase.ts keeps short notes matching the projector layer's local style. atomicWrite.ts is comments-only; logic unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Creating or switching to a set with no selected track sends reload-data with trackName: null. The projector fell back to the previously active track's name (props.trackName || this.activeTrack?.name), then tried to select that track in the new set, surfacing "Track <name> not found" on the projector. Now a null/empty trackName clears lastRequestedTrackName, deactivates, and shows "No tracks in this set" / "No track selected". Decision extracted to a pure, unit-tested helper (resolveReloadTarget, 5 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AsteroidGraph's loadMeteors (executeOnLoad) awaits an asset load, but unlike the working PerlinBlob it had no 'destroyed' guard. During the projector's deactivate/re-init churn the async load resolves on an already-destroyed instance, orphaning a p5 canvas (flaky removal) and leaving the live instance's meteors unpopulated (blank on add); preview/refresh create a clean single instance so they render. Mirror PerlinBlob's lifecycle: set destroyed=false in the constructor, bail after the await and in p5 setup/draw, and set destroyed=true first in destroy(). Unit test (RED->GREEN) covers the post-destroy guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…from the saved constructor

Confirmed from the real save folder: set2/aaa AsteroidGraph was saved with constructor [matrix, show] - loadMeteors (its executeOnLoad data loader) was missing, so the sandbox (which only ran the saved constructor methods) never populated this.meteors and drew nothing. handleAddToTrack drops executeOnLoad methods when a module is added before its introspection has populated module.methods; only AsteroidGraph showed it because its dropped method loads render DATA (other modules' dropped executeOnLoad methods are cosmetic, so they still draw). Fix: at initTrack and setMatrixForInstance, merge the module class's declared executeOnLoad methods (with default options) into the run list when absent from the saved constructor, running loaders first. Pure helper resolveConstructorRunList with 5 tests; verified against the real save ([matrix, show] -> runs [loadMeteors, show]). Fixes existing saves with no recreation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove modules, exports, types and assets confirmed unused by repo-wide
grep (no runtime, test or build references):

- delete orphaned files ThreeTemplate.ts, SequencerGrid.tsx (and its dead
  helpText key) and ProgressBar.tsx
- drop the unused helpTextAtom (recordingStateAtom is still used, kept)
- drop dead midiUtils exports (MIDI_INPUT_NAME, NOTE_TO_CHANNEL,
  noteNameToNumber, buildChannelNotesMap, buildTrackNotesMapFromTracks) and
  the now-orphaned CHANNEL_NOTES table
- drop the unwired per-track signal-settings type island in userData.ts
- delete unused assets (low-earth-orbits-objects.json, three-font.json) and
  the unreferenced Roboto Mono static font weights

No behaviour change. typecheck, test:unit, lint and build all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… style

- ErrorBoundary: default export to named export, matching the rest of the
  component layer; update the single import site
- audio/device/file dashboard hooks: export function to export const, to
  match the other hooks
- ipcFromDashboard and methodExecutor: route console.* through the projector
  logger (silences debug logs in packaged builds; logger.error keeps the
  same always-on behaviour as console.error)

No behaviour change. typecheck, test:unit, lint and build all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correct inaccuracies found by auditing the docs against the code, and trim
duplication:

- starter modules: 16 to 22, with correct names and categories
- README: fix the Electron badge (39.8.10) and source tree (.ts paths, drop
  src/renderer.ts and the deleted template, describe the real main process),
  complete the allowed-imports list, fix the built-in-methods anchor;
  de-duplicate the project tree, DAW and external-modes sections
- GETTING_STARTED: rewrite the non-existent "Monaco" editor step to match the
  read-only viewer, fill the libasound stub, point install at the README
- CONTRIBUTING: Node v20, double quotes and semicolons (Prettier), target
  develop, add the automated test commands, split validated vs recommended
  module rules, version 0.5.0-beta
- MODULE_DEVELOPMENT: add listAssets, correct the seeded-assets list, condense
  the generic performance section to nw_wrld specifics
- RUNTIME_TS_TESTING: repoint IPC-handler wiring to
  src/main/mainProcess/ipcBridge/*; consolidate the dist/runtime vs src rule
- add E2E_TESTING_GUIDELINES to the PR and copilot context lists

British English, no em dashes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… cached

Every module method trigger paid an extra sandbox:ensure IPC invoke, an
fs.statSync, and a BrowserView re-attach + setBounds in main before the
actual sandbox:request. Cache the token in TrackSandboxHost and only
ensure when absent; on an out-of-band token invalidation (sandbox
crash/respawn) re-ensure and resend the same payload once, which lands
in the same INSTANCE_NOT_FOUND end state the per-request ensure produced.
Resize re-attach is unaffected: the projector window's resize handler
already calls updateSandboxViewBounds directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…three.js renders

buildMidiConfig walked every track and channel mapping on every input
event, before the sequencer-mode and source early-outs. Add
createMidiConfigCache (reference-keyed on userData/config/input type,
both reassigned wholesale in loadUserData) and build only after the
drop checks.

threeBase rendered the scene 2-4 times per rAF tick while a camera
animation or damping was active: updateCameraAnimation ran its own
controls.update() + render() microseconds before animate() ran the same
pair, and the OrbitControls "change" listener rendered again from inside
controls.update(). Intermediate renders in the same tick are never
composited, so drop the duplicate pair and gate the change listener to
the pre-loop phase (modules that never call setModel keep rendering on
interaction).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sequencer playhead, flash setters, projector perf stats, and audio
levels all lived in (or subscribed from) the root Dashboard component,
so every 16th-note step, channel flash, 1Hz perf heartbeat, and 100ms
audio-level update re-rendered the entire dashboard, including the
eagerly-evaluated bodies of every closed modal.

- sequencerCurrentStep moves to an atom written by the playback hook and
  read only by a new memoised SequencerStepRow, dropping the prop from
  the Dashboard -> Body -> TrackItem -> SortableModuleItem -> NoteSelector
  chain (which also restores TrackItem's memo during playback).
- useFlashingChannels becomes setter-only (no caller used the value) and
  setter-only useAtom subscriptions switch to useSetAtom in Dashboard and
  TrackItem, including TrackItem's unused userDataAtom value.
- Projector perf stats park in a ref while the debug overlay is closed
  and seed the state on open.
- Audio capture/file level updates bail with the previous state when all
  six values are unchanged (kills steady churn during silence).
- SettingsModal (1001 lines) returns null before building its JSX when
  closed; DashboardFooter drops a recordingData subscription whose value
  was never read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pure track or set switch re-fired the debounced userData save with an
unchanged reference, paying stringify + atomic write + fsync + backup
copy for identical bytes, then a reload-data broadcast that made the
projector sync-read the whole file on its render thread. Skip the save
(and broadcast) when the userData reference matches the last save; all
mutations flow through immer so changed data always has a new reference,
and the existing post-save reload-data recovery for freshly created
tracks is unaffected.

set-activate now only fires when the set actually changed; same-set
track switches use track-activate alone, the exact flow MIDI-driven
track selection has always used, removing a second synchronous
userData.json read per switch.

The appState read-modify-write per switch/mute now reads disk once and
spreads from the in-memory copy (this hook and the unload flush are the
only writers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In any external input mode the footer's bottom right now shows the last
matched track selection and method trigger (TRACK / METHOD). Values are
written from useInputEvents only when a trigger actually fires and are
read by a memoised leaf component, so per-event updates re-render the
readout alone. Covered by footer-input-activity e2e (mock MIDI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clone icons next to the edit/delete controls in the set and track
modals, and next to the eye toggle on each module. Copies get a
"(Copy)" name with case-insensitive collision handling, fresh ids
throughout (set, track, module instance, with modulesData remapped),
the next free track slot for same-set track copies (alert when full),
and duplicated recordingData entries so sequencer patterns and
recordings carry over. Pure helpers in shared/utils/duplicateUtils.ts
(runtime lane, 8 unit tests) plus duplicate-set-track-module e2e.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- remove unused DraftFloatInput, FileInput, cleanupStaleTempFiles, getJsonDir
- drop leftover viewport-line console.log; fix stale JSDoc/header in moduleBase
- correct BPM range claim in README/GETTING_STARTED (no 60-130 clamp exists)
- rename optionValidator -> optionValidation to match validation/ convention
- route useDashboardUpdateConfig through the updateUserData wrapper
- rename Modal onCloseHandler -> onOverlayClick; NewModuleDialog local Modal -> DialogShell
- rename generic SortableItem -> SortableMethodItem; handleVisibilityChange naming
- add parseInt radix at 5 sites; replace deprecated substr; flatten else-after-return

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant