feat(scripts): script bundle import/export (#3245) - #3276
Conversation
Adds a versioned JSON script-bundle format with export, preview, and import under /scripts/bundle/*, plus web UI and docs, so a script library can move between Breeze instances (or in from another RMM) with its metadata intact. API: - services/scriptBundle/schema.ts: v1 bundle Zod schema. Untrusted-input hardening at intake: no tenancy/trust fields (unknown keys stripped, so isSystem/orgId/partnerId/id/createdBy in an uploaded file cannot carry through), parameters bounded by size (64KB) and depth (8) instead of the route schema's z.any(), all-null exitCodeSeverityMapping rejected, unknown bundleVersion rejected, caps on scripts-per-bundle (200) and content size (256KB). - services/scriptWrite.ts: NEW service-layer chokepoint for script creation, extracted from POST /scripts. Carries the tenancy resolution, the #3262 partner-wide capability gate (canManagePartnerWidePolicies), and the isSystem clamp; both POST /scripts and the bundle importer write through it so the two intakes cannot diverge (addresses the #3263 review finding that the gate had no service-layer chokepoint). - services/scriptBundle/index.ts: exportBundle (no tenancy identifiers, isSystem always absent), previewBundle (new/name-conflict, no writes), importBundle (skip/rename/new-version modes, tag resolution by name, per-entry failure isolation). The importer never passes requestedIsSystem, so a bundle can never create a system script at any caller scope - stricter than POST /scripts. - routes/scriptBundle.ts: GET /scripts/bundle/export, POST /scripts/bundle/preview, POST /scripts/bundle/import. Same scope/permission/MFA gating as the script write routes; route-level fail-fast on availability:'partner' (only partner scope with the capability); availability defaults to 'org'; every imported script is audited individually with the bundle's sha256. - middleware/bodyLimit.ts: 20MB carve-out for bundle import/preview. Web: - lib/scriptBundle.ts: client-side conversion of loose .ps1/.sh/.py/.bat files (or a folder) into a bundle - the server keeps a single JSON intake path (same split as the #3242 CSV handling). - components/scripts/ScriptBundleImport.tsx: export multi-select -> download .json; import with preview table, conflict-mode selector, partner-wide option gated on canManagePartnerWide, runAction commit, and an explicit scripts-run-as-SYSTEM warning. - i18n keys for all seven locales. Docs: bundle section in features/scripts.mdx (format, caps, trust model). No new tables; imported rows are ordinary scripts rows, so the existing abuse-signal sweep covers them by construction. Closes #3245 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying breeze with
|
| Latest commit: |
b6a3ac4
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://0e5766cf.breeze-9te.pages.dev |
| Branch Preview URL: | https://feat-3245-script-bundle-impo.breeze-9te.pages.dev |
Code-review round on #3276 raised 10 findings; this addresses the consequential ones: - Conflict lookups now filter is_system = false, so a bundle import in 'new-version' mode can never match — and therefore never rewrite — a system-library script that shares a name with a tenant script (the edit PUT /scripts/:id rejects as read-only). Asserted by walking the generated WHERE condition in tests. - System-scope import/preview without an orgId is rejected (400) instead of resolving to { orgId: null, partnerId: null } — which produced rows invisible to every tenant and dead conflict detection via a `partner_id = NULL` comparison. - Per-entry validation: routes validate only the bundle ENVELOPE (version + bounded array); entries are parsed individually in the service, so one oversized/invalid entry fails alone (preview status 'invalid', import errors[]) instead of rejecting the whole bundle. - findFreeName resolves all 100 rename candidates with ONE query per entry instead of up to 100 sequential probes (a fully-conflicting 200-entry bundle would otherwise issue ~20k SELECTs against the web client's 30s timeout). - 'new-version' with byte-identical content is now an idempotent skip — re-running the same bundle no longer pads version history with no-op snapshots. - findExistingByName orders by created_at so duplicate names resolve deterministically to the oldest row. - Web: stale preview is cleared before re-preview/file re-selection, so a failed preview can't leave Import armed for a bundle or scope the conflict table was never computed against; export modal fetches the full library itself (the page state holds only page 1 of 50) with a 200-script selection cap; partner-wide checkbox is shown only to partner-scope users (system scope would always 403); stray .json files in a picked folder are skipped instead of aborting the folder import, and the converter no longer emits hardcoded English prose. Deliberately not taken (noted for follow-up): shared Dialog adoption, downloadBlob/runAction unification for the export GET, shared enum reuse from @breeze/shared, audit-insert batching, and an expected-state contract between preview and import (the sibling org-import pipeline's TOCTOU posture) — cleanups without a correctness hole in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Review run: /code-review at high effort (8 finder angles: 7 review subagents + CLAUDE.md-conventions pass; 1-vote recall-biased verification; one candidate refuted — the suspected export partner-axis leak matches pre-existing |
Summary
Implements #3245 (RMM-migration epic #3249): a versioned JSON script bundle format with export, preview, and import under
/scripts/bundle/*, web UI on the Scripts page, and docs — so a script library can move between Breeze instances (or in from another RMM) carrying parameters, categories, tags, timeouts, run-as levels, and exit-code severity mappings instead of being pasted in one script at a time.Plan:
docs/superpowers/plans/open/2026-08-08-script-bundle-import-export.md· Spec:docs/superpowers/specs/onboarding-signup/2026-08-08-script-bundle-import-export-design.mdSecurity posture (the point of this PR)
A bundle is untrusted input whose contents run as SYSTEM on customer endpoints:
services/scriptBundle/schema.ts): the schema has no tenancy or trust fields —isSystem,id,orgId,partnerId,createdByin an uploaded file are stripped and never read.parametersis bounded at intake (64KB serialized + depth 8) instead of inheriting the route schema'sz.any()(whose 64KB cap is execute-time only). An all-nullexitCodeSeverityMapping(never-alert) is rejected. UnknownbundleVersionis rejected, never best-effort parsed. Caps: 200 scripts/bundle, 256KB/content, 20MB body (newbodyLimitcarve-out).isSystemcan never come from a bundle, at any caller scope — stricter thanPOST /scripts. The importer never passesrequestedIsSystem, and the clamp lives in the shared service (below), not the route.canManagePartnerWidePolicieslived only in route handlers with no service-layer chokepoint, naming this importer as the likeliest path to reopen the privilege escalation. This PR extracts thePOST /scriptstenancy resolution + capability gate +isSystemclamp intoservices/scriptWrite.tsand routes BOTHPOST /scriptsand the bundle importer through it.availability: 'partner'requirescanManagePartnerWidePolicies(auth)and setspartnerId = auth.partnerId/orgId = NULL; the import/preview routes additionally fail fast withPARTNER_WIDE_WRITE_DENIED_MESSAGE(403) before touching any entry. Default availability is'org'.script.bundle.importaudit event tagged with the bundle's sha256, mode, and availability, so a later abuse finding traces to the import that introduced it. Imported rows are ordinaryscriptsrows, so the existing abuse-signal sweep covers them by construction.isSystem— a round-trip cannot launder system scripts back in.What's included
GET /scripts/bundle/export?ids=…,POST /scripts/bundle/preview,POST /scripts/bundle/import(mode: skip | rename | new-version), mounted before the/:idroutes; same scope/permission/MFA gating as the script write routes. Tag resolution by name in the target scope (reuse existing, create missing);new-versionsnapshots the previous content intoscript_versionsbefore bumping. Per-entry failures are recorded and the rest proceed..json; import accepts a.jsonbundle, loose.ps1/.sh/.py/.batfiles, or a folder (converted client-side — the server keeps one JSON intake). Preview table with conflict statuses, mode selector, partner-wide option gated onuser.canManagePartnerWide, commit viarunAction, and an explicit "scripts run as SYSTEM — trusted sources only" warning. i18n for all seven locales.features/scripts.mdx(format, caps, unsigned-trust model).Tests
services/scriptBundle/index.test.ts(20): schema hardening (strip/bounds/all-null mapping/version), import modes, tag resolution, tenancy-ignore, isSystem ignored even for a system-scope caller, service-level partner-gate denial, export cleanliness + schema round-trip.services/scriptWrite.test.ts(10): scope resolution matrix incl. the [API][Security] POST /scripts allows partner-wide script creation without canManagePartnerWidePolicies — 'selected'-access users can push SYSTEM-level code to every org #3262 gate; isSystem clamp incl. the no-option bundle path.routes/scriptBundle.test.ts(11): 403 for a non-capability partner user importingavailability:'partner'(nothing written), org-scope 403, partner-wide row shape for a full-partner admin, default-'org', isSystem/tenancy strip over HTTP, unknown version 400, per-script audit with sha256, export shape, preview gate.middleware/bodyLimit.test.ts: 20MB carve-out.lib/scriptBundle.test.ts(8) for loose-file conversion; locale parity + translation coverage green.Local results: API affected suite 98 passed / 2 pre-existing skips (single-fork); web 71 passed;
tsc --noEmitclean for both apps/api and apps/web; eslint clean on changed files.Note:
routes/scripts.execute-schema.test.tscrashes at import in this dev environment on clean main too (Node 20 vs required 22 toolchain issue) — unrelated to this change; CI runs it normally.Known gaps / follow-ups
apps/docs/.../migration/toolkit.mdx(Recipe 6 replacement, plan Task 8) lives on the unmerged docs branch (PR docs(migration): RMM-to-Breeze migration guides #3250), not main — it should be updated there once these routes land.scripts.categoryvarchar (by name); thescript_categorieshierarchy table is not populated by import (the real scripts routes don't use it either — the hierarchical library routes are still mock-backed).Closes #3245
🤖 Generated with Claude Code