javi-forge 1.29.0 → 1.30.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.
@@ -280,4 +280,137 @@ export function planManagedClaudeHookMerge(parsed, currentAssetSha, identities)
280
280
  action: "refuse",
281
281
  };
282
282
  }
283
+ // Slice-3a write-plan helpers (Decision 8) — pure, reuse the module primitives.
284
+ /** The managed timeout, matching the fixture handler shape. */
285
+ const MANAGED_TIMEOUT = 30;
286
+ function preToolUseArray(parsed) {
287
+ const hooks = isPlainObject(parsed) ? parsed.hooks : undefined;
288
+ return isPlainObject(hooks) && Array.isArray(hooks.PreToolUse)
289
+ ? hooks.PreToolUse
290
+ : [];
291
+ }
292
+ function postToolUseArray(parsed) {
293
+ const hooks = isPlainObject(parsed) ? parsed.hooks : undefined;
294
+ return isPlainObject(hooks) && Array.isArray(hooks.PostToolUse)
295
+ ? hooks.PostToolUse
296
+ : [];
297
+ }
298
+ /**
299
+ * Plan excision of the proven four-object legacy cohort from an embedded
300
+ * container. Reuses `LEGACY_COHORT` + `deepStructuralEqual` (the same primitives
301
+ * `classifyLegacy` uses) and never re-derives the classifier. Returns the exact
302
+ * ascending object indices to remove per event plus the append position for the
303
+ * freshly built managed group; every non-cohort sibling is preserved by index.
304
+ * Only an exact-legacy cohort is eligible — a partial/edited cohort refuses
305
+ * (the classifier already routes those to `foreign`).
306
+ */
307
+ export function planLegacyCohortExcision(parsed) {
308
+ const cls = classifyLegacy(parsed);
309
+ if (cls.state !== "exact-legacy") {
310
+ return {
311
+ refused: true,
312
+ reason: `refuse cohort excision for state ${cls.state}`,
313
+ removePreIndices: [],
314
+ removePostIndices: [],
315
+ insertPreAt: 0,
316
+ };
317
+ }
318
+ const pre = preToolUseArray(parsed);
319
+ const post = postToolUseArray(parsed);
320
+ const removePreIndices = [];
321
+ for (const member of [LEGACY_COHORT.L1, LEGACY_COHORT.L2, LEGACY_COHORT.L3]) {
322
+ const index = pre.findIndex((item) => deepStructuralEqual(member, item));
323
+ if (index >= 0)
324
+ removePreIndices.push(index);
325
+ }
326
+ const removePostIndices = [];
327
+ const l4Index = post.findIndex((item) => deepStructuralEqual(LEGACY_COHORT.L4, item));
328
+ if (l4Index >= 0)
329
+ removePostIndices.push(l4Index);
330
+ removePreIndices.sort((a, b) => a - b);
331
+ removePostIndices.sort((a, b) => a - b);
332
+ return {
333
+ refused: false,
334
+ removePreIndices,
335
+ removePostIndices,
336
+ // The managed group appends after every surviving Pre sibling.
337
+ insertPreAt: pre.length - removePreIndices.length,
338
+ };
339
+ }
340
+ /**
341
+ * Plan an in-place force replacement of the single marker-proven managed
342
+ * handler for an `edited-managed` component. Eligibility (§324 / JD-A-001):
343
+ * - matcherExact === true → eligible regardless of siblingHandlers.
344
+ * - matcherExact === false && siblingHandlers === 0 → eligible.
345
+ * - matcherExact === false && siblingHandlers > 0 → refused even under force.
346
+ * A container without exactly one marker-proven handler in a valid matcher
347
+ * group also refuses.
348
+ */
349
+ export function planForceReplace(parsed, currentAssetSha) {
350
+ // Empty identities force the marker-proven state to resolve as edited-managed
351
+ // while still exposing groupIndex/handlerIndex from the marker finder.
352
+ const cls = classifySettingsEntry(parsed, currentAssetSha, {
353
+ current: null,
354
+ historical: [],
355
+ });
356
+ if (cls.state !== "edited-managed" ||
357
+ cls.groupIndex === undefined ||
358
+ cls.handlerIndex === undefined) {
359
+ return {
360
+ refused: true,
361
+ reason: `refuse force replace for state ${cls.state}`,
362
+ state: cls.state,
363
+ };
364
+ }
365
+ const groups = preToolUseArray(parsed);
366
+ const group = groups[cls.groupIndex];
367
+ const matcherExact = isPlainObject(group) && group.matcher === MANAGED_MATCHER;
368
+ const handlerCount = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks.length : 0;
369
+ const siblingHandlers = Math.max(handlerCount - 1, 0);
370
+ if (!matcherExact && siblingHandlers > 0) {
371
+ return {
372
+ refused: true,
373
+ reason: "refuse force replace: edited matcher with unrelated sibling handlers (§324)",
374
+ state: cls.state,
375
+ groupIndex: cls.groupIndex,
376
+ handlerIndex: cls.handlerIndex,
377
+ matcherExact,
378
+ siblingHandlers,
379
+ };
380
+ }
381
+ return {
382
+ refused: false,
383
+ state: cls.state,
384
+ groupIndex: cls.groupIndex,
385
+ handlerIndex: cls.handlerIndex,
386
+ matcherExact,
387
+ siblingHandlers,
388
+ };
389
+ }
390
+ /**
391
+ * Synthesize a fresh managed-only container for the two terminal states with no
392
+ * parsed value: fresh install (`absent`) and whole-file `exact-legacy`. Takes
393
+ * only the current asset SHA — not the whole Manifest — so this module never
394
+ * imports the manager's manifest reader at runtime (JD-A-002).
395
+ */
396
+ export function buildManagedContainer(currentAssetSha) {
397
+ return {
398
+ hooks: {
399
+ PreToolUse: [
400
+ {
401
+ matcher: MANAGED_MATCHER,
402
+ hooks: [
403
+ {
404
+ type: "command",
405
+ command: "node",
406
+ args: [MANAGED_ASSET_ARG],
407
+ timeout: MANAGED_TIMEOUT,
408
+ statusMessage: `${MANAGED_STATUS_PREFIX}${currentAssetSha}`,
409
+ },
410
+ ],
411
+ },
412
+ ],
413
+ },
414
+ };
415
+ }
283
416
  //# sourceMappingURL=claude-hook-settings.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * POSIX `PlatformSecureFs` adapters for the SkillGuard transactional installer
