@westbayberry/dg 2.3.2 → 2.3.4

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 (122) hide show
  1. package/README.md +95 -208
  2. package/dist/agents/copilot-cli.js +7 -2
  3. package/dist/agents/persistence.js +83 -8
  4. package/dist/agents/registry.js +5 -2
  5. package/dist/agents/routing.js +31 -14
  6. package/dist/api/analyze.js +138 -41
  7. package/dist/audit/deep.js +2 -1
  8. package/dist/audit/detectors.js +23 -2
  9. package/dist/audit/rules.js +4 -1
  10. package/dist/audit-ui/export.js +5 -4
  11. package/dist/auth/device-login.js +33 -11
  12. package/dist/auth/login-app.js +17 -12
  13. package/dist/auth/store.js +65 -9
  14. package/dist/bin/dg.js +1 -1
  15. package/dist/commands/audit.js +12 -20
  16. package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
  17. package/dist/commands/cooldown.js +2 -1
  18. package/dist/commands/decisions.js +8 -7
  19. package/dist/commands/help.js +11 -3
  20. package/dist/commands/licenses.js +17 -8
  21. package/dist/commands/login.js +58 -21
  22. package/dist/commands/router.js +12 -2
  23. package/dist/commands/sbom.js +20 -5
  24. package/dist/commands/scan.js +5 -4
  25. package/dist/commands/service.js +1 -1
  26. package/dist/commands/setup.js +12 -12
  27. package/dist/commands/status.js +4 -4
  28. package/dist/commands/uninstall.js +1 -1
  29. package/dist/commands/update.js +20 -0
  30. package/dist/commands/verify.js +4 -4
  31. package/dist/config/settings.js +81 -12
  32. package/dist/export-ui/ExportDialog.js +1 -1
  33. package/dist/gate/cooldown-request.js +22 -0
  34. package/dist/gate/egress-guard.js +185 -0
  35. package/dist/gate/host-match.js +48 -0
  36. package/dist/gate/verdict-core.js +101 -0
  37. package/dist/gate/verdict-fetch.js +214 -0
  38. package/dist/launcher/agent-check.js +1096 -42
  39. package/dist/launcher/agent-hook-exec.js +8 -1
  40. package/dist/launcher/agent-hook-io.js +5 -3
  41. package/dist/launcher/classify.js +110 -26
  42. package/dist/launcher/env.js +84 -18
  43. package/dist/launcher/install-preflight.js +26 -3
  44. package/dist/launcher/live-install.js +4 -2
  45. package/dist/launcher/manifest-screen.js +88 -7
  46. package/dist/launcher/output-redaction.js +153 -15
  47. package/dist/launcher/preflight-prompt.js +1 -1
  48. package/dist/launcher/resolve-real-binary.js +8 -3
  49. package/dist/launcher/run.js +237 -40
  50. package/dist/origin/artifact-cache.js +55 -0
  51. package/dist/origin/cargo-config.js +12 -0
  52. package/dist/origin/gate-mode.js +7 -0
  53. package/dist/origin/platform-paths.js +14 -0
  54. package/dist/origin/precedence.js +345 -0
  55. package/dist/origin/server.js +611 -0
  56. package/dist/origin/spike-worker.js +35 -0
  57. package/dist/origin/worker.js +116 -0
  58. package/dist/presentation/package-page.js +21 -1
  59. package/dist/presentation/safe-version.js +11 -0
  60. package/dist/presentation/sarif.js +109 -0
  61. package/dist/project/dgfile.js +54 -17
  62. package/dist/project/override-trust.js +0 -0
  63. package/dist/proxy/enforcement.js +27 -7
  64. package/dist/proxy/metadata-map.js +33 -7
  65. package/dist/proxy/preverified.js +0 -3
  66. package/dist/proxy/server.js +104 -446
  67. package/dist/proxy/upstream-proxy.js +23 -4
  68. package/dist/proxy/worker.js +3 -2
  69. package/dist/publish-set/collect.js +6 -0
  70. package/dist/runtime/fatal.js +2 -1
  71. package/dist/runtime/first-run.js +5 -1
  72. package/dist/runtime/node-version.js +51 -0
  73. package/dist/runtime/nudges.js +27 -1
  74. package/dist/sbom/cyclonedx.js +120 -33
  75. package/dist/sbom/graph.js +236 -0
  76. package/dist/sbom/spdx.js +53 -0
  77. package/dist/sbom-ui/SbomApp.js +5 -3
  78. package/dist/sbom-ui/inventory.js +8 -10
  79. package/dist/scan/analyze-worker.js +3 -1
  80. package/dist/scan/collect.js +38 -8
  81. package/dist/scan/command.js +77 -27
  82. package/dist/scan/discovery.js +18 -7
  83. package/dist/scan/installed.js +202 -0
  84. package/dist/scan/manifest-coverage.js +128 -0
  85. package/dist/scan/render.js +125 -40
  86. package/dist/scan/scanner-report.js +92 -22
  87. package/dist/scan/staged.js +68 -15
  88. package/dist/scan/types.js +7 -1
  89. package/dist/scan-ui/LegacyApp.js +3 -3
  90. package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
  91. package/dist/scan-ui/hooks/useScan.js +37 -11
  92. package/dist/scan-ui/launch.js +2 -2
  93. package/dist/scan-ui/logo.js +6 -2
  94. package/dist/scan-ui/shims.js +6 -6
  95. package/dist/scripts/gate.js +53 -35
  96. package/dist/security/csv.js +9 -0
  97. package/dist/security/sanitize.js +11 -2
  98. package/dist/service/state.js +120 -37
  99. package/dist/service/trust-refresh.js +12 -1
  100. package/dist/service/trust-store.js +36 -5
  101. package/dist/service/worker.js +5 -7
  102. package/dist/setup/git-hook.js +2 -2
  103. package/dist/setup/install-method.js +36 -0
  104. package/dist/setup/plan.js +119 -57
  105. package/dist/setup/uninstall-standalone.js +7 -2
  106. package/dist/setup-ui/gate.js +1 -8
  107. package/dist/setup-ui/offer.js +14 -7
  108. package/dist/setup-ui/selector.js +1 -1
  109. package/dist/setup-ui/wizard.js +73 -78
  110. package/dist/standalone/uninstall.mjs +1114 -386
  111. package/dist/state/cleanup-registry.js +14 -3
  112. package/dist/state/cooldown-held.js +36 -13
  113. package/dist/state/locks.js +239 -34
  114. package/dist/state/sessions.js +2 -2
  115. package/dist/util/sh-escape.js +6 -0
  116. package/dist/util/tty-prompt.js +13 -4
  117. package/dist/verify/local.js +60 -10
  118. package/dist/verify/package-check.js +35 -49
  119. package/dist/verify/preflight.js +59 -33
  120. package/dist/verify/render.js +11 -12
  121. package/npm-shrinkwrap.json +714 -213
  122. package/package.json +4 -2
