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
|
@@ -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":
|
|
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"}}}
|
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* (`./commands/hooks.js`) is lazy-imported inside the handler to keep cold-start
|
|
5
5
|
* minimal, because hooks are on the commit/push hot path.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Subcommands:
|
|
8
|
+
* - `hooks run <pre-commit|pre-push>` → runHook, exits with its code.
|
|
9
|
+
* - `hooks <install|doctor|repair> claude [--force]` → runClaudeHookCommand,
|
|
10
|
+
* exits with its code (wrong/missing target → usage + exit 1).
|
|
11
|
+
* Any other subcommand or a missing name → usage + exit 1.
|
|
9
12
|
*/
|
|
10
13
|
import type { CLI } from "./types.js";
|
|
11
14
|
export declare function handleHooks(cli: CLI): Promise<void>;
|
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* (`./commands/hooks.js`) is lazy-imported inside the handler to keep cold-start
|
|
5
5
|
* minimal, because hooks are on the commit/push hot path.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Subcommands:
|
|
8
|
+
* - `hooks run <pre-commit|pre-push>` → runHook, exits with its code.
|
|
9
|
+
* - `hooks <install|doctor|repair> claude [--force]` → runClaudeHookCommand,
|
|
10
|
+
* exits with its code (wrong/missing target → usage + exit 1).
|
|
11
|
+
* Any other subcommand or a missing name → usage + exit 1.
|
|
9
12
|
*/
|
|
10
13
|
import { HOOKS_HELP_TEXT } from "../help.js";
|
|
11
14
|
export async function handleHooks(cli) {
|
|
@@ -23,6 +26,17 @@ export async function handleHooks(cli) {
|
|
|
23
26
|
const code = await runHook(name, process.cwd());
|
|
24
27
|
process.exit(code);
|
|
25
28
|
}
|
|
29
|
+
const sub = cli.input[1];
|
|
30
|
+
if (sub === "install" || sub === "doctor" || sub === "repair") {
|
|
31
|
+
if (cli.input[2] !== "claude") {
|
|
32
|
+
console.error(`Usage: javi-forge hooks ${sub} claude`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const { runClaudeHookCommand } = await import("../../commands/claude-hooks.js");
|
|
36
|
+
process.exit(await runClaudeHookCommand(sub, process.cwd(), {
|
|
37
|
+
force: cli.flags.force === true,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
26
40
|
// No subcommand → show usage (exit 0). An unknown subcommand is a typo →
|
|
27
41
|
// show usage but exit 1 rather than run nothing silently.
|
|
28
42
|
console.log(HOOKS_HELP_TEXT);
|
package/dist/cli/help.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export declare const CI_HELP_TEXT = "\n Usage\n $ javi-forge ci [subcommand]
|
|
|
19
19
|
* Per-command help for `hooks`, shown by `javi-forge hooks --help` (or when
|
|
20
20
|
* `hooks` is given an unknown subcommand). Whitespace is significant.
|
|
21
21
|
*/
|
|
22
|
-
export declare const HOOKS_HELP_TEXT = "\n Usage\n $ javi-forge hooks run <pre-commit|pre-push>\n\n Run the sections enabled under hooks: in .javi-forge/ci.yaml, in a fixed\n cheap\u2192expensive order, fail-fast. With no hooks: config the default is the\n quick native CI gate (setup + lint + compile + gates \u2014 no tests, no coverage).\n\n Subcommands\n run pre-commit
|
|
22
|
+
export declare const HOOKS_HELP_TEXT = "\n Usage\n $ javi-forge hooks run <pre-commit|pre-push>\n $ javi-forge hooks <install|doctor|repair> claude [--force]\n\n Run the sections enabled under hooks: in .javi-forge/ci.yaml, in a fixed\n cheap\u2192expensive order, fail-fast. With no hooks: config the default is the\n quick native CI gate (setup + lint + compile + gates \u2014 no tests, no coverage).\n\n Subcommands\n run pre-commit Run the composed pre-commit sections\n run pre-push Run the composed pre-push sections\n install claude Install the managed Claude PreToolUse guard (.claude/)\n doctor claude Report Claude PreToolUse guard health (informational)\n repair claude Repair the managed guard; --force overwrites edited assets\n\n Notes\n A blocking section failure exits non-zero and blocks the commit/push.\n A broken .javi-forge/ci.yaml exits 1 (fail-closed \u2014 never skips a gate).\n To skip: git commit --no-verify (pre-push: git push --no-verify)\n doctor claude is informational (always exits 0); install/repair exit 0 on\n success, non-zero on refusal/failure. Use repair claude --force to overwrite\n a locally edited managed asset.\n\n Examples\n $ javi-forge hooks run pre-commit\n $ javi-forge hooks run pre-push\n $ javi-forge hooks install claude\n $ javi-forge hooks doctor claude\n $ javi-forge hooks repair claude --force\n";
|
|
23
23
|
export declare const FLAGS_SCHEMA: {
|
|
24
24
|
readonly help: {
|
|
25
25
|
readonly type: "boolean";
|
package/dist/cli/help.js
CHANGED
|
@@ -166,23 +166,33 @@ export const CI_HELP_TEXT = `
|
|
|
166
166
|
export const HOOKS_HELP_TEXT = `
|
|
167
167
|
Usage
|
|
168
168
|
$ javi-forge hooks run <pre-commit|pre-push>
|
|
169
|
+
$ javi-forge hooks <install|doctor|repair> claude [--force]
|
|
169
170
|
|
|
170
171
|
Run the sections enabled under hooks: in .javi-forge/ci.yaml, in a fixed
|
|
171
172
|
cheap→expensive order, fail-fast. With no hooks: config the default is the
|
|
172
173
|
quick native CI gate (setup + lint + compile + gates — no tests, no coverage).
|
|
173
174
|
|
|
174
175
|
Subcommands
|
|
175
|
-
run pre-commit
|
|
176
|
-
run pre-push
|
|
176
|
+
run pre-commit Run the composed pre-commit sections
|
|
177
|
+
run pre-push Run the composed pre-push sections
|
|
178
|
+
install claude Install the managed Claude PreToolUse guard (.claude/)
|
|
179
|
+
doctor claude Report Claude PreToolUse guard health (informational)
|
|
180
|
+
repair claude Repair the managed guard; --force overwrites edited assets
|
|
177
181
|
|
|
178
182
|
Notes
|
|
179
183
|
A blocking section failure exits non-zero and blocks the commit/push.
|
|
180
184
|
A broken .javi-forge/ci.yaml exits 1 (fail-closed — never skips a gate).
|
|
181
185
|
To skip: git commit --no-verify (pre-push: git push --no-verify)
|
|
186
|
+
doctor claude is informational (always exits 0); install/repair exit 0 on
|
|
187
|
+
success, non-zero on refusal/failure. Use repair claude --force to overwrite
|
|
188
|
+
a locally edited managed asset.
|
|
182
189
|
|
|
183
190
|
Examples
|
|
184
191
|
$ javi-forge hooks run pre-commit
|
|
185
192
|
$ javi-forge hooks run pre-push
|
|
193
|
+
$ javi-forge hooks install claude
|
|
194
|
+
$ javi-forge hooks doctor claude
|
|
195
|
+
$ javi-forge hooks repair claude --force
|
|
186
196
|
`;
|
|
187
197
|
export const FLAGS_SCHEMA = {
|
|
188
198
|
// `--help` is handled manually (autoHelp is disabled at the entrypoint so
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `javi-forge hooks <install|doctor|repair> claude` — console-only renderer for
|
|
3
|
+
* the already-tested Claude PreToolUse guard library (Slice 4a). It wires the
|
|
4
|
+
* three lib fns to human output + exit codes; it adds NO new security logic and
|
|
5
|
+
* never touches `runTransaction`/secure-fs directly.
|
|
6
|
+
*
|
|
7
|
+
* Honest-execution constraint (spec Requirement "…never fabricates execution
|
|
8
|
+
* status"): the doctor renderer reports effective execution as `inconclusive`
|
|
9
|
+
* only. It MUST NOT print, imply, or default to `RUNNABLE` — real host-probing
|
|
10
|
+
* is Slice 4b. Exit 2 (INCONCLUSIVE gate semantics) is likewise reserved for 4b
|
|
11
|
+
* and never emitted here.
|
|
12
|
+
*/
|
|
13
|
+
import { doctorClaudePreToolUse, installClaudePreToolUse, repairClaudePreToolUse } from "../lib/claude-hook-manager.js";
|
|
14
|
+
export type ClaudeHookSub = "install" | "doctor" | "repair";
|
|
15
|
+
/** Injectable seams for tests; each defaults to the real implementation. */
|
|
16
|
+
export interface ClaudeHookCmdDeps {
|
|
17
|
+
install?: typeof installClaudePreToolUse;
|
|
18
|
+
doctor?: typeof doctorClaudePreToolUse;
|
|
19
|
+
repair?: typeof repairClaudePreToolUse;
|
|
20
|
+
log?: (msg: string) => void;
|
|
21
|
+
logError?: (msg: string) => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Run one Claude-hook subcommand against `projectDir`. Returns the process exit
|
|
25
|
+
* code (the dispatcher calls `process.exit`, not this fn):
|
|
26
|
+
* - install/repair: 0 when `ok`, 1 on refusal/failure.
|
|
27
|
+
* - doctor: always 0 (informational).
|
|
28
|
+
*/
|
|
29
|
+
export declare function runClaudeHookCommand(sub: ClaudeHookSub, projectDir: string, opts: {
|
|
30
|
+
force?: boolean;
|
|
31
|
+
}, deps?: ClaudeHookCmdDeps): Promise<number>;
|
|
32
|
+
//# sourceMappingURL=claude-hooks.d.ts.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `javi-forge hooks <install|doctor|repair> claude` — console-only renderer for
|
|
3
|
+
* the already-tested Claude PreToolUse guard library (Slice 4a). It wires the
|
|
4
|
+
* three lib fns to human output + exit codes; it adds NO new security logic and
|
|
5
|
+
* never touches `runTransaction`/secure-fs directly.
|
|
6
|
+
*
|
|
7
|
+
* Honest-execution constraint (spec Requirement "…never fabricates execution
|
|
8
|
+
* status"): the doctor renderer reports effective execution as `inconclusive`
|
|
9
|
+
* only. It MUST NOT print, imply, or default to `RUNNABLE` — real host-probing
|
|
10
|
+
* is Slice 4b. Exit 2 (INCONCLUSIVE gate semantics) is likewise reserved for 4b
|
|
11
|
+
* and never emitted here.
|
|
12
|
+
*/
|
|
13
|
+
import { doctorClaudePreToolUse, installClaudePreToolUse, repairClaudePreToolUse, } from "../lib/claude-hook-manager.js";
|
|
14
|
+
function renderMutation(verb, result, log, logError) {
|
|
15
|
+
if (result.ok) {
|
|
16
|
+
log(`${verb} claude: ok`);
|
|
17
|
+
if (result.changed.length > 0) {
|
|
18
|
+
log("changed:");
|
|
19
|
+
for (const p of result.changed)
|
|
20
|
+
log(` ${p}`);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
log("changed: nothing (already up to date)");
|
|
24
|
+
}
|
|
25
|
+
if (result.backups.length > 0) {
|
|
26
|
+
log("backups:");
|
|
27
|
+
for (const p of result.backups)
|
|
28
|
+
log(` ${p}`);
|
|
29
|
+
}
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
logError(`${verb} claude: refused`);
|
|
33
|
+
for (const e of result.errors)
|
|
34
|
+
logError(` ${e}`);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
function renderDoctor(report, log) {
|
|
38
|
+
log(`doctor claude: ${report.healthy ? "healthy" : "unhealthy"}`);
|
|
39
|
+
log(` settings: ${report.settings.state} — ${report.settings.detail}`);
|
|
40
|
+
log(` asset: ${report.asset.state} — ${report.asset.detail}`);
|
|
41
|
+
log(` node: ${report.node.version ?? "unavailable"} (min-satisfied: ${report.node.satisfiesMinimum})`);
|
|
42
|
+
// Honest stub: 4a cannot confirm the guard is live. Never RUNNABLE.
|
|
43
|
+
log(" execution: inconclusive (effective-execution probe deferred to 4b)");
|
|
44
|
+
log(` host-residual: ${report.hostResidual}`);
|
|
45
|
+
if (report.remediation.length > 0) {
|
|
46
|
+
log(" remediation:");
|
|
47
|
+
for (const r of report.remediation)
|
|
48
|
+
log(` - ${r}`);
|
|
49
|
+
}
|
|
50
|
+
// Doctor is informational, not a gate — always exit 0 (spec Scenario
|
|
51
|
+
// "Doctor reports component health").
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Run one Claude-hook subcommand against `projectDir`. Returns the process exit
|
|
56
|
+
* code (the dispatcher calls `process.exit`, not this fn):
|
|
57
|
+
* - install/repair: 0 when `ok`, 1 on refusal/failure.
|
|
58
|
+
* - doctor: always 0 (informational).
|
|
59
|
+
*/
|
|
60
|
+
export async function runClaudeHookCommand(sub, projectDir, opts, deps = {}) {
|
|
61
|
+
const log = deps.log ?? ((m) => console.log(m));
|
|
62
|
+
const logError = deps.logError ?? ((m) => console.error(m));
|
|
63
|
+
const install = deps.install ?? installClaudePreToolUse;
|
|
64
|
+
const doctor = deps.doctor ?? doctorClaudePreToolUse;
|
|
65
|
+
const repair = deps.repair ?? repairClaudePreToolUse;
|
|
66
|
+
if (sub === "install") {
|
|
67
|
+
return renderMutation("install", await install(projectDir), log, logError);
|
|
68
|
+
}
|
|
69
|
+
if (sub === "repair") {
|
|
70
|
+
const result = await repair(projectDir, { force: opts.force === true });
|
|
71
|
+
return renderMutation("repair", result, log, logError);
|
|
72
|
+
}
|
|
73
|
+
return renderDoctor(await doctor(projectDir), log);
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=claude-hooks.js.map
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { StepFn } from "../types.js";
|
|
2
2
|
/**
|
|
3
|
-
* Step 14: Scaffold security hooks (hook-consolidation S4 fold).
|
|
3
|
+
* Step 14: Scaffold security hooks (hook-consolidation S4 fold + SkillGuard 4a).
|
|
4
4
|
*
|
|
5
5
|
* - When options.securityHooks is false, reports "skipped".
|
|
6
6
|
* - Otherwise:
|
|
7
|
-
* 1.
|
|
8
|
-
*
|
|
7
|
+
* 1. When `claudePreToolUseGuard` is set, installs the managed Claude
|
|
8
|
+
* PreToolUse guard via the transactional `installClaudePreToolUse`
|
|
9
|
+
* (SkillGuard Slice 4a). The legacy copy-if-absent
|
|
10
|
+
* `claude-settings-security.json` scaffold is RETIRED — the managed
|
|
11
|
+
* installer owns `.claude/settings.json` + the hook asset with proper
|
|
12
|
+
* ownership markers.
|
|
9
13
|
* 2. Merges the `hooks:` security sections for the selected reliability
|
|
10
14
|
* profile into `.javi-forge/ci.yaml` via `setHookFeature` (creating a
|
|
11
15
|
* minimal `version: 2` config when absent). The dispatcher composes these
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import fs from "fs-extra";
|
|
3
|
-
import { SECURITY_HOOKS_DIR } from "../../../constants.js";
|
|
4
1
|
import { setHookFeature } from "../../../lib/ci-config.js";
|
|
5
|
-
import {
|
|
2
|
+
import { installClaudePreToolUse } from "../../../lib/claude-hook-manager.js";
|
|
6
3
|
import { report } from "../report.js";
|
|
7
4
|
/**
|
|
8
5
|
* Hook-feature preset per reliability profile (hook-consolidation S4).
|
|
@@ -21,12 +18,16 @@ const PROFILE_PRESET = {
|
|
|
21
18
|
strict: { preCommit: ["secrets", "permissions"], prePush: ["deps"] },
|
|
22
19
|
};
|
|
23
20
|
/**
|
|
24
|
-
* Step 14: Scaffold security hooks (hook-consolidation S4 fold).
|
|
21
|
+
* Step 14: Scaffold security hooks (hook-consolidation S4 fold + SkillGuard 4a).
|
|
25
22
|
*
|
|
26
23
|
* - When options.securityHooks is false, reports "skipped".
|
|
27
24
|
* - Otherwise:
|
|
28
|
-
* 1.
|
|
29
|
-
*
|
|
25
|
+
* 1. When `claudePreToolUseGuard` is set, installs the managed Claude
|
|
26
|
+
* PreToolUse guard via the transactional `installClaudePreToolUse`
|
|
27
|
+
* (SkillGuard Slice 4a). The legacy copy-if-absent
|
|
28
|
+
* `claude-settings-security.json` scaffold is RETIRED — the managed
|
|
29
|
+
* installer owns `.claude/settings.json` + the hook asset with proper
|
|
30
|
+
* ownership markers.
|
|
30
31
|
* 2. Merges the `hooks:` security sections for the selected reliability
|
|
31
32
|
* profile into `.javi-forge/ci.yaml` via `setHookFeature` (creating a
|
|
32
33
|
* minimal `version: 2` config when absent). The dispatcher composes these
|
|
@@ -37,7 +38,7 @@ const PROFILE_PRESET = {
|
|
|
37
38
|
*/
|
|
38
39
|
export const stepSecurityHooks = async (ctx) => {
|
|
39
40
|
const { projectDir, dryRun, onStep, options } = ctx;
|
|
40
|
-
const { securityHooks, hookProfile } = options;
|
|
41
|
+
const { securityHooks, hookProfile, claudePreToolUseGuard } = options;
|
|
41
42
|
const stepId = "security-hooks";
|
|
42
43
|
report(onStep, stepId, "Scaffold security hooks", "running");
|
|
43
44
|
try {
|
|
@@ -48,18 +49,22 @@ export const stepSecurityHooks = async (ctx) => {
|
|
|
48
49
|
const profile = hookProfile ?? "standard";
|
|
49
50
|
const preset = PROFILE_PRESET[profile];
|
|
50
51
|
if (dryRun) {
|
|
51
|
-
|
|
52
|
+
const guardNote = claudePreToolUseGuard
|
|
53
|
+
? " + install Claude PreToolUse guard"
|
|
54
|
+
: "";
|
|
55
|
+
report(onStep, stepId, "Scaffold security hooks", "done", `dry-run: would merge ${profile} hooks preset${guardNote}`);
|
|
52
56
|
return;
|
|
53
57
|
}
|
|
54
|
-
// 1.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
await
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// 1. Install the managed Claude PreToolUse guard (transactional; owns
|
|
59
|
+
// .claude/settings.json + the hook asset). Retires the legacy copy.
|
|
60
|
+
let guardNote = "";
|
|
61
|
+
if (claudePreToolUseGuard) {
|
|
62
|
+
const result = await installClaudePreToolUse(projectDir);
|
|
63
|
+
if (!result.ok) {
|
|
64
|
+
report(onStep, stepId, "Scaffold security hooks", "error", `Claude guard install refused: ${result.errors.join("; ")}`);
|
|
65
|
+
return;
|
|
62
66
|
}
|
|
67
|
+
guardNote = "; Claude guard installed";
|
|
63
68
|
}
|
|
64
69
|
// 2. Merge the profile's security sections into .javi-forge/ci.yaml.
|
|
65
70
|
for (const feature of preset.preCommit) {
|
|
@@ -72,9 +77,10 @@ export const stepSecurityHooks = async (ctx) => {
|
|
|
72
77
|
...preset.preCommit.map((f) => `pre-commit.${f}`),
|
|
73
78
|
...preset.prePush.map((f) => `pre-push.${f}`),
|
|
74
79
|
];
|
|
75
|
-
|
|
80
|
+
const presetNote = merged.length > 0
|
|
76
81
|
? `${profile} preset: ${merged.join(", ")}`
|
|
77
|
-
: `${profile} preset: CI gate only (no security sections)
|
|
82
|
+
: `${profile} preset: CI gate only (no security sections)`;
|
|
83
|
+
report(onStep, stepId, "Scaffold security hooks", "done", `${presetNote}${guardNote}`);
|
|
78
84
|
}
|
|
79
85
|
catch (e) {
|
|
80
86
|
report(onStep, stepId, "Scaffold security hooks", "error", String(e));
|
|
@@ -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
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
|
|
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) {
|
|
@@ -286,20 +294,40 @@ export function createPosixSecureFs(acl) {
|
|
|
286
294
|
return refuse("unsafe-parent-chain", `rmdir ${handle.path}: ${errCode(error) ?? "error"}`);
|
|
287
295
|
}
|
|
288
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
|
+
},
|
|
289
306
|
};
|
|
290
307
|
return secureFs;
|
|
291
308
|
}
|
|
292
309
|
/**
|
|
293
|
-
* Select the
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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.
|
|
297
323
|
*/
|
|
298
324
|
export function selectSecureFs(platform = process.platform) {
|
|
299
325
|
if (platform === "linux")
|
|
300
326
|
return createPosixSecureFs(createLinuxAclAdapter());
|
|
301
327
|
if (platform === "darwin")
|
|
302
328
|
return createPosixSecureFs(createMacosAclAdapter());
|
|
329
|
+
if (platform === "win32")
|
|
330
|
+
return createWindowsSecureFs(createPs1Session());
|
|
303
331
|
return null;
|
|
304
332
|
}
|
|
305
333
|
//# sourceMappingURL=secure-fs-posix.js.map
|