@superblocksteam/vite-plugin-file-sync 2.0.149 → 2.0.150-next.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.
Files changed (161) hide show
  1. package/dist/ai-service/agent/prompts/api-prompts.js +1 -1
  2. package/dist/ai-service/agent/prompts/build-base-system-prompt.d.ts.map +1 -1
  3. package/dist/ai-service/agent/prompts/build-base-system-prompt.js +38 -10
  4. package/dist/ai-service/agent/prompts/build-base-system-prompt.js.map +1 -1
  5. package/dist/ai-service/agent/prompts/build-security-scan-prompt.d.ts.map +1 -1
  6. package/dist/ai-service/agent/prompts/build-security-scan-prompt.js +20 -4
  7. package/dist/ai-service/agent/prompts/build-security-scan-prompt.js.map +1 -1
  8. package/dist/ai-service/agent/subagents/testing/prompt-builder.js +1 -1
  9. package/dist/ai-service/agent/tools/apis/analysis.d.ts +6 -0
  10. package/dist/ai-service/agent/tools/apis/analysis.d.ts.map +1 -1
  11. package/dist/ai-service/agent/tools/apis/analysis.js +130 -26
  12. package/dist/ai-service/agent/tools/apis/analysis.js.map +1 -1
  13. package/dist/ai-service/agent/tools/apis/get-sdk-api-docs.js +2 -2
  14. package/dist/ai-service/agent/tools/apis/get-sdk-api-docs.js.map +1 -1
  15. package/dist/ai-service/agent/tools/apis/test-api.d.ts.map +1 -1
  16. package/dist/ai-service/agent/tools/apis/test-api.js +131 -31
  17. package/dist/ai-service/agent/tools/apis/test-api.js.map +1 -1
  18. package/dist/ai-service/agent/tools/build-delete-file.d.ts +2 -2
  19. package/dist/ai-service/agent/tools/build-delete-file.js +3 -3
  20. package/dist/ai-service/agent/tools/build-delete-file.js.map +1 -1
  21. package/dist/ai-service/agent/tools/build-install-packages.d.ts +41 -21
  22. package/dist/ai-service/agent/tools/build-install-packages.d.ts.map +1 -1
  23. package/dist/ai-service/agent/tools/build-install-packages.js +299 -20
  24. package/dist/ai-service/agent/tools/build-install-packages.js.map +1 -1
  25. package/dist/ai-service/agent/tools/build-manage-checklist.d.ts.map +1 -1
  26. package/dist/ai-service/agent/tools/build-manage-checklist.js +2 -1
  27. package/dist/ai-service/agent/tools/build-manage-checklist.js.map +1 -1
  28. package/dist/ai-service/agent/tools/databases/dev-database-tasks.d.ts +161 -24
  29. package/dist/ai-service/agent/tools/databases/dev-database-tasks.d.ts.map +1 -1
  30. package/dist/ai-service/agent/tools/databases/dev-database-tasks.js +312 -55
  31. package/dist/ai-service/agent/tools/databases/dev-database-tasks.js.map +1 -1
  32. package/dist/ai-service/agent/tools/get-logs.d.ts +2 -2
  33. package/dist/ai-service/agent/tools/report-security-findings.d.ts +3 -3
  34. package/dist/ai-service/agent/tools.d.ts +10 -11
  35. package/dist/ai-service/agent/tools.d.ts.map +1 -1
  36. package/dist/ai-service/agent/tools.js +38 -19
  37. package/dist/ai-service/agent/tools.js.map +1 -1
  38. package/dist/ai-service/agent/tools2/tools/exit-plan-mode.d.ts.map +1 -1
  39. package/dist/ai-service/agent/tools2/tools/exit-plan-mode.js +9 -8
  40. package/dist/ai-service/agent/tools2/tools/exit-plan-mode.js.map +1 -1
  41. package/dist/ai-service/agent/tools2/tools/grep.js +1 -1
  42. package/dist/ai-service/agent/tools2/tools/index.d.ts.map +1 -1
  43. package/dist/ai-service/agent/tools2/tools/index.js +2 -3
  44. package/dist/ai-service/agent/tools2/tools/index.js.map +1 -1
  45. package/dist/ai-service/app-interface/npm-package-lookup.d.ts +22 -0
  46. package/dist/ai-service/app-interface/npm-package-lookup.d.ts.map +1 -1
  47. package/dist/ai-service/app-interface/npm-package-lookup.js +111 -0
  48. package/dist/ai-service/app-interface/npm-package-lookup.js.map +1 -1
  49. package/dist/ai-service/app-interface/shell.d.ts +71 -3
  50. package/dist/ai-service/app-interface/shell.d.ts.map +1 -1
  51. package/dist/ai-service/app-interface/shell.js +575 -23
  52. package/dist/ai-service/app-interface/shell.js.map +1 -1
  53. package/dist/ai-service/app-interface/vulnerability-gate.d.ts +83 -0
  54. package/dist/ai-service/app-interface/vulnerability-gate.d.ts.map +1 -0
  55. package/dist/ai-service/app-interface/vulnerability-gate.js +543 -0
  56. package/dist/ai-service/app-interface/vulnerability-gate.js.map +1 -0
  57. package/dist/ai-service/dev-database-client.d.ts +14 -4
  58. package/dist/ai-service/dev-database-client.d.ts.map +1 -1
  59. package/dist/ai-service/dev-database-client.js +1 -24
  60. package/dist/ai-service/dev-database-client.js.map +1 -1
  61. package/dist/ai-service/features.d.ts +5 -0
  62. package/dist/ai-service/features.d.ts.map +1 -1
  63. package/dist/ai-service/features.js +5 -0
  64. package/dist/ai-service/features.js.map +1 -1
  65. package/dist/ai-service/index.d.ts +2 -0
  66. package/dist/ai-service/index.d.ts.map +1 -1
  67. package/dist/ai-service/index.js +60 -9
  68. package/dist/ai-service/index.js.map +1 -1
  69. package/dist/ai-service/integrations/store.d.ts +3 -0
  70. package/dist/ai-service/integrations/store.d.ts.map +1 -1
  71. package/dist/ai-service/integrations/store.js +2 -0
  72. package/dist/ai-service/integrations/store.js.map +1 -1
  73. package/dist/ai-service/llm/client.d.ts +2 -9
  74. package/dist/ai-service/llm/client.d.ts.map +1 -1
  75. package/dist/ai-service/llm/client.js +2 -81
  76. package/dist/ai-service/llm/client.js.map +1 -1
  77. package/dist/ai-service/llm/context-v2/compaction/client-side.d.ts.map +1 -1
  78. package/dist/ai-service/llm/context-v2/compaction/client-side.js +15 -4
  79. package/dist/ai-service/llm/context-v2/compaction/client-side.js.map +1 -1
  80. package/dist/ai-service/llm/context-v2/config.d.ts +8 -0
  81. package/dist/ai-service/llm/context-v2/config.d.ts.map +1 -1
  82. package/dist/ai-service/llm/context-v2/config.js +13 -0
  83. package/dist/ai-service/llm/context-v2/config.js.map +1 -1
  84. package/dist/ai-service/llm/context-v2/context-metrics.d.ts +18 -0
  85. package/dist/ai-service/llm/context-v2/context-metrics.d.ts.map +1 -1
  86. package/dist/ai-service/llm/context-v2/context-metrics.js +94 -2
  87. package/dist/ai-service/llm/context-v2/context-metrics.js.map +1 -1
  88. package/dist/ai-service/llm/context-v2/context.d.ts +3 -0
  89. package/dist/ai-service/llm/context-v2/context.d.ts.map +1 -1
  90. package/dist/ai-service/llm/context-v2/context.js +13 -1
  91. package/dist/ai-service/llm/context-v2/context.js.map +1 -1
  92. package/dist/ai-service/llm/stream/observers/workflow-usage.d.ts +35 -0
  93. package/dist/ai-service/llm/stream/observers/workflow-usage.d.ts.map +1 -0
  94. package/dist/ai-service/llm/stream/observers/workflow-usage.js +108 -0
  95. package/dist/ai-service/llm/stream/observers/workflow-usage.js.map +1 -0
  96. package/dist/ai-service/llm/stream/orchestrator.d.ts.map +1 -1
  97. package/dist/ai-service/llm/stream/orchestrator.js +1 -0
  98. package/dist/ai-service/llm/stream/orchestrator.js.map +1 -1
  99. package/dist/ai-service/llm/stream/session.d.ts +2 -0
  100. package/dist/ai-service/llm/stream/session.d.ts.map +1 -1
  101. package/dist/ai-service/llm/stream/session.js +2 -0
  102. package/dist/ai-service/llm/stream/session.js.map +1 -1
  103. package/dist/ai-service/managed-native-db-availability.d.ts +62 -0
  104. package/dist/ai-service/managed-native-db-availability.d.ts.map +1 -0
  105. package/dist/ai-service/managed-native-db-availability.js +37 -0
  106. package/dist/ai-service/managed-native-db-availability.js.map +1 -0
  107. package/dist/ai-service/security/vulnerability-triage.d.ts +90 -0
  108. package/dist/ai-service/security/vulnerability-triage.d.ts.map +1 -0
  109. package/dist/ai-service/security/vulnerability-triage.js +113 -0
  110. package/dist/ai-service/security/vulnerability-triage.js.map +1 -0
  111. package/dist/ai-service/skills/system/superblocks-frontend/skill.generated.d.ts +1 -1
  112. package/dist/ai-service/skills/system/superblocks-frontend/skill.generated.d.ts.map +1 -1
  113. package/dist/ai-service/skills/system/superblocks-frontend/skill.generated.js +13 -0
  114. package/dist/ai-service/skills/system/superblocks-frontend/skill.generated.js.map +1 -1
  115. package/dist/ai-service/state-machine/clark-fsm.d.ts +12 -0
  116. package/dist/ai-service/state-machine/clark-fsm.d.ts.map +1 -1
  117. package/dist/ai-service/state-machine/clark-fsm.js +23 -0
  118. package/dist/ai-service/state-machine/clark-fsm.js.map +1 -1
  119. package/dist/ai-service/state-machine/handlers/awaiting-user.d.ts.map +1 -1
  120. package/dist/ai-service/state-machine/handlers/awaiting-user.js +17 -27
  121. package/dist/ai-service/state-machine/handlers/awaiting-user.js.map +1 -1
  122. package/dist/ai-service/state-machine/handlers/idle.d.ts +1 -1
  123. package/dist/ai-service/state-machine/handlers/idle.d.ts.map +1 -1
  124. package/dist/ai-service/state-machine/handlers/idle.js +28 -2
  125. package/dist/ai-service/state-machine/handlers/idle.js.map +1 -1
  126. package/dist/ai-service/state-machine/handlers/play-dead.d.ts +2 -2
  127. package/dist/ai-service/state-machine/handlers/play-dead.d.ts.map +1 -1
  128. package/dist/ai-service/state-machine/handlers/play-dead.js +18 -4
  129. package/dist/ai-service/state-machine/handlers/play-dead.js.map +1 -1
  130. package/dist/ai-service/state-machine/helpers/prompt-billing-metrics.d.ts +26 -0
  131. package/dist/ai-service/state-machine/helpers/prompt-billing-metrics.d.ts.map +1 -0
  132. package/dist/ai-service/state-machine/helpers/prompt-billing-metrics.js +127 -0
  133. package/dist/ai-service/state-machine/helpers/prompt-billing-metrics.js.map +1 -0
  134. package/dist/ai-service/state-machine/helpers/prompt-request-metrics.d.ts +36 -3
  135. package/dist/ai-service/state-machine/helpers/prompt-request-metrics.d.ts.map +1 -1
  136. package/dist/ai-service/state-machine/helpers/prompt-request-metrics.js +119 -11
  137. package/dist/ai-service/state-machine/helpers/prompt-request-metrics.js.map +1 -1
  138. package/dist/ai-service/tasks/factory.d.ts +2 -0
  139. package/dist/ai-service/tasks/factory.d.ts.map +1 -1
  140. package/dist/ai-service/tasks/factory.js +7 -0
  141. package/dist/ai-service/tasks/factory.js.map +1 -1
  142. package/dist/ai-service/types.d.ts +57 -3
  143. package/dist/ai-service/types.d.ts.map +1 -1
  144. package/dist/ai-service/types.js +20 -0
  145. package/dist/ai-service/types.js.map +1 -1
  146. package/dist/migration/restructure.js +1 -1
  147. package/dist/migration-templates/app-fullstack/client/pages/{Page1 → Home}/index.tsx +1 -1
  148. package/dist/migration-templates/app-fullstack/client/router.tsx +1 -1
  149. package/dist/migration-templates/app-fullstack/package.json +2 -2
  150. package/dist/npm/apply-package-json-substitutions.d.ts +19 -0
  151. package/dist/npm/apply-package-json-substitutions.d.ts.map +1 -0
  152. package/dist/npm/apply-package-json-substitutions.js +108 -0
  153. package/dist/npm/apply-package-json-substitutions.js.map +1 -0
  154. package/dist/policy-gate-callback-mapper.d.ts +1 -1
  155. package/dist/policy-gate-callback-mapper.d.ts.map +1 -1
  156. package/dist/policy-gate-callback-mapper.js +5 -6
  157. package/dist/policy-gate-callback-mapper.js.map +1 -1
  158. package/dist/policy-gate-runner.d.ts.map +1 -1
  159. package/dist/policy-gate-runner.js +22 -4
  160. package/dist/policy-gate-runner.js.map +1 -1
  161. package/package.json +8 -8
