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.
- package/assets/claude-hooks/javi-forge-skillguard-pre-tool-use.mjs +97 -29
- package/assets/claude-hooks/manifest.json +1 -1
- package/dist/cli/dispatch/hooks.d.ts +9 -2
- package/dist/cli/dispatch/hooks.js +22 -8
- package/dist/commands/codex-hooks.d.ts +26 -0
- package/dist/commands/codex-hooks.js +104 -0
- package/dist/lib/__fixtures__/claude-hook-ownership.d.ts +6 -0
- package/dist/lib/__fixtures__/claude-hook-ownership.js +7 -1
- package/dist/lib/__fixtures__/fake-secure-fs.d.ts +9 -1
- package/dist/lib/__fixtures__/fake-secure-fs.js +15 -0
- package/dist/lib/agent-adapter.d.ts +56 -0
- package/dist/lib/agent-adapter.js +88 -0
- package/dist/lib/claude-hook-manager.js +3 -2
- package/dist/lib/claude-hook-settings.d.ts +3 -3
- package/dist/lib/claude-hook-settings.js +3 -3
- package/dist/lib/codex-hook-manager.d.ts +161 -0
- package/dist/lib/codex-hook-manager.js +531 -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 +40 -6
- package/dist/lib/secure-fs-transaction.js +60 -22
- package/dist/lib/secure-fs-windows.js +7 -0
- package/package.json +1 -1
|
@@ -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
|
/**
|
|
@@ -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
|
-
/**
|
|
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
|
|
133
|
-
/** Committed second. */
|
|
134
|
-
settings
|
|
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
|
-
|
|
70
|
-
|
|
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(
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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,
|
|
178
|
-
for (const component of
|
|
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
|
-
|
|
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
|
},
|