@muggleai/works 5.12.0-staging.73 → 5.12.0-staging.75

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.
@@ -69,12 +69,14 @@ __export(src_exports, {
69
69
  getElectronAppChecksums: () => getElectronAppChecksums,
70
70
  getElectronAppDir: () => getElectronAppDir,
71
71
  getElectronAppReleaseTagPrefix: () => getElectronAppReleaseTagPrefix,
72
+ getElectronAppSignedFromVersion: () => getElectronAppSignedFromVersion,
72
73
  getElectronAppVersion: () => getElectronAppVersion,
73
74
  getElectronAppVersionSource: () => getElectronAppVersionSource,
74
75
  getLocalQaTools: () => getLocalQaTools,
75
76
  getLogger: () => getLogger,
76
77
  getPlatformKey: () => getPlatformKey,
77
78
  getQaTools: () => getQaTools,
79
+ getReleaseSignerIdentityUri: () => getReleaseSignerIdentityUri,
78
80
  getValidApiKeyData: () => getValidApiKeyData,
79
81
  getValidCredentials: () => getValidCredentials,
80
82
  hasApiKey: () => hasApiKey,
@@ -271,6 +273,7 @@ function readReleaseManifest() {
271
273
  // packages/mcps/src/shared/config.ts
272
274
  var ELECTRON_APP_DIR = "electron-app";
273
275
  var API_KEY_FILE = "api-key.json";
276
+ var OAUTH_SESSION_FILE = "oauth-session.json";
274
277
  var configInstance = null;
275
278
  var muggleConfigCache = null;
276
279
  function getPackageRoot2() {
@@ -359,6 +362,12 @@ function buildElectronAppDirName(params) {
359
362
  }
360
363
  return `${params.version}-${params.releaseStream}`;
361
364
  }
365
+ function buildOAuthSessionFileName(params) {
366
+ if (params.runtimeTarget === "production" /* Production */) {
367
+ return OAUTH_SESSION_FILE;
368
+ }
369
+ return `oauth-session-${params.runtimeTarget}.json`;
370
+ }
362
371
  function getDownloadedElectronAppPath() {
363
372
  const platformName = os4.platform();
364
373
  const version = getElectronAppVersion();
@@ -479,7 +488,10 @@ function buildLocalQaConfig() {
479
488
  projectsDir: path11.join(dataDir, "projects"),
480
489
  tempDir: path11.join(dataDir, "temp"),
481
490
  apiKeyFilePath: path11.join(dataDir, API_KEY_FILE),
482
- oauthSessionFilePath: path11.join(dataDir, "oauth-session.json"),
491
+ oauthSessionFilePath: path11.join(
492
+ dataDir,
493
+ buildOAuthSessionFileName({ runtimeTarget: getActiveRuntimeTarget() })
494
+ ),
483
495
  webServicePath: resolveWebServicePath(),
484
496
  webServicePidFile: path11.join(dataDir, "web-service.pid"),
485
497
  auth0: {
@@ -569,6 +581,12 @@ function buildElectronAppChecksumsUrl(version) {
569
581
  function getElectronAppChecksums() {
570
582
  return getMuggleConfig().checksumsByStream?.[getActiveElectronAppReleaseStream()];
571
583
  }
584
+ function getElectronAppSignedFromVersion() {
585
+ return getMuggleConfig().electronAppSignedFromVersion ?? "";
586
+ }
587
+ function getReleaseSignerIdentityUri() {
588
+ return getMuggleConfig().signerIdentityUri ?? "";
589
+ }
572
590
  function isElectronAppInstalled() {
573
591
  return getDownloadedElectronAppPath() !== null;
574
592
  }
@@ -933,6 +951,14 @@ var TestResultStatus = /* @__PURE__ */ ((TestResultStatus2) => {
933
951
  return TestResultStatus2;
934
952
  })(TestResultStatus || {});
935
953
 
954
+ // packages/mcps/src/mcp/local/services/stored-auth-target.ts
955
+ function isStoredAuthForRuntimeTarget(params) {
956
+ if (!params.storedRuntimeTarget) {
957
+ return params.activeRuntimeTarget === "production" /* Production */;
958
+ }
959
+ return params.storedRuntimeTarget === params.activeRuntimeTarget;
960
+ }
961
+
936
962
  // packages/mcps/src/mcp/local/services/auth-service.ts
937
963
  var DEFAULT_LOGIN_WAIT_TIMEOUT_MS = 12e4;
938
964
  var AuthService = class {
@@ -1267,7 +1293,8 @@ var AuthService = class {
1267
1293
  refreshToken: tokenResponse.refreshToken,
1268
1294
  expiresAt,
1269
1295
  email,
1270
- userId
1296
+ userId,
1297
+ runtimeTarget: getActiveRuntimeTarget()
1271
1298
  };
1272
1299
  const dir = path11.dirname(this.oauthSessionFilePath);
1273
1300
  if (!fs6.existsSync(dir)) {
@@ -1280,7 +1307,8 @@ var AuthService = class {
1280
1307
  logger6.info("Auth stored successfully", { email, expiresAt });
1281
1308
  }
1282
1309
  /**
1283
- * Load stored authentication.
1310
+ * Load stored authentication for the active runtime target.
1311
+ * @returns Stored auth, or null when it is absent, unreadable, or was issued for another target.
1284
1312
  */
1285
1313
  loadStoredAuth() {
1286
1314
  const logger6 = getLogger();
@@ -1289,7 +1317,19 @@ var AuthService = class {
1289
1317
  }
1290
1318
  try {
1291
1319
  const content = fs6.readFileSync(this.oauthSessionFilePath, "utf-8");
1292
- return JSON.parse(content);
1320
+ const storedAuth = JSON.parse(content);
1321
+ const activeRuntimeTarget = getActiveRuntimeTarget();
1322
+ if (!isStoredAuthForRuntimeTarget({
1323
+ storedRuntimeTarget: storedAuth.runtimeTarget,
1324
+ activeRuntimeTarget
1325
+ })) {
1326
+ logger6.warn("Ignoring stored auth issued for a different runtime target", {
1327
+ storedRuntimeTarget: storedAuth.runtimeTarget ?? "unrecorded",
1328
+ activeRuntimeTarget
1329
+ });
1330
+ return null;
1331
+ }
1332
+ return storedAuth;
1293
1333
  } catch (error) {
1294
1334
  logger6.error("Failed to load stored auth", {
1295
1335
  error: error instanceof Error ? error.message : String(error)
@@ -1370,7 +1410,8 @@ var AuthService = class {
1370
1410
  refreshToken: tokenData.refresh_token ?? storedAuth.refreshToken,
1371
1411
  expiresAt: newExpiresAt,
1372
1412
  email: storedAuth.email,
1373
- userId: storedAuth.userId
1413
+ userId: storedAuth.userId,
1414
+ runtimeTarget: storedAuth.runtimeTarget ?? getActiveRuntimeTarget()
1374
1415
  };
1375
1416
  const dir = path11.dirname(this.oauthSessionFilePath);
1376
1417
  if (!fs6.existsSync(dir)) {
@@ -7727,4 +7768,4 @@ var WATCHER_LIFETIME_SECONDS = {
7727
7768
  ["never" /* Never */]: WATCHER_LIFETIME_UNBOUNDED_SECONDS
7728
7769
  };
7729
7770
 
7730
- export { DEFAULT_PREFERENCES, ElectronAppReleaseStream, EventName, PREFERENCES_FILE_NAME, PREFERENCES_PROJECT_DIR_NAME, PREFERENCES_SCHEMA, PREFERENCES_VERSION, PREFERENCE_ALLOWED_VALUES, PreferenceKey, PreferenceValue, ProjectPreferencesReconcileOutcome, RuntimeTarget, ServiceName, Surface, WATCHER_LIFETIME_SECONDS, WATCHER_LIFETIME_UNBOUNDED_SECONDS, __export, __require, assertDeviceCodeClientProvisioned, buildElectronAppChecksumsUrl, buildElectronAppReleaseAssetUrl, buildElectronAppReleaseTag, calculateFileChecksum, createApiKeyWithToken, createChildLogger, deleteApiKeyData, deleteCredentials, e2e_exports2 as e2e_exports, formatPreferencesOneLiner, getActiveElectronAppReleaseStream, getActiveRuntimeTarget, getApiKey, getApiKeyFilePath, getAuthService, getBundledElectronAppVersion, getCallerCredentials, getCallerCredentialsAsync, getChecksumForPlatform, getConfig, getCredentialsFilePath, getDataDir2 as getDataDir, getDisclosureCopy, getDownloadBaseUrl, getElectronAppChecksums, getElectronAppDir, getElectronAppReleaseTagPrefix, getElectronAppVersion, getElectronAppVersionSource, getLocalQaTools, getLogger, getPlatformKey, getQaTools, getValidApiKeyData, getValidCredentials, hasApiKey, hasShownDisclosure, initTelemetry, isElectronAppInstalled, isFirstRun, loadApiKeyData, loadCredentials, local_exports2 as local_exports, markDisclosureShown, mcp_exports, openBrowserUrl, performLogin, performLogout, pollDeviceCode, reconcileProjectPreferences, resetConfig, resetLogger, resetPreference, resolveActiveProfile, resolveActiveReleaseStream, resolveActiveReleaseTagPrefix, resolveElectronAppPathOrNull, resolvePreferences, resolveRuntimeTarget, saveApiKey, saveApiKeyData, saveCredentials, src_exports, startDeviceCodeFlow, toolRequiresAuth, track, validatePreference, verifyFileChecksum, writePreferences2 as writePreferences };
7771
+ export { DEFAULT_PREFERENCES, ElectronAppReleaseStream, EventName, PREFERENCES_FILE_NAME, PREFERENCES_PROJECT_DIR_NAME, PREFERENCES_SCHEMA, PREFERENCES_VERSION, PREFERENCE_ALLOWED_VALUES, PreferenceKey, PreferenceValue, ProjectPreferencesReconcileOutcome, RuntimeTarget, ServiceName, Surface, WATCHER_LIFETIME_SECONDS, WATCHER_LIFETIME_UNBOUNDED_SECONDS, __export, __require, assertDeviceCodeClientProvisioned, buildElectronAppChecksumsUrl, buildElectronAppReleaseAssetUrl, buildElectronAppReleaseTag, calculateFileChecksum, createApiKeyWithToken, createChildLogger, deleteApiKeyData, deleteCredentials, e2e_exports2 as e2e_exports, formatPreferencesOneLiner, getActiveElectronAppReleaseStream, getActiveRuntimeTarget, getApiKey, getApiKeyFilePath, getAuthService, getBundledElectronAppVersion, getCallerCredentials, getCallerCredentialsAsync, getChecksumForPlatform, getConfig, getCredentialsFilePath, getDataDir2 as getDataDir, getDisclosureCopy, getDownloadBaseUrl, getElectronAppChecksums, getElectronAppDir, getElectronAppReleaseTagPrefix, getElectronAppSignedFromVersion, getElectronAppVersion, getElectronAppVersionSource, getLocalQaTools, getLogger, getPlatformKey, getQaTools, getReleaseSignerIdentityUri, getValidApiKeyData, getValidCredentials, hasApiKey, hasShownDisclosure, initTelemetry, isElectronAppInstalled, isFirstRun, loadApiKeyData, loadCredentials, local_exports2 as local_exports, markDisclosureShown, mcp_exports, openBrowserUrl, performLogin, performLogout, pollDeviceCode, reconcileProjectPreferences, resetConfig, resetLogger, resetPreference, resolveActiveProfile, resolveActiveReleaseStream, resolveActiveReleaseTagPrefix, resolveElectronAppPathOrNull, resolvePreferences, resolveRuntimeTarget, saveApiKey, saveApiKeyData, saveCredentials, src_exports, startDeviceCodeFlow, toolRequiresAuth, track, validatePreference, verifyFileChecksum, writePreferences2 as writePreferences };
@@ -1,4 +1,4 @@
1
- import { __export, getLogger, getConfig, createChildLogger, buildElectronAppReleaseAssetUrl, getActiveRuntimeTarget, getAuthService, hasApiKey, getElectronAppVersion, getElectronAppDir, getPlatformKey, reconcileProjectPreferences, getDataDir, PREFERENCES_FILE_NAME, isFirstRun, writePreferences, DEFAULT_PREFERENCES, isElectronAppInstalled, getElectronAppChecksums, getChecksumForPlatform, verifyFileChecksum, calculateFileChecksum, initTelemetry, Surface, ServiceName, track, EventName, getQaTools, getLocalQaTools, performLogout, assertDeviceCodeClientProvisioned, performLogin, toolRequiresAuth, getCallerCredentials, hasShownDisclosure, getDisclosureCopy, markDisclosureShown, getBundledElectronAppVersion, getElectronAppVersionSource, getCredentialsFilePath, buildElectronAppChecksumsUrl, __require } from './chunk-FCDHJD6I.js';
1
+ import { __export, getLogger, getConfig, createChildLogger, buildElectronAppReleaseAssetUrl, getActiveRuntimeTarget, getAuthService, hasApiKey, getElectronAppVersion, getElectronAppDir, getPlatformKey, reconcileProjectPreferences, getDataDir, PREFERENCES_FILE_NAME, isFirstRun, writePreferences, DEFAULT_PREFERENCES, isElectronAppInstalled, getElectronAppChecksums, getChecksumForPlatform, getElectronAppSignedFromVersion, getReleaseSignerIdentityUri, verifyFileChecksum, calculateFileChecksum, initTelemetry, Surface, ServiceName, track, EventName, getQaTools, getLocalQaTools, performLogout, assertDeviceCodeClientProvisioned, performLogin, toolRequiresAuth, getCallerCredentials, hasShownDisclosure, getDisclosureCopy, markDisclosureShown, getBundledElectronAppVersion, getElectronAppVersionSource, getCredentialsFilePath, buildElectronAppChecksumsUrl, __require } from './chunk-DJ6W5PZA.js';
2
2
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
3
  import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { v4 } from 'uuid';
@@ -14,6 +14,8 @@ import axios from 'axios';
14
14
  import { platform, homedir, arch } from 'os';
15
15
  import { execFile } from 'child_process';
16
16
  import { pipeline } from 'stream/promises';
17
+ import { readFile } from 'fs/promises';
18
+ import { verify } from 'sigstore';
17
19
 
18
20
  var registeredTools = [];
19
21
  function registerTools(tools) {
@@ -751,7 +753,7 @@ async function resolveGsScreenshotUrls(report, opts) {
751
753
  if (gsUrls.length === 0) {
752
754
  return report;
753
755
  }
754
- const mcps = await import('./src-TFNPZD5X.js');
756
+ const mcps = await import('./src-FRYBTZ6J.js');
755
757
  const credentials = await mcps.getCallerCredentialsAsync();
756
758
  if (!credentials.bearerToken && !credentials.apiKey) {
757
759
  stderrWrite(
@@ -1731,6 +1733,79 @@ async function serveCommand(options) {
1731
1733
  process.exit(1);
1732
1734
  }
1733
1735
  }
1736
+
1737
+ // scripts/release-integrity/constants.mjs
1738
+ var SIGNATURE_BUNDLE_SUFFIX = ".sigstore.json";
1739
+ var SIGNER_CERTIFICATE_ISSUER = "https://token.actions.githubusercontent.com";
1740
+ var SIGNATURE_FETCH_TIMEOUT_MS = 3e4;
1741
+
1742
+ // scripts/release-integrity/compareVersions.mjs
1743
+ function compareVersions2(a, b) {
1744
+ const partsA = a.split(".").map(Number);
1745
+ const partsB = b.split(".").map(Number);
1746
+ for (let index = 0; index < 3; index++) {
1747
+ const partA = partsA[index] || 0;
1748
+ const partB = partsB[index] || 0;
1749
+ if (partA > partB) {
1750
+ return 1;
1751
+ }
1752
+ if (partA < partB) {
1753
+ return -1;
1754
+ }
1755
+ }
1756
+ return 0;
1757
+ }
1758
+
1759
+ // scripts/release-integrity/resolveIntegrityPolicy.mjs
1760
+ function resolveIntegrityPolicy({ version, signedFromVersion, expectedChecksum }) {
1761
+ const requiresSignature = Boolean(signedFromVersion) && compareVersions2(version, signedFromVersion) >= 0;
1762
+ const hasChecksum = Boolean(expectedChecksum && expectedChecksum.trim());
1763
+ if (!requiresSignature && !hasChecksum) {
1764
+ return {
1765
+ requiresSignature: false,
1766
+ requiresChecksum: false,
1767
+ unverifiableReason: `no integrity evidence is available for v${version}: it predates release signing (first signed version: ${signedFromVersion || "none configured"}) and no checksum is configured for this release stream`
1768
+ };
1769
+ }
1770
+ return {
1771
+ requiresSignature,
1772
+ requiresChecksum: !requiresSignature,
1773
+ unverifiableReason: ""
1774
+ };
1775
+ }
1776
+ async function verifyReleaseSignature({ artifactPath, bundleUrl, signerIdentityUri }) {
1777
+ if (!signerIdentityUri) {
1778
+ return { valid: false, reason: "no signer identity is configured to verify against" };
1779
+ }
1780
+ const controller = new AbortController();
1781
+ const timer = setTimeout(() => controller.abort(), SIGNATURE_FETCH_TIMEOUT_MS);
1782
+ let bundle;
1783
+ try {
1784
+ const response = await fetch(bundleUrl, { signal: controller.signal });
1785
+ if (!response.ok) {
1786
+ return {
1787
+ valid: false,
1788
+ reason: `signature bundle unavailable (HTTP ${response.status}) at ${bundleUrl}`
1789
+ };
1790
+ }
1791
+ bundle = await response.json();
1792
+ } catch (error) {
1793
+ return { valid: false, reason: `could not fetch signature bundle: ${error.message}` };
1794
+ } finally {
1795
+ clearTimeout(timer);
1796
+ }
1797
+ try {
1798
+ await verify(bundle, await readFile(artifactPath), {
1799
+ certificateIssuer: SIGNER_CERTIFICATE_ISSUER,
1800
+ certificateIdentityURI: signerIdentityUri
1801
+ });
1802
+ return { valid: true, reason: "" };
1803
+ } catch (error) {
1804
+ return { valid: false, reason: error.message };
1805
+ }
1806
+ }
1807
+
1808
+ // src/cli/setup.ts
1734
1809
  var logger6 = getLogger();
1735
1810
  var MAX_RETRY_ATTEMPTS = 3;
1736
1811
  var RETRY_BASE_DELAY_MS = 2e3;
@@ -1922,9 +1997,30 @@ async function setupCommand(options) {
1922
1997
  mkdirSync(versionDir, { recursive: true });
1923
1998
  const tempFile = path.join(versionDir, binaryName);
1924
1999
  await downloadWithRetry(downloadUrl, tempFile);
1925
- console.log("Download complete, verifying checksum...");
2000
+ console.log("Download complete, verifying integrity...");
1926
2001
  const checksums = getElectronAppChecksums();
1927
2002
  const expectedChecksum = getChecksumForPlatform(checksums);
2003
+ const integrityPolicy = resolveIntegrityPolicy({
2004
+ version,
2005
+ signedFromVersion: getElectronAppSignedFromVersion(),
2006
+ expectedChecksum: expectedChecksum ?? ""
2007
+ });
2008
+ if (integrityPolicy.unverifiableReason) {
2009
+ cleanupFailedInstall(versionDir);
2010
+ throw new Error("Refusing to install an unverifiable download: " + integrityPolicy.unverifiableReason);
2011
+ }
2012
+ if (integrityPolicy.requiresSignature) {
2013
+ const signatureResult = await verifyReleaseSignature({
2014
+ artifactPath: tempFile,
2015
+ bundleUrl: downloadUrl + SIGNATURE_BUNDLE_SUFFIX,
2016
+ signerIdentityUri: getReleaseSignerIdentityUri()
2017
+ });
2018
+ if (!signatureResult.valid) {
2019
+ cleanupFailedInstall(versionDir);
2020
+ throw new Error("Signature verification failed, refusing to install: " + signatureResult.reason);
2021
+ }
2022
+ console.log("Signature verified successfully.");
2023
+ }
1928
2024
  const checksumResult = await verifyFileChecksum(tempFile, expectedChecksum);
1929
2025
  if (!checksumResult.valid && expectedChecksum) {
1930
2026
  cleanupFailedInstall(versionDir);
@@ -1937,8 +2033,6 @@ The downloaded file may be corrupted or tampered with.`
1937
2033
  }
1938
2034
  if (expectedChecksum) {
1939
2035
  console.log("Checksum verified successfully.");
1940
- } else {
1941
- console.log("Warning: No checksum configured, skipping verification.");
1942
2036
  }
1943
2037
  console.log("Extracting...");
1944
2038
  if (binaryName.endsWith(".zip")) {
@@ -2067,21 +2161,6 @@ async function checkForUpdates() {
2067
2161
  throw new Error(`Failed to check for updates: ${errorMessage}`, { cause: error });
2068
2162
  }
2069
2163
  }
2070
- function compareVersions2(a, b) {
2071
- const partsA = a.split(".").map(Number);
2072
- const partsB = b.split(".").map(Number);
2073
- for (let i = 0; i < 3; i++) {
2074
- const partA = partsA[i] || 0;
2075
- const partB = partsB[i] || 0;
2076
- if (partA > partB) {
2077
- return 1;
2078
- }
2079
- if (partA < partB) {
2080
- return -1;
2081
- }
2082
- }
2083
- return 0;
2084
- }
2085
2164
  function getExpectedExecutablePath3(versionDir) {
2086
2165
  const os = platform();
2087
2166
  switch (os) {
@@ -2208,11 +2287,32 @@ async function downloadAndInstall(version, downloadUrl, checksum) {
2208
2287
  throw new Error("No response body");
2209
2288
  }
2210
2289
  await pipeline(response.body, fileStream);
2211
- console.log("Download complete, verifying checksum...");
2290
+ console.log("Download complete, verifying integrity...");
2212
2291
  let expectedChecksum = checksum;
2213
2292
  if (!expectedChecksum) {
2214
2293
  expectedChecksum = await fetchChecksumFromRelease(version);
2215
2294
  }
2295
+ const integrityPolicy = resolveIntegrityPolicy({
2296
+ version,
2297
+ signedFromVersion: getElectronAppSignedFromVersion(),
2298
+ expectedChecksum: expectedChecksum ?? ""
2299
+ });
2300
+ if (integrityPolicy.unverifiableReason) {
2301
+ rmSync(versionDir, { recursive: true, force: true });
2302
+ throw new Error("Refusing to install an unverifiable download: " + integrityPolicy.unverifiableReason);
2303
+ }
2304
+ if (integrityPolicy.requiresSignature) {
2305
+ const signatureResult = await verifyReleaseSignature({
2306
+ artifactPath: tempFile,
2307
+ bundleUrl: downloadUrl + SIGNATURE_BUNDLE_SUFFIX,
2308
+ signerIdentityUri: getReleaseSignerIdentityUri()
2309
+ });
2310
+ if (!signatureResult.valid) {
2311
+ rmSync(versionDir, { recursive: true, force: true });
2312
+ throw new Error("Signature verification failed, refusing to install: " + signatureResult.reason);
2313
+ }
2314
+ console.log("Signature verified successfully.");
2315
+ }
2216
2316
  const checksumResult = await verifyFileChecksum(tempFile, expectedChecksum || "");
2217
2317
  if (!checksumResult.valid && expectedChecksum) {
2218
2318
  rmSync(versionDir, { recursive: true, force: true });
@@ -2225,8 +2325,6 @@ The downloaded file may be corrupted or tampered with.`
2225
2325
  }
2226
2326
  if (expectedChecksum) {
2227
2327
  console.log("Checksum verified successfully.");
2228
- } else {
2229
- console.log("Warning: No checksum available, skipping verification.");
2230
2328
  }
2231
2329
  console.log("Extracting...");
2232
2330
  if (binaryName.endsWith(".zip")) {
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { runCli } from './chunk-O7LFABOC.js';
3
- import './chunk-FCDHJD6I.js';
2
+ import { runCli } from './chunk-VTSS6TGQ.js';
3
+ import './chunk-DJ6W5PZA.js';
4
4
 
5
5
  // src/cli/main.ts
6
6
  runCli().catch((error) => {
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-O7LFABOC.js';
2
- export { createChildLogger, e2e_exports as e2e, getConfig, getLocalQaTools, getLogger, getQaTools, local_exports as localQa, mcp_exports as mcp, e2e_exports as qa, src_exports as shared } from './chunk-FCDHJD6I.js';
1
+ export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-VTSS6TGQ.js';
2
+ export { createChildLogger, e2e_exports as e2e, getConfig, getLocalQaTools, getLogger, getQaTools, local_exports as localQa, mcp_exports as mcp, e2e_exports as qa, src_exports as shared } from './chunk-DJ6W5PZA.js';
@@ -24,6 +24,8 @@ Enforcement is reserved for the handoffs that were being skipped: the E2E accept
24
24
 
25
25
  ## Mechanism
26
26
 
27
+ A recorder that *clears* an obligation — a walkthrough posted, a failure diagnosed, a test case classified — records only when the call did not visibly fail (`callOutcome.ts`). Recording from the request alone let a rejected `gh pr comment` mark the walkthrough posted, so the gate went quiet on a PR that never received it.
28
+
27
29
  Each guardrail is a thin bash wrapper in `../scripts/` registered in `hooks.json`. The wrapper pipes the event payload (stdin JSON) to the bundled `../scripts/guardrails.mjs <subcommand>`, which holds the decision logic (built from `src/guardrails/`, vitest-covered). Per-session state in `~/.muggle-ai/guardrails/<session_id>.json` tracks what fired. Any *failure* degrades to `{}` (allow) — a gate blocks only by an explicit, tested decision, never by accident.
28
30
 
29
31
  Each wrapper short-circuits in shell first, so the common case never pays Node cold-start. A gate that has spent its block budget stamps a `<gate>Released` flag, which its wrapper then pre-filters on — without it a released gate keeps cold-starting Node on every remaining turn end to answer `{}`, and the walkthrough gate keeps making provider calls to do it. That pre-filter is a second, looser copy of what `guardrails.mjs` matches, and it is the one place a guardrail can fail *silently*: a payload it drops — a skip marker, a reopen line, a comment edit — reaches no recorder, and the gate keeps demanding an action the user already took. Over-matching is free; under-matching is a dead escape hatch. `src/test/guardrails/hook-prefilter.test.ts` pins every payload each subcommand acts on against the wrapper guarding it, and derives the skip-marker tokens from source so a new marker is covered the moment it exists.
@@ -40,6 +40,14 @@ var MAX_REPLY_BLOCKS = 3;
40
40
  var CAPABILITY_CLAIM_TRANSCRIPT_TAIL_BYTES = 64e3;
41
41
  var SESSION_STATE_LOCK_WAIT_MS = 250;
42
42
  var LOCK_POLL_INTERVAL_MS = 10;
43
+ var CALL_FAILURE_SIGNALS = [
44
+ /\bgh:\s/,
45
+ /\bglab:\s/,
46
+ /\bHTTP\s(?:4|5)\d\d\b/,
47
+ /"status"\s*:\s*"?(?:4|5)\d\d/,
48
+ /"isError"\s*:\s*true/
49
+ ];
50
+ var FORGE_TERMINAL_CMD = /\b(?:gh\s+pr\s+(?:merge|close|reopen)|glab\s+mr\s+(?:merge|close|reopen))\b/;
43
51
 
44
52
  // src/guardrails/store/fileLock.ts
45
53
  function isProcessAlive(pid) {
@@ -159,15 +167,21 @@ ${input2.tool_response?.output ?? ""}`;
159
167
  }
160
168
 
161
169
  // src/guardrails/prTerminal.ts
170
+ function terminalProvenance(input2) {
171
+ const command = input2.tool_input?.command;
172
+ if (command === void 0) return { acceptsForgeLine: true };
173
+ return { acceptsForgeLine: FORGE_TERMINAL_CMD.test(command) };
174
+ }
162
175
  function detectPrTerminal(input2) {
163
176
  if (input2.tool_name !== "Bash" && input2.tool_name !== "Monitor") return null;
164
177
  const response = input2.tool_response;
178
+ const provenance = terminalProvenance(input2);
165
179
  const haystack = [response?.stdout, response?.stderr, response?.output, response?.content].filter((part) => typeof part === "string").join("\n");
166
- const mergedMatch = haystack.match(GH_PR_MERGED_LINE);
180
+ const mergedMatch = provenance.acceptsForgeLine ? haystack.match(GH_PR_MERGED_LINE) : null;
167
181
  if (mergedMatch) {
168
182
  return { prNumber: Number(mergedMatch[1]), verdict: "merged" /* Merged */ };
169
183
  }
170
- const closedMatch = haystack.match(GH_PR_CLOSED_LINE);
184
+ const closedMatch = provenance.acceptsForgeLine ? haystack.match(GH_PR_CLOSED_LINE) : null;
171
185
  if (closedMatch) {
172
186
  return { prNumber: Number(closedMatch[1]), verdict: "closed" /* Closed */ };
173
187
  }
@@ -182,6 +196,7 @@ function detectPrTerminal(input2) {
182
196
  }
183
197
  function detectPrReopened(input2) {
184
198
  if (input2.tool_name !== "Bash") return null;
199
+ if (!terminalProvenance(input2).acceptsForgeLine) return null;
185
200
  const response = input2.tool_response;
186
201
  const haystack = [response?.stdout, response?.stderr, response?.output, response?.content].filter((part) => typeof part === "string").join("\n");
187
202
  const reopenedMatch = haystack.match(GH_PR_REOPENED_LINE);
@@ -362,11 +377,28 @@ function stageGateDecision(state, unreadStagePaths, maxBlocks = MAX_STAGE_BLOCKS
362
377
  return { action: "block" /* Block */, blockCount: blockCount + 1, unread: unreadStagePaths };
363
378
  }
364
379
 
380
+ // src/guardrails/callOutcome.ts
381
+ function renderedResponse(toolResponse) {
382
+ const rendered = [];
383
+ const collect = (value) => {
384
+ if (typeof value === "string") rendered.push(value);
385
+ else if (Array.isArray(value)) value.forEach(collect);
386
+ else if (value && typeof value === "object") Object.values(value).forEach(collect);
387
+ };
388
+ collect(toolResponse);
389
+ return rendered.join("\n");
390
+ }
391
+ function callFailed(input2) {
392
+ const rendered = renderedResponse(input2.tool_response);
393
+ return CALL_FAILURE_SIGNALS.some((signal) => signal.test(rendered));
394
+ }
395
+
365
396
  // src/guardrails/preExecutionClassification.ts
366
397
  var CLASSIFICATION_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_CLASSIFY_SKIP\b/;
367
398
  function detectClassifiedTestCaseId(input2) {
368
399
  if (!MUGGLE_EVENT_EMIT_TOOL.test(input2.tool_name ?? "")) return void 0;
369
400
  if (input2.tool_input?.eventType !== PRE_EXECUTION_CLASSIFICATION_EVENT) return void 0;
401
+ if (callFailed(input2)) return void 0;
370
402
  return input2.tool_input?.testCaseId ?? ANY_TEST_CASE;
371
403
  }
372
404
  function applyClassifiedTestCase(state, testCaseId) {
@@ -423,6 +455,7 @@ function detectDebugEvidenceRunIds(input2, owedRunIds) {
423
455
  const toolName = input2.tool_name ?? "";
424
456
  const isDiagnosisEmit = MUGGLE_EVENT_EMIT_TOOL.test(toolName) && FAILURE_DIAGNOSIS_EVENT.test(input2.tool_input?.eventType ?? "");
425
457
  if (!isDiagnosisEmit && !MUGGLE_FEEDBACK_CREATE_TOOL.test(toolName)) return [];
458
+ if (callFailed(input2)) return [];
426
459
  const payload = serialize(input2);
427
460
  return owedRunIds.filter((runId) => payload.includes(runId));
428
461
  }
@@ -598,6 +631,7 @@ function detectWalkthroughPost(input2, read = defaultFileReader) {
598
631
  if (input2.tool_name !== "Bash") return false;
599
632
  const cmd = input2.tool_input?.command ?? "";
600
633
  if (!isPrReportPostCommand(cmd)) return false;
634
+ if (callFailed(input2)) return false;
601
635
  return collectPrPostText(cmd, input2.cwd, read).includes(REPORT_SENTINEL);
602
636
  }
603
637
  function applyWalkthroughPosted(state, posted) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "release": "5.11.0",
3
- "buildId": "run-73-1",
4
- "commitSha": "a43a1c2c9880b35b0442c511e65ffa8613ac3eb6",
5
- "buildTime": "2026-08-26T17:58:32Z",
3
+ "buildId": "run-75-1",
4
+ "commitSha": "d2d28543763361dcae7b5bef1c31ca4ae75df64c",
5
+ "buildTime": "2026-08-26T20:14:09Z",
6
6
  "serviceName": "muggle-ai-works-mcp"
7
7
  }
@@ -1 +1 @@
1
- export { DEFAULT_PREFERENCES, ElectronAppReleaseStream, PREFERENCES_FILE_NAME, PREFERENCES_PROJECT_DIR_NAME, PREFERENCES_SCHEMA, PREFERENCES_VERSION, PREFERENCE_ALLOWED_VALUES, PreferenceKey, PreferenceValue, ProjectPreferencesReconcileOutcome, RuntimeTarget, WATCHER_LIFETIME_SECONDS, WATCHER_LIFETIME_UNBOUNDED_SECONDS, assertDeviceCodeClientProvisioned, buildElectronAppChecksumsUrl, buildElectronAppReleaseAssetUrl, buildElectronAppReleaseTag, calculateFileChecksum, createApiKeyWithToken, createChildLogger, deleteApiKeyData, deleteCredentials, e2e_exports as e2e, formatPreferencesOneLiner, getActiveElectronAppReleaseStream, getActiveRuntimeTarget, getApiKey, getApiKeyFilePath, getAuthService, getBundledElectronAppVersion, getCallerCredentials, getCallerCredentialsAsync, getChecksumForPlatform, getConfig, getCredentialsFilePath, getDataDir, getDownloadBaseUrl, getElectronAppChecksums, getElectronAppDir, getElectronAppReleaseTagPrefix, getElectronAppVersion, getElectronAppVersionSource, getLocalQaTools, getLogger, getPlatformKey, getQaTools, getValidApiKeyData, getValidCredentials, hasApiKey, isElectronAppInstalled, isFirstRun, loadApiKeyData, loadCredentials, local_exports as localQa, mcp_exports as mcp, openBrowserUrl, performLogin, performLogout, pollDeviceCode, e2e_exports as qa, reconcileProjectPreferences, resetConfig, resetLogger, resetPreference, resolveActiveProfile, resolveActiveReleaseStream, resolveActiveReleaseTagPrefix, resolveElectronAppPathOrNull, resolvePreferences, resolveRuntimeTarget, saveApiKey, saveApiKeyData, saveCredentials, startDeviceCodeFlow, toolRequiresAuth, validatePreference, verifyFileChecksum, writePreferences } from './chunk-FCDHJD6I.js';
1
+ export { DEFAULT_PREFERENCES, ElectronAppReleaseStream, PREFERENCES_FILE_NAME, PREFERENCES_PROJECT_DIR_NAME, PREFERENCES_SCHEMA, PREFERENCES_VERSION, PREFERENCE_ALLOWED_VALUES, PreferenceKey, PreferenceValue, ProjectPreferencesReconcileOutcome, RuntimeTarget, WATCHER_LIFETIME_SECONDS, WATCHER_LIFETIME_UNBOUNDED_SECONDS, assertDeviceCodeClientProvisioned, buildElectronAppChecksumsUrl, buildElectronAppReleaseAssetUrl, buildElectronAppReleaseTag, calculateFileChecksum, createApiKeyWithToken, createChildLogger, deleteApiKeyData, deleteCredentials, e2e_exports as e2e, formatPreferencesOneLiner, getActiveElectronAppReleaseStream, getActiveRuntimeTarget, getApiKey, getApiKeyFilePath, getAuthService, getBundledElectronAppVersion, getCallerCredentials, getCallerCredentialsAsync, getChecksumForPlatform, getConfig, getCredentialsFilePath, getDataDir, getDownloadBaseUrl, getElectronAppChecksums, getElectronAppDir, getElectronAppReleaseTagPrefix, getElectronAppSignedFromVersion, getElectronAppVersion, getElectronAppVersionSource, getLocalQaTools, getLogger, getPlatformKey, getQaTools, getReleaseSignerIdentityUri, getValidApiKeyData, getValidCredentials, hasApiKey, isElectronAppInstalled, isFirstRun, loadApiKeyData, loadCredentials, local_exports as localQa, mcp_exports as mcp, openBrowserUrl, performLogin, performLogout, pollDeviceCode, e2e_exports as qa, reconcileProjectPreferences, resetConfig, resetLogger, resetPreference, resolveActiveProfile, resolveActiveReleaseStream, resolveActiveReleaseTagPrefix, resolveElectronAppPathOrNull, resolvePreferences, resolveRuntimeTarget, saveApiKey, saveApiKeyData, saveCredentials, startDeviceCodeFlow, toolRequiresAuth, validatePreference, verifyFileChecksum, writePreferences } from './chunk-DJ6W5PZA.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@muggleai/works",
3
3
  "mcpName": "io.github.multiplex-ai/muggle",
4
- "version": "5.12.0-staging.73",
4
+ "version": "5.12.0-staging.75",
5
5
  "description": "Ship quality products with AI-powered E2E acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -13,6 +13,7 @@
13
13
  "plugin",
14
14
  "bin/muggle.js",
15
15
  "scripts/postinstall.mjs",
16
+ "scripts/release-integrity",
16
17
  "config/runtime-targets.json"
17
18
  ],
18
19
  "scripts": {
@@ -65,7 +66,9 @@
65
66
  "linux-x64": "929396f628a05f001782d48524cd9f76fdbb59216658982a5a3909dfe413cbea",
66
67
  "win32-x64": "a8b5b46b97ed788030be84982a3e8d463abbfa7f3e8bc795692ccbffc82bfa92"
67
68
  }
68
- }
69
+ },
70
+ "electronAppSignedFromVersion": "1.10.0",
71
+ "signerIdentityUri": "https://github.com/multiplex-ai/muggle-ai-teaching-service/.github/workflows/release-electron-app-reusable.yml@refs/heads/master"
69
72
  },
70
73
  "dependencies": {
71
74
  "@modelcontextprotocol/sdk": "^1.25.3",
@@ -73,6 +76,7 @@
73
76
  "axios": "^1.7.9",
74
77
  "commander": "^14.0.3",
75
78
  "open": "^11.0.0",
79
+ "sigstore": "^5.0.0",
76
80
  "ulid": "^3.0.2",
77
81
  "uuid": "^14.0.0",
78
82
  "winston": "^3.17.0",
@@ -24,6 +24,8 @@ Enforcement is reserved for the handoffs that were being skipped: the E2E accept
24
24
 
25
25
  ## Mechanism
26
26
 
27
+ A recorder that *clears* an obligation — a walkthrough posted, a failure diagnosed, a test case classified — records only when the call did not visibly fail (`callOutcome.ts`). Recording from the request alone let a rejected `gh pr comment` mark the walkthrough posted, so the gate went quiet on a PR that never received it.
28
+
27
29
  Each guardrail is a thin bash wrapper in `../scripts/` registered in `hooks.json`. The wrapper pipes the event payload (stdin JSON) to the bundled `../scripts/guardrails.mjs <subcommand>`, which holds the decision logic (built from `src/guardrails/`, vitest-covered). Per-session state in `~/.muggle-ai/guardrails/<session_id>.json` tracks what fired. Any *failure* degrades to `{}` (allow) — a gate blocks only by an explicit, tested decision, never by accident.
28
30
 
29
31
  Each wrapper short-circuits in shell first, so the common case never pays Node cold-start. A gate that has spent its block budget stamps a `<gate>Released` flag, which its wrapper then pre-filters on — without it a released gate keeps cold-starting Node on every remaining turn end to answer `{}`, and the walkthrough gate keeps making provider calls to do it. That pre-filter is a second, looser copy of what `guardrails.mjs` matches, and it is the one place a guardrail can fail *silently*: a payload it drops — a skip marker, a reopen line, a comment edit — reaches no recorder, and the gate keeps demanding an action the user already took. Over-matching is free; under-matching is a dead escape hatch. `src/test/guardrails/hook-prefilter.test.ts` pins every payload each subcommand acts on against the wrapper guarding it, and derives the skip-marker tokens from source so a new marker is covered the moment it exists.
@@ -40,6 +40,14 @@ var MAX_REPLY_BLOCKS = 3;
40
40
  var CAPABILITY_CLAIM_TRANSCRIPT_TAIL_BYTES = 64e3;
41
41
  var SESSION_STATE_LOCK_WAIT_MS = 250;
42
42
  var LOCK_POLL_INTERVAL_MS = 10;
43
+ var CALL_FAILURE_SIGNALS = [
44
+ /\bgh:\s/,
45
+ /\bglab:\s/,
46
+ /\bHTTP\s(?:4|5)\d\d\b/,
47
+ /"status"\s*:\s*"?(?:4|5)\d\d/,
48
+ /"isError"\s*:\s*true/
49
+ ];
50
+ var FORGE_TERMINAL_CMD = /\b(?:gh\s+pr\s+(?:merge|close|reopen)|glab\s+mr\s+(?:merge|close|reopen))\b/;
43
51
 
44
52
  // src/guardrails/store/fileLock.ts
45
53
  function isProcessAlive(pid) {
@@ -159,15 +167,21 @@ ${input2.tool_response?.output ?? ""}`;
159
167
  }
160
168
 
161
169
  // src/guardrails/prTerminal.ts
170
+ function terminalProvenance(input2) {
171
+ const command = input2.tool_input?.command;
172
+ if (command === void 0) return { acceptsForgeLine: true };
173
+ return { acceptsForgeLine: FORGE_TERMINAL_CMD.test(command) };
174
+ }
162
175
  function detectPrTerminal(input2) {
163
176
  if (input2.tool_name !== "Bash" && input2.tool_name !== "Monitor") return null;
164
177
  const response = input2.tool_response;
178
+ const provenance = terminalProvenance(input2);
165
179
  const haystack = [response?.stdout, response?.stderr, response?.output, response?.content].filter((part) => typeof part === "string").join("\n");
166
- const mergedMatch = haystack.match(GH_PR_MERGED_LINE);
180
+ const mergedMatch = provenance.acceptsForgeLine ? haystack.match(GH_PR_MERGED_LINE) : null;
167
181
  if (mergedMatch) {
168
182
  return { prNumber: Number(mergedMatch[1]), verdict: "merged" /* Merged */ };
169
183
  }
170
- const closedMatch = haystack.match(GH_PR_CLOSED_LINE);
184
+ const closedMatch = provenance.acceptsForgeLine ? haystack.match(GH_PR_CLOSED_LINE) : null;
171
185
  if (closedMatch) {
172
186
  return { prNumber: Number(closedMatch[1]), verdict: "closed" /* Closed */ };
173
187
  }
@@ -182,6 +196,7 @@ function detectPrTerminal(input2) {
182
196
  }
183
197
  function detectPrReopened(input2) {
184
198
  if (input2.tool_name !== "Bash") return null;
199
+ if (!terminalProvenance(input2).acceptsForgeLine) return null;
185
200
  const response = input2.tool_response;
186
201
  const haystack = [response?.stdout, response?.stderr, response?.output, response?.content].filter((part) => typeof part === "string").join("\n");
187
202
  const reopenedMatch = haystack.match(GH_PR_REOPENED_LINE);
@@ -362,11 +377,28 @@ function stageGateDecision(state, unreadStagePaths, maxBlocks = MAX_STAGE_BLOCKS
362
377
  return { action: "block" /* Block */, blockCount: blockCount + 1, unread: unreadStagePaths };
363
378
  }
364
379
 
380
+ // src/guardrails/callOutcome.ts
381
+ function renderedResponse(toolResponse) {
382
+ const rendered = [];
383
+ const collect = (value) => {
384
+ if (typeof value === "string") rendered.push(value);
385
+ else if (Array.isArray(value)) value.forEach(collect);
386
+ else if (value && typeof value === "object") Object.values(value).forEach(collect);
387
+ };
388
+ collect(toolResponse);
389
+ return rendered.join("\n");
390
+ }
391
+ function callFailed(input2) {
392
+ const rendered = renderedResponse(input2.tool_response);
393
+ return CALL_FAILURE_SIGNALS.some((signal) => signal.test(rendered));
394
+ }
395
+
365
396
  // src/guardrails/preExecutionClassification.ts
366
397
  var CLASSIFICATION_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_CLASSIFY_SKIP\b/;
367
398
  function detectClassifiedTestCaseId(input2) {
368
399
  if (!MUGGLE_EVENT_EMIT_TOOL.test(input2.tool_name ?? "")) return void 0;
369
400
  if (input2.tool_input?.eventType !== PRE_EXECUTION_CLASSIFICATION_EVENT) return void 0;
401
+ if (callFailed(input2)) return void 0;
370
402
  return input2.tool_input?.testCaseId ?? ANY_TEST_CASE;
371
403
  }
372
404
  function applyClassifiedTestCase(state, testCaseId) {
@@ -423,6 +455,7 @@ function detectDebugEvidenceRunIds(input2, owedRunIds) {
423
455
  const toolName = input2.tool_name ?? "";
424
456
  const isDiagnosisEmit = MUGGLE_EVENT_EMIT_TOOL.test(toolName) && FAILURE_DIAGNOSIS_EVENT.test(input2.tool_input?.eventType ?? "");
425
457
  if (!isDiagnosisEmit && !MUGGLE_FEEDBACK_CREATE_TOOL.test(toolName)) return [];
458
+ if (callFailed(input2)) return [];
426
459
  const payload = serialize(input2);
427
460
  return owedRunIds.filter((runId) => payload.includes(runId));
428
461
  }
@@ -598,6 +631,7 @@ function detectWalkthroughPost(input2, read = defaultFileReader) {
598
631
  if (input2.tool_name !== "Bash") return false;
599
632
  const cmd = input2.tool_input?.command ?? "";
600
633
  if (!isPrReportPostCommand(cmd)) return false;
634
+ if (callFailed(input2)) return false;
601
635
  return collectPrPostText(cmd, input2.cwd, read).includes(REPORT_SENTINEL);
602
636
  }
603
637
  function applyWalkthroughPosted(state, posted) {
@@ -25,6 +25,11 @@ import { dirname, join } from "path";
25
25
  import { pipeline } from "stream/promises";
26
26
  import { createRequire } from "module";
27
27
  import { fileURLToPath } from "url";
28
+ import {
29
+ SIGNATURE_BUNDLE_SUFFIX,
30
+ resolveIntegrityPolicy,
31
+ verifyReleaseSignature,
32
+ } from "./release-integrity/index.mjs";
28
33
 
29
34
  const require = createRequire(import.meta.url);
30
35
  const VERSION_DIRECTORY_NAME_PATTERN = /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/;
@@ -728,7 +733,35 @@ async function downloadElectronApp() {
728
733
  clearTimeout(streamTimer);
729
734
  }
730
735
 
731
- log("Download complete, verifying checksum...");
736
+ log("Download complete, verifying integrity...");
737
+
738
+ const integrityPolicy = resolveIntegrityPolicy({
739
+ version: version,
740
+ signedFromVersion: config.electronAppSignedFromVersion || "",
741
+ expectedChecksum: expectedChecksum,
742
+ });
743
+
744
+ if (integrityPolicy.unverifiableReason) {
745
+ rmSync(versionDir, { recursive: true, force: true });
746
+ throw new Error("Refusing to install an unverifiable download: " + integrityPolicy.unverifiableReason);
747
+ }
748
+
749
+ if (integrityPolicy.requiresSignature) {
750
+ const signatureResult = await verifyReleaseSignature({
751
+ artifactPath: tempFile,
752
+ bundleUrl: downloadUrl + SIGNATURE_BUNDLE_SUFFIX,
753
+ signerIdentityUri: config.signerIdentityUri || "",
754
+ });
755
+
756
+ if (!signatureResult.valid) {
757
+ rmSync(versionDir, { recursive: true, force: true });
758
+ throw new Error(
759
+ "Signature verification failed, refusing to install: " + signatureResult.reason,
760
+ );
761
+ }
762
+
763
+ log("Signature verified successfully.");
764
+ }
732
765
 
733
766
  // Verify checksum
734
767
  const checksumResult = await verifyFileChecksum(tempFile, expectedChecksum);
@@ -743,9 +776,7 @@ async function downloadElectronApp() {
743
776
  );
744
777
  }
745
778
 
746
- if (checksumResult.skipped) {
747
- log("Warning: No checksum configured, skipping verification.");
748
- } else {
779
+ if (!checksumResult.skipped) {
749
780
  log("Checksum verified successfully.");
750
781
  }
751
782
 
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Compare two semver versions.
3
+ * @param {string} a - First version.
4
+ * @param {string} b - Second version.
5
+ * @returns {number} 1 if a > b, -1 if a < b, 0 if equal.
6
+ */
7
+ export function compareVersions(a, b) {
8
+ const partsA = a.split(".").map(Number);
9
+ const partsB = b.split(".").map(Number);
10
+
11
+ for (let index = 0; index < 3; index++) {
12
+ const partA = partsA[index] || 0;
13
+ const partB = partsB[index] || 0;
14
+
15
+ if (partA > partB) {
16
+ return 1;
17
+ }
18
+ if (partA < partB) {
19
+ return -1;
20
+ }
21
+ }
22
+
23
+ return 0;
24
+ }
@@ -0,0 +1,8 @@
1
+ /** Suffix appended to a release asset's name to locate its Sigstore bundle. */
2
+ export const SIGNATURE_BUNDLE_SUFFIX = ".sigstore.json";
3
+
4
+ /** OIDC issuer that must have issued the signing certificate. */
5
+ export const SIGNER_CERTIFICATE_ISSUER = "https://token.actions.githubusercontent.com";
6
+
7
+ /** Abort budget for fetching a signature bundle. */
8
+ export const SIGNATURE_FETCH_TIMEOUT_MS = 30_000;
@@ -0,0 +1,21 @@
1
+ export declare const SIGNATURE_BUNDLE_SUFFIX: string;
2
+ export declare const SIGNATURE_FETCH_TIMEOUT_MS: number;
3
+ export declare const SIGNER_CERTIFICATE_ISSUER: string;
4
+
5
+ export declare function compareVersions(a: string, b: string): number;
6
+
7
+ export declare function resolveIntegrityPolicy(params: {
8
+ version: string;
9
+ signedFromVersion: string;
10
+ expectedChecksum: string;
11
+ }): {
12
+ requiresSignature: boolean;
13
+ requiresChecksum: boolean;
14
+ unverifiableReason: string;
15
+ };
16
+
17
+ export declare function verifyReleaseSignature(params: {
18
+ artifactPath: string;
19
+ bundleUrl: string;
20
+ signerIdentityUri: string;
21
+ }): Promise<{ valid: boolean; reason: string }>;
@@ -0,0 +1,4 @@
1
+ export { SIGNATURE_BUNDLE_SUFFIX, SIGNATURE_FETCH_TIMEOUT_MS, SIGNER_CERTIFICATE_ISSUER } from "./constants.mjs";
2
+ export { compareVersions } from "./compareVersions.mjs";
3
+ export { resolveIntegrityPolicy } from "./resolveIntegrityPolicy.mjs";
4
+ export { verifyReleaseSignature } from "./verifyReleaseSignature.mjs";
@@ -0,0 +1,37 @@
1
+ import { compareVersions } from "./compareVersions.mjs";
2
+
3
+ /**
4
+ * Decide which integrity evidence a download must produce before it is trusted.
5
+ *
6
+ * Releases cut before signing existed carry only a checksum, so the signature
7
+ * requirement is pinned to the first version that ships one. Below that pin the
8
+ * checksum is mandatory rather than advisory: a download with neither signature
9
+ * nor checksum is refused instead of being installed behind a warning.
10
+ *
11
+ * @param {object} params - Policy inputs.
12
+ * @param {string} params.version - Electron app version being installed.
13
+ * @param {string} params.signedFromVersion - First version published with a signature.
14
+ * @param {string} params.expectedChecksum - Configured SHA256, empty when none.
15
+ * @returns {{requiresSignature: boolean, requiresChecksum: boolean, unverifiableReason: string}} Which checks apply, and why none can.
16
+ */
17
+ export function resolveIntegrityPolicy({ version, signedFromVersion, expectedChecksum }) {
18
+ const requiresSignature = Boolean(signedFromVersion) && compareVersions(version, signedFromVersion) >= 0;
19
+ const hasChecksum = Boolean(expectedChecksum && expectedChecksum.trim());
20
+
21
+ if (!requiresSignature && !hasChecksum) {
22
+ return {
23
+ requiresSignature: false,
24
+ requiresChecksum: false,
25
+ unverifiableReason:
26
+ `no integrity evidence is available for v${version}: it predates release signing ` +
27
+ `(first signed version: ${signedFromVersion || "none configured"}) ` +
28
+ `and no checksum is configured for this release stream`,
29
+ };
30
+ }
31
+
32
+ return {
33
+ requiresSignature: requiresSignature,
34
+ requiresChecksum: !requiresSignature,
35
+ unverifiableReason: "",
36
+ };
37
+ }
@@ -0,0 +1,51 @@
1
+ /* global AbortController */
2
+ import { readFile } from "node:fs/promises";
3
+ import { verify } from "sigstore";
4
+ import { SIGNATURE_FETCH_TIMEOUT_MS, SIGNER_CERTIFICATE_ISSUER } from "./constants.mjs";
5
+
6
+ /**
7
+ * Verify a downloaded release asset against the Sigstore bundle published beside it.
8
+ *
9
+ * The bundle proves the asset was produced by a specific workflow in the studio
10
+ * source repository, so the signer identity is pinned exactly: a bundle that is
11
+ * otherwise valid but was issued to any other workflow is rejected.
12
+ *
13
+ * @param {object} params - Verification parameters.
14
+ * @param {string} params.artifactPath - Path to the downloaded asset.
15
+ * @param {string} params.bundleUrl - URL of the asset's Sigstore bundle.
16
+ * @param {string} params.signerIdentityUri - Certificate subject the signer must carry.
17
+ * @returns {Promise<{valid: boolean, reason: string}>} Verification outcome, with the rejection cause when invalid.
18
+ */
19
+ export async function verifyReleaseSignature({ artifactPath, bundleUrl, signerIdentityUri }) {
20
+ if (!signerIdentityUri) {
21
+ return { valid: false, reason: "no signer identity is configured to verify against" };
22
+ }
23
+
24
+ const controller = new AbortController();
25
+ const timer = setTimeout(() => controller.abort(), SIGNATURE_FETCH_TIMEOUT_MS);
26
+ let bundle;
27
+ try {
28
+ const response = await fetch(bundleUrl, { signal: controller.signal });
29
+ if (!response.ok) {
30
+ return {
31
+ valid: false,
32
+ reason: `signature bundle unavailable (HTTP ${response.status}) at ${bundleUrl}`,
33
+ };
34
+ }
35
+ bundle = await response.json();
36
+ } catch (error) {
37
+ return { valid: false, reason: `could not fetch signature bundle: ${error.message}` };
38
+ } finally {
39
+ clearTimeout(timer);
40
+ }
41
+
42
+ try {
43
+ await verify(bundle, await readFile(artifactPath), {
44
+ certificateIssuer: SIGNER_CERTIFICATE_ISSUER,
45
+ certificateIdentityURI: signerIdentityUri,
46
+ });
47
+ return { valid: true, reason: "" };
48
+ } catch (error) {
49
+ return { valid: false, reason: error.message };
50
+ }
51
+ }