@@ -1,7 +1,12 @@
1
1
  import { renameSync } from "node:fs";
2
2
  import { rename } from "node:fs/promises";
3
3
  import { JsonStoreError, readJsonFile, writeJsonFileAtomic } from "./store.js";
4
- import { acquireLock, CLEANUP_REGISTRY_LOCK } from "./locks.js";
4
+ import { acquireLockWithRetry, CLEANUP_REGISTRY_LOCK } from "./locks.js";
5
+ // Match the stale window the sync setup/uninstall callers pass; without staleMs
6
+ // the stale-lock takeover is disabled and a crashed holder whose PID was reused
7
+ // (classified "alive") wedges hook install/uninstall forever.
8
+ const CLEANUP_REGISTRY_LOCK_STALE_MS = 30 * 60 * 1000;
9
+ const CLEANUP_REGISTRY_LOCK_TIMEOUT_MS = 5_000;
5
10
  export function emptyCleanupRegistry() {
6
11
  return {
7
12
  version: 1,
@@ -37,7 +42,10 @@ export async function writeCleanupRegistry(paths, registry) {
37
42
  await writeJsonFileAtomic(paths.cleanupRegistryPath, registry);
38
43
  }
39
44
  export async function recordCleanupEntry(paths, entry) {
40
- const lock = await acquireLock(paths, CLEANUP_REGISTRY_LOCK);
45
+ const lock = await acquireLockWithRetry(paths, CLEANUP_REGISTRY_LOCK, {
46
+ staleMs: CLEANUP_REGISTRY_LOCK_STALE_MS,
47
+ timeoutMs: CLEANUP_REGISTRY_LOCK_TIMEOUT_MS
48
+ });
41
49
  try {
42
50
  const { registry } = await loadCleanupRegistry(paths);
43
51
  const nextEntry = {
@@ -58,7 +66,10 @@ export async function recordCleanupEntry(paths, entry) {
58
66
  }
59
67
  }
60
68
  export async function removeCleanupEntry(paths, target) {
61
- const lock = await acquireLock(paths, CLEANUP_REGISTRY_LOCK);
69
+ const lock = await acquireLockWithRetry(paths, CLEANUP_REGISTRY_LOCK, {
70
+ staleMs: CLEANUP_REGISTRY_LOCK_STALE_MS,
71
+ timeoutMs: CLEANUP_REGISTRY_LOCK_TIMEOUT_MS
72
+ });
62
73
  try {
63
74
  const { registry } = await loadCleanupRegistry(paths);
64
75
  const next = {
@@ -1,7 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { writeJsonAtomic } from "../util/json-file.js";
4
+ import { acquireLockSyncWithRetry } from "./locks.js";
4
5
  import { resolveDgPaths } from "./paths.js";
6
+ export const COOLDOWN_HELD_LOCK = "cooldown-held";
7
+ const COOLDOWN_HELD_LOCK_TIMEOUT_MS = 5_000;
8
+ const COOLDOWN_HELD_LOCK_STALE_MS = 60_000;
5
9
  export const HELD_PACKAGES_CAP = 500;
6
10
  export const UNKNOWN_ELIGIBILITY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
7
11
  export function heldPackagesPath(env = process.env) {
@@ -50,17 +54,36 @@ export function readHeldPackages(env = process.env, now = new Date()) {
50
54
  }
51
55
  export function recordHeldPackage(entry, env = process.env, now = new Date()) {
52
56
  const path = heldPackagesPath(env);
53
- const nowIso = now.toISOString();
54
- const existing = prune(readDocument(path), now);
55
- const key = heldKey(entry);
56
- const previous = existing.find((candidate) => heldKey(candidate) === key);
57
- const updated = {
58
- ...entry,
59
- firstSeenAt: previous?.firstSeenAt ?? nowIso,
60
- lastSeenAt: nowIso
61
- };
62
- const merged = [...existing.filter((candidate) => heldKey(candidate) !== key), updated]
63
- .sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt))
64
- .slice(0, HELD_PACKAGES_CAP);
65
- writeJsonAtomic(path, { schemaVersion: 1, entries: merged });
57
+ // Serialize the read-modify-write so concurrent installs don't clobber each
58
+ // other's held-package records (last-writer-wins drops entries). Recording is
59
+ // best-effort UX, so a contended/stale lock falls back to an unlocked write
60
+ // rather than failing the install.
61
+ let lock;
62
+ try {
63
+ lock = acquireLockSyncWithRetry(resolveDgPaths(env), COOLDOWN_HELD_LOCK, {
64
+ staleMs: COOLDOWN_HELD_LOCK_STALE_MS,
65
+ timeoutMs: COOLDOWN_HELD_LOCK_TIMEOUT_MS
66
+ });
67
+ }
68
+ catch {
69
+ lock = undefined;
70
+ }
71
+ try {
72
+ const nowIso = now.toISOString();
73
+ const existing = prune(readDocument(path), now);
74
+ const key = heldKey(entry);
75
+ const previous = existing.find((candidate) => heldKey(candidate) === key);
76
+ const updated = {
77
+ ...entry,
78
+ firstSeenAt: previous?.firstSeenAt ?? nowIso,
79
+ lastSeenAt: nowIso
80
+ };
81
+ const merged = [...existing.filter((candidate) => heldKey(candidate) !== key), updated]
82
+ .sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt))
83
+ .slice(0, HELD_PACKAGES_CAP);
84
+ writeJsonAtomic(path, { schemaVersion: 1, entries: merged });
85
+ }
86
+ finally {
87
+ lock?.release();
88
+ }
66
89
  }
@@ -1,8 +1,11 @@
1
- import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
- import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
1
+ import { randomBytes } from "node:crypto";
2
+ import { closeSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { link, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  export const CLEANUP_REGISTRY_LOCK = "cleanup-registry";
5
6
  let takeoverCounter = 0;
7
+ let createCounter = 0;
8
+ let reclaimCounter = 0;
6
9
  function isErrno(error, code) {
7
10
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
8
11
  }
@@ -21,26 +24,39 @@ export async function acquireLock(paths, name, options = {}) {
21
24
  mode: 0o700
22
25
  });
23
26
  const path = join(paths.locksDir, `${name}.lock`);
24
- await removeStaleLock(path, options);
25
- let handle;
27
+ const reclaim = await acquireReclaimLock(path);
26
28
  try {
27
- handle = await open(path, "wx", 0o600);
28
- }
29
- catch (error) {
30
- if (isErrno(error, "EEXIST")) {
31
- throw new LockBusyError(path);
29
+ await removeStaleLock(path, options);
30
+ const metadata = {
31
+ pid: process.pid,
32
+ acquiredAt: (options.now ?? new Date()).toISOString()
33
+ };
34
+ // Write the metadata to a temp file, then atomically claim the lock by linking
35
+ // it onto the final path (fails EEXIST if held). The lock therefore never
36
+ // exists empty, so a concurrent reader can't mis-classify a just-created lock
37
+ // as corrupt/stale and wedge or steal it.
38
+ const tmpPath = `${path}.new-${process.pid}-${++createCounter}`;
39
+ const handle = await open(tmpPath, "wx", 0o600);
40
+ try {
41
+ await handle.writeFile(`${JSON.stringify(metadata)}\n`, "utf8");
32
42
  }
33
- throw error;
34
- }
35
- const metadata = {
36
- pid: process.pid,
37
- acquiredAt: (options.now ?? new Date()).toISOString()
38
- };
39
- try {
40
- await handle.writeFile(`${JSON.stringify(metadata)}\n`, "utf8");
43
+ finally {
44
+ await handle.close();
45
+ }
46
+ try {
47
+ await link(tmpPath, path);
48
+ }
49
+ catch (error) {
50
+ await rm(tmpPath, { force: true });
51
+ if (isErrno(error, "EEXIST")) {
52
+ throw new LockBusyError(path);
53
+ }
54
+ throw error;
55
+ }
56
+ await rm(tmpPath, { force: true });
41
57
  }
42
58
  finally {
43
- await handle.close();
59
+ await releaseReclaimLock(path, reclaim);
44
60
  }
45
61
  return {
46
62
  name,
@@ -59,26 +75,36 @@ export function acquireLockSync(paths, name, options = {}) {
59
75
  mode: 0o700
60
76
  });
61
77
  const path = join(paths.locksDir, `${name}.lock`);
62
- removeStaleLockSync(path, options);
63
- let fd;
78
+ const reclaim = acquireReclaimLockSync(path);
64
79
  try {
65
- fd = openSync(path, "wx", 0o600);
66
- }
67
- catch (error) {
68
- if (isErrno(error, "EEXIST")) {
69
- throw new LockBusyError(path);
80
+ removeStaleLockSync(path, options);
81
+ const metadata = {
82
+ pid: process.pid,
83
+ acquiredAt: (options.now ?? new Date()).toISOString()
84
+ };
85
+ // Atomic claim via temp + link, so the lock is never observable empty.
86
+ const tmpPath = `${path}.new-${process.pid}-${++createCounter}`;
87
+ const fd = openSync(tmpPath, "wx", 0o600);
88
+ try {
89
+ writeFileSync(fd, `${JSON.stringify(metadata)}\n`, "utf8");
70
90
  }
71
- throw error;
72
- }
73
- const metadata = {
74
- pid: process.pid,
75
- acquiredAt: (options.now ?? new Date()).toISOString()
76
- };
77
- try {
78
- writeFileSync(fd, `${JSON.stringify(metadata)}\n`, "utf8");
91
+ finally {
92
+ closeSync(fd);
93
+ }
94
+ try {
95
+ linkSync(tmpPath, path);
96
+ }
97
+ catch (error) {
98
+ rmSync(tmpPath, { force: true });
99
+ if (isErrno(error, "EEXIST")) {
100
+ throw new LockBusyError(path);
101
+ }
102
+ throw error;
103
+ }
104
+ rmSync(tmpPath, { force: true });
79
105
  }
80
106
  finally {
81
- closeSync(fd);
107
+ releaseReclaimLockSync(path, reclaim);
82
108
  }
83
109
  return {
84
110
  name,
@@ -115,6 +141,27 @@ export function acquireLockSyncWithRetry(paths, name, options = {}) {
115
141
  function sleepSync(ms) {
116
142
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
117
143
  }
144
+ export async function acquireLockWithRetry(paths, name, options = {}) {
145
+ const deadline = Date.now() + (options.timeoutMs ?? 5_000);
146
+ const acquireOptions = {
147
+ ...(options.staleMs !== undefined ? { staleMs: options.staleMs } : {}),
148
+ ...(options.now !== undefined ? { now: options.now } : {})
149
+ };
150
+ for (;;) {
151
+ try {
152
+ return await acquireLock(paths, name, acquireOptions);
153
+ }
154
+ catch (error) {
155
+ if (!(error instanceof LockBusyError) || Date.now() + LOCK_RETRY_DELAY_MS > deadline) {
156
+ throw error;
157
+ }
158
+ await new Promise((resolve) => {
159
+ const timer = setTimeout(resolve, LOCK_RETRY_DELAY_MS);
160
+ timer.unref?.();
161
+ });
162
+ }
163
+ }
164
+ }
118
165
  export function isProcessAlive(pid) {
119
166
  try {
120
167
  process.kill(pid, 0);
@@ -148,6 +195,12 @@ async function removeStaleLock(path, options) {
148
195
  }
149
196
  content = null;
150
197
  }
198
+ if (content !== null && content.trim() === "") {
199
+ // Atomic create never leaves an empty lock, so an empty file here is a
200
+ // leftover from a crash / old version — take it over rather than wedge.
201
+ await takeoverLock(path);
202
+ return;
203
+ }
151
204
  const holder = content === null ? "unknown" : holderStateFromContent(content);
152
205
  if (holder === "alive") {
153
206
  return;
@@ -185,6 +238,10 @@ function removeStaleLockSync(path, options) {
185
238
  }
186
239
  content = null;
187
240
  }
241
+ if (content !== null && content.trim() === "") {
242
+ takeoverLockSync(path);
243
+ return;
244
+ }
188
245
  const holder = content === null ? "unknown" : holderStateFromContent(content);
189
246
  if (holder === "alive") {
190
247
  return;
@@ -242,6 +299,154 @@ function takeoverLockSync(path) {
242
299
  force: true
243
300
  });
244
301
  }
302
+ // The classify-then-takeover-then-relink reclaim of a stale/dead lock renames by
303
+ // name, so two processes that both saw the same dead holder could each rename the
304
+ // other's freshly relinked lock away and both end up "holding" it. Serialize the
305
+ // reclaim of a given lock name behind this meta-lock, claimed with the same
306
+ // EEXIST-on-create primitive, so only one process reclaims at a time. It is held
307
+ // only across a handful of syscalls; a short mtime fallback lets a crash mid-
308
+ // reclaim self-heal without wedging every future acquirer.
309
+ //
310
+ // The stale window MUST stay strictly below the smallest lock-acquire retry budget
311
+ // (the 5_000ms callers in this module and in dgfile.ts). Otherwise a meta-lock
312
+ // abandoned by a crash less than one budget ago can never be reclaimed inside a
313
+ // caller's retry loop, turning a self-healing situation into a hard acquisition
314
+ // failure.
315
+ export const META_LOCK_STALE_MS = 3_000;
316
+ function reclaimLockPath(path) {
317
+ return `${path}.takeover`;
318
+ }
319
+ // Self-identifying owner token so release only removes the meta-lock this process
320
+ // still owns. Without it, a process whose meta-lock was reclaimed as stale by a
321
+ // peer would delete the peer's successor meta-lock on release, re-opening the very
322
+ // double-reclaim window the meta-lock exists to close.
323
+ function reclaimToken() {
324
+ return `${process.pid}-${++reclaimCounter}-${randomBytes(8).toString("hex")}`;
325
+ }
326
+ export function acquireReclaimLockSync(path) {
327
+ const metaPath = reclaimLockPath(path);
328
+ const token = reclaimToken();
329
+ for (let attempt = 0; attempt < 2; attempt += 1) {
330
+ try {
331
+ const fd = openSync(metaPath, "wx", 0o600);
332
+ writeFileSync(fd, token, "utf8");
333
+ return { fd, token };
334
+ }
335
+ catch (error) {
336
+ if (!isErrno(error, "EEXIST")) {
337
+ throw error;
338
+ }
339
+ if (!reclaimStaleMetaLockSync(metaPath)) {
340
+ throw new LockBusyError(path);
341
+ }
342
+ }
343
+ }
344
+ throw new LockBusyError(path);
345
+ }
346
+ function reclaimStaleMetaLockSync(metaPath) {
347
+ let details;
348
+ try {
349
+ details = statSync(metaPath);
350
+ }
351
+ catch (error) {
352
+ if (isErrno(error, "ENOENT")) {
353
+ return true;
354
+ }
355
+ throw error;
356
+ }
357
+ if (Date.now() - details.mtimeMs < META_LOCK_STALE_MS) {
358
+ return false;
359
+ }
360
+ const stalePath = `${metaPath}.stale-${process.pid}-${++takeoverCounter}`;
361
+ try {
362
+ renameSync(metaPath, stalePath);
363
+ }
364
+ catch (error) {
365
+ if (isErrno(error, "ENOENT")) {
366
+ return true;
367
+ }
368
+ throw error;
369
+ }
370
+ rmSync(stalePath, { force: true });
371
+ return true;
372
+ }
373
+ export function releaseReclaimLockSync(path, reclaim) {
374
+ closeSync(reclaim.fd);
375
+ ownerCheckedRemoveSync(reclaimLockPath(path), reclaim.token);
376
+ }
377
+ function ownerCheckedRemoveSync(metaPath, token) {
378
+ let content;
379
+ try {
380
+ content = readFileSync(metaPath, "utf8");
381
+ }
382
+ catch {
383
+ return;
384
+ }
385
+ if (content === token) {
386
+ rmSync(metaPath, { force: true });
387
+ }
388
+ }
389
+ async function acquireReclaimLock(path) {
390
+ const metaPath = reclaimLockPath(path);
391
+ const token = reclaimToken();
392
+ for (let attempt = 0; attempt < 2; attempt += 1) {
393
+ try {
394
+ const handle = await open(metaPath, "wx", 0o600);
395
+ await handle.writeFile(token, "utf8");
396
+ return { handle, token };
397
+ }
398
+ catch (error) {
399
+ if (!isErrno(error, "EEXIST")) {
400
+ throw error;
401
+ }
402
+ if (!(await reclaimStaleMetaLock(metaPath))) {
403
+ throw new LockBusyError(path);
404
+ }
405
+ }
406
+ }
407
+ throw new LockBusyError(path);
408
+ }
409
+ async function reclaimStaleMetaLock(metaPath) {
410
+ let details;
411
+ try {
412
+ details = await stat(metaPath);
413
+ }
414
+ catch (error) {
415
+ if (isErrno(error, "ENOENT")) {
416
+ return true;
417
+ }
418
+ throw error;
419
+ }
420
+ if (Date.now() - details.mtimeMs < META_LOCK_STALE_MS) {
421
+ return false;
422
+ }
423
+ const stalePath = `${metaPath}.stale-${process.pid}-${++takeoverCounter}`;
424
+ try {
425
+ await rename(metaPath, stalePath);
426
+ }
427
+ catch (error) {
428
+ if (isErrno(error, "ENOENT")) {
429
+ return true;
430
+ }
431
+ throw error;
432
+ }
433
+ await rm(stalePath, { force: true });
434
+ return true;
435
+ }
436
+ async function releaseReclaimLock(path, reclaim) {
437
+ await reclaim.handle.close();
438
+ const metaPath = reclaimLockPath(path);
439
+ let content;
440
+ try {
441
+ content = await readFile(metaPath, "utf8");
442
+ }
443
+ catch {
444
+ return;
445
+ }
446
+ if (content === reclaim.token) {
447
+ await rm(metaPath, { force: true });
448
+ }
449
+ }
245
450
  function assertLockName(name) {
246
451
  if (!/^[a-z][a-z0-9-]{0,63}$/.test(name)) {
247
452
  throw new Error(`Invalid dg lock name: ${name}`);
@@ -84,7 +84,7 @@ export async function sweepStaleSessions(paths, options) {
84
84
  }
85
85
  throw error;
86
86
  });
87
- if (!details || now - details.mtimeMs < options.olderThanMs) {
87
+ if (!details || now - details.mtimeMs < options.olderThanMs || sessionWorkerAlive(dir)) {
88
88
  continue;
89
89
  }
90
90
  await rm(dir, {
@@ -129,7 +129,7 @@ export function findStaleSessionsSync(paths, options) {
129
129
  }
130
130
  throw error;
131
131
  }
132
- if (now - details.mtimeMs >= options.olderThanMs) {
132
+ if (now - details.mtimeMs >= options.olderThanMs && !sessionWorkerAlive(dir)) {
133
133
  stale.push(entry.name);
134
134
  }
135
135
  }
@@ -0,0 +1,6 @@
1
+ export function escapeDoubleQuotedSh(value) {
2
+ return value.replace(/[\\"$`]/g, "\\$&");
3
+ }
4
+ export function escapeDoubleQuotedFish(value) {
5
+ return value.replace(/[\\"$]/g, "\\$&");
6
+ }
@@ -1,4 +1,9 @@
1
1
  import { closeSync, openSync, readSync } from "node:fs";
2
+ import { StringDecoder } from "node:string_decoder";
3
+ const EAGAIN_POLL_INTERVAL_MS = 10;
4
+ function sleepSync(ms) {
5
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
6
+ }
2
7
  export function promptYesNo(question, defaultYes, out = process.stderr) {
3
8
  const answer = promptLine(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `, out);
4
9
  if (answer === null) {
@@ -21,14 +26,17 @@ export function promptLine(question, out = process.stderr) {
21
26
  try {
22
27
  out.write(question);
23
28
  const byte = Buffer.alloc(1);
29
+ const decoder = new StringDecoder("utf8");
24
30
  let answer = "";
25
- for (;;) {
31
+ let terminated = false;
32
+ while (!terminated) {
26
33
  let read = 0;
27
34
  try {
28
35
  read = readSync(tty, byte, 0, 1, null);
29
36
  }
30
37
  catch (error) {
31
38
  if (error.code === "EAGAIN") {
39
+ sleepSync(EAGAIN_POLL_INTERVAL_MS);
32
40
  continue;
33
41
  }
34
42
  break;
@@ -36,12 +44,13 @@ export function promptLine(question, out = process.stderr) {
36
44
  if (read === 0) {
37
45
  break;
38
46
  }
39
- const char = byte.toString("utf8");
40
- if (char === "\n" || char === "\r") {
47
+ if (byte[0] === 0x0a || byte[0] === 0x0d) {
48
+ terminated = true;
41
49
  break;
42
50
  }
43
- answer += char;
51
+ answer += decoder.write(byte);
44
52
  }
53
+ answer += decoder.end();
45
54
  return answer;
46
55
  }
47
56
  finally {
@@ -12,6 +12,8 @@ const GNU_LONGNAME_TYPE = 0x4c;
12
12
  const GNU_LONGLINK_TYPE = 0x4b;
13
13
  const PAX_EXTENDED_TYPE = 0x78;
14
14
  const PAX_GLOBAL_TYPE = 0x67;
15
+ const HARDLINK_TYPE = 0x31;
16
+ const SYMLINK_TYPE = 0x32;
15
17
  export function verifyLocalTarget(targetPath, cwd = process.cwd()) {
16
18
  const absoluteTarget = resolve(cwd, targetPath);
17
19
  if (!existsSync(absoluteTarget)) {
@@ -120,6 +122,7 @@ function scanTarball(bytes, path) {
120
122
  let offset = 0;
121
123
  let unpackedSizeBytes = 0;
122
124
  let pendingLongName = null;
125
+ let pendingLongLink = null;
123
126
  let pendingPax = null;
124
127
  let globalPax = null;
125
128
  while (offset + TAR_BLOCK_SIZE <= tarBytes.length) {
@@ -151,6 +154,7 @@ function scanTarball(bytes, path) {
151
154
  continue;
152
155
  }
153
156
  if (typeflag === GNU_LONGLINK_TYPE) {
157
+ pendingLongLink = readNullTerminated(body, 0, body.length);
154
158
  offset = nextOffset;
155
159
  continue;
156
160
  }
@@ -180,7 +184,25 @@ function scanTarball(bytes, path) {
180
184
  break;
181
185
  }
182
186
  const name = paxPath ?? pendingLongName ?? rawName;
187
+ // A symlink/hardlink target that escapes the archive root is the link-based
188
+ // half of a zip-slip — flag it (the entry name is checked separately). Use
189
+ // the same precedence as the name: GNU LONGLINK, then PAX linkpath, then the
190
+ // ustar header linkname field (offset 157, 100 bytes).
191
+ if (typeflag === SYMLINK_TYPE || typeflag === HARDLINK_TYPE) {
192
+ const paxLink = pendingPax?.get("linkpath") ?? globalPax?.get("linkpath") ?? null;
193
+ const linkTarget = pendingLongLink ?? paxLink ?? readNullTerminated(header, 157, 100);
194
+ if (linkTarget.length > 0 && linkEscapesRoot(name, linkTarget)) {
195
+ findings.push({
196
+ id: "archive-path-traversal",
197
+ severity: "block",
198
+ title: "Unsafe archive link target",
199
+ message: "archive link points outside the extraction root or to an unsafe absolute path",
200
+ location: `${name} -> ${linkTarget}`
201
+ });
202
+ }
203
+ }
183
204
  pendingLongName = null;
205
+ pendingLongLink = null;
184
206
  pendingPax = null;
185
207
  unpackedSizeBytes += size;
186
208
  entries.push({
@@ -198,7 +220,7 @@ function scanTarball(bytes, path) {
198
220
  }
199
221
  offset = nextOffset;
200
222
  }
201
- if (errors.length === 0 && (pendingLongName !== null || pendingPax !== null)) {
223
+ if (errors.length === 0 && (pendingLongName !== null || pendingLongLink !== null || pendingPax !== null)) {
202
224
  errors.push("tar archive ends with an unapplied extended header");
203
225
  }
204
226
  findings.push(...packageManifestFindings(entries));
@@ -242,11 +264,13 @@ function scanZipLike(bytes, path) {
242
264
  location: entry.name
243
265
  });
244
266
  }
245
- unpackedSizeBytes += entry.uncompressedSize;
246
- entries.push({
247
- name: entry.name,
248
- body: readZipEntryBody(bytes, entry, errors)
249
- });
267
+ // Meter the ACTUAL inflated bytes against the cumulative budget, and cap each
268
+ // entry's inflation at the remaining budget — the central-directory
269
+ // uncompressedSize is attacker-forgeable, so a bomb could claim one byte per
270
+ // entry while each inflates to the per-entry cap, exhausting memory.
271
+ const body = readZipEntryBody(bytes, entry, errors, MAX_UNPACKED_BYTES - unpackedSizeBytes);
272
+ unpackedSizeBytes += body.length;
273
+ entries.push({ name: entry.name, body });
250
274
  if (entries.length > MAX_ARCHIVE_ENTRIES) {
251
275
  findings.push(limitFinding("archive has too many entries"));
252
276
  break;
@@ -368,7 +392,7 @@ function zipLocalHeaderMismatch(bytes, central) {
368
392
  }
369
393
  return null;
370
394
  }
371
- function readZipEntryBody(bytes, entry, errors) {
395
+ function readZipEntryBody(bytes, entry, errors, maxBytes) {
372
396
  if ((entry.flags & 0x1) !== 0) {
373
397
  return Buffer.alloc(0);
374
398
  }
@@ -380,15 +404,15 @@ function readZipEntryBody(bytes, entry, errors) {
380
404
  errors.push(`${entry.name}: zip entry data is truncated`);
381
405
  return Buffer.alloc(0);
382
406
  }
383
- return readZipBody(bytes.subarray(dataStart, dataStart + entry.compressedSize), entry.method, entry.name, errors);
407
+ return readZipBody(bytes.subarray(dataStart, dataStart + entry.compressedSize), entry.method, entry.name, errors, maxBytes);
384
408
  }
385
- function readZipBody(data, method, name, errors) {
409
+ function readZipBody(data, method, name, errors, maxBytes) {
386
410
  if (method === 0) {
387
411
  return data;
388
412
  }
389
413
  if (method === 8) {
390
414
  try {
391
- return inflateRawSync(data, { maxOutputLength: MAX_UNPACKED_BYTES });
415
+ return inflateRawSync(data, { maxOutputLength: Math.max(1, Math.min(MAX_UNPACKED_BYTES, maxBytes)) });
392
416
  }
393
417
  catch (error) {
394
418
  errors.push(`${name}: could not inflate zip entry: ${error instanceof Error ? error.message : "unknown inflate error"}`);
@@ -454,6 +478,32 @@ function pathSafetyFindings(entryPath) {
454
478
  }
455
479
  return findings;
456
480
  }
481
+ // A symlink/hardlink target escapes the archive root if it is absolute, a
482
+ // Windows path, or climbs above the directory holding the link. Unlike an entry
483
+ // path, a link target legitimately uses `..` (npm `.bin/foo -> ../foo.js`), so
484
+ // resolve it relative to the link's own location and reject only a net escape.
485
+ function linkEscapesRoot(entryName, linkTarget) {
486
+ if (linkTarget.startsWith("/") || linkTarget.includes("\\") || /^[a-zA-Z]:/.test(linkTarget)) {
487
+ return true;
488
+ }
489
+ const baseDir = entryName.split("/").slice(0, -1);
490
+ let depth = 0;
491
+ for (const part of [...baseDir, ...linkTarget.split("/")]) {
492
+ if (part === "" || part === ".") {
493
+ continue;
494
+ }
495
+ if (part === "..") {
496
+ depth -= 1;
497
+ if (depth < 0) {
498
+ return true;
499
+ }
500
+ }
501
+ else {
502
+ depth += 1;
503
+ }
504
+ }
505
+ return false;
506
+ }
457
507
  function isSafeArchivePath(entryPath) {
458
508
  if (entryPath.length === 0 || entryPath.includes("\\") || entryPath.startsWith("/") || /^[a-zA-Z]:/.test(entryPath)) {
459
509
  return false;