@@ -32,16 +32,19 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
32
32
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
33
33
  done = true;
34
34
  };
35
- import { exec } from "node:child_process";
35
+ import { exec, execFile } from "node:child_process";
36
36
  import { readFile as fsReadFile, mkdir, writeFile, access, rm, stat, readdir, } from "node:fs/promises";
37
37
  import * as path from "node:path";
38
38
  import { promisify } from "node:util";
39
+ import { metrics } from "@opentelemetry/api";
39
40
  import { glob } from "glob";
40
41
  import JSON5 from "json5";
41
42
  import { detect, resolveCommand } from "package-manager-detector";
43
+ import semver from "semver";
42
44
  import { normalizePath } from "vite";
43
45
  import { isProtectedCorePackage } from "@superblocksteam/library-shared/types";
44
46
  import { bucketNpmRegistryHost, recordNpmInstall, } from "@superblocksteam/telemetry";
47
+ import { applyPackageJsonSubstitutions, readDeclaredDependencies, } from "../../npm/apply-package-json-substitutions.js";
45
48
  import { preparePackageJsonForCloudNpm } from "../../npm/prepare-package-json-for-cloud-npm.js";
46
49
  import { getErrorMeta, getLogger } from "../../util/logger.js";
47
50
  import llmobs from "../llmobs/index.js";
@@ -52,7 +55,9 @@ import { DraftManager, validateFileOperation, } from "./filesystem/index.js";
52
55
  import { Linter } from "./linter.js";
53
56
  import { parseNpmJsonDiagnostic, parseNpmJsonError, parseNpmJsonSuccess, } from "./npm-error-parser.js";
54
57
  import { prepareForPrivateRegistry, shouldIgnoreInstallScripts, } from "./npm-registry.js";
58
+ import { applyPackageSubstitutions, runVulnerabilityGate, } from "./vulnerability-gate.js";
55
59
  const execPromise = promisify(exec);
60
+ const execFilePromise = promisify(execFile);
56
61
  // `npm install --json` emits the full install summary on stdout. The default
57
62
  // 1 MiB exec buffer can be exceeded for fat dependency trees, which would
58
63
  // fail a successful install with ERR_CHILD_PROCESS_STDIO_MAXBUFFER and reach
@@ -61,6 +66,79 @@ const execPromise = promisify(exec);
61
66
  // covers real-world npm summaries (per-package entries are tiny) while
62
67
  // keeping the worst-case buffer + parsed-object peak bounded.
63
68
  const NPM_JSON_MAX_BUFFER = 4 * 1024 * 1024;
69
+ /** Lowest version a declared range admits; skips `workspace:`/`file:`/`*`/git. */
70
+ function declaredRangeFloor(range) {
71
+ if (range == null || semver.validRange(range) == null)
72
+ return null;
73
+ const floor = semver.minVersion(range);
74
+ if (floor == null)
75
+ return null;
76
+ // `*` floors to 0.0.0; an exact `0.0.0` pin is real.
77
+ if (floor.version === "0.0.0" && semver.valid(range) == null)
78
+ return null;
79
+ return floor.version;
80
+ }
81
+ function emitIndexUnavailableMetric() {
82
+ metrics
83
+ .getMeter("superblocks.clark.pkg_security")
84
+ .createCounter("superblocks.clark.pkg_security.index_unavailable", {
85
+ description: "Vulnerability index unavailable during Clark package-install gate",
86
+ })
87
+ .add(1);
88
+ }
89
+ /**
90
+ * npm `--json` stdout can be prefixed with non-JSON status lines on some
91
+ * versions (`add foo 1.2.3\n{...}`). Slice from the first `{`.
92
+ */
93
+ function parseNpmJsonStdout(stdout) {
94
+ if (typeof stdout !== "string" || stdout.length === 0) {
95
+ throw new Error("npm --json stdout contained no JSON object");
96
+ }
97
+ const start = stdout.indexOf("{");
98
+ if (start < 0) {
99
+ throw new Error("npm --json stdout contained no JSON object");
100
+ }
101
+ return JSON.parse(stdout.slice(start));
102
+ }
103
+ /** True for npm `--json` failure bodies (`{ "error": ... }`), not dry-run trees. */
104
+ function isNpmErrorEnvelope(parsed) {
105
+ if (parsed == null || typeof parsed !== "object")
106
+ return false;
107
+ if (!("error" in parsed))
108
+ return false;
109
+ return parsed.error != null;
110
+ }
111
+ /**
112
+ * True for an npm dry-run entry installed into the app's own `node_modules`
113
+ * (`<app>/node_modules/lodash`) rather than nested under a dependency
114
+ * (`<app>/node_modules/@scope/library/node_modules/lodash`). npm omits `path`
115
+ * on some shapes; treat those as top-level so a missing field cannot hide a
116
+ * package from the top-level CVE checks.
117
+ */
118
+ function isRootNodeModulesPath(entryPath) {
119
+ if (typeof entryPath !== "string" || entryPath.length === 0)
120
+ return true;
121
+ return entryPath.replace(/\\/g, "/").split("/node_modules/").length <= 2;
122
+ }
123
+ function packageNameFromLockPath(lockPath) {
124
+ if (!lockPath)
125
+ return undefined;
126
+ const marker = "node_modules/";
127
+ const idx = lockPath.lastIndexOf(marker);
128
+ if (idx < 0)
129
+ return undefined;
130
+ const name = lockPath.slice(idx + marker.length);
131
+ return name.length > 0 ? name : undefined;
132
+ }
133
+ function isPackageLookupResult(value) {
134
+ if (value == null || typeof value !== "object")
135
+ return false;
136
+ const record = value;
137
+ return (typeof record.name === "string" &&
138
+ typeof record.version === "string" &&
139
+ typeof record.hasMalware === "boolean" &&
140
+ Array.isArray(record.advisories));
141
+ }
64
142
  /**
65
143
  * Map an install failure to a value in the closed `npm.outcome` enum
66
144
  * (APPS-4189). `NpmInstallBlocked` carries the structured reason from
@@ -194,12 +272,18 @@ let AppShell = (() => {
194
272
  * The reporter is fire-and-forget — see `maybeReportBlockedInstall`.
195
273
  */
196
274
  reportNpmInstallBlocked;
275
+ /**
276
+ * Optional deps for the install-time vulnerability index lookup.
277
+ * When absent the malware/CVE gate is skipped (test fixtures / deliberate
278
+ * opt-out). Production AiService always wires these.
279
+ */
280
+ packageLookup;
197
281
  /**
198
282
  * Tracks files that must be read before they can be written.
199
283
  * Used to enforce read-before-write pattern for AI safety.
200
284
  */
201
285
  requiredReads = new Set();
202
- constructor({ appRootDirPath, fsOperationQueue, messageSender, draftInterface, templateRenderer, virtualFileSystem, pathValidator, enableDeletingLockfiles, npmRegistryClient, reportNpmInstallBlocked, }) {
286
+ constructor({ appRootDirPath, fsOperationQueue, messageSender, draftInterface, templateRenderer, virtualFileSystem, pathValidator, enableDeletingLockfiles, npmRegistryClient, reportNpmInstallBlocked, packageLookup, }) {
203
287
  // Normalize path to handle Windows drive letter case
204
288
  this.appRootDirPath = normalizePath(appRootDirPath);
205
289
  this.fsOperationQueue = fsOperationQueue;
@@ -214,6 +298,7 @@ let AppShell = (() => {
214
298
  this.enableDeletingLockfiles = enableDeletingLockfiles ?? false;
215
299
  this.npmRegistryClient = npmRegistryClient;
216
300
  this.reportNpmInstallBlocked = reportNpmInstallBlocked;
301
+ this.packageLookup = packageLookup;
217
302
  }
218
303
  /**
219
304
  * Toggle the API virtual-file projection. When disabled, reads to `apis/*`
@@ -658,25 +743,118 @@ let AppShell = (() => {
658
743
  * server throttles + sanitizes + persists. Fire-and-forget: a reporting
659
744
  * failure must never surface to (or break) the install caller.
660
745
  */
746
+ safeAudit(report) {
747
+ if (!this.reportNpmInstallBlocked)
748
+ return;
749
+ try {
750
+ this.reportNpmInstallBlocked(report);
751
+ }
752
+ catch {
753
+ // Audit reporting is best-effort and must not affect the install.
754
+ }
755
+ }
661
756
  maybeReportBlockedInstall(err, runner) {
662
757
  if (!(err instanceof NpmInstallBlocked))
663
758
  return;
664
- if (!this.reportNpmInstallBlocked)
759
+ // Audit only real CVE/malware findings — index/probe failures are fail-closed
760
+ // blocks, not malware hits, and must not pollute SIEM as vulnerabilities.
761
+ const isAuditableFinding = (finding) => finding.kind === "cve" || finding.kind === "malware";
762
+ const vulnerabilities = err.findings?.filter(isAuditableFinding).map((finding) => ({
763
+ advisoryId: finding.advisoryId,
764
+ kind: finding.kind,
765
+ package: finding.package,
766
+ ...(finding.severity !== undefined
767
+ ? { severity: finding.severity }
768
+ : {}),
769
+ ...(finding.suggestedVersion !== undefined
770
+ ? { suggestedVersion: finding.suggestedVersion }
771
+ : {}),
772
+ }));
773
+ // A block whose findings are all index/probe outages still carries
774
+ // `reason: vulnerable_package`, so auditing it would file an outage as a
775
+ // CVE hit. The `index_unavailable` metric already covers those.
776
+ if (err.findings?.length && !vulnerabilities?.length)
665
777
  return;
666
- try {
667
- this.reportNpmInstallBlocked({
668
- reason: err.reason,
669
- registryHost: err.registryHost,
670
- packages: err.packages,
671
- httpStatus: err.httpStatus,
672
- npmErrorCode: err.npmErrorCode,
778
+ this.safeAudit({
779
+ reason: err.reason,
780
+ registryHost: err.registryHost,
781
+ packages: err.packages,
782
+ httpStatus: err.httpStatus,
783
+ npmErrorCode: err.npmErrorCode,
784
+ runner,
785
+ occurredAt: Date.now(),
786
+ ...(vulnerabilities?.length ? { vulnerabilities } : {}),
787
+ });
788
+ }
789
+ reportNpmTriageVerdict(verdict) {
790
+ this.safeAudit({
791
+ occurredAt: Date.now(),
792
+ packages: [verdict.package],
793
+ reason: "vulnerability_triaged",
794
+ runner: "install_specific",
795
+ triage: verdict,
796
+ });
797
+ }
798
+ reportNpmUserOverride(args) {
799
+ this.safeAudit({
800
+ occurredAt: Date.now(),
801
+ packages: args.packages,
802
+ reason: "vulnerability_user_override",
803
+ runner: "install_specific",
804
+ userOverride: {
805
+ packages: args.packages,
806
+ reason: args.reason,
807
+ },
808
+ });
809
+ }
810
+ /**
811
+ * Report silent CVE/quarantine substitutions to the same audit sink as
812
+ * blocked installs. Best-effort; never fails the install.
813
+ */
814
+ maybeReportSubstitutions(substitutions, runner) {
815
+ for (const entry of substitutions) {
816
+ this.safeAudit({
817
+ reason: "package_substituted",
818
+ packages: [`${entry.name}@${entry.fromVersion}`],
673
819
  runner,
674
820
  occurredAt: Date.now(),
821
+ substitution: {
822
+ fromVersion: entry.fromVersion,
823
+ toVersion: entry.toVersion,
824
+ reason: entry.reason,
825
+ },
675
826
  });
676
827
  }
677
- catch {
678
- // Audit reporting is best-effort and must not affect the install.
828
+ }
829
+ /** Mode 2: write gate substitutions into package.json. Audit is the caller's job after install succeeds (same as Mode 1). */
830
+ async applyDeclaredDependencyUpgrades(remediation) {
831
+ if (remediation.substitutions.length === 0)
832
+ return [];
833
+ const rewritten = await this.enqueueFsOperation("applyPackageJsonSubstitutions", () => applyPackageJsonSubstitutions({
834
+ appRootDirPath: this.appRootDirPath,
835
+ logger: getLogger(),
836
+ substitutions: remediation.substitutions,
837
+ }));
838
+ if (rewritten.length === 0)
839
+ return [];
840
+ const applied = new Set(rewritten);
841
+ return remediation.substitutions.filter((entry) => applied.has(entry.name));
842
+ }
843
+ async remediateDeclaredDependencies(abortSignal) {
844
+ // Kill switch / unwired lookup: do not short-circuit registry or rewrite
845
+ // package.json — startup must behave as pre-gate.
846
+ if (!this.packageLookup) {
847
+ return;
679
848
  }
849
+ await this.withAuthFailedRetry(async (registryFetchResult) => {
850
+ this.maybeShortCircuitForUnreachable(registryFetchResult, []);
851
+ // Cloud: strip workspace: before Mode 2 dry-run malware scan.
852
+ await this.maybePreparePackageJsonForCloudNpm();
853
+ await this.applyDeclaredDependencyUpgrades(await this.maybeRemediateVulnerabilities({
854
+ abortSignal,
855
+ mode: "all",
856
+ }));
857
+ });
680
858
  }
681
859
  /**
682
860
  * Env overlay for AppShell-spawned npm/pnpm installs in the live-edit
@@ -724,11 +902,14 @@ let AppShell = (() => {
724
902
  // was at some point configured with a private registry. See
725
903
  // `maybeShortCircuitForUnreachable` for the full rationale.
726
904
  this.maybeShortCircuitForUnreachable(registryFetchResult, []);
905
+ await this.maybePreparePackageJsonForCloudNpm();
906
+ const appliedSubstitutions = await this.applyDeclaredDependencyUpgrades(await this.maybeRemediateVulnerabilities({
907
+ abortSignal,
908
+ mode: "all",
909
+ }));
727
910
  const ignoreScripts = shouldIgnoreInstallScripts(registryFetchResult);
728
911
  const isNpm = process.env.SUPERBLOCKS_SERVER_ENV !== "local";
729
- if (isNpm) {
730
- await this.maybePreparePackageJsonForCloudNpm();
731
- }
912
+ // prepare already ran above so Mode 2 cloud dry-run sees semver deps.
732
913
  // In csb mock server (local dev) we use pnpm to avoid lockfile / store
733
914
  // conflicts with the surrounding workspace; everywhere else we force npm
734
915
  // so the `--json` error contract — and therefore the registry-blocked
@@ -741,7 +922,7 @@ let AppShell = (() => {
741
922
  const command = ignoreScripts
742
923
  ? `${baseCommand} --ignore-scripts`
743
924
  : baseCommand;
744
- return await new Promise((resolve, reject) => {
925
+ await new Promise((resolve, reject) => {
745
926
  exec(command, {
746
927
  cwd: this.appRootDirPath,
747
928
  env: this.installLogEnv(),
@@ -767,6 +948,9 @@ let AppShell = (() => {
767
948
  return resolve();
768
949
  });
769
950
  });
951
+ // Audit only after the install succeeds: a selected substitution is
952
+ // not an applied substitution if npm fails (same as Mode 1).
953
+ this.maybeReportSubstitutions(appliedSubstitutions, "install_all");
770
954
  });
771
955
  }
772
956
  catch (err) {
@@ -814,6 +998,344 @@ let AppShell = (() => {
814
998
  hasAnyRegistryConfigured: true,
815
999
  });
816
1000
  }
1001
+ /**
1002
+ * POST `/api/v1/security/package-lookup` for a batch of `{name,version}`
1003
+ * pairs. One request — the server cap is 5000. Throws on non-2xx / network
1004
+ * / malformed body so the caller can apply malware fail-closed.
1005
+ */
1006
+ async lookupPackageVulnerabilities(packages) {
1007
+ const deps = this.packageLookup;
1008
+ if (!deps) {
1009
+ return [];
1010
+ }
1011
+ if (packages.length === 0) {
1012
+ return [];
1013
+ }
1014
+ const jwt = await deps.getJwt();
1015
+ const base = deps.baseUrl.replace(/\/$/, "");
1016
+ const url = `${base}/api/v1/security/package-lookup`;
1017
+ const fetchFn = deps.fetch ?? globalThis.fetch;
1018
+ const response = await fetchFn(url, {
1019
+ method: "POST",
1020
+ headers: {
1021
+ Authorization: `Bearer ${jwt}`,
1022
+ "Content-Type": "application/json",
1023
+ },
1024
+ body: JSON.stringify({ packages }),
1025
+ });
1026
+ if (!response.ok) {
1027
+ throw new Error(`package-lookup returned HTTP ${response.status}`);
1028
+ }
1029
+ const body = await response.json();
1030
+ if (body == null ||
1031
+ typeof body !== "object" ||
1032
+ !("data" in body) ||
1033
+ body.data == null ||
1034
+ typeof body.data !== "object" ||
1035
+ !("results" in body.data) ||
1036
+ !Array.isArray(body.data.results)) {
1037
+ throw new Error("package-lookup response missing data.results");
1038
+ }
1039
+ const results = [];
1040
+ for (const entry of body.data.results) {
1041
+ if (!isPackageLookupResult(entry)) {
1042
+ throw new Error("package-lookup response contained an off-shape result");
1043
+ }
1044
+ results.push(entry);
1045
+ }
1046
+ const checked = new Set(results.map((result) => `${result.name}@${result.version}`));
1047
+ for (const pkg of packages) {
1048
+ if (!checked.has(`${pkg.name}@${pkg.version}`)) {
1049
+ throw new Error(`package-lookup response missing result for ${pkg.name}@${pkg.version}`);
1050
+ }
1051
+ }
1052
+ return results;
1053
+ }
1054
+ /**
1055
+ * Mode 1: npm dry-run + lockfile.
1056
+ * Mode 2: `topLevel` is always declared package.json floors (CVE remediations).
1057
+ * Cloud malware tree = floors ∪ package-lock ∪ dry-run (after workspace:
1058
+ * rewrite). Local stays floors-only — pnpm + lingering `workspace:*` mean
1059
+ * full-tree coverage is not available there.
1060
+ */
1061
+ async resolveInstallTree(mode, packages, abortSignal) {
1062
+ if (mode === "all") {
1063
+ const topLevel = [];
1064
+ for (const pkg of packages ?? []) {
1065
+ const version = declaredRangeFloor(pkg.version);
1066
+ if (version != null)
1067
+ topLevel.push({ name: pkg.name, version });
1068
+ }
1069
+ // Local: floors only. Cloud: union lockfile + dry-run for malware.
1070
+ if (process.env.SUPERBLOCKS_SERVER_ENV === "local") {
1071
+ return { resolves: true, topLevel, tree: topLevel };
1072
+ }
1073
+ const fromLock = await this.readPackagesFromPackageLock();
1074
+ let dryTree = [];
1075
+ try {
1076
+ dryTree = (await this.resolveTreeViaNpmDryRun([], abortSignal)).tree;
1077
+ }
1078
+ catch (err) {
1079
+ // Do not continue remediation after cancel.
1080
+ if (abortSignal?.aborted ||
1081
+ (err instanceof Error && err.name === "AbortError")) {
1082
+ throw err;
1083
+ }
1084
+ // Unexpected probe failure (not ERESOLVE / error envelope) — still
1085
+ // malware-scan floors + lockfile instead of aborting the gate.
1086
+ }
1087
+ const byKey = new Map();
1088
+ for (const pkg of [...(fromLock?.tree ?? []), ...dryTree, ...topLevel]) {
1089
+ byKey.set(`${pkg.name}@${pkg.version}`, pkg);
1090
+ }
1091
+ // Keep resolves:true so a failed dry-run cannot skip floor CVE swaps;
1092
+ // malware still sees whatever floors/lock/dry contributed.
1093
+ return { resolves: true, topLevel, tree: [...byKey.values()] };
1094
+ }
1095
+ const specs = (packages ?? []).map((pkg) => pkg.version ? `${pkg.name}@${pkg.version}` : pkg.name);
1096
+ const resolved = await this.resolveTreeViaNpmDryRun(specs, abortSignal);
1097
+ // Prefer dry-run concrete pins for top-level. Never inject the raw
1098
+ // requested range/`latest` string into the lookup tree — the index
1099
+ // keys on concrete versions, and a range would fail coverage checks.
1100
+ const topLevel = [];
1101
+ for (const pkg of packages ?? []) {
1102
+ // An exact pin is what npm puts at the root, so it needs no resolution —
1103
+ // and looking it up in the dry-run tree risks matching some other copy of
1104
+ // the same name. It stays checkable even when npm cannot resolve the full
1105
+ // tree; the lockfile union below preserves malware coverage for the tree
1106
+ // already on disk.
1107
+ if (pkg.version && semver.valid(pkg.version)) {
1108
+ topLevel.push({ name: pkg.name, version: pkg.version });
1109
+ continue;
1110
+ }
1111
+ const fromDry = resolved.topLevel.find((entry) => entry.name === pkg.name);
1112
+ if (fromDry)
1113
+ topLevel.push(fromDry);
1114
+ }
1115
+ // Lockfile entries come first so already-satisfied installs (empty
1116
+ // dry-run `add`) still malware-scan locked transitives; dry-run pins and
1117
+ // top-level overwrite on collision because they reflect what this
1118
+ // install actually changes.
1119
+ const fromLock = await this.readPackagesFromPackageLock();
1120
+ const byKey = new Map();
1121
+ for (const pkg of [
1122
+ ...(fromLock?.tree ?? []),
1123
+ ...resolved.tree,
1124
+ ...topLevel,
1125
+ ]) {
1126
+ byKey.set(`${pkg.name}@${pkg.version}`, pkg);
1127
+ }
1128
+ return {
1129
+ tree: [...byKey.values()],
1130
+ topLevel,
1131
+ resolves: resolved.resolves,
1132
+ };
1133
+ }
1134
+ async resolveTreeViaNpmDryRun(specs, abortSignal) {
1135
+ const args = [
1136
+ "install",
1137
+ "--dry-run",
1138
+ "--json",
1139
+ "--fund=false",
1140
+ "--audit=false",
1141
+ // `--` so a spec that looks like an option (`--registry=…`,
1142
+ // `--userconfig=…`) is parsed as a package name instead of
1143
+ // reconfiguring the probe: the gate scans the tree this resolves, so a
1144
+ // redirected registry would vouch for packages the real install never
1145
+ // fetches. npm rejects the name and we fail closed.
1146
+ "--",
1147
+ ...specs,
1148
+ ];
1149
+ try {
1150
+ // execFile (no shell) so hostile package specs cannot inject shell
1151
+ // metacharacters into the dry-run probe.
1152
+ const { stdout } = await execFilePromise("npm", args, {
1153
+ cwd: this.appRootDirPath,
1154
+ env: this.installLogEnv(),
1155
+ signal: abortSignal,
1156
+ maxBuffer: NPM_JSON_MAX_BUFFER,
1157
+ });
1158
+ const parsed = parseNpmJsonStdout(stdout);
1159
+ return this.treeFromNpmDryRunJson(parsed, specs);
1160
+ }
1161
+ catch (error) {
1162
+ const err = error;
1163
+ const combined = `${err.stdout ?? ""}\n${err.stderr ?? ""}\n${err.message ?? ""}`;
1164
+ if (/ERESOLVE|EPEERINVALID/i.test(combined)) {
1165
+ return { tree: [], topLevel: [], resolves: false };
1166
+ }
1167
+ // Non-zero exit can still carry a usable dry-run body — or an `{ error }`
1168
+ // envelope (EUNSUPPORTEDPROTOCOL / E404 / ETARGET). Treat the latter as
1169
+ // unresolved so the gate fails closed instead of scanning an empty tree.
1170
+ if (err.stdout && err.stdout.includes("{")) {
1171
+ try {
1172
+ const parsed = parseNpmJsonStdout(err.stdout);
1173
+ if (isNpmErrorEnvelope(parsed)) {
1174
+ return { tree: [], topLevel: [], resolves: false };
1175
+ }
1176
+ return this.treeFromNpmDryRunJson(parsed, specs);
1177
+ }
1178
+ catch {
1179
+ // fall through
1180
+ }
1181
+ }
1182
+ throw error;
1183
+ }
1184
+ }
1185
+ treeFromNpmDryRunJson(parsed, specs) {
1186
+ if (parsed == null || typeof parsed !== "object") {
1187
+ return { tree: [], topLevel: [], resolves: true };
1188
+ }
1189
+ const record = parsed;
1190
+ const tree = [];
1191
+ const root = [];
1192
+ const seen = new Set();
1193
+ const push = (name, version, entryPath) => {
1194
+ if (typeof name !== "string" || typeof version !== "string")
1195
+ return;
1196
+ if (!name || !version)
1197
+ return;
1198
+ const key = `${name}@${version}`;
1199
+ if (!seen.has(key)) {
1200
+ seen.add(key);
1201
+ tree.push({ name, version });
1202
+ }
1203
+ if (isRootNodeModulesPath(entryPath))
1204
+ root.push({ name, version });
1205
+ };
1206
+ if (Array.isArray(record.add)) {
1207
+ for (const entry of record.add) {
1208
+ if (entry == null || typeof entry !== "object")
1209
+ continue;
1210
+ const pkg = entry;
1211
+ push(pkg.name, pkg.version, pkg.path);
1212
+ }
1213
+ }
1214
+ if (Array.isArray(record.change)) {
1215
+ for (const entry of record.change) {
1216
+ if (entry == null || typeof entry !== "object")
1217
+ continue;
1218
+ const change = entry;
1219
+ if (change.to == null || typeof change.to !== "object")
1220
+ continue;
1221
+ const to = change.to;
1222
+ push(to.name, to.version, to.path);
1223
+ }
1224
+ }
1225
+ const topLevelNames = new Set(specs.map((spec) => {
1226
+ if (spec.startsWith("@")) {
1227
+ const at = spec.indexOf("@", 1);
1228
+ return at > 0 ? spec.slice(0, at) : spec;
1229
+ }
1230
+ return spec.split("@")[0] ?? spec;
1231
+ }));
1232
+ // Top-level is the copy npm puts at the app's own `node_modules/<name>`.
1233
+ // A dependency's nested copy shares the requested name but is a different
1234
+ // version, and vouching for it would let the requested one install
1235
+ // unchecked.
1236
+ const topLevel = root.filter((pkg) => topLevelNames.has(pkg.name));
1237
+ return { tree, topLevel, resolves: true };
1238
+ }
1239
+ async readPackagesFromPackageLock() {
1240
+ const lockPath = path.join(this.appRootDirPath, "package-lock.json");
1241
+ let raw;
1242
+ try {
1243
+ raw = await fsReadFile(lockPath, "utf8");
1244
+ }
1245
+ catch {
1246
+ return null;
1247
+ }
1248
+ let parsed;
1249
+ try {
1250
+ parsed = JSON.parse(raw);
1251
+ }
1252
+ catch {
1253
+ return null;
1254
+ }
1255
+ if (parsed == null || typeof parsed !== "object") {
1256
+ return null;
1257
+ }
1258
+ const lock = parsed;
1259
+ const tree = [];
1260
+ const seen = new Set();
1261
+ const push = (name, version) => {
1262
+ const key = `${name}@${version}`;
1263
+ if (seen.has(key))
1264
+ return;
1265
+ seen.add(key);
1266
+ tree.push({ name, version });
1267
+ };
1268
+ if (lock.packages && typeof lock.packages === "object") {
1269
+ for (const [lockPathKey, meta] of Object.entries(lock.packages)) {
1270
+ if (!meta || typeof meta.version !== "string")
1271
+ continue;
1272
+ // Only `node_modules/**` entries are installed packages. `packages[""]`
1273
+ // (and any workspace root) is first-party code carrying a
1274
+ // builder-chosen name, so scanning it would block every install by an
1275
+ // app that happens to be named after a malicious package.
1276
+ const pathName = packageNameFromLockPath(lockPathKey);
1277
+ if (!pathName)
1278
+ continue;
1279
+ // An aliased install (`"node_modules/alias": { name: "real" }`) fetches
1280
+ // `meta.name` from the registry; that is the name to scan.
1281
+ push(typeof meta.name === "string" && meta.name.length > 0
1282
+ ? meta.name
1283
+ : pathName, meta.version);
1284
+ }
1285
+ }
1286
+ else if (lock.dependencies && typeof lock.dependencies === "object") {
1287
+ // lockfileVersion 1: nested `dependencies` hold transitive pins.
1288
+ const walkV1 = (deps) => {
1289
+ if (deps == null || typeof deps !== "object")
1290
+ return;
1291
+ for (const [name, meta] of Object.entries(deps)) {
1292
+ if (meta == null || typeof meta !== "object")
1293
+ continue;
1294
+ if (!("version" in meta) || typeof meta.version !== "string") {
1295
+ continue;
1296
+ }
1297
+ push(name, meta.version);
1298
+ if ("dependencies" in meta &&
1299
+ meta.dependencies != null &&
1300
+ typeof meta.dependencies === "object") {
1301
+ walkV1(meta.dependencies);
1302
+ }
1303
+ }
1304
+ };
1305
+ walkV1(lock.dependencies);
1306
+ }
1307
+ return { tree, topLevel: tree, resolves: true };
1308
+ }
1309
+ /**
1310
+ * Vulnerability gate. Mode 1 remediates/blocks top-level CVEs; Mode 2
1311
+ * silently rewrites declared deps (same major) and leaves the rest.
1312
+ * No-op when `packageLookup` is unwired. Index failures → malware fail-closed.
1313
+ */
1314
+ async maybeRemediateVulnerabilities(args) {
1315
+ const { abortSignal, cveUserOverride, mode } = args;
1316
+ if (!this.packageLookup) {
1317
+ return { substitutions: [] };
1318
+ }
1319
+ const packages = mode === "all"
1320
+ ? await readDeclaredDependencies(this.appRootDirPath)
1321
+ : args.packages;
1322
+ return await runVulnerabilityGate({
1323
+ abortSignal,
1324
+ capabilities: {
1325
+ lookupPackages: async (resolved) => await this.lookupPackageVulnerabilities(resolved),
1326
+ onIndexUnavailable: ({ error, message }) => {
1327
+ emitIndexUnavailableMetric();
1328
+ getLogger().warn(message, getErrorMeta(error));
1329
+ },
1330
+ pickQuarantineHoldback: this.packageLookup.pickQuarantineHoldback,
1331
+ resolveInstallTree: async (resolveArgs) => await this.resolveInstallTree(resolveArgs.mode, resolveArgs.packages, resolveArgs.abortSignal),
1332
+ resolvePackageSpecs: async (resolveArgs) => await this.resolveTreeViaNpmDryRun(resolveArgs.specs, resolveArgs.abortSignal),
1333
+ },
1334
+ cveUserOverride,
1335
+ mode,
1336
+ packages,
1337
+ });
1338
+ }
817
1339
  /**
818
1340
  * Derive the `hasAnyRegistryConfigured` tri-state from the
819
1341
  * `NpmRegistryFetchResult` that the immediately-preceding
@@ -966,14 +1488,14 @@ let AppShell = (() => {
966
1488
  // APPS-4370: short-circuit before any exec when server is unreachable
967
1489
  // AND this org's marker says it was previously configured.
968
1490
  this.maybeShortCircuitForUnreachable(registryFetchResult, []);
969
- const ignoreScripts = shouldIgnoreInstallScripts(registryFetchResult);
970
1491
  // In cloud edit mode, always use npm because pnpm is not available in the container.
971
1492
  // The user's project may have pnpm signals (pnpm-lock.yaml, packageManager field)
972
1493
  // from local editing, but we must use npm in cloud mode.
973
1494
  const isCloudMode = process.env.SUPERBLOCKS_SERVER_ENV !== "local";
974
1495
  let packageManagerAgent;
975
1496
  if (isCloudMode) {
976
- // Force npm in cloud mode
1497
+ // Force npm in cloud mode; prepare before Mode 2 so dry-run malware
1498
+ // scan does not choke on workspace: protocol entries.
977
1499
  packageManagerAgent = "npm";
978
1500
  await this.maybePreparePackageJsonForCloudNpm();
979
1501
  }
@@ -993,6 +1515,11 @@ let AppShell = (() => {
993
1515
  }
994
1516
  packageManagerAgent = packageManager.agent;
995
1517
  }
1518
+ const appliedSubstitutions = await this.applyDeclaredDependencyUpgrades(await this.maybeRemediateVulnerabilities({
1519
+ abortSignal,
1520
+ mode: "all",
1521
+ }));
1522
+ const ignoreScripts = shouldIgnoreInstallScripts(registryFetchResult);
996
1523
  const isNpm = packageManagerAgent === "npm";
997
1524
  const baseInstallArgs = isNpm
998
1525
  ? ["--fund=false", "--audit=false", "--json"]
@@ -1014,6 +1541,8 @@ let AppShell = (() => {
1014
1541
  // default 1 MiB exec buffer on fat dep trees.
1015
1542
  maxBuffer: NPM_JSON_MAX_BUFFER,
1016
1543
  });
1544
+ // Audit only after the install succeeds (same as Mode 1).
1545
+ this.maybeReportSubstitutions(appliedSubstitutions, "install_all");
1017
1546
  // On the npm path stdout is a JSON blob; translate to a compact
1018
1547
  // human-readable summary for the agent, falling back to raw stdout
1019
1548
  // when the JSON shape drifts.
@@ -1038,7 +1567,7 @@ let AppShell = (() => {
1038
1567
  this.observeNpmInstall("install_all", capturedFetchResult, outcome, Date.now() - installStart);
1039
1568
  }
1040
1569
  }
1041
- async installSpecificPackages(packages, abortSignal) {
1570
+ async installSpecificPackages(packages, abortSignal, options) {
1042
1571
  if (!packages || packages.length === 0) {
1043
1572
  throw new Error("No packages specified for installation");
1044
1573
  }
@@ -1053,10 +1582,10 @@ let AppShell = (() => {
1053
1582
  // surfaces them to the agent ("we tried to install lodash and the
1054
1583
  // private registry was unreachable").
1055
1584
  this.maybeShortCircuitForUnreachable(registryFetchResult, packages.map((pkg) => pkg.version ? `${pkg.name}@${pkg.version}` : pkg.name));
1056
- const ignoreScripts = shouldIgnoreInstallScripts(registryFetchResult);
1057
1585
  // In cloud edit mode, always use npm because pnpm is not available in the container.
1058
1586
  // The user's project may have pnpm signals (pnpm-lock.yaml, packageManager field)
1059
- // from local editing, but we must use npm in cloud mode.
1587
+ // from local editing, but we must use npm in cloud mode. Prepare before the
1588
+ // Mode 1 dry-run so leftover workspace: entries cannot break malware coverage.
1060
1589
  const isCloudMode = process.env.SUPERBLOCKS_SERVER_ENV !== "local";
1061
1590
  let packageManagerAgent;
1062
1591
  if (isCloudMode) {
@@ -1079,10 +1608,21 @@ let AppShell = (() => {
1079
1608
  }
1080
1609
  packageManagerAgent = packageManager.agent;
1081
1610
  }
1611
+ // Malware hard-block on the dry-run resolved tree (top-level +
1612
+ // transitive) before the real install runs. Mode 1 also returns
1613
+ // silent CVE substitutions applied to the install argv below.
1614
+ const remediation = await this.maybeRemediateVulnerabilities({
1615
+ abortSignal,
1616
+ cveUserOverride: options?.cveUserOverride,
1617
+ mode: "specific",
1618
+ packages,
1619
+ });
1620
+ const packagesToInstall = applyPackageSubstitutions(packages, remediation.substitutions);
1621
+ const ignoreScripts = shouldIgnoreInstallScripts(registryFetchResult);
1082
1622
  const isNpm = packageManagerAgent === "npm";
1083
1623
  // Separate regular and dev dependencies
1084
- const regularPackages = packages.filter((pkg) => !pkg.dev);
1085
- const devPackages = packages.filter((pkg) => pkg.dev);
1624
+ const regularPackages = packagesToInstall.filter((pkg) => !pkg.dev);
1625
+ const devPackages = packagesToInstall.filter((pkg) => pkg.dev);
1086
1626
  const outputs = [];
1087
1627
  // Install regular dependencies
1088
1628
  if (regularPackages.length > 0) {
@@ -1146,6 +1686,18 @@ let AppShell = (() => {
1146
1686
  throw new NpmInstallError(error.stderr || error.message);
1147
1687
  }
1148
1688
  }
1689
+ // Audit only after every install group succeeds: a selected
1690
+ // substitution is not an applied substitution if npm fails.
1691
+ this.maybeReportSubstitutions(remediation.substitutions, "install_specific");
1692
+ // Audited here, not at the tool layer: the gate is what consumed the
1693
+ // override, and the install has now actually landed.
1694
+ if (options?.cveUserOverride &&
1695
+ remediation.userOverridePackages?.length) {
1696
+ this.reportNpmUserOverride({
1697
+ packages: remediation.userOverridePackages,
1698
+ reason: options.cveUserOverride.reason,
1699
+ });
1700
+ }
1149
1701
  return outputs.join("\n");
1150
1702
  });
1151
1703
  }