3
+ * (Slice 3a). This is the ONLY place a shell tool runs (`getfacl` on Linux,
4
+ * `/bin/ls -lde` on macOS) and the ONLY place `os`/`fs` ownership bits are
5
+ * interpreted. Everything fails closed: a missing/erroring/timed-out ACL tool,
6
+ * an extended/named/mask/default/inherited ACL entry, a foreign-owned or
7
+ * group/other-writable directory, or any inconclusive result refuses rather than
8
+ * degrading to a weaker mode-only write. It never strips an ACL.
9
+ */
10
+ import type { PlatformSecureFs, SecureResult } from "./secure-fs-transaction.js";
11
+ /** Outcome of a bounded, locale-C spawn of an ACL inspection tool. */
12
+ export interface SpawnOutcome {
13
+ /** The executable could not be spawned (e.g. ENOENT). */
14
+ spawnError?: boolean;
15
+ /** The call exceeded the bounded timeout. */
16
+ timedOut?: boolean;
17
+ /** Exit code, or null when it never exited cleanly. */
18
+ code: number | null;
19
+ stdout: string;
20
+ }
21
+ export type SpawnFn = (cmd: string, args: string[]) => Promise<SpawnOutcome>;
22
+ /** The bounded ACL prover behind each platform adapter. */
23
+ export interface PosixAclAdapter {
24
+ /** Run the bounded, LC_ALL=C ACL tool and decide clean|extended|inconclusive. */
25
+ proveClean(target: string): Promise<SecureResult<void>>;
26
+ }
27
+ export declare function createLinuxAclAdapter(spawn?: SpawnFn): PosixAclAdapter;
28
+ export declare function createMacosAclAdapter(spawn?: SpawnFn): PosixAclAdapter;
29
+ export declare function createPosixSecureFs(acl: PosixAclAdapter): PlatformSecureFs;
30
+ /**
31
+ * Select the POSIX secure filesystem for the host platform. Linux uses the
32
+ * `getfacl` adapter, macOS uses `/bin/ls -lde`; Windows and every other platform
33
+ * return `null` so the manager refuses with `windows-secure-object-unavailable`
34
+ * and mutates nothing (Slice 3b implements Windows).
35
+ */
36
+ export declare function selectSecureFs(platform?: NodeJS.Platform): PlatformSecureFs | null;
37
+ //# sourceMappingURL=secure-fs-posix.d.ts.map
@@ -0,0 +1,302 @@
1
+ /**
2
+ * POSIX `PlatformSecureFs` adapters for the SkillGuard transactional installer
3
+ * (Slice 3a). This is the ONLY place a shell tool runs (`getfacl` on Linux,
4
+ * `/bin/ls -lde` on macOS) and the ONLY place `os`/`fs` ownership bits are
5
+ * interpreted. Everything fails closed: a missing/erroring/timed-out ACL tool,
6
+ * an extended/named/mask/default/inherited ACL entry, a foreign-owned or
7
+ * group/other-writable directory, or any inconclusive result refuses rather than
8
+ * degrading to a weaker mode-only write. It never strips an ACL.
9
+ */
10
+ import { execFile } from "node:child_process";
11
+ import { createHash } from "node:crypto";
12
+ import { constants as FS } from "node:fs";
13
+ import { chmod, lstat, mkdir, open, rename, rmdir, unlink, } from "node:fs/promises";
14
+ import path from "node:path";
15
+ /** Bounded time budget for a single ACL inspection. */
16
+ const ACL_TIMEOUT_MS = 2000;
17
+ /** Read budget for the ACL tool output (defensive; ACLs are tiny). */
18
+ const ACL_MAX_BUFFER = 1024 * 1024;
19
+ // --- result constructors -----------------------------------------------------
20
+ const ok = () => ({ ok: true });
21
+ const okValue = (value) => ({ ok: true, value });
22
+ const refuse = (refusal, detail) => ({ ok: false, refusal, detail });
23
+ function errCode(error) {
24
+ return typeof error === "object" && error !== null && "code" in error
25
+ ? error.code
26
+ : undefined;
27
+ }
28
+ // --- default spawn (bounded, LC_ALL=C, argv never a shell string) ------------
29
+ const defaultSpawn = (cmd, args) => new Promise((resolve) => {
30
+ execFile(cmd, args, {
31
+ timeout: ACL_TIMEOUT_MS,
32
+ maxBuffer: ACL_MAX_BUFFER,
33
+ encoding: "utf8",
34
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
35
+ }, (error, stdout) => {
36
+ if (!error)
37
+ return resolve({ code: 0, stdout: stdout ?? "" });
38
+ const e = error;
39
+ if (e.code === "ENOENT") {
40
+ return resolve({ spawnError: true, code: null, stdout: "" });
41
+ }
42
+ if (e.killed || e.signal === "SIGTERM") {
43
+ return resolve({ timedOut: true, code: null, stdout: stdout ?? "" });
44
+ }
45
+ const code = typeof e.code === "number" ? e.code : 1;
46
+ return resolve({ code, stdout: stdout ?? "" });
47
+ });
48
+ });
49
+ // --- Linux getfacl adapter (Algorithm D) -------------------------------------
50
+ const LINUX_BASE_ENTRY = /^(user|group|other)::/;
51
+ export function createLinuxAclAdapter(spawn = defaultSpawn) {
52
+ return {
53
+ async proveClean(target) {
54
+ const res = await spawn("getfacl", [
55
+ "--absolute-names",
56
+ "--numeric",
57
+ "--omit-header",
58
+ "--",
59
+ target,
60
+ ]);
61
+ if (res.spawnError)
62
+ return refuse("unsupported-posix-acl", "getfacl absent");
63
+ if (res.timedOut)
64
+ return refuse("unsupported-posix-acl", "getfacl timeout");
65
+ if (res.code !== 0) {
66
+ return refuse("unsupported-posix-acl", `getfacl exit ${res.code}`);
67
+ }
68
+ for (const raw of res.stdout.split("\n")) {
69
+ const line = raw.trim();
70
+ if (line === "" || line.startsWith("#"))
71
+ continue;
72
+ if (LINUX_BASE_ENTRY.test(line))
73
+ continue;
74
+ return refuse("unsupported-posix-acl", "extended ACL entry");
75
+ }
76
+ return ok();
77
+ },
78
+ };
79
+ }
80
+ // --- macOS /bin/ls -lde adapter (Algorithm E) --------------------------------
81
+ const MACOS_ACE_LINE = /^\s*\d+:\s/;
82
+ export function createMacosAclAdapter(spawn = defaultSpawn) {
83
+ return {
84
+ async proveClean(target) {
85
+ const res = await spawn("/bin/ls", ["-lde", "--", target]);
86
+ if (res.spawnError)
87
+ return refuse("unsupported-posix-acl", "/bin/ls absent");
88
+ if (res.timedOut)
89
+ return refuse("unsupported-posix-acl", "ls timeout");
90
+ if (res.code !== 0) {
91
+ return refuse("unsupported-posix-acl", `ls exit ${res.code}`);
92
+ }
93
+ const lines = res.stdout.split("\n");
94
+ const modeLine = lines[0] ?? "";
95
+ if (modeLine[10] === "+") {
96
+ return refuse("unsupported-posix-acl", "ACL present (+ flag)");
97
+ }
98
+ if (lines.some((line) => MACOS_ACE_LINE.test(line))) {
99
+ return refuse("unsupported-posix-acl", "ACE listed");
100
+ }
101
+ return ok();
102
+ },
103
+ };
104
+ }
105
+ // --- the POSIX secure filesystem --------------------------------------------
106
+ const DIR_FLAGS = FS.O_DIRECTORY | FS.O_NOFOLLOW | FS.O_RDONLY;
107
+ const CAPTURE_FLAGS = FS.O_NOFOLLOW | FS.O_RDONLY;
108
+ const EXCLUSIVE_FLAGS = FS.O_CREAT | FS.O_EXCL | FS.O_WRONLY | FS.O_NOFOLLOW;
109
+ function identityOf(stats) {
110
+ return { dev: stats.dev, ino: stats.ino };
111
+ }
112
+ function ownershipTrusted(uid) {
113
+ const euid = typeof process.geteuid === "function" ? process.geteuid() : -1;
114
+ return uid === euid || uid === 0;
115
+ }
116
+ export function createPosixSecureFs(acl) {
117
+ async function fsyncDir(dirPath) {
118
+ const handle = await open(dirPath, DIR_FLAGS);
119
+ try {
120
+ await handle.sync();
121
+ }
122
+ finally {
123
+ await handle.close();
124
+ }
125
+ }
126
+ const secureFs = {
127
+ async openDirNoFollow(dirPath) {
128
+ try {
129
+ const handle = await open(dirPath, DIR_FLAGS);
130
+ try {
131
+ const stats = await handle.stat();
132
+ const identity = identityOf(stats);
133
+ return okValue({
134
+ path: dirPath,
135
+ identity,
136
+ close: () => handle.close(),
137
+ });
138
+ }
139
+ catch (error) {
140
+ await handle.close();
141
+ throw error;
142
+ }
143
+ }
144
+ catch (error) {
145
+ return refuse("unsafe-parent-chain", `openDir ${dirPath}: ${errCode(error) ?? "error"}`);
146
+ }
147
+ },
148
+ async revalidateIdentity(target, held) {
149
+ try {
150
+ const stats = await lstat(target);
151
+ if (stats.dev === held.dev && stats.ino === held.ino)
152
+ return ok();
153
+ return refuse("unsafe-parent-chain", `identity drift at ${target}`);
154
+ }
155
+ catch (error) {
156
+ return refuse("unsafe-parent-chain", `revalidate ${target}: ${errCode(error) ?? "error"}`);
157
+ }
158
+ },
159
+ async proveOwnershipAndMode(dirPath) {
160
+ try {
161
+ const stats = await lstat(dirPath);
162
+ if (!ownershipTrusted(stats.uid)) {
163
+ return refuse("unsafe-parent-chain", `foreign owner at ${dirPath}`);
164
+ }
165
+ if ((stats.mode & 0o022) !== 0) {
166
+ return refuse("unsafe-parent-chain", `group/other-writable ${dirPath}`);
167
+ }
168
+ return ok();
169
+ }
170
+ catch (error) {
171
+ return refuse("unsafe-parent-chain", `ownership ${dirPath}: ${errCode(error) ?? "error"}`);
172
+ }
173
+ },
174
+ proveNoExtendedAcl(target) {
175
+ return acl.proveClean(target);
176
+ },
177
+ async createDirExclusive(parent, name, mode) {
178
+ const full = path.join(parent.path, name);
179
+ try {
180
+ await mkdir(full, { mode }); // fails EEXIST if it already exists
181
+ await chmod(full, mode); // defeat umask masking of mkdir mode
182
+ }
183
+ catch (error) {
184
+ return refuse("unsafe-parent-chain", `mkdir ${full}: ${errCode(error) ?? "error"}`);
185
+ }
186
+ const opened = await secureFs.openDirNoFollow(full);
187
+ if (!opened.ok || !opened.value)
188
+ return opened;
189
+ const owned = await secureFs.proveOwnershipAndMode(full);
190
+ if (!owned.ok) {
191
+ await opened.value.close();
192
+ return refuse("unsafe-parent-chain", owned.detail ?? full);
193
+ }
194
+ return opened;
195
+ },
196
+ async captureFile(target) {
197
+ try {
198
+ const handle = await open(target, CAPTURE_FLAGS);
199
+ try {
200
+ const stats = await handle.stat();
201
+ const bytes = await handle.readFile();
202
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
203
+ return okValue({
204
+ bytes,
205
+ mode: stats.mode & 0o7777,
206
+ identity: identityOf(stats),
207
+ sha256,
208
+ });
209
+ }
210
+ finally {
211
+ await handle.close();
212
+ }
213
+ }
214
+ catch (error) {
215
+ return refuse("unsafe-parent-chain", `capture ${target}: ${errCode(error) ?? "error"}`);
216
+ }
217
+ },
218
+ async writeExclusive(dir, name, bytes, mode) {
219
+ const full = path.join(dir.path, name);
220
+ let handle;
221
+ try {
222
+ handle = await open(full, EXCLUSIVE_FLAGS, mode);
223
+ await handle.writeFile(bytes);
224
+ await handle.sync();
225
+ return ok();
226
+ }
227
+ catch (error) {
228
+ return refuse("unsafe-parent-chain", `writeExclusive ${full}: ${errCode(error) ?? "error"}`);
229
+ }
230
+ finally {
231
+ await handle?.close().catch(() => { });
232
+ }
233
+ },
234
+ async applyExactMode(target, mode) {
235
+ try {
236
+ await chmod(target, mode);
237
+ const stats = await lstat(target);
238
+ if ((stats.mode & 0o7777) !== mode) {
239
+ return refuse("unsafe-parent-chain", `mode mismatch ${target}`);
240
+ }
241
+ }
242
+ catch (error) {
243
+ return refuse("unsafe-parent-chain", `applyMode ${target}: ${errCode(error) ?? "error"}`);
244
+ }
245
+ return acl.proveClean(target);
246
+ },
247
+ async renameInDir(dir, from, to) {
248
+ try {
249
+ await rename(path.join(dir.path, from), path.join(dir.path, to));
250
+ await fsyncDir(dir.path);
251
+ return ok();
252
+ }
253
+ catch (error) {
254
+ return refuse("unsafe-parent-chain", `rename ${from}->${to}: ${errCode(error) ?? "error"}`);
255
+ }
256
+ },
257
+ async unlinkIfIdentity(dir, name, held) {
258
+ const full = path.join(dir.path, name);
259
+ try {
260
+ const stats = await lstat(full);
261
+ if (stats.dev !== held.dev || stats.ino !== held.ino) {
262
+ return refuse("unsafe-parent-chain", `identity mismatch ${full}`);
263
+ }
264
+ await unlink(full);
265
+ await fsyncDir(dir.path);
266
+ return ok();
267
+ }
268
+ catch (error) {
269
+ return refuse("unsafe-parent-chain", `unlink ${full}: ${errCode(error) ?? "error"}`);
270
+ }
271
+ },
272
+ async rmdirIfIdentityEmpty(handle) {
273
+ try {
274
+ const stats = await lstat(handle.path);
275
+ if (stats.dev !== handle.identity.dev ||
276
+ stats.ino !== handle.identity.ino) {
277
+ return refuse("unsafe-parent-chain", `identity mismatch ${handle.path}`);
278
+ }
279
+ await rmdir(handle.path); // ENOTEMPTY if non-empty → refuse, no escalation
280
+ return ok();
281
+ }
282
+ catch (error) {
283
+ return refuse("unsafe-parent-chain", `rmdir ${handle.path}: ${errCode(error) ?? "error"}`);
284
+ }
285
+ },
286
+ };
287
+ return secureFs;
288
+ }
289
+ /**
290
+ * Select the POSIX secure filesystem for the host platform. Linux uses the
291
+ * `getfacl` adapter, macOS uses `/bin/ls -lde`; Windows and every other platform
292
+ * return `null` so the manager refuses with `windows-secure-object-unavailable`
293
+ * and mutates nothing (Slice 3b implements Windows).
294
+ */
295
+ export function selectSecureFs(platform = process.platform) {
296
+ if (platform === "linux")
297
+ return createPosixSecureFs(createLinuxAclAdapter());
298
+ if (platform === "darwin")
299
+ return createPosixSecureFs(createMacosAclAdapter());
300
+ return null;
301
+ }
302
+ //# sourceMappingURL=secure-fs-posix.js.map
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Platform-agnostic transactional file-write core for the SkillGuard Claude
3
+ * PreToolUse installer (Slice 3a). This module owns all irreversible I/O behind
4
+ * a single `PlatformSecureFs` adapter interface: it NEVER spawns a process and
5
+ * NEVER branches on `process.platform`. Every ownership/identity/ACL/exclusive
6
+ * decision is delegated to the injected adapter, so the engine is reviewed once
7
+ * (platform-free) and exercised by a synchronous in-memory fake in tests.
8
+ *
9
+ * WU-2 defines the interface + result types here so the POSIX adapter can
10
+ * implement them; WU-3 grows this file with `runTransaction` + `TransactionDeps`.
11
+ */
12
+ /** Identity of an opened directory/file, captured from its handle. */
13
+ export interface SecureIdentity {
14
+ dev: number;
15
+ ino: number;
16
+ }
17
+ /** A held, no-follow directory handle plus its captured identity and path. */
18
+ export interface SecureDirHandle {
19
+ readonly path: string;
20
+ readonly identity: SecureIdentity;
21
+ close(): Promise<void>;
22
+ }
23
+ /** Captured prior state of a target file, taken through the validated gate. */
24
+ export interface CapturedFile {
25
+ bytes: Buffer;
26
+ mode: number;
27
+ identity: SecureIdentity;
28
+ sha256: string;
29
+ }
30
+ export type SecureRefusal = "unsafe-parent-chain" | "unsupported-posix-acl" | "windows-secure-object-unavailable";
31
+ export interface SecureResult<T> {
32
+ ok: boolean;
33
+ value?: T;
34
+ refusal?: SecureRefusal;
35
+ detail?: string;
36
+ }
37
+ /**
38
+ * The whole platform boundary. Every host-dependent operation is a method here;
39
+ * the transaction core talks only to this interface. POSIX now, Windows stub in
40
+ * Slice 3b, a synchronous fake in tests.
41
+ */
42
+ export interface PlatformSecureFs {
43
+ /** Open an existing directory no-follow (O_DIRECTORY|O_NOFOLLOW) and capture dev+ino. */
44
+ openDirNoFollow(dirPath: string): Promise<SecureResult<SecureDirHandle>>;
45
+ /** Reopen a path and confirm its identity equals a previously held one. */
46
+ revalidateIdentity(target: string, held: SecureIdentity): Promise<SecureResult<void>>;
47
+ /** Prove owner == effective uid or root AND no group/other write bits. */
48
+ proveOwnershipAndMode(dirPath: string): Promise<SecureResult<void>>;
49
+ /** Prove no extended/named/mask/default/inherited ACL on the path. */
50
+ proveNoExtendedAcl(target: string): Promise<SecureResult<void>>;
51
+ /** Create ONE child directory exclusively at mode, reopen+verify, return its handle. */
52
+ createDirExclusive(parent: SecureDirHandle, name: string, mode: number): Promise<SecureResult<SecureDirHandle>>;
53
+ /**
54
+ * Capture a regular file's bytes+mode+identity+sha through the validated gate.
55
+ * MUST open the target with `O_NOFOLLOW|O_RDONLY` (never a plain path read): a
56
+ * symlink swapped in at the target name must fail the open, not be
57
+ * dereferenced. This is a security boundary (JD-B-003) — the capture is the
58
+ * source of both the persistent backup and the in-memory rollback bytes.
59
+ */
60
+ captureFile(target: string): Promise<SecureResult<CapturedFile>>;
61
+ /**
62
+ * Create <name> in dir with O_CREAT|O_EXCL|O_WRONLY|O_NOFOLLOW at mode; write
63
+ * bytes; `handle.sync()` (fsync) the file before close. The write and the
64
+ * fsync are one method so an fsync fault is an injectable fault point of
65
+ * `writeExclusive` itself (JD-B-004): a fake can succeed the write and fail
66
+ * the sync to drive that branch.
67
+ */
68
+ writeExclusive(dir: SecureDirHandle, name: string, bytes: Buffer, mode: number): Promise<SecureResult<void>>;
69
+ /** Apply an exact mode to an existing staged file and re-verify mode + ACL absence. */
70
+ applyExactMode(target: string, mode: number): Promise<SecureResult<void>>;
71
+ /** Same-directory rename from -> to, then fsync the directory. */
72
+ renameInDir(dir: SecureDirHandle, from: string, to: string): Promise<SecureResult<void>>;
73
+ /** Unlink an identity-matched file (rollback of a newly created target). */
74
+ unlinkIfIdentity(dir: SecureDirHandle, name: string, held: SecureIdentity): Promise<SecureResult<void>>;
75
+ /** Remove an identity-matched EMPTY directory (rollback of a created segment). */
76
+ rmdirIfIdentityEmpty(handle: SecureDirHandle): Promise<SecureResult<void>>;
77
+ }
78
+ /** Injected seams making the engine deterministic and host-independent. */
79
+ export interface TransactionDeps {
80
+ secureFs: PlatformSecureFs;
81
+ clock: () => Date;
82
+ nonce: () => string;
83
+ }
84
+ /**
85
+ * One target of the transaction (asset or settings). All Claude-specific state
86
+ * is collapsed to explicit flags so the engine stays agent-agnostic.
87
+ */
88
+ export interface TransactionComponent {
89
+ /** Absolute target path. */
90
+ path: string;
91
+ /** Bytes to write, or null to skip this component (no-op / already current). */
92
+ desired: Buffer | null;
93
+ /** Capture prior bytes for in-memory rollback (existing managed/legacy content). */
94
+ capturePrior: boolean;
95
+ /** Create a persistent backup — forced edited-managed only (Decision 5). */
96
+ forceBackup: boolean;
97
+ /** True when the target did not exist before this op (rollback = unlink). */
98
+ wasAbsent: boolean;
99
+ }
100
+ export interface RunTransactionInput extends TransactionDeps {
101
+ /** Existing project directory; `.claude` / `.claude/hooks` are created under it. */
102
+ projectDir: string;
103
+ /** Committed first. */
104
+ asset: TransactionComponent;
105
+ /** Committed second. */
106
+ settings: TransactionComponent;
107
+ }
108
+ export interface TransactionOutcome {
109
+ ok: boolean;
110
+ committed: string[];
111
+ backups: string[];
112
+ errors: string[];
113
+ }
114
+ /**
115
+ * Run the staged, two-target transaction. Preflight gates the private parent
116
+ * chain, creates `.claude`/`.claude/hooks` one exclusive `0o700` segment at a
117
+ * time, captures prior bytes (+ a forced-only persistent backup), stages each
118
+ * new byte set into a same-directory `0o600` temp, re-proves the full gate
119
+ * immediately before the first rename (JD-007), then commits asset-first and
120
+ * settings-second via same-directory rename + parent fsync. A failure after a
121
+ * commit triggers guarded reverse-order rollback from the in-memory captured
122
+ * bytes; lost proof or a post-write hash drift STOPS cleanup and returns
123
+ * manual-recovery guidance (never clobbers a concurrent change).
124
+ */
125
+ export declare function runTransaction(input: RunTransactionInput): Promise<TransactionOutcome>;
126
+ //# sourceMappingURL=secure-fs-transaction.d.ts.map