javi-forge 1.35.0 → 1.36.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.
@@ -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
  /**
@@ -125,13 +139,33 @@ export interface TransactionComponent {
125
139
  /** True when the target did not exist before this op (rollback = unlink). */
126
140
  wasAbsent: boolean;
127
141
  }
142
+ /**
143
+ * Optional non-Claude container topology (Codex adapter, agnostic slice 2). When
144
+ * present it FULLY REPLACES the default `.claude`/`.claude/hooks` + [asset,settings]
145
+ * wiring; every proof primitive (ancestor gate, managed-container proof,
146
+ * capture/stage/commit/rollback) runs unchanged over the supplied dirs/components.
147
+ * When absent, the transaction behaves byte-identically to before this seam.
148
+ */
149
+ export interface TransactionLayout {
150
+ /** Managed containers to ensure/prove/create, ordered PARENT-FIRST (absolute). */
151
+ containers: string[];
152
+ /** Ordered write components (captured/staged/committed in array order). */
153
+ components: TransactionComponent[];
154
+ }
128
155
  export interface RunTransactionInput extends TransactionDeps {
129
- /** Existing project directory; `.claude` / `.claude/hooks` are created under it. */
156
+ /**
157
+ * Existing base directory; the ancestor chain root..projectDir is gated. The
158
+ * default topology creates `.claude` / `.claude/hooks` under it; a `layout`
159
+ * (Codex) uses it only as the gated ancestor-chain leaf (its containers are
160
+ * absolute and supplied directly).
161
+ */
130
162
  projectDir: string;
131
- /** Committed first. */
132
- asset: TransactionComponent;
133
- /** Committed second. */
134
- settings: TransactionComponent;
163
+ /** Committed first (default Claude topology; ignored when `layout` is present). */
164
+ asset?: TransactionComponent;
165
+ /** Committed second (default Claude topology; ignored when `layout` is present). */
166
+ settings?: TransactionComponent;
167
+ /** Non-Claude container topology; overrides `asset`/`settings` when present. */
168
+ layout?: TransactionLayout;
135
169
  }
