@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.
- package/generated/agenc-runtime-manifest-v2.json +104 -102
- package/generated/startup-preflight.mjs +1 -0
- package/lib/generated-wrapper.mjs +49 -12
- package/lib/home-authority.d.mts +26 -0
- package/lib/home-authority.mjs +64 -0
- package/lib/runtime-manager.mjs +161 -25
- package/lib/sqlite-lock.d.mts +4 -0
- package/lib/sqlite-lock.mjs +375 -38
- package/package.json +6 -3
- package/scripts/postinstall.mjs +23 -2
- package/src/launcher.mjs +163 -38
package/lib/sqlite-lock.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
69
|
-
$
|
|
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
|
-
$
|
|
72
|
-
$
|
|
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(
|
|
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
|
-
$
|
|
86
|
-
if (($
|
|
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
|
-
$
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
//
|
|
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
|
|
388
|
-
workingDirectory
|
|
389
|
-
executable
|
|
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
|
|
400
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = [];
|