javi-forge 1.30.0 → 1.31.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.
@@ -1 +1 @@
1
- {"schemaVersion":1,"asset":{"name":"javi-forge-skillguard-pre-tool-use.mjs","version":1,"policyVersion":1,"sha256":"78be7e6613c012280b7ad17886462ba166b63ebd031e34565d757b3a0796d7cc","historical":[]},"settingsEntries":{"current":{"version":1,"canonicalSha256":"038c59a91bf8967f6908afed74c465f1e7030254e11e4f8738975d6d708424d4"},"historical":[]},"installerHelpers":{"windowsSecureObject":null}}
1
+ {"schemaVersion":1,"asset":{"name":"javi-forge-skillguard-pre-tool-use.mjs","version":1,"policyVersion":1,"sha256":"78be7e6613c012280b7ad17886462ba166b63ebd031e34565d757b3a0796d7cc","historical":[]},"settingsEntries":{"current":{"version":1,"canonicalSha256":"038c59a91bf8967f6908afed74c465f1e7030254e11e4f8738975d6d708424d4"},"historical":[]},"installerHelpers":{"windowsSecureObject":{"name":"javi-forge-windows-secure-object.ps1","sha256":"2289ef6ac6b039ec74dc3ea0894413e243ff9bea963f04008a356b3838f9b8dd"}}}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Host-independent fake `HelperTransport` for the win32 `PlatformSecureFs`
3
+ * adapter tests. It returns canned framed responses per op so every adapter
4
+ * branch — request build, response parse, refusal mapping, opaque identity,
5
+ * notFound discrimination, directory-attribute assertion, and
6
+ * transport-error → fail-closed — is exercisable on Linux with NO real Windows
7
+ * host and NO PowerShell. The `.ps1` (Phase 3) computes Predicate A/B verdicts;
8
+ * this fake stands in for those already-decided verdicts.
9
+ */
10
+ import type { HelperOp, HelperRequest, HelperResponse, HelperTransport } from "../secure-fs-windows.js";
11
+ /** A responder decides the framed response for a single request of an op. */
12
+ export type FakeResponder = (req: HelperRequest) => HelperResponse;
13
+ export interface FakeHelperTransport extends HelperTransport {
14
+ /** Every request the adapter sent, in order (for request-build assertions). */
15
+ readonly requests: HelperRequest[];
16
+ /** True once close() ran. */
17
+ readonly closed: boolean;
18
+ /** Register the canned responder for an op (last registration wins). */
19
+ on(op: HelperOp, responder: FakeResponder): void;
20
+ /** Make the NEXT request reject with a transport error (session death). */
21
+ failNext(error: Error): void;
22
+ }
23
+ /** Build a programmable fake transport with no default behavior. */
24
+ export declare function makeFakeHelperTransport(): FakeHelperTransport;
25
+ /** A void-success verdict (proveOwner/proveDacl/proveContainer/write/etc. ok). */
26
+ export declare const okVoid: () => HelperResponse;
27
+ /** A win32 DACL refusal (Predicate A/B verdict from the .ps1). */
28
+ export declare const daclRefuse: (detail: string) => HelperResponse;
29
+ /** Named Predicate-A refuse postures (ground-truth + design fixtures). */
30
+ export declare const foreignWrite: () => HelperResponse;
31
+ export declare const deleteChild: () => HelperResponse;
32
+ export declare const genericWrite: () => HelperResponse;
33
+ export declare const genericAll: () => HelperResponse;
34
+ export declare const nullDacl: () => HelperResponse;
35
+ export declare const foreignOwner: () => HelperResponse;
36
+ /** proveContainer-only add-child refusal (CREATE_PARENT_DIR). */
37
+ export declare const addChild: () => HelperResponse;
38
+ /** A successful openDir/createDir value carrying handle, identity, attributes. */
39
+ export declare const openOk: (handleId: string, opaque: string, attributes: number) => HelperResponse;
40
+ /** A genuine not-found openDir failure (ENOENT / ERROR_FILE/PATH_NOT_FOUND). */
41
+ export declare const openNotFound: (status: number) => HelperResponse;
42
+ /** A present-but-unopenable openDir failure (reparse/EACCES/transient). */
43
+ export declare const openUnopenable: (detail: string, status?: number) => HelperResponse;
44
+ /** A successful capture value; bytes are base64 in the JSON body. */
45
+ export declare const captureOk: (bytes: Buffer, opaque: string) => HelperResponse;
46
+ //# sourceMappingURL=fake-helper-transport.d.ts.map
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Host-independent fake `HelperTransport` for the win32 `PlatformSecureFs`
3
+ * adapter tests. It returns canned framed responses per op so every adapter
4
+ * branch — request build, response parse, refusal mapping, opaque identity,
5
+ * notFound discrimination, directory-attribute assertion, and
6
+ * transport-error → fail-closed — is exercisable on Linux with NO real Windows
7
+ * host and NO PowerShell. The `.ps1` (Phase 3) computes Predicate A/B verdicts;
8
+ * this fake stands in for those already-decided verdicts.
9
+ */
10
+ /** Build a programmable fake transport with no default behavior. */
11
+ export function makeFakeHelperTransport() {
12
+ const requests = [];
13
+ const responders = new Map();
14
+ let closed = false;
15
+ let pendingError = null;
16
+ const fake = {
17
+ get requests() {
18
+ return requests;
19
+ },
20
+ get closed() {
21
+ return closed;
22
+ },
23
+ on(op, responder) {
24
+ responders.set(op, responder);
25
+ },
26
+ failNext(error) {
27
+ pendingError = error;
28
+ },
29
+ async request(req) {
30
+ requests.push(req);
31
+ if (pendingError) {
32
+ const err = pendingError;
33
+ pendingError = null;
34
+ throw err;
35
+ }
36
+ const responder = responders.get(req.op);
37
+ if (!responder) {
38
+ throw new Error(`fake transport: no responder for op ${req.op}`);
39
+ }
40
+ return responder(req);
41
+ },
42
+ async close() {
43
+ closed = true;
44
+ },
45
+ };
46
+ return fake;
47
+ }
48
+ // --- canned response builders (the .ps1 verdicts, pre-decided) --------------
49
+ /** A void-success verdict (proveOwner/proveDacl/proveContainer/write/etc. ok). */
50
+ export const okVoid = () => ({ ok: true });
51
+ /** A win32 DACL refusal (Predicate A/B verdict from the .ps1). */
52
+ export const daclRefuse = (detail) => ({
53
+ ok: false,
54
+ refusal: "unsafe-windows-dacl",
55
+ detail,
56
+ });
57
+ /** Named Predicate-A refuse postures (ground-truth + design fixtures). */
58
+ export const foreignWrite = () => daclRefuse("foreign trustee S-1-5-11 path-endangering");
59
+ export const deleteChild = () => daclRefuse("foreign trustee S-1-1-0 path-endangering");
60
+ export const genericWrite = () => daclRefuse("foreign trustee S-1-1-0 path-endangering");
61
+ export const genericAll = () => daclRefuse("foreign trustee S-1-1-0 path-endangering");
62
+ export const nullDacl = () => daclRefuse("null DACL");
63
+ export const foreignOwner = () => daclRefuse("foreign owner S-1-5-21-1-2-3-1001");
64
+ /** proveContainer-only add-child refusal (CREATE_PARENT_DIR). */
65
+ export const addChild = () => daclRefuse("foreign trustee S-1-1-0 add-child");
66
+ /** A successful openDir/createDir value carrying handle, identity, attributes. */
67
+ export const openOk = (handleId, opaque, attributes) => ({
68
+ ok: true,
69
+ value: { handleId, opaque, attributes },
70
+ });
71
+ /** A genuine not-found openDir failure (ENOENT / ERROR_FILE/PATH_NOT_FOUND). */
72
+ export const openNotFound = (status) => ({
73
+ ok: false,
74
+ refusal: "unsafe-parent-chain",
75
+ detail: "not found",
76
+ status,
77
+ });
78
+ /** A present-but-unopenable openDir failure (reparse/EACCES/transient). */
79
+ export const openUnopenable = (detail, status = 5) => ({
80
+ ok: false,
81
+ refusal: "unsafe-parent-chain",
82
+ detail,
83
+ status,
84
+ });
85
+ /** A successful capture value; bytes are base64 in the JSON body. */
86
+ export const captureOk = (bytes, opaque) => ({
87
+ ok: true,
88
+ value: { bytes: bytes.toString("base64"), opaque },
89
+ });
90
+ //# sourceMappingURL=fake-helper-transport.js.map
@@ -27,6 +27,18 @@ export interface FakeFaults {
27
27
  writeRefuse?: (name: string, callIndex: number) => boolean;
28
28
  /** Refuse renameInDir when the destination base name matches. */
29
29
  renameRefuse?: (to: string) => boolean;
30
+ /**
31
+ * Refuse proveManagedContainer for a path on its Nth call (a foreign
32
+ * add/delete-child ACE on a managed container we own — the win32 CREATE_PARENT_DIR
33
+ * strictness that the lenient ancestor gate deliberately tolerates).
34
+ */
35
+ managedContainerRefuse?: (dirPath: string, callIndex: number) => boolean;
36
+ /**
37
+ * Make openDirNoFollow return a PRESENT-BUT-UNOPENABLE refusal (a reparse
38
+ * point/junction, EACCES, ENOTDIR, or transient) — a refusal with `notFound`
39
+ * absent, so ensureManagedContainer must fail closed, never skip (JDA6-001).
40
+ */
41
+ openDirUnopenable?: (dirPath: string) => boolean;
30
42
  }
