Skip to content

feat(shell): AppForge-2 — install an app from vault code files - #366

Merged
th3-br41n merged 1 commit into
mainfrom
feat/appforge-2-install-from-vault
Jul 29, 2026
Merged

feat(shell): AppForge-2 — install an app from vault code files#366
th3-br41n merged 1 commit into
mainfrom
feat/appforge-2-install-from-vault

Conversation

@th3-br41n

Copy link
Copy Markdown
Contributor

Closes the AppForge loop: the code files a user writes in the Code editor install as a real sandboxed app — no export, no disk round-trip. Builds on AppForge-1 (#364, now merged), reusing its installer path, provenance stamping, icon pin and typed-result posture.

The seam I chose (and why)

The verb lives in the DASHBOARD, not the Code editor. Marketplace → Install from…From vault code files… opens a shell-rendered picker of app candidates found in the vault's CodeFile/v1 rows.

The Code editor has no files caps and must not gain any. The alternatives were an intent verb or a cap-gated broker method that forwards ids — both add a new app-reachable surface whose only job is to reach a privileged action, and both still need the shell to own the confirm (an app can be driven, so app-side consent is worthless). The dashboard placement gets the same user flow with zero new app-side surface: the picker, the consent, and the install all run in the trusted shell, and the Marketplace is already the place installs happen. If a Code-editor-side entry point is wanted later it can dispatch to this exact handler without changing the trust story.

What landed

Two new privileged, dashboard-only ipcMain.handle channels (same event.sender === dashboard.webContents gate as #364):

  • apps:list-vault-app-sources — groups CodeFile/v1 rows into candidates (one per manifest.json, carrying everything under its folder), parses + validates each manifest server-side, returns name/id/version/capabilities or a problem string.
  • apps:install-from-vault — input is entity ids only. Type, path, and content are resolved server-side from the entity rows (per the resolve-entity-type-server-side rule — a Note id claimed as a CodeFile is refused). Files are materialised into a fresh mkdtemp staging dir, then the SAME AppInstaller.install({ bundleDir, provenance: { origin: InstallOrigin.LocalFile, … } })placeDashboardIcon → typed SideloadInstallResult. Staging dir removed in a finally.

The shell-side confirm reuses the existing trust chrome (shared confirm + the update-consent pattern — not forked): app name, id, version, requested capabilities, and the Unsigned advisory. A candidate with an invalid manifest renders disabled with the validator's reason.

Path sanitisation (the new trust edge)

CodeFile.path is a vault string becoming a filesystem path for the first time, so sanitizeVaultRelPath is fail-closed — it allowlists one shape rather than scrubbing:

Rule Rejects
non-empty string undefined / null / non-string / ""
no absolute form /etc/passwd, C:/x, c:\x
no backslash a\b.js (a separator on Windows — ambiguity is refused, not normalised)
no . / .. / empty segments ../up.js, a/../../up.js, a/./b.js, a//b.js
no control chars NUL, \n, DEL
bounded ≤ 512 chars, ≤ 16 segments

On top of that, before anything touches disk: ids validated against isSafeEntityId with no duplicates; ≤ 256 files; ≤ 4 MiB per file and ≤ 32 MiB total; exactly one shallowest manifest.json (two roots → no-manifest); every file must live under that root (root prefix stripped so the bundle root is the app root); duplicate and file-vs-directory-conflicting relative paths rejected case-insensitively (macOS/Windows would silently clobber); and a resolve() containment re-check per write so nothing can leave staging even if the sanitiser were bypassed. The candidate scan itself is row-bounded.

New typed failure codes: bad-request, not-code-files, bad-path, no-manifest, source-too-large — surfaced as distinct toast strings.

Tests

packages/shell/src/main/ipc/sideload-handlers.test.ts — 23 cases total (13 from #364, 10 new), real DataStores / EntitiesRepository / CapabilityLedger / DashboardStore / AppInstaller, only Electron mocked:

  • happy two-file install from real vault entities → registry row with local-file provenance, pinned icon, unsigned surfaced, the installed bundle's bytes match the sources, and the source entities are untouched (the Code editor can re-open them), staging dir cleaned up
  • type spoof (a Note id claimed as a CodeFile) and an id that isn't in the vault → not-code-files
  • traversing / absolute / backslash / drive-letter / // / /./ / empty paths → bad-path, with a check that nothing escaped into the temp root
  • duplicate paths (case-insensitive) → bad-path; duplicate ids → bad-request
  • file outside the app root → bad-path; two root manifests → no-manifest
  • no manifest → no-manifest; unparseable manifest → bad-manifest
  • oversized file and oversized selection → source-too-large
  • malformed requests (undefined, string, [], non-string id, traversing id, over-long id) → bad-request
  • non-dashboard sender → not-allowed; no vault session → no-vault-session (both channels)
  • listing: candidates with parsed manifests + capabilities, a broken-manifest candidate carrying its problem, a traversing candidate dropped, listed ids install as-is, foreign sender refused
  • a sanitizeVaultRelPath table over every accept/reject shape

bun run verify green (36 files / 418 tests), bun run typecheck (packages + apps) green, bun run lint green (all ratchets pass), targeted marketplace + sideload suites green (41 tests).

i18n

New shell-renderer strings in en.json (ICU plural for the file count) with matching entries in en.descriptions.json — description coverage stays at zero uncovered. No app-side strings (no app surface was added).

Review

🔒 Flagging for security review — vault-content → filesystem materialisation is a new trust edge: an attacker-authored (or sync-delivered) CodeFile.path is the first vault string this codebase turns into a real file path. The sanitiser, the containment re-check, the bounds, and the id-only wire contract are the mitigations; please attack them.

🤖 Generated with Claude Code

Close the "an app built inside Brainstorm becomes a real app" loop: the
code files a user writes in the Code editor install as a real sandboxed
app, with no disk round-trip.

Two new privileged, dashboard-only channels alongside AppForge-1's:

  - `apps:list-vault-app-sources` — groups the vault's `CodeFile/v1`
    rows into install candidates (one per `manifest.json`, carrying
    everything under its folder) and parses each manifest server-side.
  - `apps:install-from-vault` — takes a list of entity **ids only**,
    resolves type/path/content server-side from the entity rows,
    materialises them into a fresh `mkdtemp` staging dir, and runs the
    SAME `AppInstaller.install({ bundleDir, provenance: LocalFile })`
    → icon pin → typed result. Staging removed in a `finally`.

The new trust edge is a vault string becoming a filesystem path for the
first time, so `sanitizeVaultRelPath` is fail-closed: relative `/`-form
only — no absolute, drive-letter, backslash, `.`/`..`/empty segment or
control character — bounded to 512 chars / 16 segments, with a resolve()
containment re-check at write time. On top: 256-file / 4 MiB-per-file /
32 MiB-total bounds, case-insensitive duplicate + file-vs-directory
conflict rejection, a single-root-manifest requirement, and every file
required to live under that root. New typed codes (`bad-request`,
`not-code-files`, `bad-path`, `no-manifest`, `source-too-large`).

UI seam: the affordance lives in the DASHBOARD (Marketplace →
"Install from…" → "From vault code files…"), so the Code editor gains
no new capability surface and the shell — not an app — drives consent.
The pre-install confirm reuses the shared `confirm` + update-consent
chrome: app name, id, version, requested capabilities, unsigned
advisory.

Tests: 10 new cases in `sideload-handlers.test.ts` (real DataStores /
EntitiesRepository / CapabilityLedger / AppInstaller, mocked Electron)
— happy two-file install asserting the installed bundle bytes AND that
the source entities are untouched, type-spoof refusal, traversal /
absolute / backslash / dup-path refusals, outside-root, two-root,
no-manifest, oversized file + selection, malformed request, foreign
sender, plus a `sanitizeVaultRelPath` table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@th3-br41n
th3-br41n merged commit 7b769d1 into main Jul 29, 2026
3 checks passed
@th3-br41n
th3-br41n deleted the feat/appforge-2-install-from-vault branch July 29, 2026 14:59
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