136
170
  export interface TransactionOutcome {
137
171
  ok: boolean;
@@ -65,14 +65,34 @@ function ancestorChain(leaf) {
65
65
  * manual-recovery guidance (never clobbers a concurrent change).
66
66
  */
67
67
  export async function runTransaction(input) {
68
+ // Seam validation (fail-closed): the topology is EITHER a `layout` OR the
69
+ // default Claude pair [asset, settings]. A caller with neither would deref
70
+ // `undefined.desired` deep in the engine — refuse up front with a clear error.
71
+ if (!input.layout && !(input.asset && input.settings)) {
72
+ throw new TxAbort("validate", "runTransaction requires `layout` or both `asset` and `settings`");
73
+ }
68
74
  const { secureFs, clock, nonce, projectDir } = input;
69
- const claudeDir = path.join(projectDir, ".claude");
70
- const hooksDir = path.join(claudeDir, "hooks");
75
+ // Container topology + write components. The default (no `layout`) is the exact
76
+ // Claude `.claude` → `.claude/hooks` nesting with [asset, settings]; a `layout`
77
+ // (Codex) supplies absolute containers parent-first and an ordered component
78
+ // list. Either way the proof primitives below are identical.
79
+ const containers = input.layout
80
+ ? input.layout.containers
81
+ : [
82
+ path.join(projectDir, ".claude"),
83
+ path.join(projectDir, ".claude", "hooks"),
84
+ ];
85
+ const components = input.layout
86
+ ? input.layout.components
87
+ : [
88
+ input.asset,
89
+ input.settings,
90
+ ];
71
91
  // The dirs the tool OWNS: their children include the executed asset and the
72
92
  // settings it is referenced from. Fixed and known to the core regardless of
73
93
  // the per-run write plan; each existing member is proved on EVERY anyWrite run
74
94
  // (Round-4/5 / JDA-401 + JDB5-001).
75
- const managedContainers = new Set([claudeDir, hooksDir]);
95
+ const managedContainers = new Set(containers);
76
96
  const heldByPath = new Map();
77
97
  const heldOrder = [];
78
98
  const createdDirs = [];
@@ -80,10 +100,22 @@ export async function runTransaction(input) {
80
100
  const committed = [];
81
101
  const backups = [];
82
102
  const needsWrite = (c) => c.desired !== null;
83
- const anyWrite = needsWrite(input.asset) || needsWrite(input.settings);
103
+ const anyWrite = components.some(needsWrite);
104
+ // A component writes into `container` when its parent dir IS the container or a
105
+ // descendant of it — used to decide which absent containers to create.
106
+ const writesInto = (container) => components.some((c) => needsWrite(c) &&
107
+ (path.dirname(c.path) === container ||
108
+ path.dirname(c.path).startsWith(`${container}${path.sep}`)));
109
+ // The uniform per-held-dir gate. It runs the LENIENT ancestor ACL predicate on
110
+ // EVERY held dir — including managed containers, which are ALSO proved strict by
111
+ // `proveManagedContainer` right after they are gated (in ensureManagedContainer)
112
+ // and re-proved strict pre-commit/rollback. So `.claude`/`.claude/hooks` still
113
+ // refuse ANY extended entry (lenient-gated THEN strict-managed); only ancestor-
114
+ // only segments loosen. No `process.platform` here — role is expressed by which
115
+ // dirs get proveManagedContainer'd (the managedContainers set).
84
116
  async function gate(dirPath, handle) {
85
117
  must(`ownership ${dirPath}`, await secureFs.proveOwnershipAndMode(dirPath));
86
- must(`acl ${dirPath}`, await secureFs.proveNoExtendedAcl(dirPath));
118
+ must(`acl ${dirPath}`, await secureFs.proveNoEndangeringAcl(dirPath));
87
119
  heldByPath.set(dirPath, handle);
88
120
  heldOrder.push(handle);
89
121
  }
@@ -134,7 +166,9 @@ export async function runTransaction(input) {
134
166
  return false;
135
167
  if (!(await secureFs.proveOwnershipAndMode(handle.path)).ok)
136
168
  return false;
137
- if (!(await secureFs.proveNoExtendedAcl(handle.path)).ok)
169
+ // Lenient ancestor predicate — identical to preflight `gate()`, so an ACL
170
+ // that passed preflight is not spuriously refused at rollback re-prove.
171
+ if (!(await secureFs.proveNoEndangeringAcl(handle.path)).ok)
138
172
  return false;
139
173
  // Re-check the container add/delete-child dimension on the rollback path
140
174
  // too, for full symmetry with the pre-commit re-prove (JDB5-002).
@@ -159,23 +193,23 @@ export async function runTransaction(input) {
159
193
  // pre-commit); one that is absent is CREATED only when a child is written
160
194
  // into it this run, else left alone (nothing to secure).
161
195
  if (anyWrite) {
162
- const projectHandle = heldByPath.get(projectDir);
163
- // .claude always ensured on anyWrite (holds settings; grandparent of
164
- // the asset). createIfAbsent=true never returns null (opens, creates, or
165
- // throws) → narrow non-null before passing as parent (JDA6-003).
166
- const claudeHandle = await ensureManagedContainer(projectHandle, claudeDir,
167
- /* createIfAbsent */ true);
168
- if (!claudeHandle) {
169
- throw new TxAbort(`container ${claudeDir}`, "unexpected null handle");
196
+ // Ensure every managed container PARENT-FIRST. Each container's parent is
197
+ // already held (its dirname was gated in preflight or ensured by an earlier
198
+ // iteration). A container is CREATED only when a component writes into it
199
+ // or a descendant container this run; otherwise it is proved IF present,
200
+ // else left alone (JDB5-001). This generalizes the former hardcoded
201
+ // `.claude` → `.claude/hooks` pair without changing any proof primitive.
202
+ for (const container of containers) {
203
+ const parentHandle = heldByPath.get(path.dirname(container));
204
+ if (!parentHandle) {
205
+ throw new TxAbort(`container ${container}`, "parent chain not held");
206
+ }
207
+ await ensureManagedContainer(parentHandle, container,
208
+ /* createIfAbsent */ writesInto(container));
170
209
  }
171
- // .claude/hooks: create when the asset writes into it; otherwise prove
172
- // IF it exists (settings-only repair must still secure the hook's
173
- // container — JDB5-001).
174
- await ensureManagedContainer(claudeHandle, hooksDir,
175
- /* createIfAbsent */ needsWrite(input.asset));
176
210
  }
177
- // --- CAPTURE + (FORCED) BACKUP + STAGE, asset then settings ---
178
- for (const component of [input.asset, input.settings]) {
211
+ // --- CAPTURE + (FORCED) BACKUP + STAGE, in component order ---
212
+ for (const component of components) {
179
213
  if (!needsWrite(component))
180
214
  continue;
181
215
  const parentPath = path.dirname(component.path);
@@ -201,7 +235,11 @@ export async function runTransaction(input) {
201
235
  for (const handle of heldOrder) {
202
236
  must(`recheck-id ${handle.path}`, await secureFs.revalidateIdentity(handle.path, handle.identity));
203
237
  must(`recheck-own ${handle.path}`, await secureFs.proveOwnershipAndMode(handle.path));
204
- must(`recheck-acl ${handle.path}`, await secureFs.proveNoExtendedAcl(handle.path));
238
+ // LENIENT ancestor predicate — MUST match preflight `gate()` so an ancestor
239
+ // ACL accepted at preflight is not refused at commit (spec: predicate is
240
+ // consistent across preflight and re-prove). Managed containers held here
241
+ // are ALSO re-proved strict just below via `recheck-container`.
242
+ must(`recheck-acl ${handle.path}`, await secureFs.proveNoEndangeringAcl(handle.path));
205
243
  // Re-prove the managed-container add/delete-child dimension for held
206
244
  // handles that ARE managed containers, closing the TOCTOU window between
207
245
  // 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.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {