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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|