javi-forge 1.34.1 → 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.
@@ -87,13 +87,31 @@ function policyPathKeys(input, options = {}) {
87
87
  return real === lexical ? [lexical] : [lexical, real];
88
88
  }
89
89
  function isAbsolutePolicyPath(value) { return POSIX_ABSOLUTE.test(value) || WINDOWS_DRIVE.test(value) || WINDOWS_UNC.test(value) || /^\\(?:\\\?|\?\?|\\\.)\\/i.test(value); }
90
+ // Home-relative credential files, matched as a path SUFFIX (never an expanded
91
+ // literal home) so one rule holds for every user, container and HOME. Literals
92
+ // stay lowercase because lexicalNormalize folds keys on darwin/win32.
93
+ const SENSITIVE_PATH_SUFFIXES = Object.freeze(["/.aws/credentials", "/.kube/config", "/.config/gcloud/application_default_credentials.json", "/.docker/config.json", "/.config/gh/hosts.yml"]);
94
+ // Absolute system secrets: the shadow suite holds every local account's password
95
+ // hash, and passwd/vipw leave the "-" backup beside it holding the same secret.
96
+ const SENSITIVE_ABSOLUTE_KEYS = Object.freeze(["/etc/shadow", "/etc/shadow-", "/etc/gshadow", "/etc/gshadow-"]);
97
+ // /proc/<pid>/environ is the FULL environment - every secret - of ANY process the
98
+ // agent can see, so it is a first-class exfiltration sink. The corpus idiom is
99
+ // literal matching; this is the one place a wildcard is unavoidable because the
100
+ // pid component is unbounded. Kept narrow (environ leaves only) and fail-closed:
101
+ // it covers the self/thread-self aliases and the per-thread task/<tid> form.
102
+ const PROC_ENVIRON_KEY = /^\/proc\/(?:\d+|self|thread-self)\/(?:task\/\d+\/)?environ$/;
103
+ // Secret Service / GNOME keyring store: every file under it is credential
104
+ // material, so the directory itself and its whole subtree are sensitive.
105
+ const SENSITIVE_DIRECTORY_SUFFIXES = Object.freeze(["/.local/share/keyrings"]);
90
106
  export function isSensitivePolicyKey(key, platform = process.platform) {
91
107
  const parts = key.split("/").filter(Boolean);
92
108
  const basename = parts.at(-1) ?? "";
93
109
  if (/^\.env(?:\..+)?$/i.test(basename) && !/^\.env\.(?:example|sample|template)$/i.test(basename)) return true;
94
110
  if ([".npmrc", ".pypirc", ".netrc", ".git-credentials"].includes(basename.toLowerCase())) return true;
95
111
  if (parts.some((part) => part === ".ssh" || part === ".gnupg")) return true;
96
- if (key.endsWith("/.aws/credentials") || key.endsWith("/.kube/config") || key.endsWith("/.config/gcloud/application_default_credentials.json")) return true;
112
+ if (SENSITIVE_ABSOLUTE_KEYS.includes(key) || PROC_ENVIRON_KEY.test(key)) return true;
113
+ if (SENSITIVE_PATH_SUFFIXES.some((suffix) => key.endsWith(suffix))) return true;
114
+ if (SENSITIVE_DIRECTORY_SUFFIXES.some((directory) => key.endsWith(directory) || key.includes(`${directory}/`))) return true;
97
115
  return platform === "win32" || platform === "darwin" ? basename.toLowerCase() === "serviceaccountkey.json" : basename === "serviceAccountKey.json";
98
116
  }
99
117
  function isManaged(key) {
@@ -284,7 +302,12 @@ function classifyLongOption(token, longOptions) {
284
302
  }
285
303
  const ASSIGNMENT_TOKEN = /^[A-Za-z_][A-Za-z0-9_]*=/;
286
304
  const SHORT_BUNDLE = /^-[^-]/;
287
- const CRITICAL_TARGET_CORPUS = Object.freeze(["/", "/*", "~", "$HOME", "${HOME}", ".", "..", PROJECT_ROOT]);
305
+ // FHS roots whose wholesale destruction bricks the host. CRITICAL_TARGET gates
306
+ // ONLY destructive writes (rm -rf, recursive/777 chmod) and never reads, and
307
+ // membership is exact-token, so `rm -rf /var/tmp/scratch` stays allowed: only the
308
+ // root token itself is protected, in the same bare / trailing / glob forms as "/".
309
+ const CRITICAL_SYSTEM_ROOTS = ["/etc", "/usr", "/var", "/boot"];
310
+ const CRITICAL_TARGET_CORPUS = Object.freeze(["/", "/*", "~", "$HOME", "${HOME}", ".", "..", PROJECT_ROOT, ...CRITICAL_SYSTEM_ROOTS.flatMap((root) => [root, `${root}/`, `${root}/*`])]);
288
311
  const isCriticalTarget = (token) => CRITICAL_TARGET_CORPUS.includes(token);
289
312
  const OCTAL_MODE_SHAPE = /^0?[0-7]{3,4}$/;
290
313
  const SYMBOLIC_MODE_SHAPE = /^[ugoa]*[+=-][rwxXstugo]+(?:,[ugoa]*[+=-][rwxXstugo]+)*$/;
@@ -1 +1 @@
1
- {"schemaVersion":1,"asset":{"name":"javi-forge-skillguard-pre-tool-use.mjs","version":1,"policyVersion":1,"sha256":"78be7e6613c012280b7ad17886462ba166b63ebd031e34565d757b3a0796d7cc","historical":[]},"settingsEntries":{"current":{"version":1,"canonicalSha256":"038c59a91bf8967f6908afed74c465f1e7030254e11e4f8738975d6d708424d4"},"historical":[]},"installerHelpers":{"windowsSecureObject":{"name":"javi-forge-windows-secure-object.ps1","sha256":"2289ef6ac6b039ec74dc3ea0894413e243ff9bea963f04008a356b3838f9b8dd"}}}
1
+ {"schemaVersion":1,"asset":{"name":"javi-forge-skillguard-pre-tool-use.mjs","version":1,"policyVersion":1,"sha256":"5dc2a5c31131f4ac7d8657c78b950de52776aad6eaefe78ea0d764a9963c4425","historical":["78be7e6613c012280b7ad17886462ba166b63ebd031e34565d757b3a0796d7cc"]},"settingsEntries":{"current":{"version":1,"canonicalSha256":"038c59a91bf8967f6908afed74c465f1e7030254e11e4f8738975d6d708424d4"},"historical":[]},"installerHelpers":{"windowsSecureObject":{"name":"javi-forge-windows-secure-object.ps1","sha256":"2289ef6ac6b039ec74dc3ea0894413e243ff9bea963f04008a356b3838f9b8dd"}}}
@@ -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
- /** Run the bounded, LC_ALL=C ACL tool and decide clean|extended|inconclusive. */
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
- export function createLinuxAclAdapter(spawn = defaultSpawn) {
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("getfacl", [
71
- "--absolute-names",
72
- "--numeric",
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
- return {
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
- // On POSIX, permission to ADD a child to a directory IS the directory's
357
- // write bit; proveOwnershipAndMode already refuses any group/other write
358
- // (stats.mode & 0o022). So the managed-container check is definitionally the
359
- // same predicate gate() just ran on this path idempotent, no new refusal
360
- // surface. The seam has teeth only on win32, where Predicate A tolerates
361
- // add-child on high ancestors (Round-4 / JDA-401).
362
- proveManagedContainer(dirPath) {
363
- return secureFs.proveOwnershipAndMode(dirPath);
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
- /** Prove no extended/named/mask/default/inherited ACL on the path. */
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.proveNoExtendedAcl(dirPath));
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
- if (!(await secureFs.proveNoExtendedAcl(handle.path)).ok)
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
- must(`recheck-acl ${handle.path}`, await secureFs.proveNoExtendedAcl(handle.path));
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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.34.1",
3
+ "version": "1.35.1",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {