javi-forge 1.30.1 → 1.32.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-windows-secure-object.ps1 +1223 -0
- package/assets/claude-hooks/manifest.json +1 -1
- package/dist/cli/dispatch/hooks.d.ts +5 -2
- package/dist/cli/dispatch/hooks.js +16 -2
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +12 -2
- package/dist/commands/claude-hooks.d.ts +32 -0
- package/dist/commands/claude-hooks.js +75 -0
- package/dist/commands/init/steps/security.d.ts +7 -3
- package/dist/commands/init/steps/security.js +25 -19
- package/dist/lib/__fixtures__/fake-helper-transport.d.ts +46 -0
- package/dist/lib/__fixtures__/fake-helper-transport.js +90 -0
- package/dist/lib/__fixtures__/fake-secure-fs.d.ts +12 -0
- package/dist/lib/__fixtures__/fake-secure-fs.js +26 -1
- package/dist/lib/secure-fs-posix.d.ts +13 -4
- package/dist/lib/secure-fs-posix.js +33 -5
- package/dist/lib/secure-fs-transaction.d.ts +29 -1
- package/dist/lib/secure-fs-transaction.js +65 -5
- package/dist/lib/secure-fs-windows.d.ts +124 -0
- package/dist/lib/secure-fs-windows.js +588 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/ui/App.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows `PlatformSecureFs` adapter for the SkillGuard transactional installer
|
|
3
|
+
* (Slice 3b). It is the win32 analog of the POSIX adapter: the ONLY place a
|
|
4
|
+
* Windows security decision is requested, delegated across an injectable
|
|
5
|
+
* `HelperTransport` seam to a bundled, digest-bound PowerShell helper (Phase 3)
|
|
6
|
+
* that owns the real OS handles and computes Predicate A/B verdicts.
|
|
7
|
+
*
|
|
8
|
+
* This module is host-independent and fully testable on Linux via a fake
|
|
9
|
+
* transport: `createWindowsSecureFs` builds framed requests and maps framed
|
|
10
|
+
* responses to `SecureResult`s; it enforces the TS-side invariants the design
|
|
11
|
+
* pins to the adapter (never the `.ps1`):
|
|
12
|
+
* - C1 (Decision 1a): `mode` is a sentinel, NOT POSIX bits. `captureFile`
|
|
13
|
+
* returns `WIN32_MODE_SENTINEL`; `applyExactMode` refuses any other mode.
|
|
14
|
+
* - C4 (Decision 1b): identity is the full-precision `volumeSerial:FileId`
|
|
15
|
+
* `opaque` token; an absent/zero/malformed token is a HARD REFUSAL, never a
|
|
16
|
+
* fallback to the truncated `dev`/`ino` (display-only).
|
|
17
|
+
* - JDA6-001 (Round-6): `openDir` maps ONLY `ERROR_FILE_NOT_FOUND` (2) /
|
|
18
|
+
* `ERROR_PATH_NOT_FOUND` (3) to `notFound:true`; every other failure leaves
|
|
19
|
+
* it absent so a present-but-unopenable container fails the transaction closed.
|
|
20
|
+
* - JDA7-001 (Round-7): `openDir` asserts `FILE_ATTRIBUTE_DIRECTORY` and
|
|
21
|
+
* refuses a non-directory with `notFound:false` (POSIX `O_DIRECTORY` parity).
|
|
22
|
+
* Every transport error (spawn failure, dead session, bad frame, timeout) maps
|
|
23
|
+
* to a fail-closed refusal — Windows is never a weaker tier than POSIX.
|
|
24
|
+
*/
|
|
25
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import { readFileSync } from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { CLAUDE_HOOK_ASSETS_DIR } from "../constants.js";
|
|
30
|
+
// --- protocol constants ------------------------------------------------------
|
|
31
|
+
/**
|
|
32
|
+
* The mode `captureFile` returns and `applyExactMode` demands on win32 (C1).
|
|
33
|
+
* NTFS has no POSIX bits; the value only has to survive the core's opaque
|
|
34
|
+
* round-trip, and `0o600` is the private-file mode the core already threads.
|
|
35
|
+
*/
|
|
36
|
+
export const WIN32_MODE_SENTINEL = 0o600;
|
|
37
|
+
/** Reject any frame whose declared length exceeds this (hook assets are tiny). */
|
|
38
|
+
export const HELPER_FRAME_LIMIT = 8 * 1024 * 1024; // 8 MiB
|
|
39
|
+
/** `GetFileInformationByHandle` attribute for a directory node. */
|
|
40
|
+
const FILE_ATTRIBUTE_DIRECTORY = 0x10;
|
|
41
|
+
/** win32 not-found status codes → the ONLY `notFound:true` mapping (JDA6-001). */
|
|
42
|
+
const ERROR_FILE_NOT_FOUND = 2;
|
|
43
|
+
const ERROR_PATH_NOT_FOUND = 3;
|
|
44
|
+
/** Windows PowerShell 5.1 host (always present on windows-latest). */
|
|
45
|
+
const POWERSHELL = "powershell.exe";
|
|
46
|
+
const POWERSHELL_ARGS = [
|
|
47
|
+
"-NoProfile",
|
|
48
|
+
"-NonInteractive",
|
|
49
|
+
"-ExecutionPolicy",
|
|
50
|
+
"Bypass",
|
|
51
|
+
"-File",
|
|
52
|
+
];
|
|
53
|
+
/** Kill an idle session this long after the last transaction (zero handles). */
|
|
54
|
+
const HELPER_IDLE_MS = 30_000;
|
|
55
|
+
/**
|
|
56
|
+
* R4-001 (Phase-4 hard gate): the per-request / handshake deadline. A timer is
|
|
57
|
+
* armed when a frame is written to the child (and while awaiting the startup
|
|
58
|
+
* handshake) and cleared the instant its response arrives. If it fires, the
|
|
59
|
+
* child is killed and every pending/subsequent op fails closed — a hung or
|
|
60
|
+
* non-responding `.ps1` can no longer hang the installer transaction forever.
|
|
61
|
+
*/
|
|
62
|
+
export const HELPER_OP_TIMEOUT_MS = 30_000;
|
|
63
|
+
// --- framing (pure, host-independent) ---------------------------------------
|
|
64
|
+
/** Encode a JSON body as `[uint32 BE byteLength][UTF-8 JSON]`. */
|
|
65
|
+
export function encodeFrame(body) {
|
|
66
|
+
const json = Buffer.from(JSON.stringify(body), "utf8");
|
|
67
|
+
const header = Buffer.alloc(4);
|
|
68
|
+
header.writeUInt32BE(json.byteLength, 0);
|
|
69
|
+
return Buffer.concat([header, json]);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Decode as many complete frames as `buf` holds, returning them plus the
|
|
73
|
+
* unconsumed remainder. Throws on a declared length past `HELPER_FRAME_LIMIT`
|
|
74
|
+
* (the caller kills the session and fails closed).
|
|
75
|
+
*/
|
|
76
|
+
export function decodeFrames(buf) {
|
|
77
|
+
const frames = [];
|
|
78
|
+
let offset = 0;
|
|
79
|
+
while (buf.byteLength - offset >= 4) {
|
|
80
|
+
const len = buf.readUInt32BE(offset);
|
|
81
|
+
if (len > HELPER_FRAME_LIMIT) {
|
|
82
|
+
throw new Error(`oversized frame: ${len} > ${HELPER_FRAME_LIMIT}`);
|
|
83
|
+
}
|
|
84
|
+
if (buf.byteLength - offset - 4 < len)
|
|
85
|
+
break; // partial body — wait for more
|
|
86
|
+
const body = buf.subarray(offset + 4, offset + 4 + len);
|
|
87
|
+
frames.push(JSON.parse(body.toString("utf8")));
|
|
88
|
+
offset += 4 + len;
|
|
89
|
+
}
|
|
90
|
+
return { frames, rest: buf.subarray(offset) };
|
|
91
|
+
}
|
|
92
|
+
// --- result helpers ----------------------------------------------------------
|
|
93
|
+
const ok = () => ({ ok: true });
|
|
94
|
+
const okValue = (value) => ({ ok: true, value });
|
|
95
|
+
const refuse = (refusal, detail) => ({ ok: false, refusal, detail });
|
|
96
|
+
/** Map a void framed response, defaulting a refusal to the win32 DACL class. */
|
|
97
|
+
function mapVoid(res, step) {
|
|
98
|
+
// R1-002: acceptance is STRICT — a malformed frame with a truthy-non-boolean
|
|
99
|
+
// `ok` (e.g. `1` or `"false"`) must NOT coerce to ACCEPT.
|
|
100
|
+
if (res.ok === true)
|
|
101
|
+
return ok();
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
refusal: res.refusal ?? "unsafe-windows-dacl",
|
|
105
|
+
detail: res.detail ?? step,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A valid win32 identity token is `"<volHex>:<fileIdHex>"` with a NON-zero
|
|
110
|
+
* FileId. Absent/empty/malformed/zero-FileId → refuse (C4): distinct SMB/exotic
|
|
111
|
+
* objects share `FileId == 0`, and a swap on a colliding identity would be
|
|
112
|
+
* accepted. Never falls back to the truncated `dev`/`ino`.
|
|
113
|
+
*/
|
|
114
|
+
function validOpaque(opaque) {
|
|
115
|
+
if (typeof opaque !== "string")
|
|
116
|
+
return false;
|
|
117
|
+
const parts = opaque.split(":");
|
|
118
|
+
if (parts.length !== 2)
|
|
119
|
+
return false;
|
|
120
|
+
const [vol, fileId] = parts;
|
|
121
|
+
if (!/^[0-9a-fA-F]+$/.test(vol) || !/^[0-9a-fA-F]+$/.test(fileId))
|
|
122
|
+
return false;
|
|
123
|
+
if (/^0+$/.test(fileId))
|
|
124
|
+
return false; // zero FileId → collision → refuse
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
/** Derive DISPLAY-ONLY `dev`/`ino` from the opaque token; never compared. */
|
|
128
|
+
function identityFromOpaque(opaque) {
|
|
129
|
+
const [vol, fileId] = opaque.split(":");
|
|
130
|
+
const dev = Number.parseInt(vol, 16) >>> 0;
|
|
131
|
+
const ino = Number.parseInt(fileId.slice(-8), 16) >>> 0;
|
|
132
|
+
return { dev, ino, opaque };
|
|
133
|
+
}
|
|
134
|
+
// --- adapter -----------------------------------------------------------------
|
|
135
|
+
export function createWindowsSecureFs(transport) {
|
|
136
|
+
// Maps a returned handle object to the `.ps1`-side handleId so ops taking a
|
|
137
|
+
// SecureDirHandle can address the retained kernel handle.
|
|
138
|
+
const handleIds = new WeakMap();
|
|
139
|
+
/** Send a request, mapping ANY transport error to a fail-closed refusal. */
|
|
140
|
+
async function call(req) {
|
|
141
|
+
try {
|
|
142
|
+
return await transport.request(req);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
refusal: "windows-secure-object-unavailable",
|
|
149
|
+
detail: `helper ${cause}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Always emits a releaseHandle op, even for a missing/malformed id: the
|
|
154
|
+
// session increments `outstanding` on ANY ok openDir/createDir frame, so a
|
|
155
|
+
// balancing releaseHandle is the ONLY way to decrement it back (R4-003).
|
|
156
|
+
async function releaseHandle(handleId) {
|
|
157
|
+
const handle = typeof handleId === "string" ? handleId : null;
|
|
158
|
+
await call({ op: "releaseHandle", args: { handle } });
|
|
159
|
+
}
|
|
160
|
+
function makeHandle(dirPath, handleId, opaque) {
|
|
161
|
+
const handle = {
|
|
162
|
+
path: dirPath,
|
|
163
|
+
identity: identityFromOpaque(opaque),
|
|
164
|
+
close: () => releaseHandle(handleId),
|
|
165
|
+
};
|
|
166
|
+
handleIds.set(handle, handleId);
|
|
167
|
+
return handle;
|
|
168
|
+
}
|
|
169
|
+
/** Shared open→handle path for openDir and createDir. */
|
|
170
|
+
async function toHandle(res, dirPath, step) {
|
|
171
|
+
// R1-002: STRICT accept — a truthy-non-boolean `ok` must not open a handle.
|
|
172
|
+
if (res.ok !== true || !res.value) {
|
|
173
|
+
const result = refuse(res.refusal ?? "unsafe-parent-chain", res.detail ?? step);
|
|
174
|
+
if (res.status === ERROR_FILE_NOT_FOUND ||
|
|
175
|
+
res.status === ERROR_PATH_NOT_FOUND) {
|
|
176
|
+
result.notFound = true; // the ONLY safe skip/create signal (JDA6-001)
|
|
177
|
+
}
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
const v = res.value;
|
|
181
|
+
const handleId = typeof v.handleId === "string" ? v.handleId : undefined;
|
|
182
|
+
const attributes = typeof v.attributes === "number" ? v.attributes : 0;
|
|
183
|
+
// JDA7-001: refuse a non-directory (POSIX O_DIRECTORY parity), notFound=false.
|
|
184
|
+
if ((attributes & FILE_ATTRIBUTE_DIRECTORY) === 0) {
|
|
185
|
+
await releaseHandle(handleId);
|
|
186
|
+
return refuse("unsafe-parent-chain", `${step}: not a directory ${dirPath}`);
|
|
187
|
+
}
|
|
188
|
+
// C4: a directory we cannot identify by full-precision FileId is a refusal.
|
|
189
|
+
if (!validOpaque(v.opaque)) {
|
|
190
|
+
await releaseHandle(handleId);
|
|
191
|
+
return refuse("unsafe-parent-chain", `${step}: unresolvable identity ${dirPath}`);
|
|
192
|
+
}
|
|
193
|
+
if (!handleId) {
|
|
194
|
+
// R4-003: the .ps1 acked (session already incremented `outstanding`) but
|
|
195
|
+
// sent no usable id — emit a balancing release so the idle watchdog can
|
|
196
|
+
// still arm; otherwise a malformed ok permanently disarms it.
|
|
197
|
+
await releaseHandle(v.handleId);
|
|
198
|
+
return refuse("unsafe-parent-chain", `${step}: no handle ${dirPath}`);
|
|
199
|
+
}
|
|
200
|
+
return okValue(makeHandle(dirPath, handleId, v.opaque));
|
|
201
|
+
}
|
|
202
|
+
const secureFs = {
|
|
203
|
+
async openDirNoFollow(dirPath) {
|
|
204
|
+
return toHandle(await call({ op: "openDir", args: { path: dirPath } }), dirPath, `openDir ${dirPath}`);
|
|
205
|
+
},
|
|
206
|
+
async revalidateIdentity(target, held) {
|
|
207
|
+
if (!validOpaque(held.opaque)) {
|
|
208
|
+
return refuse("unsafe-parent-chain", `revalidate ${target}: unresolvable identity`);
|
|
209
|
+
}
|
|
210
|
+
return mapVoid(await call({
|
|
211
|
+
op: "revalidate",
|
|
212
|
+
args: { path: target, opaque: held.opaque },
|
|
213
|
+
}), `revalidate ${target}`);
|
|
214
|
+
},
|
|
215
|
+
async proveOwnershipAndMode(dirPath) {
|
|
216
|
+
return mapVoid(await call({ op: "proveOwner", args: { path: dirPath } }), `ownership ${dirPath}`);
|
|
217
|
+
},
|
|
218
|
+
async proveNoExtendedAcl(target) {
|
|
219
|
+
return mapVoid(await call({ op: "proveDacl", args: { path: target } }), `acl ${target}`);
|
|
220
|
+
},
|
|
221
|
+
async proveManagedContainer(dirPath) {
|
|
222
|
+
return mapVoid(await call({ op: "proveContainer", args: { path: dirPath } }), `container ${dirPath}`);
|
|
223
|
+
},
|
|
224
|
+
async createDirExclusive(parent, name, mode) {
|
|
225
|
+
// C1/JD-A-104: createDir ignores the numeric mode (Predicate B descriptor
|
|
226
|
+
// governs), so NO sentinel assertion here — the core threads 0o700.
|
|
227
|
+
const parentHandle = handleIds.get(parent);
|
|
228
|
+
const full = path.join(parent.path, name);
|
|
229
|
+
return toHandle(await call({
|
|
230
|
+
op: "createDir",
|
|
231
|
+
args: { parentHandle, name, mode },
|
|
232
|
+
}), full, `createDir ${full}`);
|
|
233
|
+
},
|
|
234
|
+
async captureFile(target) {
|
|
235
|
+
const res = await call({ op: "capture", args: { path: target } });
|
|
236
|
+
// R1-002: STRICT accept — reject a truthy-non-boolean `ok`.
|
|
237
|
+
if (res.ok !== true || !res.value) {
|
|
238
|
+
return refuse(res.refusal ?? "unsafe-windows-dacl", res.detail ?? `capture ${target}`);
|
|
239
|
+
}
|
|
240
|
+
const v = res.value;
|
|
241
|
+
if (typeof v.bytes !== "string") {
|
|
242
|
+
return refuse("unsafe-parent-chain", `capture ${target}: missing bytes`);
|
|
243
|
+
}
|
|
244
|
+
if (!validOpaque(v.opaque)) {
|
|
245
|
+
return refuse("unsafe-parent-chain", `capture ${target}: unresolvable identity`);
|
|
246
|
+
}
|
|
247
|
+
const bytes = Buffer.from(v.bytes, "base64");
|
|
248
|
+
return okValue({
|
|
249
|
+
bytes,
|
|
250
|
+
mode: WIN32_MODE_SENTINEL, // C1: sentinel, not a real NTFS permission
|
|
251
|
+
identity: identityFromOpaque(v.opaque),
|
|
252
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
253
|
+
});
|
|
254
|
+
},
|
|
255
|
+
async writeExclusive(dir, name, bytes, mode) {
|
|
256
|
+
const dirHandle = handleIds.get(dir);
|
|
257
|
+
return mapVoid(await call({
|
|
258
|
+
op: "writeExcl",
|
|
259
|
+
args: {
|
|
260
|
+
dirHandle,
|
|
261
|
+
name,
|
|
262
|
+
bytes: bytes.toString("base64"), // binary payload → base64 JSON body
|
|
263
|
+
mode,
|
|
264
|
+
},
|
|
265
|
+
}), `writeExclusive ${name}`);
|
|
266
|
+
},
|
|
267
|
+
async applyExactMode(target, mode) {
|
|
268
|
+
// JD-A-104: the sentinel equality check lives ONLY here — the core only
|
|
269
|
+
// ever calls applyExactMode with the 0o600 file mode it captured.
|
|
270
|
+
if (mode !== WIN32_MODE_SENTINEL) {
|
|
271
|
+
return refuse("unsafe-parent-chain", `applyMode ${target}: unexpected mode ${mode.toString(8)}`);
|
|
272
|
+
}
|
|
273
|
+
return mapVoid(await call({ op: "applyMode", args: { path: target, mode } }), `applyMode ${target}`);
|
|
274
|
+
},
|
|
275
|
+
async renameInDir(dir, from, to) {
|
|
276
|
+
const dirHandle = handleIds.get(dir);
|
|
277
|
+
return mapVoid(await call({ op: "rename", args: { dirHandle, from, to } }), `rename ${from}->${to}`);
|
|
278
|
+
},
|
|
279
|
+
async unlinkIfIdentity(dir, name, held) {
|
|
280
|
+
if (!validOpaque(held.opaque)) {
|
|
281
|
+
return refuse("unsafe-parent-chain", `unlink ${name}: unresolvable identity`);
|
|
282
|
+
}
|
|
283
|
+
const dirHandle = handleIds.get(dir);
|
|
284
|
+
return mapVoid(await call({
|
|
285
|
+
op: "unlink",
|
|
286
|
+
args: { dirHandle, name, opaque: held.opaque },
|
|
287
|
+
}), `unlink ${name}`);
|
|
288
|
+
},
|
|
289
|
+
async rmdirIfIdentityEmpty(handle) {
|
|
290
|
+
if (!validOpaque(handle.identity.opaque)) {
|
|
291
|
+
return refuse("unsafe-parent-chain", `rmdir ${handle.path}: unresolvable identity`);
|
|
292
|
+
}
|
|
293
|
+
const id = handleIds.get(handle);
|
|
294
|
+
return mapVoid(await call({
|
|
295
|
+
op: "rmdir",
|
|
296
|
+
args: { handle: id, opaque: handle.identity.opaque },
|
|
297
|
+
}), `rmdir ${handle.path}`);
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
return secureFs;
|
|
301
|
+
}
|
|
302
|
+
// --- digest-bound refusing transport ----------------------------------------
|
|
303
|
+
/**
|
|
304
|
+
* A transport that refuses EVERY op — used when the `.ps1` digest does not match
|
|
305
|
+
* the manifest binding (or the binding is absent). No PowerShell is spawned.
|
|
306
|
+
*/
|
|
307
|
+
export function refusingTransport(detail) {
|
|
308
|
+
return {
|
|
309
|
+
async request() {
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
refusal: "windows-secure-object-unavailable",
|
|
313
|
+
detail,
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
async close() { },
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The real transport: verify the on-disk `.ps1` sha256 against the manifest
|
|
321
|
+
* binding BEFORE spawning (tamper-evident, symmetric with the `.mjs`); on a
|
|
322
|
+
* mismatch/absent binding return `refusingTransport` and spawn nothing. On a
|
|
323
|
+
* match, spawn `powershell.exe` lazily on the first request, complete the
|
|
324
|
+
* handshake, and exchange strictly-serial length-prefixed frames. Any oversized
|
|
325
|
+
* frame, bad handshake, child exit, or session error kills the child and fails
|
|
326
|
+
* every pending/subsequent op closed. The idle watchdog only arms when ZERO
|
|
327
|
+
* directory handles are outstanding (W1) so it never kills a live transaction.
|
|
328
|
+
*/
|
|
329
|
+
export function createPs1Session(opts = {}) {
|
|
330
|
+
const assetsDir = opts.assetsDir ?? CLAUDE_HOOK_ASSETS_DIR;
|
|
331
|
+
const readFile = opts.readFile ?? ((p) => readFileSync(p));
|
|
332
|
+
const spawn = opts.spawn ??
|
|
333
|
+
((cmd, args) => nodeSpawn(cmd, args));
|
|
334
|
+
const idleMs = opts.idleMs ?? HELPER_IDLE_MS;
|
|
335
|
+
const opTimeoutMs = opts.opTimeoutMs ?? HELPER_OP_TIMEOUT_MS;
|
|
336
|
+
const setTimer = opts.setTimer ??
|
|
337
|
+
((fn, ms) => {
|
|
338
|
+
const t = setTimeout(fn, ms);
|
|
339
|
+
t.unref?.();
|
|
340
|
+
return t;
|
|
341
|
+
});
|
|
342
|
+
const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
|
|
343
|
+
const registerExitHook = opts.registerExitHook ?? ((fn) => process.once("exit", fn));
|
|
344
|
+
let initialized = false;
|
|
345
|
+
let dead = false;
|
|
346
|
+
let deadDetail = "helper closed";
|
|
347
|
+
let child = null;
|
|
348
|
+
let ready = false;
|
|
349
|
+
let buffer = Buffer.alloc(0);
|
|
350
|
+
let current = null;
|
|
351
|
+
const queue = [];
|
|
352
|
+
let outstanding = 0; // live directory handles in the .ps1 handle table
|
|
353
|
+
let idleTimer = null;
|
|
354
|
+
let opTimer = null; // R4-001 deadline
|
|
355
|
+
/** Resolve the manifest binding from exactly ONE source (R1-001). */
|
|
356
|
+
function resolveBinding() {
|
|
357
|
+
return opts.manifest
|
|
358
|
+
? opts.manifest.installerHelpers?.windowsSecureObject
|
|
359
|
+
: readManifestBinding();
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* R1-001: resolve the binding ONCE, verify the on-disk `.ps1` sha256 against
|
|
363
|
+
* it, and RETURN the verified binding so `init` spawns EXACTLY the artifact
|
|
364
|
+
* that was hashed. A manifest swap between verify and spawn can no longer slip
|
|
365
|
+
* an unverified `.ps1` through (there is no second manifest read). Any
|
|
366
|
+
* mismatch/absent binding/read error → a fail-closed detail, no binding.
|
|
367
|
+
* (The file-content TOCTOU — hash reads the `.ps1`, powershell re-opens it by
|
|
368
|
+
* path — is an already-accepted design residual, parity with the `.mjs`.)
|
|
369
|
+
*/
|
|
370
|
+
function verifyDigest() {
|
|
371
|
+
const binding = resolveBinding();
|
|
372
|
+
if (!binding)
|
|
373
|
+
return { detail: "helper digest mismatch" };
|
|
374
|
+
try {
|
|
375
|
+
const bytes = readFile(path.join(assetsDir, binding.name));
|
|
376
|
+
const sha = createHash("sha256").update(bytes).digest("hex");
|
|
377
|
+
if (sha !== binding.sha256)
|
|
378
|
+
return { detail: "helper digest mismatch" };
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return { detail: "helper digest mismatch" };
|
|
382
|
+
}
|
|
383
|
+
return { binding };
|
|
384
|
+
}
|
|
385
|
+
function readManifestBinding() {
|
|
386
|
+
try {
|
|
387
|
+
const raw = readFile(path.join(assetsDir, "manifest.json"));
|
|
388
|
+
const parsed = JSON.parse(raw.toString("utf8"));
|
|
389
|
+
return parsed.installerHelpers?.windowsSecureObject;
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** R4-001: clear the per-op / handshake deadline (idempotent). */
|
|
396
|
+
function clearOpTimer() {
|
|
397
|
+
if (opTimer) {
|
|
398
|
+
clearTimer(opTimer);
|
|
399
|
+
opTimer = null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* R4-001: arm the per-op / handshake deadline. Idempotent — one outstanding
|
|
404
|
+
* deadline at a time (the protocol is strictly serial). On expiry the child is
|
|
405
|
+
* killed and every pending/subsequent op fails closed via `fail`.
|
|
406
|
+
*/
|
|
407
|
+
function armOpTimer() {
|
|
408
|
+
if (dead || opTimer)
|
|
409
|
+
return;
|
|
410
|
+
opTimer = setTimer(() => {
|
|
411
|
+
opTimer = null;
|
|
412
|
+
fail("timeout");
|
|
413
|
+
}, opTimeoutMs);
|
|
414
|
+
}
|
|
415
|
+
function init() {
|
|
416
|
+
// R1-004: a completed close() is TERMINAL. Never re-init/spawn after close —
|
|
417
|
+
// the finished close would not reap the new child, leaking it. `dead` is set
|
|
418
|
+
// by close() (and by fail()), so guarding here + the `dead` check in request()
|
|
419
|
+
// makes the session single-shot: once closed, every request refuses closed.
|
|
420
|
+
if (dead)
|
|
421
|
+
return;
|
|
422
|
+
initialized = true;
|
|
423
|
+
const verified = verifyDigest();
|
|
424
|
+
if ("detail" in verified) {
|
|
425
|
+
dead = true;
|
|
426
|
+
deadDetail = verified.detail;
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
// R1-001: spawn EXACTLY the binding that verifyDigest hashed — no re-read.
|
|
430
|
+
const ps1Path = path.join(assetsDir, verified.binding.name);
|
|
431
|
+
try {
|
|
432
|
+
child = spawn(POWERSHELL, [...POWERSHELL_ARGS, ps1Path]);
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
// R4-004: a synchronous spawn throw must fail closed, not hang callers.
|
|
436
|
+
dead = true;
|
|
437
|
+
deadDetail = `helper spawn ${error instanceof Error ? error.message : String(error)}`;
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
child.unref?.();
|
|
441
|
+
child.stdout.on("data", onData);
|
|
442
|
+
child.on("exit", () => onExit());
|
|
443
|
+
child.on("error", () => onExit());
|
|
444
|
+
registerExitHook(() => child?.kill());
|
|
445
|
+
// R4-001: arm the handshake deadline — a `.ps1` that spawns but never emits
|
|
446
|
+
// the ready frame must not hang the first caller forever.
|
|
447
|
+
armOpTimer();
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Settle every pending/queued request (current is always `queue[0]` until its
|
|
451
|
+
* response shifts it) with the current fail-closed `deadDetail`, so no caller
|
|
452
|
+
* ever hangs. Shared by `fail` and `close` (R4-002).
|
|
453
|
+
*/
|
|
454
|
+
function drainPending() {
|
|
455
|
+
const all = [...queue];
|
|
456
|
+
queue.length = 0;
|
|
457
|
+
current = null;
|
|
458
|
+
for (const item of all) {
|
|
459
|
+
item.resolve({
|
|
460
|
+
ok: false,
|
|
461
|
+
refusal: "windows-secure-object-unavailable",
|
|
462
|
+
detail: deadDetail,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
function fail(detail) {
|
|
467
|
+
if (dead)
|
|
468
|
+
return;
|
|
469
|
+
dead = true;
|
|
470
|
+
deadDetail = `helper ${detail}`;
|
|
471
|
+
child?.kill();
|
|
472
|
+
if (idleTimer) {
|
|
473
|
+
clearTimer(idleTimer);
|
|
474
|
+
idleTimer = null;
|
|
475
|
+
}
|
|
476
|
+
clearOpTimer(); // R4-001
|
|
477
|
+
drainPending();
|
|
478
|
+
}
|
|
479
|
+
function onExit() {
|
|
480
|
+
fail("child exited");
|
|
481
|
+
}
|
|
482
|
+
function onData(chunk) {
|
|
483
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
484
|
+
let decoded;
|
|
485
|
+
try {
|
|
486
|
+
decoded = decodeFrames(buffer);
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
// R3-004: both stay fail-closed, but distinguish a genuine oversized
|
|
490
|
+
// frame from a malformed/zero-length/bad-JSON body in the diagnostic.
|
|
491
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
492
|
+
fail(/oversized/i.test(msg) ? "oversized frame" : `malformed frame: ${msg}`);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
buffer = decoded.rest;
|
|
496
|
+
for (const frame of decoded.frames)
|
|
497
|
+
handleFrame(frame);
|
|
498
|
+
}
|
|
499
|
+
function handleFrame(frame) {
|
|
500
|
+
if (dead)
|
|
501
|
+
return;
|
|
502
|
+
if (!ready) {
|
|
503
|
+
const hs = frame;
|
|
504
|
+
if (hs?.ready === true && hs.protocolVersion === 1) {
|
|
505
|
+
clearOpTimer(); // R4-001: handshake arrived; pump re-arms per request
|
|
506
|
+
ready = true;
|
|
507
|
+
pump();
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
fail("bad handshake");
|
|
511
|
+
}
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const item = current;
|
|
515
|
+
if (!item)
|
|
516
|
+
return; // stray frame with nothing outstanding — ignore
|
|
517
|
+
clearOpTimer(); // R4-001: response arrived; pump re-arms for the next frame
|
|
518
|
+
current = null;
|
|
519
|
+
queue.shift();
|
|
520
|
+
adjustHandles(item.req, frame);
|
|
521
|
+
item.resolve(frame);
|
|
522
|
+
maybeArmIdle();
|
|
523
|
+
pump();
|
|
524
|
+
}
|
|
525
|
+
function adjustHandles(req, res) {
|
|
526
|
+
if ((req.op === "openDir" || req.op === "createDir") && res.ok === true) {
|
|
527
|
+
outstanding++;
|
|
528
|
+
}
|
|
529
|
+
else if (req.op === "releaseHandle") {
|
|
530
|
+
outstanding = Math.max(0, outstanding - 1);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function maybeArmIdle() {
|
|
534
|
+
if (dead || current || queue.length > 0 || outstanding > 0)
|
|
535
|
+
return;
|
|
536
|
+
if (idleTimer)
|
|
537
|
+
return;
|
|
538
|
+
idleTimer = setTimer(() => {
|
|
539
|
+
idleTimer = null;
|
|
540
|
+
fail("idle");
|
|
541
|
+
}, idleMs);
|
|
542
|
+
}
|
|
543
|
+
function pump() {
|
|
544
|
+
if (dead || !ready || current || queue.length === 0)
|
|
545
|
+
return;
|
|
546
|
+
current = queue[0];
|
|
547
|
+
child?.stdin.write(encodeFrame(current.req));
|
|
548
|
+
armOpTimer(); // R4-001: deadline for this outstanding request
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
async request(req) {
|
|
552
|
+
if (!initialized)
|
|
553
|
+
init();
|
|
554
|
+
if (dead) {
|
|
555
|
+
return {
|
|
556
|
+
ok: false,
|
|
557
|
+
refusal: "windows-secure-object-unavailable",
|
|
558
|
+
detail: deadDetail,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
if (idleTimer) {
|
|
562
|
+
clearTimer(idleTimer);
|
|
563
|
+
idleTimer = null;
|
|
564
|
+
}
|
|
565
|
+
return new Promise((resolve) => {
|
|
566
|
+
queue.push({ req, resolve });
|
|
567
|
+
pump();
|
|
568
|
+
});
|
|
569
|
+
},
|
|
570
|
+
async close() {
|
|
571
|
+
if (!dead) {
|
|
572
|
+
dead = true;
|
|
573
|
+
deadDetail = "helper closed";
|
|
574
|
+
}
|
|
575
|
+
if (idleTimer) {
|
|
576
|
+
clearTimer(idleTimer);
|
|
577
|
+
idleTimer = null;
|
|
578
|
+
}
|
|
579
|
+
clearOpTimer(); // R4-001
|
|
580
|
+
// R4-002: drain BEFORE killing the child so pending/queued promises settle
|
|
581
|
+
// fail-closed even when close() (not onExit) is the terminator — otherwise
|
|
582
|
+
// a caller that wired abort→close() would hang forever.
|
|
583
|
+
drainPending();
|
|
584
|
+
child?.kill();
|
|
585
|
+
},
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
//# sourceMappingURL=secure-fs-windows.js.map
|
package/dist/types/index.d.ts
CHANGED
|
@@ -16,6 +16,11 @@ export interface InitOptions {
|
|
|
16
16
|
claudeMd: boolean;
|
|
17
17
|
securityHooks: boolean;
|
|
18
18
|
hookProfile: HookProfile;
|
|
19
|
+
/**
|
|
20
|
+
* Install the managed Claude PreToolUse guard during init. Derived from
|
|
21
|
+
* `securityHooks` at the single App.tsx call site (all profiles incl. Minimal).
|
|
22
|
+
*/
|
|
23
|
+
claudePreToolUseGuard: boolean;
|
|
19
24
|
codeGraph: boolean;
|
|
20
25
|
dockerDeploy: boolean;
|
|
21
26
|
/** Service name for docker rollout (default: 'app') */
|
package/dist/ui/App.js
CHANGED
|
@@ -93,6 +93,9 @@ export default function App({ dryRun = false, presetStack, presetCI, presetMemor
|
|
|
93
93
|
claudeMd: opts.claudeMd,
|
|
94
94
|
securityHooks: opts.securityHooks,
|
|
95
95
|
hookProfile: opts.hookProfile,
|
|
96
|
+
// Derived from securityHooks alone — ALL profiles incl. Minimal
|
|
97
|
+
// install the managed guard when security hooks are enabled.
|
|
98
|
+
claudePreToolUseGuard: opts.securityHooks,
|
|
96
99
|
codeGraph: opts.codeGraph,
|
|
97
100
|
localAi: opts.localAi,
|
|
98
101
|
dockerDeploy: false,
|