javi-forge 1.35.0 → 1.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/__fixtures__/fake-secure-fs.d.ts +9 -1
- package/dist/lib/__fixtures__/fake-secure-fs.js +15 -0
- package/dist/lib/secure-fs-posix.d.ts +20 -2
- package/dist/lib/secure-fs-posix.js +162 -24
- package/dist/lib/secure-fs-transaction.d.ts +15 -1
- package/dist/lib/secure-fs-transaction.js +16 -3
- package/dist/lib/secure-fs-windows.js +7 -0
- package/package.json +1 -1
|
@@ -17,8 +17,16 @@ export interface FakeFaults {
|
|
|
17
17
|
revalidateRefuse?: (target: string, callIndex: number) => boolean;
|
|
18
18
|
/** Refuse proveOwnershipAndMode for a path on its Nth call. */
|
|
19
19
|
ownershipRefuse?: (dirPath: string, callIndex: number) => boolean;
|
|
20
|
-
/** Refuse proveNoExtendedAcl for a path on its Nth call. */
|
|
20
|
+
/** Refuse proveNoExtendedAcl (STRICT) for a path on its Nth call. */
|
|
21
21
|
aclRefuse?: (target: string, callIndex: number) => boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Refuse proveNoEndangeringAcl (LENIENT ancestor predicate) for a path on its
|
|
24
|
+
* Nth call. When absent the fake falls back to `aclRefuse`, so an existing test
|
|
25
|
+
* that models an endangering ancestor ACL via `aclRefuse` still aborts the
|
|
26
|
+
* ancestor gate. Set this to drive the lenient and strict predicates
|
|
27
|
+
* independently on the same path.
|
|
28
|
+
*/
|
|
29
|
+
endangeringAclRefuse?: (target: string, callIndex: number) => boolean;
|
|
22
30
|
/** Refuse captureFile for a path (simulate open/read failure). */
|
|
23
31
|
captureRefuse?: (target: string) => boolean;
|
|
24
32
|
/** Override the sha of a captured file (simulate post-commit drift). */
|
|
@@ -31,6 +31,7 @@ export function makeFakeSecureFs() {
|
|
|
31
31
|
const revalidateCounts = new Map();
|
|
32
32
|
const ownershipCounts = new Map();
|
|
33
33
|
const aclCounts = new Map();
|
|
34
|
+
const endangeringCounts = new Map();
|
|
34
35
|
const writeCounts = new Map();
|
|
35
36
|
const managedCounts = new Map();
|
|
36
37
|
const inoFor = (p) => {
|
|
@@ -118,6 +119,20 @@ export function makeFakeSecureFs() {
|
|
|
118
119
|
}
|
|
119
120
|
return ok();
|
|
120
121
|
},
|
|
122
|
+
// The lenient ancestor predicate. The engine calls THIS on ancestor
|
|
123
|
+
// controlling dirs (formerly `proveNoExtendedAcl`). It shares the `aclRefuse`
|
|
124
|
+
// toggle so a test that models "a controlling directory carries an
|
|
125
|
+
// endangering ACL" still drives the ancestor-gate abort; a dedicated
|
|
126
|
+
// `endangeringAclRefuse` toggle overrides it when a test needs to distinguish
|
|
127
|
+
// the lenient predicate from the strict one on the same path.
|
|
128
|
+
async proveNoEndangeringAcl(target) {
|
|
129
|
+
const idx = bump(endangeringCounts, target);
|
|
130
|
+
const refuseFn = fake.faults.endangeringAclRefuse ?? fake.faults.aclRefuse;
|
|
131
|
+
if (refuseFn?.(target, idx)) {
|
|
132
|
+
return { ok: false, refusal: "unsupported-posix-acl", detail: target };
|
|
133
|
+
}
|
|
134
|
+
return ok();
|
|
135
|
+
},
|
|
121
136
|
async createDirExclusive(parent, name, mode) {
|
|
122
137
|
const full = path.join(parent.path, name);
|
|
123
138
|
if (dirs.has(full) || files.has(full))
|
|
@@ -19,10 +19,28 @@ export interface SpawnOutcome {
|
|
|
19
19
|
stdout: string;
|
|
20
20
|
}
|
|
21
21
|
export type SpawnFn = (cmd: string, args: string[]) => Promise<SpawnOutcome>;
|
|
22
|
+
/** Minimal `lstat` seam: yields the on-disk owner uid of the target. Injectable. */
|
|
23
|
+
export type StatFn = (target: string) => Promise<{
|
|
24
|
+
uid: number;
|
|
25
|
+
}>;
|
|
22
26
|
/** The bounded ACL prover behind each platform adapter. */
|
|
23
27
|
export interface PosixAclAdapter {
|
|
24
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* STRICT any-extended-entry proof: refuse ANY named/mask/default/inherited ACL
|
|
30
|
+
* entry. Used on managed containers (`.claude`/`.claude/hooks`) and leaf source
|
|
31
|
+
* files, where the tool owns the node and tolerates no foreign ACL surface.
|
|
32
|
+
*/
|
|
25
33
|
proveClean(target: string): Promise<SecureResult<void>>;
|
|
34
|
+
/**
|
|
35
|
+
* LENIENT path-endangering proof for ANCESTOR (non-managed) controlling dirs:
|
|
36
|
+
* refuse only when a foreign principal can swap/delete/rename the on-path node
|
|
37
|
+
* — a named-user for a uid outside {owner, root, euid} with effective (raw ∩
|
|
38
|
+
* mask) `w`, OR any named-group with effective `w`. Everything else (base
|
|
39
|
+
* entries, a lone `mask::`, effective-non-write named entries, x-only, trusted
|
|
40
|
+
* named users, `default:*`) proceeds. Same fail-closed spawn edges as
|
|
41
|
+
* `proveClean`. On darwin this is the no-op alias of `proveClean` (deferred).
|
|
42
|
+
*/
|
|
43
|
+
proveNoEndangeringAcl(target: string): Promise<SecureResult<void>>;
|
|
26
44
|
}
|
|
27
45
|
/**
|
|
28
46
|
* The EXACT detail strings the POSIX adapters emit, exported so consumers (the
|
|
@@ -38,7 +56,7 @@ export declare const ACL_DETAIL: {
|
|
|
38
56
|
readonly macosAclFlag: "ACL present (+ flag)";
|
|
39
57
|
readonly macosAceListed: "ACE listed";
|
|
40
58
|
};
|
|
41
|
-
export declare function createLinuxAclAdapter(spawn?: SpawnFn): PosixAclAdapter;
|
|
59
|
+
export declare function createLinuxAclAdapter(spawn?: SpawnFn, stat?: StatFn): PosixAclAdapter;
|
|
42
60
|
export declare function createMacosAclAdapter(spawn?: SpawnFn): PosixAclAdapter;
|
|
43
61
|
/**
|
|
44
62
|
* Whether the host's ACL adapter is RESOLVABLE — an install-time capability
|
|
@@ -64,23 +64,119 @@ export const ACL_DETAIL = {
|
|
|
64
64
|
};
|
|
65
65
|
// --- Linux getfacl adapter (Algorithm D) -------------------------------------
|
|
66
66
|
const LINUX_BASE_ENTRY = /^(user|group|other)::/;
|
|
67
|
-
|
|
67
|
+
// Numeric getfacl entry shapes (LC_ALL=C, --numeric, --omit-header).
|
|
68
|
+
const MASK_PERMS = /^mask::([r-][w-][x-])$/;
|
|
69
|
+
const MASK_ANY = /^mask::/;
|
|
70
|
+
const NAMED_USER = /^user:(\d+):([r-][w-][x-])$/;
|
|
71
|
+
const NAMED_GROUP = /^group:(\d+):([r-][w-][x-])$/;
|
|
72
|
+
const DEFAULT_ENTRY = /^default:/;
|
|
73
|
+
/** Default owner-uid source: an lstat of the target (authoritative carve-out). */
|
|
74
|
+
const defaultStat = async (target) => {
|
|
75
|
+
const stats = await lstat(target);
|
|
76
|
+
return { uid: stats.uid };
|
|
77
|
+
};
|
|
78
|
+
/** Run the shared, bounded, LC_ALL=C getfacl spawn and map its fail-closed edges. */
|
|
79
|
+
async function runGetfacl(spawn, target) {
|
|
80
|
+
const res = await spawn("getfacl", [
|
|
81
|
+
"--absolute-names",
|
|
82
|
+
"--numeric",
|
|
83
|
+
"--omit-header",
|
|
84
|
+
"--",
|
|
85
|
+
target,
|
|
86
|
+
]);
|
|
87
|
+
if (res.spawnError)
|
|
88
|
+
return refuse("unsupported-posix-acl", ACL_DETAIL.getfaclAbsent);
|
|
89
|
+
if (res.timedOut)
|
|
90
|
+
return refuse("unsupported-posix-acl", ACL_DETAIL.getfaclTimeout);
|
|
91
|
+
if (res.code !== 0)
|
|
92
|
+
return refuse("unsupported-posix-acl", `getfacl exit ${res.code}`);
|
|
93
|
+
return res;
|
|
94
|
+
}
|
|
95
|
+
function isSpawnOutcome(v) {
|
|
96
|
+
return "stdout" in v;
|
|
97
|
+
}
|
|
98
|
+
/** Strip an inline `#effective:...` suffix (tab-separated) and trim. */
|
|
99
|
+
function stripEffective(raw) {
|
|
100
|
+
const hash = raw.indexOf("#");
|
|
101
|
+
return (hash === -1 ? raw : raw.slice(0, hash)).trim();
|
|
102
|
+
}
|
|
103
|
+
/** Non-empty, non-comment ACL lines with any inline `#effective` suffix removed. */
|
|
104
|
+
function aclEntries(stdout) {
|
|
105
|
+
const entries = [];
|
|
106
|
+
for (const raw of stdout.split("\n")) {
|
|
107
|
+
if (raw.trim() === "" || raw.trimStart().startsWith("#"))
|
|
108
|
+
continue;
|
|
109
|
+
const line = stripEffective(raw);
|
|
110
|
+
if (line === "")
|
|
111
|
+
continue;
|
|
112
|
+
entries.push(line);
|
|
113
|
+
}
|
|
114
|
+
return entries;
|
|
115
|
+
}
|
|
116
|
+
const hasW = (perm) => perm.includes("w");
|
|
117
|
+
/**
|
|
118
|
+
* Two-pass path-endangering classifier over numeric getfacl output. Returns
|
|
119
|
+
* `ok()` when no foreign principal can endanger the on-path node, or a
|
|
120
|
+
* fail-closed `unsupported-posix-acl` refusal. `trusted` is {ownerUid, 0, euid}.
|
|
121
|
+
*/
|
|
122
|
+
function classifyEndangering(entries, trusted) {
|
|
123
|
+
// PASS 1 — locate the mask (order-independent). A malformed mask fails closed.
|
|
124
|
+
let maskPerm = null; // null = no `mask::` entry present
|
|
125
|
+
for (const line of entries) {
|
|
126
|
+
const m = MASK_PERMS.exec(line);
|
|
127
|
+
if (m) {
|
|
128
|
+
maskPerm = m[1];
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (MASK_ANY.test(line)) {
|
|
132
|
+
return refuse("unsupported-posix-acl", ACL_DETAIL.extendedAclEntry);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const checkNamed = (id, raw, isUser) => {
|
|
136
|
+
if (!hasW(raw))
|
|
137
|
+
return true; // x-only traverse / r-only read ≠ endanger
|
|
138
|
+
// raw carries `w`: a named entry with raw `w` REQUIRES a mask to be
|
|
139
|
+
// effective; POSIX always emits one when a named entry exists. Its absence
|
|
140
|
+
// is anomalous → fail closed.
|
|
141
|
+
if (maskPerm === null)
|
|
142
|
+
return false;
|
|
143
|
+
if (!hasW(maskPerm))
|
|
144
|
+
return true; // masked out → effective lacks `w`
|
|
145
|
+
if (isUser && trusted.has(id))
|
|
146
|
+
return true; // owner/root/euid carve-out
|
|
147
|
+
return false; // foreign named-user OR any named-group with effective `w`
|
|
148
|
+
};
|
|
149
|
+
// PASS 2 — classify each entry.
|
|
150
|
+
for (const line of entries) {
|
|
151
|
+
if (LINUX_BASE_ENTRY.test(line))
|
|
152
|
+
continue; // base user::/group::/other::
|
|
153
|
+
if (MASK_ANY.test(line))
|
|
154
|
+
continue; // mask ceiling (validated in PASS 1)
|
|
155
|
+
if (DEFAULT_ENTRY.test(line))
|
|
156
|
+
continue; // inheritance-only; strict backstop
|
|
157
|
+
const nu = NAMED_USER.exec(line);
|
|
158
|
+
if (nu) {
|
|
159
|
+
if (checkNamed(Number(nu[1]), nu[2], true))
|
|
160
|
+
continue;
|
|
161
|
+
return refuse("unsupported-posix-acl", ACL_DETAIL.extendedAclEntry);
|
|
162
|
+
}
|
|
163
|
+
const ng = NAMED_GROUP.exec(line);
|
|
164
|
+
if (ng) {
|
|
165
|
+
if (checkNamed(Number(ng[1]), ng[2], false))
|
|
166
|
+
continue;
|
|
167
|
+
return refuse("unsupported-posix-acl", ACL_DETAIL.extendedAclEntry);
|
|
168
|
+
}
|
|
169
|
+
// Unrecognized/unparseable shape → fail closed.
|
|
170
|
+
return refuse("unsupported-posix-acl", ACL_DETAIL.extendedAclEntry);
|
|
171
|
+
}
|
|
172
|
+
return ok();
|
|
173
|
+
}
|
|
174
|
+
export function createLinuxAclAdapter(spawn = defaultSpawn, stat = defaultStat) {
|
|
68
175
|
return {
|
|
69
176
|
async proveClean(target) {
|
|
70
|
-
const res = await spawn
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"--omit-header",
|
|
74
|
-
"--",
|
|
75
|
-
target,
|
|
76
|
-
]);
|
|
77
|
-
if (res.spawnError)
|
|
78
|
-
return refuse("unsupported-posix-acl", ACL_DETAIL.getfaclAbsent);
|
|
79
|
-
if (res.timedOut)
|
|
80
|
-
return refuse("unsupported-posix-acl", ACL_DETAIL.getfaclTimeout);
|
|
81
|
-
if (res.code !== 0) {
|
|
82
|
-
return refuse("unsupported-posix-acl", `getfacl exit ${res.code}`);
|
|
83
|
-
}
|
|
177
|
+
const res = await runGetfacl(spawn, target);
|
|
178
|
+
if (!isSpawnOutcome(res))
|
|
179
|
+
return res;
|
|
84
180
|
for (const raw of res.stdout.split("\n")) {
|
|
85
181
|
const line = raw.trim();
|
|
86
182
|
if (line === "" || line.startsWith("#"))
|
|
@@ -91,12 +187,31 @@ export function createLinuxAclAdapter(spawn = defaultSpawn) {
|
|
|
91
187
|
}
|
|
92
188
|
return ok();
|
|
93
189
|
},
|
|
190
|
+
async proveNoEndangeringAcl(target) {
|
|
191
|
+
const res = await runGetfacl(spawn, target);
|
|
192
|
+
if (!isSpawnOutcome(res))
|
|
193
|
+
return res;
|
|
194
|
+
// Owner uid is the authoritative carve-out source (proveOwnershipAndMode
|
|
195
|
+
// has already proven owner ∈ {euid, root}, so this narrows the trusted
|
|
196
|
+
// set to exactly those principals). An unresolvable lstat cannot prove
|
|
197
|
+
// the carve-out → fail closed.
|
|
198
|
+
let ownerUid;
|
|
199
|
+
try {
|
|
200
|
+
ownerUid = (await stat(target)).uid;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return refuse("unsupported-posix-acl", "acl owner-stat failed");
|
|
204
|
+
}
|
|
205
|
+
const euid = typeof process.geteuid === "function" ? process.geteuid() : -1;
|
|
206
|
+
const trusted = new Set([ownerUid, 0, euid]);
|
|
207
|
+
return classifyEndangering(aclEntries(res.stdout), trusted);
|
|
208
|
+
},
|
|
94
209
|
};
|
|
95
210
|
}
|
|
96
211
|
// --- macOS /bin/ls -lde adapter (Algorithm E) --------------------------------
|
|
97
212
|
const MACOS_ACE_LINE = /^\s*\d+:\s/;
|
|
98
213
|
export function createMacosAclAdapter(spawn = defaultSpawn) {
|
|
99
|
-
|
|
214
|
+
const adapter = {
|
|
100
215
|
async proveClean(target) {
|
|
101
216
|
const res = await spawn("/bin/ls", ["-lde", "--", target]);
|
|
102
217
|
if (res.spawnError)
|
|
@@ -116,7 +231,15 @@ export function createMacosAclAdapter(spawn = defaultSpawn) {
|
|
|
116
231
|
}
|
|
117
232
|
return ok();
|
|
118
233
|
},
|
|
234
|
+
// DEFERRED (user: Linux only): darwin ancestors stay STRICT = status-quo
|
|
235
|
+
// over-refusal, not the reported Linux bug. `/bin/ls -lde` ACE text carries
|
|
236
|
+
// no numeric mask to compute effective rights from, so the path-endangering
|
|
237
|
+
// narrowing is a documented follow-up. Alias to the strict proof.
|
|
238
|
+
proveNoEndangeringAcl(target) {
|
|
239
|
+
return adapter.proveClean(target);
|
|
240
|
+
},
|
|
119
241
|
};
|
|
242
|
+
return adapter;
|
|
120
243
|
}
|
|
121
244
|
/**
|
|
122
245
|
* Probe the ACL adapter READ-ONLY: it resolves and runs a version/list argv and
|
|
@@ -241,6 +364,14 @@ export function createPosixSecureFs(acl) {
|
|
|
241
364
|
proveNoExtendedAcl(target) {
|
|
242
365
|
return acl.proveClean(target);
|
|
243
366
|
},
|
|
367
|
+
// Lenient ANCESTOR predicate: refuse only path-endangering foreign ACL
|
|
368
|
+
// entries. The transaction core calls THIS on ancestor (non-managed)
|
|
369
|
+
// controlling dirs and keeps `proveNoExtendedAcl`/`proveManagedContainer`
|
|
370
|
+
// (strict) on the dirs it owns — selection is by the managed-containers set,
|
|
371
|
+
// never by `process.platform`.
|
|
372
|
+
proveNoEndangeringAcl(target) {
|
|
373
|
+
return acl.proveNoEndangeringAcl(target);
|
|
374
|
+
},
|
|
244
375
|
async createDirExclusive(parent, name, mode) {
|
|
245
376
|
const full = path.join(parent.path, name);
|
|
246
377
|
try {
|
|
@@ -353,14 +484,21 @@ export function createPosixSecureFs(acl) {
|
|
|
353
484
|
return refuse("unsafe-parent-chain", `rmdir ${handle.path}: ${errCode(error) ?? "error"}`);
|
|
354
485
|
}
|
|
355
486
|
},
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
487
|
+
// A MANAGED CONTAINER (`.claude`/`.claude/hooks`) the tool owns must refuse
|
|
488
|
+
// ANY extended ACL entry — strictly more than the lenient ancestor `gate()`,
|
|
489
|
+
// which now tolerates benign path-non-endangering entries. Two arms, both
|
|
490
|
+
// fail-closed: (1) ownership/mode — on POSIX, group/other write IS add-child
|
|
491
|
+
// on a directory (stats.mode & 0o022); (2) the STRICT any-extended-entry ACL
|
|
492
|
+
// proof, re-homed here from the ancestor gate so the net managed-container
|
|
493
|
+
// guarantee stays byte-identical to the pre-narrowing strict-everywhere
|
|
494
|
+
// behavior. The seam's add-child dimension has teeth only on win32; the
|
|
495
|
+
// strict ACL arm is what keeps `.claude` from accepting a benign entry an
|
|
496
|
+
// ancestor now would.
|
|
497
|
+
async proveManagedContainer(dirPath) {
|
|
498
|
+
const owned = await secureFs.proveOwnershipAndMode(dirPath);
|
|
499
|
+
if (!owned.ok)
|
|
500
|
+
return owned;
|
|
501
|
+
return acl.proveClean(dirPath);
|
|
364
502
|
},
|
|
365
503
|
};
|
|
366
504
|
return secureFs;
|
|
@@ -63,8 +63,22 @@ export interface PlatformSecureFs {
|
|
|
63
63
|
revalidateIdentity(target: string, held: SecureIdentity): Promise<SecureResult<void>>;
|
|
64
64
|
/** Prove owner == effective uid or root AND no group/other write bits. */
|
|
65
65
|
proveOwnershipAndMode(dirPath: string): Promise<SecureResult<void>>;
|
|
66
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* STRICT proof: no extended/named/mask/default/inherited ACL on the path. Used
|
|
68
|
+
* on managed containers (via `proveManagedContainer`) and leaf source files.
|
|
69
|
+
*/
|
|
67
70
|
proveNoExtendedAcl(target: string): Promise<SecureResult<void>>;
|
|
71
|
+
/**
|
|
72
|
+
* LENIENT ancestor proof: refuse only a path-ENDANGERING ACL entry (a foreign
|
|
73
|
+
* principal that can swap/delete/rename the on-path node) and proceed on benign
|
|
74
|
+
* ones (a lone mask, an effective-non-write or x-only or trusted named entry, a
|
|
75
|
+
* `default:*`). Called by the engine on ANCESTOR (non-managed) controlling dirs
|
|
76
|
+
* only; managed containers keep the strict proof. Selection is by the
|
|
77
|
+
* managed-containers role, NEVER by `process.platform`. On win32 this mirrors
|
|
78
|
+
* the ratified Predicate A (already lenient on ancestors); on darwin it is the
|
|
79
|
+
* strict no-op alias (deferred).
|
|
80
|
+
*/
|
|
81
|
+
proveNoEndangeringAcl(target: string): Promise<SecureResult<void>>;
|
|
68
82
|
/** Create ONE child directory exclusively at mode, reopen+verify, return its handle. */
|
|
69
83
|
createDirExclusive(parent: SecureDirHandle, name: string, mode: number): Promise<SecureResult<SecureDirHandle>>;
|
|
70
84
|
/**
|
|
@@ -81,9 +81,16 @@ export async function runTransaction(input) {
|
|
|
81
81
|
const backups = [];
|
|
82
82
|
const needsWrite = (c) => c.desired !== null;
|
|
83
83
|
const anyWrite = needsWrite(input.asset) || needsWrite(input.settings);
|
|
84
|
+
// The uniform per-held-dir gate. It runs the LENIENT ancestor ACL predicate on
|
|
85
|
+
// EVERY held dir — including managed containers, which are ALSO proved strict by
|
|
86
|
+
// `proveManagedContainer` right after they are gated (in ensureManagedContainer)
|
|
87
|
+
// and re-proved strict pre-commit/rollback. So `.claude`/`.claude/hooks` still
|
|
88
|
+
// refuse ANY extended entry (lenient-gated THEN strict-managed); only ancestor-
|
|
89
|
+
// only segments loosen. No `process.platform` here — role is expressed by which
|
|
90
|
+
// dirs get proveManagedContainer'd (the managedContainers set).
|
|
84
91
|
async function gate(dirPath, handle) {
|
|
85
92
|
must(`ownership ${dirPath}`, await secureFs.proveOwnershipAndMode(dirPath));
|
|
86
|
-
must(`acl ${dirPath}`, await secureFs.
|
|
93
|
+
must(`acl ${dirPath}`, await secureFs.proveNoEndangeringAcl(dirPath));
|
|
87
94
|
heldByPath.set(dirPath, handle);
|
|
88
95
|
heldOrder.push(handle);
|
|
89
96
|
}
|
|
@@ -134,7 +141,9 @@ export async function runTransaction(input) {
|
|
|
134
141
|
return false;
|
|
135
142
|
if (!(await secureFs.proveOwnershipAndMode(handle.path)).ok)
|
|
136
143
|
return false;
|
|
137
|
-
|
|
144
|
+
// Lenient ancestor predicate — identical to preflight `gate()`, so an ACL
|
|
145
|
+
// that passed preflight is not spuriously refused at rollback re-prove.
|
|
146
|
+
if (!(await secureFs.proveNoEndangeringAcl(handle.path)).ok)
|
|
138
147
|
return false;
|
|
139
148
|
// Re-check the container add/delete-child dimension on the rollback path
|
|
140
149
|
// too, for full symmetry with the pre-commit re-prove (JDB5-002).
|
|
@@ -201,7 +210,11 @@ export async function runTransaction(input) {
|
|
|
201
210
|
for (const handle of heldOrder) {
|
|
202
211
|
must(`recheck-id ${handle.path}`, await secureFs.revalidateIdentity(handle.path, handle.identity));
|
|
203
212
|
must(`recheck-own ${handle.path}`, await secureFs.proveOwnershipAndMode(handle.path));
|
|
204
|
-
|
|
213
|
+
// LENIENT ancestor predicate — MUST match preflight `gate()` so an ancestor
|
|
214
|
+
// ACL accepted at preflight is not refused at commit (spec: predicate is
|
|
215
|
+
// consistent across preflight and re-prove). Managed containers held here
|
|
216
|
+
// are ALSO re-proved strict just below via `recheck-container`.
|
|
217
|
+
must(`recheck-acl ${handle.path}`, await secureFs.proveNoEndangeringAcl(handle.path));
|
|
205
218
|
// Re-prove the managed-container add/delete-child dimension for held
|
|
206
219
|
// handles that ARE managed containers, closing the TOCTOU window between
|
|
207
220
|
// ensure time and commit (JDB7-003; parity with 3a Decision 6 / JD-007).
|
|
@@ -218,6 +218,13 @@ export function createWindowsSecureFs(transport) {
|
|
|
218
218
|
async proveNoExtendedAcl(target) {
|
|
219
219
|
return mapVoid(await call({ op: "proveDacl", args: { path: target } }), `acl ${target}`);
|
|
220
220
|
},
|
|
221
|
+
// win32 already ships the ratified lenient Predicate A on ancestors via
|
|
222
|
+
// `proveDacl` (managed-container strictness lives in `proveManagedContainer`).
|
|
223
|
+
// So the lenient ancestor predicate is the SAME `proveDacl` op — ancestor and
|
|
224
|
+
// managed-container behavior stay byte-identical on Windows.
|
|
225
|
+
async proveNoEndangeringAcl(target) {
|
|
226
|
+
return mapVoid(await call({ op: "proveDacl", args: { path: target } }), `acl ${target}`);
|
|
227
|
+
},
|
|
221
228
|
async proveManagedContainer(dirPath) {
|
|
222
229
|
return mapVoid(await call({ op: "proveContainer", args: { path: dirPath } }), `container ${dirPath}`);
|
|
223
230
|
},
|