javi-forge 1.28.1 → 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.
- package/assets/claude-hooks/javi-forge-skillguard-pre-tool-use.mjs +828 -0
- package/assets/claude-hooks/manifest.json +1 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/lib/__fixtures__/claude-hook-ownership.d.ts +112 -0
- package/dist/lib/__fixtures__/claude-hook-ownership.js +96 -0
- package/dist/lib/__fixtures__/fake-secure-fs.d.ts +43 -0
- package/dist/lib/__fixtures__/fake-secure-fs.js +183 -0
- package/dist/lib/claude-hook-manager.d.ts +102 -0
- package/dist/lib/claude-hook-manager.js +490 -0
- package/dist/lib/claude-hook-settings.d.ts +188 -0
- package/dist/lib/claude-hook-settings.js +416 -0
- package/dist/lib/secure-fs-posix.d.ts +37 -0
- package/dist/lib/secure-fs-posix.js +302 -0
- package/dist/lib/secure-fs-transaction.d.ts +126 -0
- package/dist/lib/secure-fs-transaction.js +239 -0
- package/package.json +1 -1
|
@@ -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
|
|
@@ -0,0 +1,239 @@
|
|
|
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
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
/** Bounded nonce retries for an exclusive backup create. */
|
|
15
|
+
const BACKUP_NONCE_CANDIDATES = 8;
|
|
16
|
+
class TxAbort extends Error {
|
|
17
|
+
step;
|
|
18
|
+
detail;
|
|
19
|
+
constructor(step, detail) {
|
|
20
|
+
super(`${step}${detail ? `: ${detail}` : ""}`);
|
|
21
|
+
this.step = step;
|
|
22
|
+
this.detail = detail;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function must(step, res) {
|
|
26
|
+
if (!res.ok)
|
|
27
|
+
throw new TxAbort(step, res.detail ?? res.refusal);
|
|
28
|
+
return res.value;
|
|
29
|
+
}
|
|
30
|
+
function sha256(bytes) {
|
|
31
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
/** Compact ISO-ms stamp: 2026-08-16T19:00:00.123Z -> 20260816T190000123Z. */
|
|
34
|
+
function timestamp(clock) {
|
|
35
|
+
return clock().toISOString().replace(/[-:.]/g, "");
|
|
36
|
+
}
|
|
37
|
+
function backupName(base, clock, nonce) {
|
|
38
|
+
return `${base}.javi-forge.bak.${timestamp(clock)}.${nonce}`;
|
|
39
|
+
}
|
|
40
|
+
function tempName(base, nonce) {
|
|
41
|
+
return `${base}.javi-forge.tmp.${process.pid}.${nonce}`;
|
|
42
|
+
}
|
|
43
|
+
/** Existing directory chain from the filesystem root through `leaf`, root first. */
|
|
44
|
+
function ancestorChain(leaf) {
|
|
45
|
+
const chain = [];
|
|
46
|
+
let current = leaf;
|
|
47
|
+
while (true) {
|
|
48
|
+
chain.push(current);
|
|
49
|
+
const parent = path.dirname(current);
|
|
50
|
+
if (parent === current)
|
|
51
|
+
break;
|
|
52
|
+
current = parent;
|
|
53
|
+
}
|
|
54
|
+
return chain.reverse();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Run the staged, two-target transaction. Preflight gates the private parent
|
|
58
|
+
* chain, creates `.claude`/`.claude/hooks` one exclusive `0o700` segment at a
|
|
59
|
+
* time, captures prior bytes (+ a forced-only persistent backup), stages each
|
|
60
|
+
* new byte set into a same-directory `0o600` temp, re-proves the full gate
|
|
61
|
+
* immediately before the first rename (JD-007), then commits asset-first and
|
|
62
|
+
* settings-second via same-directory rename + parent fsync. A failure after a
|
|
63
|
+
* commit triggers guarded reverse-order rollback from the in-memory captured
|
|
64
|
+
* bytes; lost proof or a post-write hash drift STOPS cleanup and returns
|
|
65
|
+
* manual-recovery guidance (never clobbers a concurrent change).
|
|
66
|
+
*/
|
|
67
|
+
export async function runTransaction(input) {
|
|
68
|
+
const { secureFs, clock, nonce, projectDir } = input;
|
|
69
|
+
const claudeDir = path.join(projectDir, ".claude");
|
|
70
|
+
const hooksDir = path.join(claudeDir, "hooks");
|
|
71
|
+
const heldByPath = new Map();
|
|
72
|
+
const heldOrder = [];
|
|
73
|
+
const createdDirs = [];
|
|
74
|
+
const staged = [];
|
|
75
|
+
const committed = [];
|
|
76
|
+
const backups = [];
|
|
77
|
+
const needsWrite = (c) => c.desired !== null;
|
|
78
|
+
const anyWrite = needsWrite(input.asset) || needsWrite(input.settings);
|
|
79
|
+
async function gate(dirPath, handle) {
|
|
80
|
+
must(`ownership ${dirPath}`, await secureFs.proveOwnershipAndMode(dirPath));
|
|
81
|
+
must(`acl ${dirPath}`, await secureFs.proveNoExtendedAcl(dirPath));
|
|
82
|
+
heldByPath.set(dirPath, handle);
|
|
83
|
+
heldOrder.push(handle);
|
|
84
|
+
}
|
|
85
|
+
async function ensureDir(parent, fullPath) {
|
|
86
|
+
const opened = await secureFs.openDirNoFollow(fullPath);
|
|
87
|
+
if (opened.ok && opened.value) {
|
|
88
|
+
await gate(fullPath, opened.value);
|
|
89
|
+
return opened.value;
|
|
90
|
+
}
|
|
91
|
+
const created = must(`create ${fullPath}`, await secureFs.createDirExclusive(parent, path.basename(fullPath), 0o700));
|
|
92
|
+
// Post-create identity revalidation + full gate on the new segment.
|
|
93
|
+
must(`revalidate-created ${fullPath}`, await secureFs.revalidateIdentity(fullPath, created.identity));
|
|
94
|
+
createdDirs.push(created);
|
|
95
|
+
await gate(fullPath, created);
|
|
96
|
+
return created;
|
|
97
|
+
}
|
|
98
|
+
async function gateStillValid() {
|
|
99
|
+
for (const handle of heldOrder) {
|
|
100
|
+
const id = await secureFs.revalidateIdentity(handle.path, handle.identity);
|
|
101
|
+
if (!id.ok)
|
|
102
|
+
return false;
|
|
103
|
+
if (!(await secureFs.proveOwnershipAndMode(handle.path)).ok)
|
|
104
|
+
return false;
|
|
105
|
+
if (!(await secureFs.proveNoExtendedAcl(handle.path)).ok)
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
// --- PREFLIGHT: gate the existing chain root..projectDir ---
|
|
112
|
+
for (const dirPath of ancestorChain(projectDir)) {
|
|
113
|
+
const handle = must(`openDir ${dirPath}`, await secureFs.openDirNoFollow(dirPath));
|
|
114
|
+
await gate(dirPath, handle);
|
|
115
|
+
}
|
|
116
|
+
// --- SEGMENT CREATION: .claude then .claude/hooks, one at a time ---
|
|
117
|
+
if (anyWrite) {
|
|
118
|
+
const projectHandle = heldByPath.get(projectDir);
|
|
119
|
+
const claudeHandle = await ensureDir(projectHandle, claudeDir);
|
|
120
|
+
if (needsWrite(input.asset))
|
|
121
|
+
await ensureDir(claudeHandle, hooksDir);
|
|
122
|
+
}
|
|
123
|
+
// --- CAPTURE + (FORCED) BACKUP + STAGE, asset then settings ---
|
|
124
|
+
for (const component of [input.asset, input.settings]) {
|
|
125
|
+
if (!needsWrite(component))
|
|
126
|
+
continue;
|
|
127
|
+
const parentPath = path.dirname(component.path);
|
|
128
|
+
const dir = heldByPath.get(parentPath);
|
|
129
|
+
const base = path.basename(component.path);
|
|
130
|
+
must(`revalidate ${parentPath}`, await secureFs.revalidateIdentity(dir.path, dir.identity));
|
|
131
|
+
let prior = null;
|
|
132
|
+
if (component.capturePrior || component.forceBackup) {
|
|
133
|
+
prior = must(`capture ${component.path}`, await secureFs.captureFile(component.path));
|
|
134
|
+
must(`source-acl ${component.path}`, await secureFs.proveNoExtendedAcl(component.path));
|
|
135
|
+
if (component.forceBackup) {
|
|
136
|
+
backups.push(await writeBackup(dir, base, prior));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const tName = tempName(base, nonce());
|
|
140
|
+
must(`stage ${tName}`, await secureFs.writeExclusive(dir, tName, component.desired, 0o600));
|
|
141
|
+
must(`stage-mode ${tName}`, await secureFs.applyExactMode(path.join(dir.path, tName), prior ? prior.mode : 0o600));
|
|
142
|
+
// Identity revalidation after each writeExclusive (JD-B-005).
|
|
143
|
+
must(`revalidate-staged ${parentPath}`, await secureFs.revalidateIdentity(dir.path, dir.identity));
|
|
144
|
+
staged.push({ dir, tempName: tName, target: component, prior });
|
|
145
|
+
}
|
|
146
|
+
// --- PRE-FIRST-RENAME FULL-GATE RE-PROVE (JD-007) ---
|
|
147
|
+
for (const handle of heldOrder) {
|
|
148
|
+
must(`recheck-id ${handle.path}`, await secureFs.revalidateIdentity(handle.path, handle.identity));
|
|
149
|
+
must(`recheck-own ${handle.path}`, await secureFs.proveOwnershipAndMode(handle.path));
|
|
150
|
+
must(`recheck-acl ${handle.path}`, await secureFs.proveNoExtendedAcl(handle.path));
|
|
151
|
+
}
|
|
152
|
+
// --- COMMIT: asset first, settings second ---
|
|
153
|
+
for (const entry of staged) {
|
|
154
|
+
const base = path.basename(entry.target.path);
|
|
155
|
+
must(`pre-rename ${entry.dir.path}`, await secureFs.revalidateIdentity(entry.dir.path, entry.dir.identity));
|
|
156
|
+
const wroteHash = sha256(entry.target.desired);
|
|
157
|
+
must(`rename ${base}`, await secureFs.renameInDir(entry.dir, entry.tempName, base));
|
|
158
|
+
must(`post-rename ${entry.dir.path}`, await secureFs.revalidateIdentity(entry.dir.path, entry.dir.identity));
|
|
159
|
+
committed.push({
|
|
160
|
+
path: entry.target.path,
|
|
161
|
+
dir: entry.dir,
|
|
162
|
+
wroteHash,
|
|
163
|
+
wasAbsent: entry.target.wasAbsent,
|
|
164
|
+
prior: entry.prior,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
committed: committed.map((c) => c.path),
|
|
170
|
+
backups,
|
|
171
|
+
errors: [],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
const errors = [
|
|
176
|
+
error instanceof TxAbort ? error.message : String(error),
|
|
177
|
+
];
|
|
178
|
+
await rollback(committed, createdDirs, errors);
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
committed: committed.map((c) => c.path),
|
|
182
|
+
backups,
|
|
183
|
+
errors,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
for (const handle of [...createdDirs, ...heldOrder]) {
|
|
188
|
+
await handle.close().catch(() => { });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function writeBackup(dir, base, prior) {
|
|
192
|
+
for (let attempt = 0; attempt < BACKUP_NONCE_CANDIDATES; attempt++) {
|
|
193
|
+
const name = backupName(base, clock, nonce());
|
|
194
|
+
const written = await secureFs.writeExclusive(dir, name, prior.bytes, 0o600);
|
|
195
|
+
if (!written.ok)
|
|
196
|
+
continue; // collision or transient — retry with a fresh nonce
|
|
197
|
+
must(`backup-mode ${name}`, await secureFs.applyExactMode(path.join(dir.path, name), prior.mode));
|
|
198
|
+
must(`backup-revalidate ${dir.path}`, await secureFs.revalidateIdentity(dir.path, dir.identity));
|
|
199
|
+
return path.join(dir.path, name);
|
|
200
|
+
}
|
|
201
|
+
throw new TxAbort("backup", `no exclusive backup name after ${BACKUP_NONCE_CANDIDATES}`);
|
|
202
|
+
}
|
|
203
|
+
async function rollback(done, created, errors) {
|
|
204
|
+
for (const entry of [...done].reverse()) {
|
|
205
|
+
if (!(await gateStillValid())) {
|
|
206
|
+
errors.push(`STOP: lost parent-chain proof; manual recovery at ${entry.path}`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const current = await secureFs.captureFile(entry.path);
|
|
210
|
+
if (!current.ok || !current.value) {
|
|
211
|
+
errors.push(`STOP: cannot re-read ${entry.path}; manual recovery`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (current.value.sha256 !== entry.wroteHash) {
|
|
215
|
+
errors.push(`STOP: ${entry.path} changed after commit; manual recovery (concurrent edit)`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const base = path.basename(entry.path);
|
|
219
|
+
if (entry.wasAbsent) {
|
|
220
|
+
await secureFs.unlinkIfIdentity(entry.dir, base, current.value.identity);
|
|
221
|
+
}
|
|
222
|
+
else if (entry.prior) {
|
|
223
|
+
const rName = tempName(base, nonce());
|
|
224
|
+
const wrote = await secureFs.writeExclusive(entry.dir, rName, entry.prior.bytes, 0o600);
|
|
225
|
+
if (!wrote.ok) {
|
|
226
|
+
errors.push(`STOP: cannot stage rollback for ${entry.path}`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
await secureFs.applyExactMode(path.join(entry.dir.path, rName), entry.prior.mode);
|
|
230
|
+
await secureFs.renameInDir(entry.dir, rName, base);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Remove only tx-created, identity-matched, still-empty segments, child-first.
|
|
234
|
+
for (const handle of [...created].reverse()) {
|
|
235
|
+
await secureFs.rmdirIfIdentityEmpty(handle);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
//# sourceMappingURL=secure-fs-transaction.js.map
|