@checkstack/script-packages-backend 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ import { spawn } from "bun";
2
+ import { mkdir, writeFile, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type {
5
+ AuditAdvisory,
6
+ PackageSpec,
7
+ } from "@checkstack/script-packages-common";
8
+ import { buildDependencies, buildStorePackageJson } from "./lockfile";
9
+ import { renderNpmrc, type NpmrcInput } from "./npmrc";
10
+ import { parseBunAudit } from "./audit-parse";
11
+
12
+ /**
13
+ * Minimal subset of Bun's `spawn` the scanner relies on. Injectable so tests
14
+ * can assert on the spawn options (e.g. `BUN_INSTALL_CACHE_DIR`) without
15
+ * launching a real process.
16
+ */
17
+ export interface SpawnHandle {
18
+ // The scanner always spawns with `stdout`/`stderr: "pipe"`, so both are
19
+ // readable streams (never fds).
20
+ stdout: ReadableStream<Uint8Array>;
21
+ stderr: ReadableStream<Uint8Array>;
22
+ exited: Promise<number>;
23
+ }
24
+ export interface SpawnOptions {
25
+ cmd: string[];
26
+ cwd: string;
27
+ env: Record<string, string | undefined>;
28
+ stdout: "pipe";
29
+ stderr: "pipe";
30
+ }
31
+ export type SpawnFn = (options: SpawnOptions) => SpawnHandle;
32
+
33
+ /**
34
+ * Runs `bun audit --json` against the resolved script-packages tree, reusing
35
+ * the EXACT scratch / `.npmrc` / registry-config setup the installer's
36
+ * {@link createCentralResolver} uses (same `package.json`, same token-bearing
37
+ * `.npmrc`, same throwaway-scratch cleanup) so audit and install never drift
38
+ * on registry/auth config. The audit reports purely from the lockfile against
39
+ * the advisory DB - no install scripts run.
40
+ *
41
+ * The token-bearing scratch dir is removed on EVERY exit path (success OR
42
+ * throw) so the plaintext registry token never lingers on disk.
43
+ */
44
+
45
+ export interface AuditScannerOptions {
46
+ /** Scratch dir for the audit (created + removed per scan). */
47
+ scratchDir: string;
48
+ /**
49
+ * Dedicated Bun cache dir to install into - MUST be the same
50
+ * `BUN_INSTALL_CACHE_DIR` the installer's resolver uses (`paths.cache`), so
51
+ * the audit reuses already-fetched packages instead of re-downloading into
52
+ * Bun's default global cache and drifting from the install path.
53
+ */
54
+ cacheDir: string;
55
+ /** Registry config (token already resolved from the secret store). */
56
+ registry: NpmrcInput;
57
+ /** Injectable spawn (defaults to Bun's `spawn`); for tests. */
58
+ spawnFn?: SpawnFn;
59
+ }
60
+
61
+ export interface AuditScanResult {
62
+ advisories: AuditAdvisory[];
63
+ }
64
+
65
+ function bunfigDisableAutoInstall(): string {
66
+ return '[install]\nauto = "disable"\n';
67
+ }
68
+
69
+ export interface AuditScanner {
70
+ scan(input: {
71
+ packages: PackageSpec[];
72
+ ignoreScripts: boolean;
73
+ }): Promise<AuditScanResult>;
74
+ }
75
+
76
+ export function createAuditScanner(options: AuditScannerOptions): AuditScanner {
77
+ return {
78
+ async scan({ packages, ignoreScripts }) {
79
+ const { scratchDir, cacheDir, registry } = options;
80
+ const spawnFn: SpawnFn = options.spawnFn ?? ((o) => spawn(o));
81
+
82
+ // No enabled packages → nothing to audit.
83
+ if (Object.keys(buildDependencies(packages)).length === 0) {
84
+ return { advisories: [] };
85
+ }
86
+
87
+ await rm(scratchDir, { recursive: true, force: true });
88
+ await mkdir(scratchDir, { recursive: true });
89
+ await mkdir(cacheDir, { recursive: true });
90
+
91
+ await writeFile(
92
+ path.join(scratchDir, "package.json"),
93
+ buildStorePackageJson(packages),
94
+ );
95
+ await writeFile(path.join(scratchDir, ".npmrc"), renderNpmrc(registry));
96
+ await writeFile(
97
+ path.join(scratchDir, "bunfig.toml"),
98
+ bunfigDisableAutoInstall(),
99
+ );
100
+
101
+ try {
102
+ // Install (writes bun.lock from the fully-pinned set) so audit has a
103
+ // lockfile to report against. `--ignore-scripts` keeps the
104
+ // supply-chain guardrail: audit reads the lockfile + advisory DB, it
105
+ // does not execute package code.
106
+ const installArgs = ["install"];
107
+ if (ignoreScripts) installArgs.push("--ignore-scripts");
108
+ const install = spawnFn({
109
+ cmd: [process.execPath, ...installArgs],
110
+ cwd: scratchDir,
111
+ env: { ...process.env, BUN_INSTALL_CACHE_DIR: cacheDir },
112
+ stdout: "pipe",
113
+ stderr: "pipe",
114
+ });
115
+ const [installStderr, installExit] = await Promise.all([
116
+ new Response(install.stderr).text(),
117
+ install.exited,
118
+ ]);
119
+ if (installExit !== 0) {
120
+ throw new Error(
121
+ `bun install (for audit) failed (exit ${installExit}): ${installStderr.slice(0, 800)}`,
122
+ );
123
+ }
124
+
125
+ // `bun audit --json` exit code is unreliable (non-zero even on a
126
+ // clean tree in some Bun versions), so we IGNORE it and parse stdout.
127
+ const audit = spawnFn({
128
+ cmd: [process.execPath, "audit", "--json"],
129
+ cwd: scratchDir,
130
+ env: { ...process.env, BUN_INSTALL_CACHE_DIR: cacheDir },
131
+ stdout: "pipe",
132
+ stderr: "pipe",
133
+ });
134
+ const [stdout, stderr] = await Promise.all([
135
+ new Response(audit.stdout).text(),
136
+ new Response(audit.stderr).text(),
137
+ audit.exited,
138
+ ]);
139
+
140
+ try {
141
+ return parseBunAudit(stdout);
142
+ } catch (parseError) {
143
+ // Surface Bun's own stderr (never the .npmrc / token) to aid
144
+ // diagnosis when the output wasn't parseable JSON.
145
+ throw new Error(
146
+ `Could not parse bun audit output: ${(parseError as Error).message}${
147
+ stderr ? ` (stderr: ${stderr.slice(0, 400)})` : ""
148
+ }`,
149
+ );
150
+ }
151
+ } finally {
152
+ await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
153
+ }
154
+ },
155
+ };
156
+ }
package/src/hooks.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { createHook } from "@checkstack/backend-api";
2
2
  import {
3
3
  SCRIPT_PACKAGES_CHANGED_HOOK_ID,
4
+ SCRIPT_SANDBOX_POLICY_CHANGED_HOOK_ID,
4
5
  type ScriptPackagesChangedPayload,
6
+ type SandboxPolicyChangedPayload,
5
7
  } from "@checkstack/script-packages-common";
6
8
 
7
9
  /**
@@ -18,3 +20,15 @@ import {
18
20
  export const scriptPackagesChangedHook = createHook<ScriptPackagesChangedPayload>(
19
21
  SCRIPT_PACKAGES_CHANGED_HOOK_ID,
20
22
  );
23
+
24
+ /**
25
+ * Backend hook fired after a successful global sandbox-policy write.
26
+ *
27
+ * Core instances subscribe in `broadcast` mode (every pod receives it) and each
28
+ * pushes the new policy to its OWN connected satellites (push-on-change relay).
29
+ * Best-effort liveness; the durable policy row is the source of truth and a
30
+ * satellite re-pulls the relayed policy on (re)connect.
31
+ */
32
+ export const sandboxPolicyChangedHook = createHook<SandboxPolicyChangedPayload>(
33
+ SCRIPT_SANDBOX_POLICY_CHANGED_HOOK_ID,
34
+ );
package/src/index.ts CHANGED
@@ -8,10 +8,16 @@ import {
8
8
  } from "./registry-token";
9
9
  import {
10
10
  pluginMetadata,
11
+ scriptPackagesAccess,
11
12
  scriptPackagesAccessRules,
12
13
  scriptPackagesContract,
14
+ SCRIPT_PACKAGES_AUDIT_COMPLETED_SIGNAL,
15
+ type AuditRunSummary,
13
16
  type BlobGcSummary,
14
17
  } from "@checkstack/script-packages-common";
18
+ import type { SandboxPolicy } from "@checkstack/common";
19
+ import { AuthApi } from "@checkstack/auth-common";
20
+ import { NotificationApi } from "@checkstack/notification-common";
15
21
  import type { PluginMetadata } from "@checkstack/common";
16
22
  import { extractErrorMessage } from "@checkstack/common";
17
23
  import { blobStoreExtensionPoint, type BlobStore } from "./blob-store";
@@ -28,8 +34,11 @@ import {
28
34
  createBlobIndexStore,
29
35
  createBlobGcStateStore,
30
36
  createLockfileHistoryStore,
37
+ createAuditStore,
31
38
  } from "./stores";
32
39
  import { createBlobGcTrigger } from "./blob-gc-runner";
40
+ import { createAuditRunner } from "./audit-runner";
41
+ import { createAuditScanner } from "./audit-scanner";
33
42
  import {
34
43
  createInstallStateStore,
35
44
  createInstallerLock,
@@ -43,10 +52,23 @@ import { createCentralResolver } from "./resolver";
43
52
  import { resolveRegistryRequestConfig } from "./registry-request-config";
44
53
  import { createReconcileFsDeps } from "./reconcile-fs";
45
54
  import { reconcileToHash } from "./reconciler";
46
- import { scriptPackagesChangedHook } from "./hooks";
55
+ import { scriptPackagesChangedHook, sandboxPolicyChangedHook } from "./hooks";
56
+ import {
57
+ createSandboxPolicyService,
58
+ registerScriptPackagesSandboxProvider,
59
+ } from "./sandbox-policy";
60
+ import { logSandboxStartupReadiness } from "./sandbox-startup-log";
47
61
  import { createScriptPackagesRouter } from "./router";
48
62
  import { createTypeClosureHttpHandler } from "./type-acquisition-route";
49
- import { TYPE_ACQUISITION_PATH_PREFIX } from "@checkstack/script-packages-common";
63
+ import { createSdkTypesHttpHandler } from "./sdk-types-route";
64
+ import {
65
+ TYPE_ACQUISITION_PATH_PREFIX,
66
+ SDK_TYPES_PATH_PREFIX,
67
+ } from "@checkstack/script-packages-common";
68
+ import {
69
+ SDK_EDITOR_BUNDLE_DTS,
70
+ SDK_RELEASE_VERSION,
71
+ } from "@checkstack/sdk/editor-bundle";
50
72
  import * as schema from "./schema";
51
73
 
52
74
  interface EnvStash {
@@ -57,6 +79,14 @@ interface EnvStash {
57
79
  * Undefined until `afterPluginsReady` runs.
58
80
  */
59
81
  emitChanged?: (lockfileHash: string) => Promise<void>;
82
+ /**
83
+ * Set in `afterPluginsReady` (where `emitHook` exists) and called by the
84
+ * `setSandboxPolicy` handler (wired in `init`) after a successful policy
85
+ * write, so core instances broadcast the new policy to their satellites.
86
+ * Undefined until `afterPluginsReady` runs (a write before then still
87
+ * persists durably; satellites pick it up on next connect).
88
+ */
89
+ emitSandboxPolicyChanged?: (policy: SandboxPolicy) => Promise<void>;
60
90
  /** Registry token store (internal secrets), set in `init`. */
61
91
  registryToken?: RegistryTokenStore;
62
92
  /**
@@ -64,6 +94,12 @@ interface EnvStash {
64
94
  * Reused by the scheduled recurring job registered in `afterPluginsReady`.
65
95
  */
66
96
  triggerBlobGc?: () => Promise<BlobGcSummary>;
97
+ /**
98
+ * Vulnerability-audit trigger built in `init` (wires the scanner, stores,
99
+ * installer lock, and notification path). Reused by the scheduled recurring
100
+ * job registered in `afterPluginsReady`.
101
+ */
102
+ triggerAudit?: () => Promise<AuditRunSummary>;
67
103
  }
68
104
 
69
105
  export default createBackendPlugin({
@@ -86,17 +122,23 @@ export default createBackendPlugin({
86
122
  deps: {
87
123
  logger: coreServices.logger,
88
124
  rpc: coreServices.rpc,
125
+ rpcClient: coreServices.rpcClient,
126
+ signalService: coreServices.signalService,
89
127
  auth: coreServices.auth,
90
128
  advisoryLock: coreServices.advisoryLock,
91
129
  queueManager: coreServices.queueManager,
130
+ config: coreServices.config,
92
131
  internalSecrets: internalSecretsRef,
93
132
  },
94
133
  init: async ({
95
134
  logger,
96
135
  database,
97
136
  rpc,
137
+ rpcClient,
138
+ signalService,
98
139
  auth,
99
140
  advisoryLock,
141
+ config,
100
142
  internalSecrets,
101
143
  }) => {
102
144
  logger.debug("📦 Initializing Script Packages Backend...");
@@ -148,6 +190,89 @@ export default createBackendPlugin({
148
190
  });
149
191
  (env as unknown as EnvStash).triggerBlobGc = triggerBlobGc;
150
192
 
193
+ // Vulnerability-audit trigger: shared by the admin `auditNow` RPC and
194
+ // the scheduled recurring job. Holds the installer lock for the pass
195
+ // (mutually exclusive with installs / migrations / GC). Reuses the
196
+ // installer's registry/`.npmrc` resolution so audit + install never
197
+ // drift, and records advisories to the plugin's own Postgres tables
198
+ // (cluster-wide source of truth; the on-disk tree is pod-local).
199
+ const auditStore = createAuditStore(database);
200
+ const authClient = rpcClient.forPlugin(AuthApi);
201
+ const notificationClient = rpcClient.forPlugin(NotificationApi);
202
+ const triggerAudit = createAuditRunner({
203
+ installerLock,
204
+ auditStore,
205
+ loadCurrent: async () => {
206
+ const state = await installState.load();
207
+ const reg = await registry.get();
208
+ const list = await packages.list();
209
+ return {
210
+ lockfileHash: state.lockfileHash,
211
+ packages: list.map((p) => ({
212
+ name: p.name,
213
+ version: p.version,
214
+ enabled: p.enabled,
215
+ })),
216
+ ignoreScripts: reg.ignoreScripts,
217
+ };
218
+ },
219
+ scan: async ({ packages: pkgs, ignoreScripts }) => {
220
+ const reqConfig = await resolveRegistryRequestConfig({
221
+ registry,
222
+ registryToken,
223
+ logger,
224
+ });
225
+ const paths = storePaths(storeRoot);
226
+ const scanner = createAuditScanner({
227
+ scratchDir: path.join(paths.root, ".audit-scratch"),
228
+ // Same shared cache the installer's resolver uses, so the audit
229
+ // reuses already-fetched packages instead of re-downloading.
230
+ cacheDir: paths.cache,
231
+ registry: {
232
+ registryUrl: reqConfig.registryUrl,
233
+ scopedRegistries: reqConfig.scopedRegistries,
234
+ authToken: reqConfig.authToken,
235
+ },
236
+ });
237
+ return scanner.scan({
238
+ packages: pkgs.map((p) => ({
239
+ name: p.name,
240
+ version: p.version,
241
+ enabled: p.enabled,
242
+ })),
243
+ ignoreScripts,
244
+ });
245
+ },
246
+ getUserIds: async () => {
247
+ const users = await authClient.getUsers();
248
+ return users.map((u) => u.id);
249
+ },
250
+ filterManagers: (userIds) =>
251
+ authClient.filterUsersByAccessRule({
252
+ userIds,
253
+ accessRule: scriptPackagesAccess.manage.id,
254
+ }),
255
+ notifyUser: async ({ userId, title, body, importance, action }) => {
256
+ // sendTransactional's importance vocabulary is info|warning|critical.
257
+ const notification: {
258
+ title: string;
259
+ body: string;
260
+ importance: "info" | "warning" | "critical";
261
+ action?: { label: string; url: string };
262
+ } = { title, body, importance };
263
+ if (action) notification.action = action;
264
+ await notificationClient.sendTransactional({ userId, notification });
265
+ },
266
+ emitCompleted: async ({ lockfileHash, total }) => {
267
+ await signalService.broadcast(
268
+ SCRIPT_PACKAGES_AUDIT_COMPLETED_SIGNAL,
269
+ { lockfileHash, total },
270
+ );
271
+ },
272
+ logger,
273
+ });
274
+ (env as unknown as EnvStash).triggerAudit = triggerAudit;
275
+
151
276
  // Build the install orchestration. The resolver + active blob store
152
277
  // are resolved lazily at install time so config/registry changes
153
278
  // and store-plugin registration order don't matter.
@@ -254,6 +379,31 @@ export default createBackendPlugin({
254
379
  return { started: true };
255
380
  };
256
381
 
382
+ // GLOBAL sandbox policy: the single owning row lives in THIS plugin's
383
+ // ConfigService (shared Postgres, NOT pod-local). script-packages is
384
+ // the single source of truth — it registers the one process-wide policy
385
+ // provider that every script runner on this pod resolves through, so
386
+ // both script plugins read the identical value (no more last-writer-wins
387
+ // across two plugin-scoped rows). The runners FAIL CLOSED if this read
388
+ // throws, so a transient DB error never widens the sandbox.
389
+ const sandboxPolicy = createSandboxPolicyService({
390
+ configService: config,
391
+ });
392
+ registerScriptPackagesSandboxProvider({ service: sandboxPolicy });
393
+
394
+ // One-time, per-pod startup observability for the script sandbox: the
395
+ // host readiness banner AND the capability/effective-enforcement line
396
+ // for the configured global default. Both surfaces are emitted here, in
397
+ // process, by the single policy owner — the policy is read through the
398
+ // in-process `sandboxPolicy.read()` closure with NO RPC. This replaces
399
+ // the old per-script-plugin `getSandboxPolicy` RPC log, which 404'd when
400
+ // a script plugin's init ran before this plugin had mounted its router.
401
+ // Best-effort: never throws, never relaxes enforcement.
402
+ await logSandboxStartupReadiness({
403
+ logger,
404
+ readPolicy: () => sandboxPolicy.read(),
405
+ });
406
+
257
407
  const router = createScriptPackagesRouter({
258
408
  db: database,
259
409
  blobStores,
@@ -261,7 +411,18 @@ export default createBackendPlugin({
261
411
  triggerInstall,
262
412
  triggerMigration,
263
413
  triggerBlobGc,
414
+ triggerAudit,
264
415
  registryToken,
416
+ sandboxPolicy,
417
+ // Push-on-change: broadcast the new policy to all connected
418
+ // satellites via the cluster-wide hook (each pod fans it out to its
419
+ // own satellites). No-op until `afterPluginsReady` wires `emitHook`;
420
+ // the durable row + connect-time relay are the backstop.
421
+ onSandboxPolicyChanged: async (policy) => {
422
+ await (env as unknown as EnvStash).emitSandboxPolicyChanged?.(
423
+ policy,
424
+ );
425
+ },
265
426
  });
266
427
  rpc.registerRouter(router, scriptPackagesContract);
267
428
 
@@ -282,6 +443,21 @@ export default createBackendPlugin({
282
443
  TYPE_ACQUISITION_PATH_PREFIX,
283
444
  );
284
445
 
446
+ // Raw, HTTP-cacheable route serving the running release's generated
447
+ // @checkstack/sdk editor bundle for the in-app script editor. Keyed by
448
+ // the running release version so a deployment upgrade refreshes the
449
+ // editor's SDK types (never stale); mismatched version -> 409.
450
+ // Mounted at `/api/script-packages/sdk-types/:releaseVersion`.
451
+ rpc.registerHttpHandler(
452
+ createSdkTypesHttpHandler({
453
+ auth,
454
+ getReleaseVersion: () => SDK_RELEASE_VERSION,
455
+ getSdkBundle: () => SDK_EDITOR_BUNDLE_DTS,
456
+ logger,
457
+ }),
458
+ SDK_TYPES_PATH_PREFIX,
459
+ );
460
+
285
461
  logger.debug("✅ Script Packages Backend initialized.");
286
462
  },
287
463
 
@@ -379,6 +555,13 @@ export default createBackendPlugin({
379
555
  await emitHook(scriptPackagesChangedHook, { lockfileHash });
380
556
  };
381
557
 
558
+ // Let the `setSandboxPolicy` handler (in init's router) broadcast the
559
+ // new global policy cluster-wide; each core pod's broadcast subscriber
560
+ // (in satellite-backend) pushes it to its own connected satellites.
561
+ stash.emitSandboxPolicyChanged = async (policy) => {
562
+ await emitHook(sandboxPolicyChangedHook, { policy });
563
+ };
564
+
382
565
  // Reconcile this instance to a desired hash using the shared blob
383
566
  // store (delta pull from whichever backend holds each blob).
384
567
  const reconcileLocal = async (input: {
@@ -483,6 +666,49 @@ export default createBackendPlugin({
483
666
  }
484
667
  }
485
668
 
669
+ // Scheduled recurring vulnerability audit: run `bun audit` against the
670
+ // installed tree, persist advisories (cluster-wide source of truth),
671
+ // and notify `script-packages.manage` holders about newly-appeared /
672
+ // escalated advisories. The trigger (built in `init`) holds the
673
+ // installer advisory lock for the pass, so exactly one pod audits at a
674
+ // time and it is mutually exclusive with installs / migrations / GC.
675
+ // Runs daily (an admin-configurable interval is a follow-up).
676
+ const triggerAudit = stash.triggerAudit;
677
+ if (triggerAudit) {
678
+ try {
679
+ const auditQueue = queueManager.getQueue<Record<string, never>>(
680
+ "script-packages-audit",
681
+ );
682
+ await auditQueue.consume(
683
+ async () => {
684
+ const summary = await triggerAudit();
685
+ if (summary.ran) {
686
+ logger.debug(
687
+ `Scheduled audit: ${summary.total} advisor(ies), ${summary.notified} notified.`,
688
+ );
689
+ } else {
690
+ logger.debug(
691
+ `Scheduled audit skipped: ${summary.reason ?? "unknown"}.`,
692
+ );
693
+ }
694
+ },
695
+ { consumerGroup: "script-packages-audit-worker", maxRetries: 0 },
696
+ );
697
+ await auditQueue.scheduleRecurring(
698
+ {},
699
+ {
700
+ jobId: "script-packages-audit-daily",
701
+ intervalSeconds: 24 * 60 * 60,
702
+ },
703
+ );
704
+ logger.debug("🛡️ Script-packages vulnerability audit scheduled (daily).");
705
+ } catch (error) {
706
+ logger.error(
707
+ `Failed to schedule vulnerability audit: ${extractErrorMessage(error)}`,
708
+ );
709
+ }
710
+ }
711
+
486
712
  logger.debug("✅ Script Packages Backend afterPluginsReady complete.");
487
713
  },
488
714
  });
@@ -548,7 +774,16 @@ export {
548
774
  type ReconcileDeps,
549
775
  type ReconcileResult,
550
776
  } from "./reconciler";
551
- export { scriptPackagesChangedHook } from "./hooks";
777
+ export { scriptPackagesChangedHook, sandboxPolicyChangedHook } from "./hooks";
778
+ export {
779
+ createSandboxPolicyService,
780
+ registerScriptPackagesSandboxProvider,
781
+ type SandboxPolicyService,
782
+ } from "./sandbox-policy";
783
+ export {
784
+ logSandboxStartupReadiness,
785
+ type SandboxStartupLogger,
786
+ } from "./sandbox-startup-log";
552
787
  export {
553
788
  createCentralResolver,
554
789
  type CentralResolverOptions,
@@ -561,6 +796,10 @@ export {
561
796
  extractReferences,
562
797
  } from "./package-types";
563
798
  export { createTypeClosureHttpHandler } from "./type-acquisition-route";
799
+ export {
800
+ createSdkTypesHttpHandler,
801
+ SDK_BUNDLE_VIRTUAL_PATH,
802
+ } from "./sdk-types-route";
564
803
  export {
565
804
  resolveResolutionRoot,
566
805
  resolveResolutionRootForHost,
@@ -590,5 +829,27 @@ export { sweepTreeGc, type TreeGcResult } from "./tree-gc";
590
829
  export {
591
830
  createLockfileHistoryStore,
592
831
  createBlobGcStateStore,
832
+ createAuditStore,
833
+ type AuditStore,
593
834
  } from "./stores";
835
+ export {
836
+ parseBunAudit,
837
+ countBySeverity,
838
+ meetsThreshold,
839
+ type ParseAuditResult,
840
+ } from "./audit-parse";
841
+ export {
842
+ computeAuditDelta,
843
+ advisoryKey,
844
+ type AuditDeltaInput,
845
+ type AuditDeltaResult,
846
+ } from "./audit-delta";
847
+ export {
848
+ createAuditScanner,
849
+ type AuditScanner,
850
+ type AuditScannerOptions,
851
+ type AuditScanResult,
852
+ type SpawnFn,
853
+ } from "./audit-scanner";
854
+ export { createAuditRunner, type AuditRunnerDeps } from "./audit-runner";
594
855
  export * as schema from "./schema";
package/src/router.ts CHANGED
@@ -10,9 +10,12 @@ import {
10
10
  import type { RegistryTokenStore } from "./registry-token";
11
11
  import {
12
12
  scriptPackagesContract,
13
+ type AuditRunSummary,
13
14
  type BlobGcSummary,
14
15
  } from "@checkstack/script-packages-common";
15
16
  import type { BlobStoreRegistry } from "./blob-store-registry";
17
+ import type { SandboxPolicyService } from "./sandbox-policy";
18
+ import type { SandboxPolicy } from "@checkstack/common";
16
19
  import {
17
20
  createPackageStore,
18
21
  createRegistryConfigStore,
@@ -20,6 +23,7 @@ import {
20
23
  createStorageConfigStore,
21
24
  createSatelliteStateStore,
22
25
  createBlobGcStateStore,
26
+ createAuditStore,
23
27
  } from "./stores";
24
28
  import { createInstallStateStore } from "./install-state-store";
25
29
  import { resolveRegistryRequestConfig } from "./registry-request-config";
@@ -50,11 +54,28 @@ export interface ScriptPackagesRouterDeps {
50
54
  * (wires the blob stores + retention/grace). Returns a summary.
51
55
  */
52
56
  triggerBlobGc(): Promise<BlobGcSummary>;
57
+ /**
58
+ * Run a vulnerability-audit pass on demand (elected via the installer
59
+ * advisory lock; refuses while an install / migration / GC is in flight).
60
+ * Provided by the plugin (wires the scanner + notification path).
61
+ */
62
+ triggerAudit(): Promise<AuditRunSummary>;
53
63
  /**
54
64
  * Registry auth-token store, backed by the secrets platform's internal
55
65
  * secrets. Provided by the plugin (which injects `internalSecretsRef`).
56
66
  */
57
67
  registryToken: RegistryTokenStore;
68
+ /**
69
+ * The GLOBAL script-sandbox policy service (single owning row). Powers the
70
+ * admin `getSandboxPolicy` / `setSandboxPolicy` endpoints.
71
+ */
72
+ sandboxPolicy: SandboxPolicyService;
73
+ /**
74
+ * Called after a successful `setSandboxPolicy` with the resolved policy, so
75
+ * the plugin can broadcast it to all connected satellites (push-on-change).
76
+ * Provided by the plugin (wires the broadcast hook).
77
+ */
78
+ onSandboxPolicyChanged(policy: SandboxPolicy): Promise<void>;
58
79
  }
59
80
 
60
81
  export function createScriptPackagesRouter({
@@ -64,7 +85,10 @@ export function createScriptPackagesRouter({
64
85
  triggerInstall,
65
86
  triggerMigration,
66
87
  triggerBlobGc,
88
+ triggerAudit,
67
89
  registryToken,
90
+ sandboxPolicy,
91
+ onSandboxPolicyChanged,
68
92
  }: ScriptPackagesRouterDeps) {
69
93
  const packages = createPackageStore(db);
70
94
  const registry = createRegistryConfigStore(db);
@@ -73,6 +97,7 @@ export function createScriptPackagesRouter({
73
97
  const satellites = createSatelliteStateStore(db);
74
98
  const installState = createInstallStateStore(db);
75
99
  const blobGcState = createBlobGcStateStore(db);
100
+ const auditStore = createAuditStore(db);
76
101
 
77
102
  const os = implement(scriptPackagesContract)
78
103
  .$context<RpcContext>()
@@ -221,6 +246,17 @@ export function createScriptPackagesRouter({
221
246
 
222
247
  getBlobGcState: os.getBlobGcState.handler(async () => blobGcState.get()),
223
248
 
249
+ // ─── Vulnerability audit ──────────────────────────────────────────────
250
+ getAuditState: os.getAuditState.handler(async () => {
251
+ const state = await auditStore.getState();
252
+ const advisories = state.lockfileHash
253
+ ? await auditStore.advisoriesForHash(state.lockfileHash)
254
+ : [];
255
+ return { state, advisories };
256
+ }),
257
+
258
+ auditNow: os.auditNow.handler(async () => triggerAudit()),
259
+
224
260
  // ─── Per-host status ──────────────────────────────────────────────────
225
261
  listSatelliteSyncState: os.listSatelliteSyncState.handler(async () => ({
226
262
  items: await satellites.list(),
@@ -233,6 +269,19 @@ export function createScriptPackagesRouter({
233
269
  },
234
270
  ),
235
271
 
272
+ // ─── Global sandbox policy (admin-only) ───────────────────────────────
273
+ getSandboxPolicy: os.getSandboxPolicy.handler(async () =>
274
+ sandboxPolicy.read(),
275
+ ),
276
+
277
+ setSandboxPolicy: os.setSandboxPolicy.handler(async ({ input }) => {
278
+ const resolved = await sandboxPolicy.write(input);
279
+ // Push-on-change: relay the new policy to all connected satellites. The
280
+ // broadcast is best-effort liveness; satellites re-pull on (re)connect.
281
+ await onSandboxPolicyChanged(resolved);
282
+ return resolved;
283
+ }),
284
+
236
285
  // ─── Authoring / runtime ──────────────────────────────────────────────
237
286
  getInstallState: os.getInstallState.handler(async () => installState.load()),
238
287