@tetsuo-ai/agenc 0.16.1 → 0.19.0

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.
@@ -7,6 +7,14 @@
7
7
  // contention is retried asynchronously against one monotonic deadline.
8
8
 
9
9
  import { execFile } from "node:child_process";
10
+ import {
11
+ closeSync,
12
+ constants as fsConstants,
13
+ fstatSync,
14
+ lstatSync,
15
+ openSync,
16
+ realpathSync,
17
+ } from "node:fs";
10
18
  import {
11
19
  chmod,
12
20
  lstat,
@@ -33,7 +41,18 @@ const REGISTRY_SYMBOL = Symbol.for("@tetsuo-ai/agenc.sqlite-lock-registry");
33
41
  const MAX_BUSY_RETRY_MS = 50;
34
42
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
35
43
  const UNSUPPORTED_FILE_ID_64 = 0xffff_ffff_ffff_ffffn;
44
+ const WINDOWS_PATH_TRANSPORT_MAX_CHARS = 16_384;
45
+ const WINDOWS_PATH_TRANSPORT_MAX_ENTRIES = 128;
36
46
  const WINDOWS_SYSTEM_ROOT = String.raw`\\?\GLOBALROOT\SystemRoot`;
47
+ const WINDOWS_EXECUTABLE_FILESYSTEM = {
48
+ lstat: (path) => lstatSync(path, { bigint: true }),
49
+ open: (path) => openSync(
50
+ path,
51
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0),
52
+ ),
53
+ fstat: (descriptor) => fstatSync(descriptor, { bigint: true }),
54
+ close: closeSync,
55
+ };
37
56
  const LOCAL_FILESYSTEM_TYPES = new Set([
38
57
  "apfs", "bcachefs", "btrfs", "exfat", "ext2", "ext3", "ext4", "f2fs",
39
58
  "hfs", "hfsplus", "jfs", "msdos", "nilfs2", "ntfs", "ntfs3", "overlay",
@@ -54,10 +73,20 @@ const DARWIN_ACL_KNOWN_TOKENS = new Set([
54
73
  ...DARWIN_ACL_INHERITANCE_FLAGS,
55
74
  ...DARWIN_ACL_MUTATION_RIGHTS,
56
75
  ]);
76
+ const DARWIN_ACL_VERDICT_CACHE_LIMIT = 512;
77
+ const darwinAclVerdicts = new Map();
57
78
 
58
79
  const WINDOWS_SECURITY_SCRIPT = String.raw`
59
80
  $ErrorActionPreference = 'Stop'
60
- $entries = @(ConvertFrom-Json -InputObject $env:AGENC_LOCK_PATHS_JSON)
81
+ # Keep this direct-.NET only: module autoload can exhaust the shared lock deadline.
82
+ $transport = [string]$env:AGENC_LOCK_PATHS
83
+ if ([string]::IsNullOrEmpty($transport) -or $transport.Length -gt ${WINDOWS_PATH_TRANSPORT_MAX_CHARS}) {
84
+ throw 'invalid protected-path transport'
85
+ }
86
+ $entries = [string[]]$transport.Split([char]10)
87
+ if ($entries.Count -lt 1 -or $entries.Count -gt ${WINDOWS_PATH_TRANSPORT_MAX_ENTRIES}) {
88
+ throw 'invalid protected-path transport'
89
+ }
61
90
  $currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
62
91
  $trusted = @(
63
92
  $currentSid,
@@ -65,27 +94,50 @@ $trusted = @(
65
94
  'S-1-5-32-544',
66
95
  'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
67
96
  )
68
- $leafMutationMask = [int64]852310
69
- $ancestorMutationMask = [int64]852306
97
+ # Specific mutation rights plus GENERIC_WRITE and GENERIC_ALL.
98
+ $leafMutationMask = [int64]1343029590
99
+ $ancestorMutationMask = [int64]1343029586
70
100
  foreach ($entry in $entries) {
71
- $requested = [string]$entry.path
72
- $role = [string]$entry.role
101
+ $separator = $entry.IndexOf([char]58)
102
+ if ($separator -lt 1 -or $separator -eq ($entry.Length - 1)) {
103
+ throw 'invalid protected-path transport'
104
+ }
105
+ $role = $entry.Substring(0, $separator)
73
106
  if (@('leafDirectory', 'ancestorDirectory', 'file') -notcontains $role) {
74
107
  throw "invalid protected-path role: $role"
75
108
  }
109
+ $encodedPath = $entry.Substring($separator + 1)
110
+ if (($encodedPath.Length % 4) -ne 0) { throw 'invalid protected-path transport' }
111
+ $pathBytes = [System.Convert]::FromBase64String($encodedPath)
112
+ if (($pathBytes.Length % 2) -ne 0) { throw 'invalid protected-path transport' }
113
+ if ([System.Convert]::ToBase64String($pathBytes) -cne $encodedPath) {
114
+ throw 'non-canonical protected-path transport'
115
+ }
116
+ $pathCharacters = [char[]]::new([int]($pathBytes.Length / 2))
117
+ for ($index = 0; $index -lt $pathCharacters.Length; $index += 1) {
118
+ $byteOffset = $index * 2
119
+ $lowByte = [int]$pathBytes[$byteOffset]
120
+ $highByte = [int]$pathBytes[$byteOffset + 1]
121
+ $pathCharacters[$index] = [char]($lowByte -bor ($highByte -shl 8))
122
+ }
123
+ $requested = [string]::new($pathCharacters)
76
124
  $mutationMask = if ($role -eq 'ancestorDirectory') {
77
125
  $ancestorMutationMask
78
126
  } else {
79
127
  $leafMutationMask
80
128
  }
81
- $full = [System.IO.Path]::GetFullPath([string]$requested)
129
+ $full = [System.IO.Path]::GetFullPath($requested)
82
130
  if ($full.StartsWith('\\') -or $full.StartsWith('\\?\') -or $full.StartsWith('\\.\')) {
83
131
  throw "network and device paths are unsupported: $full"
84
132
  }
85
- $item = Get-Item -LiteralPath $full -Force
86
- if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
133
+ $attributes = [System.IO.File]::GetAttributes($full)
134
+ if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
87
135
  throw "reparse points are unsupported: $full"
88
136
  }
137
+ $isDirectory = ($attributes -band [System.IO.FileAttributes]::Directory) -ne 0
138
+ if ($isDirectory -ne ($role -ne 'file')) {
139
+ throw "protected-path role does not match its type: $full"
140
+ }
89
141
  $drive = [System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($full))
90
142
  if (@(2, 3, 6) -notcontains [int]$drive.DriveType) {
91
143
  throw "non-local drive is unsupported: $full"
@@ -93,12 +145,16 @@ foreach ($entry in $entries) {
93
145
  if ($drive.DriveFormat -ne 'NTFS') {
94
146
  throw "filesystem cannot enforce the required ACL contract: $full"
95
147
  }
96
- $acl = Get-Acl -LiteralPath $full
148
+ $aclSections = [System.Security.AccessControl.AccessControlSections]::Owner -bor [System.Security.AccessControl.AccessControlSections]::Access
149
+ if ($isDirectory) {
150
+ $acl = [System.IO.Directory]::GetAccessControl($full, $aclSections)
151
+ } else {
152
+ $acl = [System.IO.File]::GetAccessControl($full, $aclSections)
153
+ }
97
154
  if (-not $acl.AreAccessRulesCanonical) {
98
155
  throw "non-canonical ACL is unsupported: $full"
99
156
  }
100
- $bytes = [byte[]]::new($acl.BinaryLength)
101
- $acl.GetSecurityDescriptorBinaryForm($bytes, 0)
157
+ $bytes = $acl.GetSecurityDescriptorBinaryForm()
102
158
  $raw = [System.Security.AccessControl.RawSecurityDescriptor]::new($bytes, 0)
103
159
  if ($null -eq $raw.DiscretionaryAcl) {
104
160
  throw "null DACL is unsupported: $full"
@@ -125,7 +181,8 @@ foreach ($entry in $entries) {
125
181
  }
126
182
  }
127
183
  $sid = $rule.IdentityReference.Value
128
- if ($trusted -notcontains $sid -and (([int64]$rule.FileSystemRights -band $mutationMask) -ne 0)) {
184
+ $rights = ([int64]$rule.FileSystemRights) -band [int64]4294967295
185
+ if ($trusted -notcontains $sid -and (($rights -band $mutationMask) -ne 0)) {
129
186
  throw "untrusted mutation ACE on lock path: $full"
130
187
  }
131
188
  }
@@ -170,6 +227,17 @@ function throwIfExpired(context, path, cause) {
170
227
  }
171
228
  }
172
229
 
230
+ function reportProgress(context, phase) {
231
+ try {
232
+ const result = context.onProgress?.(phase);
233
+ if (result !== undefined) {
234
+ void Promise.resolve(result).catch(() => {});
235
+ }
236
+ } catch {
237
+ // Diagnostics must never change lock acquisition or release semantics.
238
+ }
239
+ }
240
+
173
241
  function processLockRegistry() {
174
242
  const current = process[REGISTRY_SYMBOL];
175
243
  if (current !== undefined) {
@@ -355,10 +423,45 @@ function validateDarwinAclListing(stdout, path, role) {
355
423
  }
356
424
 
357
425
  async function assertDarwinPathSecurity(path, role, context) {
426
+ return assertDarwinPathSecurityWithIO(
427
+ path,
428
+ role,
429
+ context,
430
+ execFileUtf8,
431
+ (candidate) => lstat(candidate, { bigint: true }),
432
+ );
433
+ }
434
+
435
+ // HFS+ reports change time in whole seconds, so an ACL edit within the same
436
+ // second can leave the identity unchanged. Only sub-second change times are
437
+ // precise enough to cache a verdict.
438
+ function hasSubsecondChangeTime(stats) {
439
+ if (typeof stats.ctimeNs === "bigint") return stats.ctimeNs % 1_000_000_000n !== 0n;
440
+ return Number.isFinite(stats.ctimeMs) && stats.ctimeMs % 1000 !== 0;
441
+ }
442
+
443
+ function darwinAclIdentity(stats) {
444
+ return [
445
+ stats.dev, stats.ino, stats.ctimeNs ?? stats.ctimeMs,
446
+ stats.mode, stats.uid, stats.gid,
447
+ process.getuid?.(), process.geteuid?.(),
448
+ ].join(":");
449
+ }
450
+
451
+ async function assertDarwinPathSecurityWithIO(path, role, context, lister, statPath) {
358
452
  throwIfExpired(context, path);
453
+ // A Darwin ACL edit advances ctime. Re-stat on every lookup so an old
454
+ // successful verdict cannot authorize a changed inode, owner, or ACL.
455
+ const before = darwinAclIdentity(await statPath(path));
456
+ const cacheKey = `${role}\0${path}`;
457
+ if (darwinAclVerdicts.get(cacheKey) === before) {
458
+ throwIfExpired(context, path);
459
+ return;
460
+ }
461
+ darwinAclVerdicts.delete(cacheKey);
359
462
  let result;
360
463
  try {
361
- result = await execFileUtf8(
464
+ result = await lister(
362
465
  "/bin/ls",
363
466
  ["-ldeq", path],
364
467
  {
@@ -376,33 +479,136 @@ async function assertDarwinPathSecurity(path, role, context) {
376
479
  }
377
480
  validateDarwinAclListing(result.stdout, path, role);
378
481
  throwIfExpired(context, path);
482
+ // A path changed while ls ran must never seed a verdict for its new state.
483
+ const afterStats = await statPath(path);
484
+ const after = darwinAclIdentity(afterStats);
485
+ throwIfExpired(context, path);
486
+ if (after === before && hasSubsecondChangeTime(afterStats)) {
487
+ if (darwinAclVerdicts.size >= DARWIN_ACL_VERDICT_CACHE_LIMIT) {
488
+ darwinAclVerdicts.delete(darwinAclVerdicts.keys().next().value);
489
+ }
490
+ darwinAclVerdicts.set(cacheKey, before);
491
+ }
379
492
  }
380
493
 
381
- function trustedWindowsPowerShellPath() {
382
- // GLOBALROOT enters the true system object-manager namespace instead of a
383
- // session-specific or environment-selected DOS path. Never resolve this
384
- // executable through PATH, SystemRoot, WINDIR, or another caller-controlled
385
- // value.
494
+ function trustedWindowsPowerShellPath(
495
+ canonicalize = realpathSync.native,
496
+ filesystem = WINDOWS_EXECUTABLE_FILESYSTEM,
497
+ ) {
498
+ // Derive a CreateProcess-compatible DOS spelling from GLOBALROOT, then prove
499
+ // both spellings still name the same regular system file before launch.
500
+ const systemRoot = canonicalize(WINDOWS_SYSTEM_ROOT);
501
+ if (!/^[a-z]:\\/iu.test(systemRoot) || win32.normalize(systemRoot) !== systemRoot) {
502
+ throw new Error("trusted Windows SystemRoot did not resolve to a canonical local DOS path");
503
+ }
504
+ const workingDirectory = win32.join(systemRoot, "System32");
505
+ const executable = win32.join(
506
+ workingDirectory,
507
+ "WindowsPowerShell",
508
+ "v1.0",
509
+ "powershell.exe",
510
+ );
511
+ const namespaceExecutable = win32.join(
512
+ WINDOWS_SYSTEM_ROOT,
513
+ "System32",
514
+ "WindowsPowerShell",
515
+ "v1.0",
516
+ "powershell.exe",
517
+ );
518
+ verifyWindowsExecutableAliases(namespaceExecutable, executable, filesystem);
386
519
  return {
387
- systemRoot: WINDOWS_SYSTEM_ROOT,
388
- workingDirectory: win32.join(WINDOWS_SYSTEM_ROOT, "System32"),
389
- executable: win32.join(
390
- WINDOWS_SYSTEM_ROOT,
391
- "System32",
392
- "WindowsPowerShell",
393
- "v1.0",
394
- "powershell.exe",
395
- ),
520
+ systemRoot,
521
+ workingDirectory,
522
+ executable,
396
523
  };
397
524
  }
398
525
 
399
- function windowsPowerShellEnvironment(paths) {
400
- const { systemRoot, workingDirectory } = trustedWindowsPowerShellPath();
526
+ function verifyWindowsExecutableAliases(namespaceExecutable, executable, filesystem) {
527
+ let namespaceDescriptor;
528
+ let candidateDescriptor;
529
+ let operationError;
530
+ try {
531
+ const namespaceBefore = filesystem.lstat(namespaceExecutable);
532
+ const candidateBefore = filesystem.lstat(executable);
533
+ assertRegularWindowsExecutable(namespaceBefore, "GLOBALROOT path");
534
+ assertRegularWindowsExecutable(candidateBefore, "DOS path");
535
+ namespaceDescriptor = filesystem.open(namespaceExecutable);
536
+ candidateDescriptor = filesystem.open(executable);
537
+ const namespaceOpened = filesystem.fstat(namespaceDescriptor);
538
+ const candidateOpened = filesystem.fstat(candidateDescriptor);
539
+ const namespaceAfter = filesystem.lstat(namespaceExecutable);
540
+ const candidateAfter = filesystem.lstat(executable);
541
+ assertRegularWindowsExecutable(namespaceOpened, "GLOBALROOT descriptor");
542
+ assertRegularWindowsExecutable(candidateOpened, "DOS descriptor");
543
+ assertRegularWindowsExecutable(namespaceAfter, "GLOBALROOT path");
544
+ assertRegularWindowsExecutable(candidateAfter, "DOS path");
545
+ for (const identity of [
546
+ namespaceBefore,
547
+ candidateBefore,
548
+ namespaceOpened,
549
+ candidateOpened,
550
+ namespaceAfter,
551
+ candidateAfter,
552
+ ]) {
553
+ if (
554
+ identity.dev <= 0n || identity.ino <= 0n ||
555
+ identity.dev === UNSUPPORTED_FILE_ID_64 || identity.ino === UNSUPPORTED_FILE_ID_64
556
+ ) {
557
+ throw new Error("trusted Windows system executable identity is unavailable");
558
+ }
559
+ }
560
+ if (
561
+ !sameFileIdentity(namespaceBefore, namespaceOpened) ||
562
+ !sameFileIdentity(namespaceOpened, namespaceAfter) ||
563
+ !sameFileIdentity(candidateBefore, candidateOpened) ||
564
+ !sameFileIdentity(candidateOpened, candidateAfter) ||
565
+ !sameFileIdentity(namespaceOpened, candidateOpened)
566
+ ) {
567
+ throw new Error("trusted Windows system executable identity mismatch");
568
+ }
569
+ } catch (error) {
570
+ operationError = error;
571
+ }
572
+ const closeErrors = [];
573
+ for (const descriptor of [candidateDescriptor, namespaceDescriptor]) {
574
+ if (descriptor === undefined) continue;
575
+ try { filesystem.close(descriptor); } catch (error) { closeErrors.push(error); }
576
+ }
577
+ if (operationError !== undefined) {
578
+ if (closeErrors.length > 0) {
579
+ throw new AggregateError(
580
+ [operationError, ...closeErrors],
581
+ "trusted Windows executable validation and cleanup both failed",
582
+ );
583
+ }
584
+ throw operationError;
585
+ }
586
+ if (closeErrors.length === 1) throw closeErrors[0];
587
+ if (closeErrors.length > 1) {
588
+ throw new AggregateError(
589
+ closeErrors,
590
+ "trusted Windows executable descriptor cleanup failed",
591
+ );
592
+ }
593
+ }
594
+
595
+ function assertRegularWindowsExecutable(identity, spelling) {
596
+ if (!identity.isFile() || identity.isSymbolicLink()) {
597
+ throw new Error(`trusted Windows ${spelling} executable is not a regular non-link file`);
598
+ }
599
+ }
600
+
601
+ function sameFileIdentity(left, right) {
602
+ return left.dev === right.dev && left.ino === right.ino;
603
+ }
604
+
605
+ function windowsPowerShellEnvironment(paths, trustedPaths = trustedWindowsPowerShellPath()) {
606
+ const { systemRoot, workingDirectory } = trustedPaths;
401
607
  // libuv fills a fixed set of "required" Windows variables from the parent
402
608
  // when they are absent. Define every one so poisoned caller state cannot be
403
609
  // silently inherited into the validation helper.
404
610
  return {
405
- AGENC_LOCK_PATHS_JSON: JSON.stringify(paths),
611
+ AGENC_LOCK_PATHS: windowsPathTransport(paths),
406
612
  APPDATA: "",
407
613
  COMSPEC: "",
408
614
  HOMEDRIVE: "",
@@ -423,10 +629,42 @@ function windowsPowerShellEnvironment(paths) {
423
629
  };
424
630
  }
425
631
 
632
+ function windowsPathTransport(entries) {
633
+ if (
634
+ !Array.isArray(entries) ||
635
+ entries.length < 1 ||
636
+ entries.length > WINDOWS_PATH_TRANSPORT_MAX_ENTRIES
637
+ ) {
638
+ throw new Error("agenc: Windows protected-path transport has an invalid entry count");
639
+ }
640
+ const records = [];
641
+ let transportChars = 0;
642
+ for (const entry of entries) {
643
+ const path = entry?.path;
644
+ const role = entry?.role;
645
+ if (
646
+ typeof path !== "string" ||
647
+ path.length === 0 ||
648
+ path.length > WINDOWS_PATH_TRANSPORT_MAX_CHARS ||
649
+ (role !== "leafDirectory" && role !== "ancestorDirectory" && role !== "file")
650
+ ) {
651
+ throw new Error("agenc: Windows protected-path transport entry is invalid");
652
+ }
653
+ const record = `${role}:${Buffer.from(path, "utf16le").toString("base64")}`;
654
+ transportChars += record.length + (records.length === 0 ? 0 : 1);
655
+ if (transportChars > WINDOWS_PATH_TRANSPORT_MAX_CHARS) {
656
+ throw new Error("agenc: Windows protected-path transport exceeds its limit");
657
+ }
658
+ records.push(record);
659
+ }
660
+ return records.join("\n");
661
+ }
662
+
426
663
  async function assertWindowsPathSecurity(entries, context) {
427
664
  const displayPath = entries.at(-1)?.path ?? "unknown";
428
665
  throwIfExpired(context, displayPath);
429
- const { workingDirectory, executable } = trustedWindowsPowerShellPath();
666
+ const trustedPaths = trustedWindowsPowerShellPath();
667
+ const { workingDirectory, executable } = trustedPaths;
430
668
  let result;
431
669
  try {
432
670
  result = await execFileUtf8(
@@ -440,7 +678,7 @@ async function assertWindowsPathSecurity(entries, context) {
440
678
  ],
441
679
  {
442
680
  cwd: workingDirectory,
443
- env: windowsPowerShellEnvironment(entries),
681
+ env: windowsPowerShellEnvironment(entries, trustedPaths),
444
682
  maxBuffer: 1024 * 1024,
445
683
  windowsHide: true,
446
684
  },
@@ -526,6 +764,7 @@ export async function assertLocalPrivateDirectory(
526
764
  label = "AgenC operation",
527
765
  deadline: suppliedDeadline,
528
766
  allowTrustedStickyLeaf = false,
767
+ onProgress,
529
768
  } = {},
530
769
  ) {
531
770
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
@@ -537,6 +776,9 @@ export async function assertLocalPrivateDirectory(
537
776
  if (typeof allowTrustedStickyLeaf !== "boolean") {
538
777
  throw new TypeError("allowTrustedStickyLeaf must be boolean");
539
778
  }
779
+ if (onProgress !== undefined && typeof onProgress !== "function") {
780
+ throw new TypeError("lock onProgress must be a function");
781
+ }
540
782
  const context = {
541
783
  deadline: Math.min(
542
784
  suppliedDeadline ?? Number.POSITIVE_INFINITY,
@@ -544,7 +786,9 @@ export async function assertLocalPrivateDirectory(
544
786
  ),
545
787
  label,
546
788
  timeoutMs,
789
+ onProgress,
547
790
  };
791
+ reportProgress(context, "private directory validation started");
548
792
  const absolute = resolve(requestedPath);
549
793
  throwIfExpired(context, absolute);
550
794
  const canonical = await realpath(absolute);
@@ -584,6 +828,7 @@ export async function assertLocalPrivateDirectory(
584
828
  identityFromStats(stats, path);
585
829
  }
586
830
  if (process.platform === "win32") {
831
+ reportProgress(context, "Windows ancestor ACL validation started");
587
832
  await assertWindowsPathSecurity(
588
833
  ancestors.map((path, index) => ({
589
834
  path,
@@ -591,6 +836,7 @@ export async function assertLocalPrivateDirectory(
591
836
  })),
592
837
  context,
593
838
  );
839
+ reportProgress(context, "Windows ancestor ACL validation complete");
594
840
  } else {
595
841
  await assertLocalFilesystem(canonical, context);
596
842
  if (process.platform === "darwin") {
@@ -614,6 +860,7 @@ export async function assertLocalPrivateDirectory(
614
860
  throw new Error(`agenc: protected directory identity changed during validation: ${path}`);
615
861
  }
616
862
  }
863
+ reportProgress(context, "private directory validation complete");
617
864
  return canonical;
618
865
  }
619
866
 
@@ -660,6 +907,7 @@ export async function assertLocalPrivateFile(
660
907
  timeoutMs = 60_000,
661
908
  label = "AgenC operation",
662
909
  deadline: suppliedDeadline,
910
+ onProgress,
663
911
  } = {},
664
912
  ) {
665
913
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
@@ -668,6 +916,9 @@ export async function assertLocalPrivateFile(
668
916
  if (suppliedDeadline !== undefined && !Number.isFinite(suppliedDeadline)) {
669
917
  throw new TypeError("lock deadline must be finite");
670
918
  }
919
+ if (onProgress !== undefined && typeof onProgress !== "function") {
920
+ throw new TypeError("lock onProgress must be a function");
921
+ }
671
922
  const context = {
672
923
  deadline: Math.min(
673
924
  suppliedDeadline ?? Number.POSITIVE_INFINITY,
@@ -675,13 +926,16 @@ export async function assertLocalPrivateFile(
675
926
  ),
676
927
  label,
677
928
  timeoutMs,
929
+ onProgress,
678
930
  };
931
+ reportProgress(context, "private file validation started");
679
932
  const absolute = resolve(requestedPath);
680
933
  const parent = dirname(absolute);
681
934
  const canonicalParent = await assertLocalPrivateDirectory(parent, {
682
935
  timeoutMs,
683
936
  label,
684
937
  deadline: context.deadline,
938
+ ...(onProgress === undefined ? {} : { onProgress }),
685
939
  });
686
940
  if (canonicalParent !== parent) {
687
941
  throw new Error(`agenc: protected file parent must use its canonical path: ${parent}`);
@@ -706,7 +960,9 @@ export async function assertLocalPrivateFile(
706
960
  throw new Error(`agenc: protected file must use its canonical path: ${absolute}`);
707
961
  }
708
962
  if (process.platform === "win32") {
963
+ reportProgress(context, "Windows file ACL validation started");
709
964
  await assertWindowsPathSecurity([{ path: canonical, role: "file" }], context);
965
+ reportProgress(context, "Windows file ACL validation complete");
710
966
  } else if (process.platform === "darwin") {
711
967
  await assertDarwinPathSecurity(canonical, "file", context);
712
968
  }
@@ -718,21 +974,30 @@ export async function assertLocalPrivateFile(
718
974
  ) {
719
975
  throw new Error(`agenc: protected file identity changed during validation: ${canonical}`);
720
976
  }
977
+ reportProgress(context, "private file validation complete");
721
978
  return canonical;
722
979
  }
723
980
 
724
981
  async function prepareLockPath(requestedPath, context) {
725
982
  const absolute = resolve(requestedPath);
983
+ reportProgress(context, "lock path preparation started");
726
984
  throwIfExpired(context, absolute);
727
985
  await mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
986
+ reportProgress(context, "lock parent creation complete");
728
987
  throwIfExpired(context, absolute);
729
988
  const parent = await realpath(dirname(absolute));
989
+ reportProgress(context, "lock parent canonicalization complete");
730
990
  throwIfExpired(context, absolute);
991
+ reportProgress(context, "lock parent security validation started");
731
992
  const validatedParent = await assertLocalPrivateDirectory(parent, {
732
993
  timeoutMs: context.timeoutMs,
733
994
  label: context.label,
734
995
  deadline: context.deadline,
996
+ ...(context.onProgress === undefined
997
+ ? {}
998
+ : { onProgress: context.onProgress }),
735
999
  });
1000
+ reportProgress(context, "lock parent security validation complete");
736
1001
  if (validatedParent !== parent) {
737
1002
  throw new Error(`agenc: lock database parent must use its canonical path: ${parent}`);
738
1003
  }
@@ -755,6 +1020,7 @@ async function prepareLockPath(requestedPath, context) {
755
1020
  } catch (error) {
756
1021
  if (error?.code !== "EEXIST") throw error;
757
1022
  }
1023
+ reportProgress(context, "lock database file creation complete");
758
1024
  throwIfExpired(context, path);
759
1025
  const pathStats = await lstat(path, { bigint: true });
760
1026
  assertRegularSingleLink(pathStats, path);
@@ -769,10 +1035,12 @@ async function prepareLockPath(requestedPath, context) {
769
1035
  await assertDarwinPathSecurity(canonical, "lock database file", context);
770
1036
  }
771
1037
  } else {
1038
+ reportProgress(context, "lock database ACL validation started");
772
1039
  await assertWindowsPathSecurity([
773
1040
  { path: parent, role: "leafDirectory" },
774
1041
  { path: canonical, role: "file" },
775
1042
  ], context);
1043
+ reportProgress(context, "lock database ACL validation complete");
776
1044
  }
777
1045
  throwIfExpired(context, canonical);
778
1046
  const securedStats = await lstat(canonical, { bigint: true });
@@ -787,7 +1055,11 @@ async function prepareLockPath(requestedPath, context) {
787
1055
  };
788
1056
  }
789
1057
 
790
- async function revalidatePreparedLock(prepared, context) {
1058
+ async function revalidatePreparedLock(
1059
+ prepared,
1060
+ context,
1061
+ { validateWindowsAcl = false } = {},
1062
+ ) {
791
1063
  throwIfExpired(context, prepared.path);
792
1064
  const parentStats = await lstat(prepared.parent, { bigint: true });
793
1065
  if (!parentStats.isDirectory() || parentStats.isSymbolicLink()) {
@@ -800,7 +1072,7 @@ async function revalidatePreparedLock(prepared, context) {
800
1072
  if (stats.dev !== prepared.dev || stats.ino !== prepared.ino) {
801
1073
  throw new Error(`agenc: lock database identity changed during acquisition: ${prepared.path}`);
802
1074
  }
803
- if (process.platform === "win32") {
1075
+ if (process.platform === "win32" && validateWindowsAcl) {
804
1076
  await assertWindowsPathSecurity([
805
1077
  { path: prepared.parent, role: "leafDirectory" },
806
1078
  { path: prepared.path, role: "file" },
@@ -896,18 +1168,27 @@ function busyTransitionError(path, mode) {
896
1168
  );
897
1169
  }
898
1170
 
899
- function beginAndValidateLock(database, path) {
1171
+ function beginAndValidateLock(database, path, context) {
900
1172
  for (let phase = 0; phase < 8; phase += 1) {
1173
+ reportProgress(context, "SQLite transaction begin started");
901
1174
  database.exec("BEGIN IMMEDIATE");
1175
+ reportProgress(context, "SQLite transaction begin complete");
1176
+ reportProgress(context, "SQLite lock database inspection started");
902
1177
  const state = inspectLockDatabase(database, path);
1178
+ reportProgress(context, "SQLite lock database inspection complete");
1179
+ reportProgress(context, "SQLite journal mode inspection started");
903
1180
  const journalMode = pragmaText(database, "journal_mode");
1181
+ reportProgress(context, "SQLite journal mode inspection complete");
904
1182
  if (journalMode !== "delete") {
1183
+ reportProgress(context, "SQLite journal mode reset started");
905
1184
  database.exec("ROLLBACK");
906
1185
  const selected = pragmaText(database, "journal_mode=DELETE");
907
1186
  if (selected !== "delete") throw busyTransitionError(path, selected);
1187
+ reportProgress(context, "SQLite journal mode reset complete");
908
1188
  continue;
909
1189
  }
910
1190
  if (state === "empty") {
1191
+ reportProgress(context, "SQLite lock database initialization started");
911
1192
  database.exec(`
912
1193
  PRAGMA application_id = ${LOCK_APPLICATION_ID};
913
1194
  CREATE TABLE agenc_local_process_lock (
@@ -918,6 +1199,7 @@ function beginAndValidateLock(database, path) {
918
1199
  VALUES (1, ${LOCK_FORMAT_VERSION});
919
1200
  COMMIT;
920
1201
  `);
1202
+ reportProgress(context, "SQLite lock database initialization complete");
921
1203
  continue;
922
1204
  }
923
1205
  return;
@@ -954,7 +1236,32 @@ async function waitForBusyRetry(context, path, attempt, cause) {
954
1236
  if (remaining <= 0) throw timeoutError(context, path, cause);
955
1237
  const exponentialCap = Math.min(MAX_BUSY_RETRY_MS, 2 ** Math.min(attempt, 6));
956
1238
  const jitter = Math.max(1, Math.floor(Math.random() * (exponentialCap + 1)));
957
- await delay(Math.min(remaining, jitter));
1239
+ const waitMs = Math.min(remaining, jitter);
1240
+ const signal = context.retryWakeSignal;
1241
+ if (signal === undefined || context.retryWakeConsumed) {
1242
+ await delay(waitMs);
1243
+ } else {
1244
+ // A hint only shortens one sleep. The next attempt must still validate
1245
+ // the path and acquire the SQLite transaction against the same deadline.
1246
+ let timer;
1247
+ let wake;
1248
+ try {
1249
+ await new Promise((resolve) => {
1250
+ wake = () => {
1251
+ context.retryWakeConsumed = true;
1252
+ resolve();
1253
+ };
1254
+ timer = setTimeout(resolve, waitMs);
1255
+ signal.addEventListener("abort", wake, { once: true });
1256
+ // Covers readiness before subscription as well as before acquisition.
1257
+ if (signal.aborted) wake();
1258
+ reportProgress(context, "SQLite busy retry wait started");
1259
+ });
1260
+ } finally {
1261
+ clearTimeout(timer);
1262
+ if (wake !== undefined) signal.removeEventListener("abort", wake);
1263
+ }
1264
+ }
958
1265
  throwIfExpired(context, path, cause);
959
1266
  }
960
1267
 
@@ -963,16 +1270,29 @@ async function acquireSqliteDatabase(DatabaseSync, prepared, context) {
963
1270
  let lastBusy;
964
1271
  while (true) {
965
1272
  throwIfExpired(context, prepared.path, lastBusy);
966
- await revalidatePreparedLock(prepared, context);
1273
+ reportProgress(context, "SQLite pre-open lock validation started");
1274
+ await revalidatePreparedLock(prepared, context, {
1275
+ // The creation path already validated the complete ancestor chain and
1276
+ // the exact parent/file ACLs. Revalidate ACLs after contention because
1277
+ // another holder and an unbounded interval have crossed this attempt.
1278
+ validateWindowsAcl: attempt > 0,
1279
+ });
1280
+ reportProgress(context, "SQLite pre-open lock validation complete");
967
1281
  let database;
968
1282
  try {
1283
+ reportProgress(context, "SQLite database open started");
969
1284
  database = new DatabaseSync(prepared.path, {
970
1285
  allowExtension: false,
971
1286
  timeout: 0,
972
1287
  });
1288
+ reportProgress(context, "SQLite database open complete");
1289
+ reportProgress(context, "SQLite connection hardening started");
973
1290
  configureLocalSqliteLockConnection(database);
1291
+ reportProgress(context, "SQLite connection hardening complete");
1292
+ reportProgress(context, "SQLite post-open lock validation started");
974
1293
  await revalidatePreparedLock(prepared, context);
975
- beginAndValidateLock(database, prepared.path);
1294
+ reportProgress(context, "SQLite post-open lock validation complete");
1295
+ beginAndValidateLock(database, prepared.path, context);
976
1296
  throwIfExpired(context, prepared.path, lastBusy);
977
1297
  return database;
978
1298
  } catch (error) {
@@ -1027,6 +1347,8 @@ export async function acquireLocalSqliteLocks(
1027
1347
  timeoutMs = 60_000,
1028
1348
  label = "AgenC operation",
1029
1349
  deadline: suppliedDeadline,
1350
+ onProgress,
1351
+ retryWakeSignal,
1030
1352
  } = {},
1031
1353
  ) {
1032
1354
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
@@ -1038,6 +1360,12 @@ export async function acquireLocalSqliteLocks(
1038
1360
  if (!Array.isArray(requestedPaths)) {
1039
1361
  throw new TypeError("lock paths must be an array");
1040
1362
  }
1363
+ if (onProgress !== undefined && typeof onProgress !== "function") {
1364
+ throw new TypeError("lock onProgress must be a function");
1365
+ }
1366
+ if (retryWakeSignal !== undefined && !(retryWakeSignal instanceof AbortSignal)) {
1367
+ throw new TypeError("lock retryWakeSignal must be an AbortSignal");
1368
+ }
1041
1369
  if (requestedPaths.length === 0) return () => {};
1042
1370
 
1043
1371
  const startedAt = performance.now();
@@ -1048,6 +1376,9 @@ export async function acquireLocalSqliteLocks(
1048
1376
  ),
1049
1377
  label,
1050
1378
  timeoutMs,
1379
+ onProgress,
1380
+ retryWakeSignal,
1381
+ retryWakeConsumed: false,
1051
1382
  };
1052
1383
  const firstDisplayPath = resolve(requestedPaths[0]);
1053
1384
  throwIfExpired(context, firstDisplayPath);
@@ -1058,6 +1389,7 @@ export async function acquireLocalSqliteLocks(
1058
1389
  const prepared = await prepareLockPath(requestedPath, context);
1059
1390
  preparedByIdentity.set(prepared.identityKey, prepared);
1060
1391
  }
1392
+ reportProgress(context, "lock path preparation complete");
1061
1393
  const preparedLocks = [...preparedByIdentity.values()].sort((left, right) =>
1062
1394
  left.identityKey < right.identityKey ? -1 : left.identityKey > right.identityKey ? 1 : 0);
1063
1395
  const pendingLocal = [];
@@ -1069,8 +1401,11 @@ export async function acquireLocalSqliteLocks(
1069
1401
  const release = await acquireInProcessLock(prepared, context);
1070
1402
  pendingLocal.push({ prepared, release });
1071
1403
  }
1404
+ reportProgress(context, "in-process lock acquisition complete");
1072
1405
  throwIfExpired(context, currentPath);
1406
+ reportProgress(context, "SQLite module import started");
1073
1407
  const { DatabaseSync } = await import("node:sqlite");
1408
+ reportProgress(context, "SQLite module import complete");
1074
1409
  throwIfExpired(context, currentPath);
1075
1410
  for (const { prepared, release } of pendingLocal) {
1076
1411
  currentPath = prepared.path;
@@ -1080,7 +1415,9 @@ export async function acquireLocalSqliteLocks(
1080
1415
  inProcessReleased: false,
1081
1416
  };
1082
1417
  acquired.push(item);
1418
+ reportProgress(context, "SQLite transaction acquisition started");
1083
1419
  item.database = await acquireSqliteDatabase(DatabaseSync, prepared, context);
1420
+ reportProgress(context, "SQLite transaction acquisition complete");
1084
1421
  }
1085
1422
  } catch (error) {
1086
1423
  const cleanupErrors = [];