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.
@@ -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) {
@@ -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