31
43
  export interface FakeSecureFs extends PlatformSecureFs {
32
44
  readonly dirs: Set<string>;
@@ -15,6 +15,13 @@ const unsafe = (detail) => ({
15
15
  refusal: "unsafe-parent-chain",
16
16
  detail,
17
17
  });
18
+ /** A GENUINE not-found refusal (the only state ensureManagedContainer may skip/create on). */
19
+ const notFound = (detail) => ({
20
+ ok: false,
21
+ refusal: "unsafe-parent-chain",
22
+ detail,
23
+ notFound: true,
24
+ });
18
25
  export function makeFakeSecureFs() {
19
26
  const dirs = new Set();
20
27
  const files = new Map();
@@ -25,6 +32,7 @@ export function makeFakeSecureFs() {
25
32
  const ownershipCounts = new Map();
26
33
  const aclCounts = new Map();
27
34
  const writeCounts = new Map();
35
+ const managedCounts = new Map();
28
36
  const inoFor = (p) => {
29
37
  let ino = inos.get(p);
30
38
  if (ino === undefined) {
@@ -76,8 +84,12 @@ export function makeFakeSecureFs() {
76
84
  return false;
77
85
  },
78
86
  async openDirNoFollow(dirPath) {
87
+ if (fake.faults.openDirUnopenable?.(dirPath)) {
88
+ // PRESENT-but-unopenable-no-follow: refusal WITHOUT notFound.
89
+ return unsafe(`openDir unopenable ${dirPath}`);
90
+ }
79
91
  if (!dirs.has(dirPath))
80
- return unsafe(`openDir enoent ${dirPath}`);
92
+ return notFound(`openDir enoent ${dirPath}`);
81
93
  return okValue(handleFor(dirPath));
82
94
  },
83
95
  async revalidateIdentity(target, held) {
@@ -177,6 +189,19 @@ export function makeFakeSecureFs() {
177
189
  dirs.delete(handle.path);
178
190
  return ok();
179
191
  },
192
+ async proveManagedContainer(dirPath) {
193
+ const idx = bump(managedCounts, dirPath);
194
+ if (fake.faults.managedContainerRefuse?.(dirPath, idx)) {
195
+ return {
196
+ ok: false,
197
+ refusal: "unsafe-windows-dacl",
198
+ detail: `add-child ${dirPath}`,
199
+ };
200
+ }
201
+ if (!dirs.has(dirPath))
202
+ return unsafe(`container enoent ${dirPath}`);
203
+ return ok();
204
+ },
180
205
  };
181
206
  return fake;
182
207
  }
@@ -28,10 +28,19 @@ export declare function createLinuxAclAdapter(spawn?: SpawnFn): PosixAclAdapter;
28
28
  export declare function createMacosAclAdapter(spawn?: SpawnFn): PosixAclAdapter;
29
29
  export declare function createPosixSecureFs(acl: PosixAclAdapter): PlatformSecureFs;
30
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).
31
+ * Select the secure filesystem for the host platform. Linux uses the `getfacl`
32
+ * adapter, macOS uses `/bin/ls -lde`, and win32 uses the digest-bound PowerShell
33
+ * helper over a lazily-spawned session (Slice 3b). Every other platform returns
34
+ * `null` so the manager refuses with `windows-secure-object-unavailable` and
35
+ * mutates nothing.
36
+ *
37
+ * The win32 branch is host-independent to CONSTRUCT: `createPs1Session` spawns
38
+ * nothing until the first request, and it verifies the on-disk `.ps1` sha256
39
+ * against the manifest binding before spawning. If the binding is absent or the
40
+ * digest mismatches, the transport refuses every op (`refusingTransport`), so
41
+ * the adapter fails closed exactly like the pre-3b `null` did — but a real,
42
+ * matching helper now drives Windows installs. The `.ps1`'s runtime behavior is
43
+ * validated by the `windows-latest` CI job (Phase 5), never on the dev box.
35
44
  */
36
45
  export declare function selectSecureFs(platform?: NodeJS.Platform): PlatformSecureFs | null;
37
46
  //# sourceMappingURL=secure-fs-posix.d.ts.map
@@ -12,6 +12,7 @@ import { createHash } from "node:crypto";
12
12
  import { constants as FS } from "node:fs";
13
13
  import { chmod, lstat, mkdir, open, rename, rmdir, unlink, } from "node:fs/promises";
14
14
  import path from "node:path";
15
+ import { createPs1Session, createWindowsSecureFs, } from "./secure-fs-windows.js";
15
16
  /** Bounded time budget for a single ACL inspection. */
16
17
  const ACL_TIMEOUT_MS = 2000;
17
18
  /** Read budget for the ACL tool output (defensive; ACLs are tiny). */
@@ -142,7 +143,14 @@ export function createPosixSecureFs(acl) {
142
143
  }
143
144
  }
144
145
  catch (error) {
145
- return refuse("unsafe-parent-chain", `openDir ${dirPath}: ${errCode(error) ?? "error"}`);
146
+ const code = errCode(error);
147
+ const result = refuse("unsafe-parent-chain", `openDir ${dirPath}: ${code ?? "error"}`);
148
+ // Genuine not-found ONLY on ENOENT (Round-6 / JDA6-001). Every other
149
+ // errno — ELOOP/reparse, EACCES, ENOTDIR, transient — leaves notFound
150
+ // absent so a present-but-unopenable managed container fails closed.
151
+ if (code === "ENOENT")
152
+ result.notFound = true;
153
+ return result;
146
154
  }
147
155
  },
148
156
  async revalidateIdentity(target, held) {
@@ -198,6 +206,9 @@ export function createPosixSecureFs(acl) {
198
206
  const handle = await open(target, CAPTURE_FLAGS);
199
207
  try {
200
208
  const stats = await handle.stat();
209
+ if (!stats.isFile()) {
210
+ return refuse("unsafe-parent-chain", `capture ${target}: not a regular file`);
211
+ }
201
212
  const bytes = await handle.readFile();
202
213
  const sha256 = createHash("sha256").update(bytes).digest("hex");
203
214
  return okValue({
@@ -283,20 +294,40 @@ export function createPosixSecureFs(acl) {
283
294
  return refuse("unsafe-parent-chain", `rmdir ${handle.path}: ${errCode(error) ?? "error"}`);
284
295
  }
285
296
  },
297
+ // On POSIX, permission to ADD a child to a directory IS the directory's
298
+ // write bit; proveOwnershipAndMode already refuses any group/other write
299
+ // (stats.mode & 0o022). So the managed-container check is definitionally the
300
+ // same predicate gate() just ran on this path — idempotent, no new refusal
301
+ // surface. The seam has teeth only on win32, where Predicate A tolerates
302
+ // add-child on high ancestors (Round-4 / JDA-401).
303
+ proveManagedContainer(dirPath) {
304
+ return secureFs.proveOwnershipAndMode(dirPath);
305
+ },
286
306
  };
287
307
  return secureFs;
288
308
  }
289
309
  /**
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).
310
+ * Select the secure filesystem for the host platform. Linux uses the `getfacl`
311
+ * adapter, macOS uses `/bin/ls -lde`, and win32 uses the digest-bound PowerShell
312
+ * helper over a lazily-spawned session (Slice 3b). Every other platform returns
313
+ * `null` so the manager refuses with `windows-secure-object-unavailable` and
314
+ * mutates nothing.
315
+ *
316
+ * The win32 branch is host-independent to CONSTRUCT: `createPs1Session` spawns
317
+ * nothing until the first request, and it verifies the on-disk `.ps1` sha256
318
+ * against the manifest binding before spawning. If the binding is absent or the
319
+ * digest mismatches, the transport refuses every op (`refusingTransport`), so
320
+ * the adapter fails closed exactly like the pre-3b `null` did — but a real,
321
+ * matching helper now drives Windows installs. The `.ps1`'s runtime behavior is
322
+ * validated by the `windows-latest` CI job (Phase 5), never on the dev box.
294
323
  */
295
324
  export function selectSecureFs(platform = process.platform) {
296
325
  if (platform === "linux")
297
326
  return createPosixSecureFs(createLinuxAclAdapter());
298
327
  if (platform === "darwin")
299
328
  return createPosixSecureFs(createMacosAclAdapter());
329
+ if (platform === "win32")
330
+ return createWindowsSecureFs(createPs1Session());
300
331
  return null;
301
332
  }
302
333
  //# sourceMappingURL=secure-fs-posix.js.map
@@ -13,6 +13,14 @@
13
13
  export interface SecureIdentity {
14
14
  dev: number;
15
15
  ino: number;
16
+ /**
17
+ * Full-precision, platform-opaque identity token. POSIX leaves this undefined
18
+ * (identity is `dev`+`ino`). win32 sets `"<volumeSerialHex>:<fileIdHex>"` and
19
+ * compares ONLY on this — an absent/zero token is a hard refusal there, never
20
+ * a fallback to the truncated `dev`/`ino` (Decision 1b). Additive: the core
21
+ * passes it back to the adapter opaquely and never interprets it.
22
+ */
23
+ opaque?: string;
16
24
  }
17
25
  /** A held, no-follow directory handle plus its captured identity and path. */
18
26
  export interface SecureDirHandle {
@@ -27,12 +35,21 @@ export interface CapturedFile {
27
35
  identity: SecureIdentity;
28
36
  sha256: string;
29
37
  }
30
- export type SecureRefusal = "unsafe-parent-chain" | "unsupported-posix-acl" | "windows-secure-object-unavailable";
38
+ export type SecureRefusal = "unsafe-parent-chain" | "unsupported-posix-acl" | "unsafe-windows-dacl" | "windows-secure-object-unavailable";
31
39
  export interface SecureResult<T> {
32
40
  ok: boolean;
33
41
  value?: T;
34
42
  refusal?: SecureRefusal;
35
43
  detail?: string;
44
+ /**
45
+ * Set ONLY by `openDirNoFollow` on a refusal, and ONLY for a GENUINE
46
+ * not-found (POSIX ENOENT / win32 `ERROR_FILE_NOT_FOUND`|`ERROR_PATH_NOT_FOUND`).
47
+ * Every other refusal — a reparse point/junction, EACCES, ENOTDIR, a transient
48
+ * error, or any non-open proof — leaves this absent/false so a managed
49
+ * container that is PRESENT-but-unopenable fails the transaction closed rather
50
+ * than being silently skipped (Round-6 / JDA6-001). Additive.
51
+ */
52
+ notFound?: boolean;
36
53
  }
37
54
  /**
38
55
  * The whole platform boundary. Every host-dependent operation is a method here;
@@ -74,6 +91,17 @@ export interface PlatformSecureFs {
74
91
  unlinkIfIdentity(dir: SecureDirHandle, name: string, held: SecureIdentity): Promise<SecureResult<void>>;
75
92
  /** Remove an identity-matched EMPTY directory (rollback of a created segment). */
76
93
  rmdirIfIdentityEmpty(handle: SecureDirHandle): Promise<SecureResult<void>>;
94
+ /**
95
+ * Prove a directory the tool OWNS as a MANAGED CONTAINER (`.claude`,
96
+ * `.claude/hooks`): refuse ALL foreign add/delete-child rights — strictly more
97
+ * than the lenient ancestor `gate()`, which tolerates harmless add-child on
98
+ * high traversal ancestors (Round-4 / JDA-401). The core calls this ONLY on the
99
+ * dirs it constructs, expressing the managed-container role by WHICH method it
100
+ * invokes — no `process.platform` ever enters the engine. POSIX delegates to its
101
+ * existing strict ownership/mode check (group/other write IS add-child on a
102
+ * directory), so POSIX behavior is unchanged; the seam has teeth only on win32.
103
+ */
104
+ proveManagedContainer(dirPath: string): Promise<SecureResult<void>>;
77
105
  }
78
106
  /** Injected seams making the engine deterministic and host-independent. */
79
107
  export interface TransactionDeps {
@@ -68,6 +68,11 @@ export async function runTransaction(input) {
68
68
  const { secureFs, clock, nonce, projectDir } = input;
69
69
  const claudeDir = path.join(projectDir, ".claude");
70
70
  const hooksDir = path.join(claudeDir, "hooks");
71
+ // The dirs the tool OWNS: their children include the executed asset and the
72
+ // settings it is referenced from. Fixed and known to the core regardless of
73
+ // the per-run write plan; each existing member is proved on EVERY anyWrite run
74
+ // (Round-4/5 / JDA-401 + JDB5-001).
75
+ const managedContainers = new Set([claudeDir, hooksDir]);
71
76
  const heldByPath = new Map();
72
77
  const heldOrder = [];
73
78
  const createdDirs = [];
@@ -82,17 +87,44 @@ export async function runTransaction(input) {
82
87
  heldByPath.set(dirPath, handle);
83
88
  heldOrder.push(handle);
84
89
  }
85
- async function ensureDir(parent, fullPath) {
90
+ /**
91
+ * Ensure a MANAGED CONTAINER (`.claude`/`.claude/hooks`): an existing one is
92
+ * ALWAYS gated + proveManagedContainer'd (→ heldOrder → re-proved pre-commit);
93
+ * an absent one is created Predicate-B strict ONLY when `createIfAbsent` (a
94
+ * child is written into it this run), else left alone. Four fail-closed
95
+ * branches (Round-4/5/6 / JDA-401 + JDB5-001 + JDA6-001):
96
+ * (1) present + openable → gate + proveManagedContainer, return handle
97
+ * (2) notFound + create → createDirExclusive + gate + proveManagedContainer
98
+ * (3) notFound + !create → return null (nothing to secure)
99
+ * (4) any non-notFound !ok → FAIL CLOSED explicitly (present-but-unopenable:
100
+ * junction/reparse/EACCES) regardless of create.
101
+ */
102
+ async function ensureManagedContainer(parent, fullPath, createIfAbsent) {
86
103
  const opened = await secureFs.openDirNoFollow(fullPath);
104
+ // (1) PRESENT + openable no-follow.
87
105
  if (opened.ok && opened.value) {
88
106
  await gate(fullPath, opened.value);
107
+ must(`container ${fullPath}`, await secureFs.proveManagedContainer(fullPath));
89
108
  return opened.value;
90
109
  }
110
+ // (4) ANY non-notFound refusal → fail the whole transaction closed,
111
+ // regardless of createIfAbsent. Refuse EXPLICITLY (JDB7-002): do not rely
112
+ // on a later must() throwing — propagate the refusal here so a
113
+ // present-but-unopenable managed container is never silently skipped.
114
+ if (!opened.notFound) {
115
+ throw new TxAbort(`container ${fullPath}`, opened.detail ?? opened.refusal ?? "present-but-unopenable");
116
+ }
117
+ // GENUINELY ABSENT (notFound === true) — the ONLY safe skip/create path.
118
+ // (3) absent + no child written this run → nothing to secure.
119
+ if (!createIfAbsent)
120
+ return null;
121
+ // (2) absent + a child IS written into it this run → create + prove.
91
122
  const created = must(`create ${fullPath}`, await secureFs.createDirExclusive(parent, path.basename(fullPath), 0o700));
92
123
  // Post-create identity revalidation + full gate on the new segment.
93
124
  must(`revalidate-created ${fullPath}`, await secureFs.revalidateIdentity(fullPath, created.identity));
94
125
  createdDirs.push(created);
95
126
  await gate(fullPath, created);
127
+ must(`container ${fullPath}`, await secureFs.proveManagedContainer(fullPath));
96
128
  return created;
97
129
  }
98
130
  async function gateStillValid() {
@@ -104,6 +136,13 @@ export async function runTransaction(input) {
104
136
  return false;
105
137
  if (!(await secureFs.proveNoExtendedAcl(handle.path)).ok)
106
138
  return false;
139
+ // Re-check the container add/delete-child dimension on the rollback path
140
+ // too, for full symmetry with the pre-commit re-prove (JDB5-002).
141
+ if (managedContainers.has(handle.path)) {
142
+ if (!(await secureFs.proveManagedContainer(handle.path)).ok) {
143
+ return false;
144
+ }
145
+ }
107
146
  }
108
147
  return true;
109
148
  }
@@ -113,12 +152,27 @@ export async function runTransaction(input) {
113
152
  const handle = must(`openDir ${dirPath}`, await secureFs.openDirNoFollow(dirPath));
114
153
  await gate(dirPath, handle);
115
154
  }
116
- // --- SEGMENT CREATION: .claude then .claude/hooks, one at a time ---
155
+ // --- SEGMENT CREATION + MANAGED-CONTAINER PROOF ---
156
+ // Prove the COMPLETE managed set {claudeDir, hooksDir} on every anyWrite run,
157
+ // decoupled from which child is written. A managed container that EXISTS is
158
+ // always gate()d + proveManagedContainer'd (→ heldOrder → re-proved
159
+ // pre-commit); one that is absent is CREATED only when a child is written
160
+ // into it this run, else left alone (nothing to secure).
117
161
  if (anyWrite) {
118
162
  const projectHandle = heldByPath.get(projectDir);
119
- const claudeHandle = await ensureDir(projectHandle, claudeDir);
120
- if (needsWrite(input.asset))
121
- await ensureDir(claudeHandle, hooksDir);
163
+ // .claude always ensured on anyWrite (holds settings; grandparent of
164
+ // the asset). createIfAbsent=true never returns null (opens, creates, or
165
+ // throws) → narrow non-null before passing as parent (JDA6-003).
166
+ const claudeHandle = await ensureManagedContainer(projectHandle, claudeDir,
167
+ /* createIfAbsent */ true);
168
+ if (!claudeHandle) {
169
+ throw new TxAbort(`container ${claudeDir}`, "unexpected null handle");
170
+ }
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));
122
176
  }
123
177
  // --- CAPTURE + (FORCED) BACKUP + STAGE, asset then settings ---
124
178
  for (const component of [input.asset, input.settings]) {
@@ -148,6 +202,12 @@ export async function runTransaction(input) {
148
202
  must(`recheck-id ${handle.path}`, await secureFs.revalidateIdentity(handle.path, handle.identity));
149
203
  must(`recheck-own ${handle.path}`, await secureFs.proveOwnershipAndMode(handle.path));
150
204
  must(`recheck-acl ${handle.path}`, await secureFs.proveNoExtendedAcl(handle.path));
205
+ // Re-prove the managed-container add/delete-child dimension for held
206
+ // handles that ARE managed containers, closing the TOCTOU window between
207
+ // ensure time and commit (JDB7-003; parity with 3a Decision 6 / JD-007).
208
+ if (managedContainers.has(handle.path)) {
209
+ must(`recheck-container ${handle.path}`, await secureFs.proveManagedContainer(handle.path));
210
+ }
151
211
  }
152
212
  // --- COMMIT: asset first, settings second ---
153
213
  for (const entry of staged) {
@@ -227,7 +287,11 @@ export async function runTransaction(input) {
227
287
  return;
228
288
  }
229
289
  await secureFs.applyExactMode(path.join(entry.dir.path, rName), entry.prior.mode);
230
- await secureFs.renameInDir(entry.dir, rName, base);
290
+ const restored = await secureFs.renameInDir(entry.dir, rName, base);
291
+ if (!restored.ok) {
292
+ errors.push(`STOP: cannot restore ${entry.path}; prior payload staged at ${path.join(entry.dir.path, rName)} for manual recovery`);
293
+ return;
294
+ }
231
295
  }
232
296
  }
233
297
  // Remove only tx-created, identity-matched, still-empty segments, child-first.
@@ -0,0 +1,124 @@
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 type { PlatformSecureFs, SecureRefusal } from "./secure-fs-transaction.js";
26
+ /**
27
+ * The mode `captureFile` returns and `applyExactMode` demands on win32 (C1).
28
+ * NTFS has no POSIX bits; the value only has to survive the core's opaque
29
+ * round-trip, and `0o600` is the private-file mode the core already threads.
30
+ */
31
+ export declare const WIN32_MODE_SENTINEL = 384;
32
+ /** Reject any frame whose declared length exceeds this (hook assets are tiny). */
33
+ export declare const HELPER_FRAME_LIMIT: number;
34
+ /**
35
+ * R4-001 (Phase-4 hard gate): the per-request / handshake deadline. A timer is
36
+ * armed when a frame is written to the child (and while awaiting the startup
37
+ * handshake) and cleared the instant its response arrives. If it fires, the
38
+ * child is killed and every pending/subsequent op fails closed — a hung or
39
+ * non-responding `.ps1` can no longer hang the installer transaction forever.
40
+ */
41
+ export declare const HELPER_OP_TIMEOUT_MS = 30000;
42
+ export type HelperOp = "openDir" | "revalidate" | "proveOwner" | "proveDacl" | "proveContainer" | "createDir" | "capture" | "writeExcl" | "applyMode" | "rename" | "unlink" | "rmdir" | "releaseHandle";
43
+ export interface HelperRequest {
44
+ op: HelperOp;
45
+ args: Record<string, unknown>;
46
+ }
47
+ export interface HelperResponse {
48
+ ok: boolean;
49
+ value?: unknown;
50
+ refusal?: SecureRefusal;
51
+ detail?: string;
52
+ /** win32 error code on an openDir failure; drives the notFound mapping. */
53
+ status?: number;
54
+ }
55
+ export interface HelperTransport {
56
+ /** Strictly serial: exactly one outstanding request at a time. */
57
+ request(req: HelperRequest): Promise<HelperResponse>;
58
+ /** Idempotent; kills the child. */
59
+ close(): Promise<void>;
60
+ }
61
+ /** Encode a JSON body as `[uint32 BE byteLength][UTF-8 JSON]`. */
62
+ export declare function encodeFrame(body: unknown): Buffer;
63
+ /**
64
+ * Decode as many complete frames as `buf` holds, returning them plus the
65
+ * unconsumed remainder. Throws on a declared length past `HELPER_FRAME_LIMIT`
66
+ * (the caller kills the session and fails closed).
67
+ */
68
+ export declare function decodeFrames(buf: Buffer): {
69
+ frames: unknown[];
70
+ rest: Buffer;
71
+ };
72
+ export declare function createWindowsSecureFs(transport: HelperTransport): PlatformSecureFs;
73
+ /**
74
+ * A transport that refuses EVERY op — used when the `.ps1` digest does not match
75
+ * the manifest binding (or the binding is absent). No PowerShell is spawned.
76
+ */
77
+ export declare function refusingTransport(detail: string): HelperTransport;
78
+ /** The subset of a spawned child process this session drives. */
79
+ export interface Ps1Child {
80
+ stdin: {
81
+ write(chunk: Buffer): void;
82
+ };
83
+ stdout: {
84
+ on(event: "data", cb: (chunk: Buffer) => void): void;
85
+ };
86
+ stderr?: {
87
+ on(event: "data", cb: (chunk: Buffer) => void): void;
88
+ };
89
+ on(event: "exit" | "error", cb: (...args: unknown[]) => void): void;
90
+ kill(): void;
91
+ unref?(): void;
92
+ }
93
+ export interface WindowsHelperBinding {
94
+ name: string;
95
+ sha256: string;
96
+ }
97
+ export interface WindowsHelperManifest {
98
+ installerHelpers?: {
99
+ windowsSecureObject?: WindowsHelperBinding | null;
100
+ };
101
+ }
102
+ export interface Ps1SessionOptions {
103
+ assetsDir?: string;
104
+ manifest?: WindowsHelperManifest | null;
105
+ readFile?: (filePath: string) => Buffer;
106
+ spawn?: (cmd: string, args: string[]) => Ps1Child;
107
+ idleMs?: number;
108
+ opTimeoutMs?: number;
109
+ setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
110
+ clearTimer?: (handle: ReturnType<typeof setTimeout>) => void;
111
+ registerExitHook?: (fn: () => void) => void;
112
+ }
113
+ /**
114
+ * The real transport: verify the on-disk `.ps1` sha256 against the manifest
115
+ * binding BEFORE spawning (tamper-evident, symmetric with the `.mjs`); on a
116
+ * mismatch/absent binding return `refusingTransport` and spawn nothing. On a
117
+ * match, spawn `powershell.exe` lazily on the first request, complete the
118
+ * handshake, and exchange strictly-serial length-prefixed frames. Any oversized
119
+ * frame, bad handshake, child exit, or session error kills the child and fails
120
+ * every pending/subsequent op closed. The idle watchdog only arms when ZERO
121
+ * directory handles are outstanding (W1) so it never kills a live transaction.
122
+ */
123
+ export declare function createPs1Session(opts?: Ps1SessionOptions): HelperTransport;
124
+ //# sourceMappingURL=secure-fs-windows.d.ts.map