javi-forge 1.28.1 → 1.30.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.
@@ -0,0 +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}}
@@ -11,6 +11,8 @@ export declare const WORKFLOWS_DIR: string;
11
11
  export declare const CI_LOCAL_DIR: string;
12
12
  /** Git hook assets directory (installed by `javi-forge ci init`) */
13
13
  export declare const HOOK_ASSETS_DIR: string;
14
+ /** Standalone Claude command-hook assets directory */
15
+ export declare const CLAUDE_HOOK_ASSETS_DIR: string;
14
16
  /** Security hooks template directory */
15
17
  export declare const SECURITY_HOOKS_DIR: string;
16
18
  /** Local AI stack template directory */
package/dist/constants.js CHANGED
@@ -13,6 +13,8 @@ export const WORKFLOWS_DIR = path.join(FORGE_ROOT, "workflows");
13
13
  export const CI_LOCAL_DIR = path.join(FORGE_ROOT, "ci-local");
14
14
  /** Git hook assets directory (installed by `javi-forge ci init`) */
15
15
  export const HOOK_ASSETS_DIR = path.join(FORGE_ROOT, "assets", "hooks");
16
+ /** Standalone Claude command-hook assets directory */
17
+ export const CLAUDE_HOOK_ASSETS_DIR = path.join(FORGE_ROOT, "assets", "claude-hooks");
16
18
  /** Security hooks template directory */
17
19
  export const SECURITY_HOOKS_DIR = path.join(TEMPLATES_DIR, "security-hooks");
18
20
  /** Local AI stack template directory */
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Shared frozen ownership fixtures for the SkillGuard Claude PreToolUse guard.
3
+ *
4
+ * This module is the single source of truth for the byte/structure-exact
5
+ * identities the read-only recognition layer (Slice 2) classifies against:
6
+ *
7
+ * - the managed settings-entry handler group the Slice-3 writer installs;
8
+ * - the four v0 legacy cohort objects, copied verbatim from
9
+ * `templates/security-hooks/claude-settings-security.json` (never normalized);
10
+ * - the marker/placeholder/hash constants.
11
+ *
12
+ * Everything here is data only (frozen literals plus a couple of pure
13
+ * builders) so it is deterministic, host-independent, and trivially testable.
14
+ * The production classifier imports the constants and the legacy cohort from
15
+ * here; the tests additionally use the one-byte-edited and duplicate variants.
16
+ */
17
+ /** Exact managed marker prefix on the settings handler's `statusMessage`. */
18
+ export declare const MANAGED_STATUS_PREFIX = "javi-forge-global-pretooluse:v1:sha256:";
19
+ /** Placeholder the canonical hash normalizes the live asset SHA token to. */
20
+ export declare const ASSET_SHA_PLACEHOLDER = "<ASSET_SHA256>";
21
+ /** Whole-file SHA-256 of the retained v0 legacy scaffold template. */
22
+ export declare const LEGACY_FILE_SHA256 = "b4638222ecddc2daac6ec3339596d853a626906bbd1233d789d80a319325c68d";
23
+ /** Exact managed matcher — an exact-name alternative list, never a wildcard. */
24
+ export declare const MANAGED_MATCHER = "Bash|PowerShell|Read|Write|Edit";
25
+ /** Single project-local MJS argument, kept as a literal placeholder path. */
26
+ export declare const MANAGED_ASSET_ARG = "${CLAUDE_PROJECT_DIR}/.claude/hooks/javi-forge-skillguard-pre-tool-use.mjs";
27
+ /** Exact asset filename under `.claude/hooks/`. */
28
+ export declare const ASSET_NAME = "javi-forge-skillguard-pre-tool-use.mjs";
29
+ /** Exact first-line comment marking the managed asset. */
30
+ export declare const ASSET_MANAGED_MARKER = "// javi-forge-managed: claude-pretooluse v1";
31
+ /**
32
+ * A representative live asset SHA. Used only to build marker `statusMessage`
33
+ * values in fixtures; the canonical settings identity is invariant under this
34
+ * value (Decision ②), which the rotation-invariance test proves.
35
+ */
36
+ export declare const SAMPLE_ASSET_SHA256 = "78be7e6613c012280b7ad17886462ba166b63ebd031e34565d757b3a0796d7cc";
37
+ /** Build the exact marker `statusMessage` for a given live asset SHA. */
38
+ export declare function managedStatusMessage(assetSha?: string): string;
39
+ /** Build the exact managed command handler for a given live asset SHA. */
40
+ export declare function managedHandler(assetSha?: string): {
41
+ type: string;
42
+ command: string;
43
+ args: string[];
44
+ timeout: number;
45
+ statusMessage: string;
46
+ };
47
+ /** Build the exact managed matcher group for a given live asset SHA. */
48
+ export declare function managedGroup(assetSha?: string): {
49
+ matcher: string;
50
+ hooks: {
51
+ type: string;
52
+ command: string;
53
+ args: string[];
54
+ timeout: number;
55
+ statusMessage: string;
56
+ }[];
57
+ };
58
+ /** Build a valid settings container around the given hook sections. */
59
+ export declare function settingsContainer(pre?: unknown[], post?: unknown[], extra?: Record<string, unknown>): Record<string, unknown>;
60
+ export declare const L1_BASH_DANGEROUS: {
61
+ readonly matcher: "Bash";
62
+ readonly hook: "COMMAND=\"$CLAUDE_TOOL_INPUT\"\nBLOCKED_PATTERNS=(\n 'rm -rf /'\n 'rm -rf ~'\n 'chmod 777'\n 'curl.*|.*sh'\n 'wget.*|.*sh'\n 'eval.*\\$'\n 'base64.*-d.*|.*sh'\n ':(){:|:&};:'\n)\nfor pattern in \"${BLOCKED_PATTERNS[@]}\"; do\n if echo \"$COMMAND\" | grep -qE \"$pattern\"; then\n echo \"BLOCKED: Dangerous command pattern detected: $pattern\"\n exit 2\n fi\ndone\nexit 0";
63
+ readonly description: "Block dangerous shell commands (rm -rf /, chmod 777, pipe-to-sh, fork bombs)";
64
+ };
65
+ export declare const L2_BASH_SENSITIVE_READ: {
66
+ readonly matcher: "Bash";
67
+ readonly hook: "COMMAND=\"$CLAUDE_TOOL_INPUT\"\nSENSITIVE_PATHS=(\n '.env'\n '.env.local'\n '.env.production'\n 'credentials'\n 'secrets'\n '.ssh'\n '.gnupg'\n '.aws/credentials'\n)\nfor path in \"${SENSITIVE_PATHS[@]}\"; do\n if echo \"$COMMAND\" | grep -qE \"(cat|less|head|tail|bat|read).*$path\"; then\n echo \"BLOCKED: Attempt to read sensitive file: $path\"\n exit 2\n fi\ndone\nexit 0";
68
+ readonly description: "Prevent reading sensitive files (.env, credentials, SSH keys)";
69
+ };
70
+ export declare const L3_WRITE_EDIT_PROTECTED: {
71
+ readonly matcher: "Write|Edit";
72
+ readonly hook: "INPUT=\"$CLAUDE_TOOL_INPUT\"\nPROTECTED_FILES=(\n '.env'\n '.env.production'\n 'credentials.json'\n 'serviceAccountKey.json'\n '.ssh/'\n '.gnupg/'\n)\nfor pf in \"${PROTECTED_FILES[@]}\"; do\n if echo \"$INPUT\" | grep -q \"$pf\"; then\n echo \"BLOCKED: Cannot modify protected file: $pf\"\n exit 2\n fi\ndone\nexit 0";
73
+ readonly description: "Prevent writing to credential and secret files";
74
+ };
75
+ export declare const L4_BASH_POST_SECRET_SCAN: {
76
+ readonly matcher: "Bash";
77
+ readonly hook: "OUTPUT=\"$CLAUDE_TOOL_OUTPUT\"\nLEAK_PATTERNS=(\n 'AKIA[0-9A-Z]{16}'\n 'ghp_[A-Za-z0-9]{36}'\n 'sk_live_[0-9a-zA-Z]{24,}'\n 'xox[baprs]-[0-9a-zA-Z-]+'\n '-----BEGIN.*PRIVATE KEY-----'\n)\nfor pattern in \"${LEAK_PATTERNS[@]}\"; do\n if echo \"$OUTPUT\" | grep -qE \"$pattern\"; then\n echo \"WARNING: Command output may contain secrets. Review carefully.\"\n exit 0\n fi\ndone\nexit 0";
78
+ readonly description: "Warn if command output contains potential secrets";
79
+ };
80
+ /** The complete four-object v0 legacy cohort in committed order (L1–L3 Pre, L4 Post). */
81
+ export declare const LEGACY_COHORT: {
82
+ readonly L1: {
83
+ readonly matcher: "Bash";
84
+ readonly hook: "COMMAND=\"$CLAUDE_TOOL_INPUT\"\nBLOCKED_PATTERNS=(\n 'rm -rf /'\n 'rm -rf ~'\n 'chmod 777'\n 'curl.*|.*sh'\n 'wget.*|.*sh'\n 'eval.*\\$'\n 'base64.*-d.*|.*sh'\n ':(){:|:&};:'\n)\nfor pattern in \"${BLOCKED_PATTERNS[@]}\"; do\n if echo \"$COMMAND\" | grep -qE \"$pattern\"; then\n echo \"BLOCKED: Dangerous command pattern detected: $pattern\"\n exit 2\n fi\ndone\nexit 0";
85
+ readonly description: "Block dangerous shell commands (rm -rf /, chmod 777, pipe-to-sh, fork bombs)";
86
+ };
87
+ readonly L2: {
88
+ readonly matcher: "Bash";
89
+ readonly hook: "COMMAND=\"$CLAUDE_TOOL_INPUT\"\nSENSITIVE_PATHS=(\n '.env'\n '.env.local'\n '.env.production'\n 'credentials'\n 'secrets'\n '.ssh'\n '.gnupg'\n '.aws/credentials'\n)\nfor path in \"${SENSITIVE_PATHS[@]}\"; do\n if echo \"$COMMAND\" | grep -qE \"(cat|less|head|tail|bat|read).*$path\"; then\n echo \"BLOCKED: Attempt to read sensitive file: $path\"\n exit 2\n fi\ndone\nexit 0";
90
+ readonly description: "Prevent reading sensitive files (.env, credentials, SSH keys)";
91
+ };
92
+ readonly L3: {
93
+ readonly matcher: "Write|Edit";
94
+ readonly hook: "INPUT=\"$CLAUDE_TOOL_INPUT\"\nPROTECTED_FILES=(\n '.env'\n '.env.production'\n 'credentials.json'\n 'serviceAccountKey.json'\n '.ssh/'\n '.gnupg/'\n)\nfor pf in \"${PROTECTED_FILES[@]}\"; do\n if echo \"$INPUT\" | grep -q \"$pf\"; then\n echo \"BLOCKED: Cannot modify protected file: $pf\"\n exit 2\n fi\ndone\nexit 0";
95
+ readonly description: "Prevent writing to credential and secret files";
96
+ };
97
+ readonly L4: {
98
+ readonly matcher: "Bash";
99
+ readonly hook: "OUTPUT=\"$CLAUDE_TOOL_OUTPUT\"\nLEAK_PATTERNS=(\n 'AKIA[0-9A-Z]{16}'\n 'ghp_[A-Za-z0-9]{36}'\n 'sk_live_[0-9a-zA-Z]{24,}'\n 'xox[baprs]-[0-9a-zA-Z-]+'\n '-----BEGIN.*PRIVATE KEY-----'\n)\nfor pattern in \"${LEAK_PATTERNS[@]}\"; do\n if echo \"$OUTPUT\" | grep -qE \"$pattern\"; then\n echo \"WARNING: Command output may contain secrets. Review carefully.\"\n exit 0\n fi\ndone\nexit 0";
100
+ readonly description: "Warn if command output contains potential secrets";
101
+ };
102
+ };
103
+ /**
104
+ * One-byte-edited L1: a single trailing character removed from the description.
105
+ * It must fail deep-structural equality against the exact cohort member.
106
+ */
107
+ export declare const L1_ONE_BYTE_EDITED: {
108
+ readonly description: string;
109
+ readonly matcher: "Bash";
110
+ readonly hook: "COMMAND=\"$CLAUDE_TOOL_INPUT\"\nBLOCKED_PATTERNS=(\n 'rm -rf /'\n 'rm -rf ~'\n 'chmod 777'\n 'curl.*|.*sh'\n 'wget.*|.*sh'\n 'eval.*\\$'\n 'base64.*-d.*|.*sh'\n ':(){:|:&};:'\n)\nfor pattern in \"${BLOCKED_PATTERNS[@]}\"; do\n if echo \"$COMMAND\" | grep -qE \"$pattern\"; then\n echo \"BLOCKED: Dangerous command pattern detected: $pattern\"\n exit 2\n fi\ndone\nexit 0";
111
+ };
112
+ //# sourceMappingURL=claude-hook-ownership.d.ts.map
@@ -0,0 +1,96 @@
1
+ // biome-ignore-all lint/suspicious/noTemplateCurlyInString: verbatim shell `${…}` and the literal Claude `${CLAUDE_PROJECT_DIR}` placeholder — never JS templates.
2
+ /**
3
+ * Shared frozen ownership fixtures for the SkillGuard Claude PreToolUse guard.
4
+ *
5
+ * This module is the single source of truth for the byte/structure-exact
6
+ * identities the read-only recognition layer (Slice 2) classifies against:
7
+ *
8
+ * - the managed settings-entry handler group the Slice-3 writer installs;
9
+ * - the four v0 legacy cohort objects, copied verbatim from
10
+ * `templates/security-hooks/claude-settings-security.json` (never normalized);
11
+ * - the marker/placeholder/hash constants.
12
+ *
13
+ * Everything here is data only (frozen literals plus a couple of pure
14
+ * builders) so it is deterministic, host-independent, and trivially testable.
15
+ * The production classifier imports the constants and the legacy cohort from
16
+ * here; the tests additionally use the one-byte-edited and duplicate variants.
17
+ */
18
+ /** Exact managed marker prefix on the settings handler's `statusMessage`. */
19
+ export const MANAGED_STATUS_PREFIX = "javi-forge-global-pretooluse:v1:sha256:";
20
+ /** Placeholder the canonical hash normalizes the live asset SHA token to. */
21
+ export const ASSET_SHA_PLACEHOLDER = "<ASSET_SHA256>";
22
+ /** Whole-file SHA-256 of the retained v0 legacy scaffold template. */
23
+ export const LEGACY_FILE_SHA256 = "b4638222ecddc2daac6ec3339596d853a626906bbd1233d789d80a319325c68d";
24
+ /** Exact managed matcher — an exact-name alternative list, never a wildcard. */
25
+ export const MANAGED_MATCHER = "Bash|PowerShell|Read|Write|Edit";
26
+ /** Single project-local MJS argument, kept as a literal placeholder path. */
27
+ export const MANAGED_ASSET_ARG = "${CLAUDE_PROJECT_DIR}/.claude/hooks/javi-forge-skillguard-pre-tool-use.mjs";
28
+ /** Exact asset filename under `.claude/hooks/`. */
29
+ export const ASSET_NAME = "javi-forge-skillguard-pre-tool-use.mjs";
30
+ /** Exact first-line comment marking the managed asset. */
31
+ export const ASSET_MANAGED_MARKER = "// javi-forge-managed: claude-pretooluse v1";
32
+ /**
33
+ * A representative live asset SHA. Used only to build marker `statusMessage`
34
+ * values in fixtures; the canonical settings identity is invariant under this
35
+ * value (Decision ②), which the rotation-invariance test proves.
36
+ */
37
+ export const SAMPLE_ASSET_SHA256 = "78be7e6613c012280b7ad17886462ba166b63ebd031e34565d757b3a0796d7cc";
38
+ /** Build the exact marker `statusMessage` for a given live asset SHA. */
39
+ export function managedStatusMessage(assetSha = SAMPLE_ASSET_SHA256) {
40
+ return `${MANAGED_STATUS_PREFIX}${assetSha}`;
41
+ }
42
+ /** Build the exact managed command handler for a given live asset SHA. */
43
+ export function managedHandler(assetSha = SAMPLE_ASSET_SHA256) {
44
+ return {
45
+ type: "command",
46
+ command: "node",
47
+ args: [MANAGED_ASSET_ARG],
48
+ timeout: 30,
49
+ statusMessage: managedStatusMessage(assetSha),
50
+ };
51
+ }
52
+ /** Build the exact managed matcher group for a given live asset SHA. */
53
+ export function managedGroup(assetSha = SAMPLE_ASSET_SHA256) {
54
+ return { matcher: MANAGED_MATCHER, hooks: [managedHandler(assetSha)] };
55
+ }
56
+ /** Build a valid settings container around the given hook sections. */
57
+ export function settingsContainer(pre = [], post = [], extra = {}) {
58
+ return { ...extra, hooks: { PreToolUse: pre, PostToolUse: post } };
59
+ }
60
+ // --- v0 legacy cohort (verbatim from the retained template) ------------------
61
+ export const L1_BASH_DANGEROUS = {
62
+ matcher: "Bash",
63
+ hook: "COMMAND=\"$CLAUDE_TOOL_INPUT\"\nBLOCKED_PATTERNS=(\n 'rm -rf /'\n 'rm -rf ~'\n 'chmod 777'\n 'curl.*|.*sh'\n 'wget.*|.*sh'\n 'eval.*\\$'\n 'base64.*-d.*|.*sh'\n ':(){:|:&};:'\n)\nfor pattern in \"${BLOCKED_PATTERNS[@]}\"; do\n if echo \"$COMMAND\" | grep -qE \"$pattern\"; then\n echo \"BLOCKED: Dangerous command pattern detected: $pattern\"\n exit 2\n fi\ndone\nexit 0",
64
+ description: "Block dangerous shell commands (rm -rf /, chmod 777, pipe-to-sh, fork bombs)",
65
+ };
66
+ export const L2_BASH_SENSITIVE_READ = {
67
+ matcher: "Bash",
68
+ hook: "COMMAND=\"$CLAUDE_TOOL_INPUT\"\nSENSITIVE_PATHS=(\n '.env'\n '.env.local'\n '.env.production'\n 'credentials'\n 'secrets'\n '.ssh'\n '.gnupg'\n '.aws/credentials'\n)\nfor path in \"${SENSITIVE_PATHS[@]}\"; do\n if echo \"$COMMAND\" | grep -qE \"(cat|less|head|tail|bat|read).*$path\"; then\n echo \"BLOCKED: Attempt to read sensitive file: $path\"\n exit 2\n fi\ndone\nexit 0",
69
+ description: "Prevent reading sensitive files (.env, credentials, SSH keys)",
70
+ };
71
+ export const L3_WRITE_EDIT_PROTECTED = {
72
+ matcher: "Write|Edit",
73
+ hook: "INPUT=\"$CLAUDE_TOOL_INPUT\"\nPROTECTED_FILES=(\n '.env'\n '.env.production'\n 'credentials.json'\n 'serviceAccountKey.json'\n '.ssh/'\n '.gnupg/'\n)\nfor pf in \"${PROTECTED_FILES[@]}\"; do\n if echo \"$INPUT\" | grep -q \"$pf\"; then\n echo \"BLOCKED: Cannot modify protected file: $pf\"\n exit 2\n fi\ndone\nexit 0",
74
+ description: "Prevent writing to credential and secret files",
75
+ };
76
+ export const L4_BASH_POST_SECRET_SCAN = {
77
+ matcher: "Bash",
78
+ hook: "OUTPUT=\"$CLAUDE_TOOL_OUTPUT\"\nLEAK_PATTERNS=(\n 'AKIA[0-9A-Z]{16}'\n 'ghp_[A-Za-z0-9]{36}'\n 'sk_live_[0-9a-zA-Z]{24,}'\n 'xox[baprs]-[0-9a-zA-Z-]+'\n '-----BEGIN.*PRIVATE KEY-----'\n)\nfor pattern in \"${LEAK_PATTERNS[@]}\"; do\n if echo \"$OUTPUT\" | grep -qE \"$pattern\"; then\n echo \"WARNING: Command output may contain secrets. Review carefully.\"\n exit 0\n fi\ndone\nexit 0",
79
+ description: "Warn if command output contains potential secrets",
80
+ };
81
+ /** The complete four-object v0 legacy cohort in committed order (L1–L3 Pre, L4 Post). */
82
+ export const LEGACY_COHORT = {
83
+ L1: L1_BASH_DANGEROUS,
84
+ L2: L2_BASH_SENSITIVE_READ,
85
+ L3: L3_WRITE_EDIT_PROTECTED,
86
+ L4: L4_BASH_POST_SECRET_SCAN,
87
+ };
88
+ /**
89
+ * One-byte-edited L1: a single trailing character removed from the description.
90
+ * It must fail deep-structural equality against the exact cohort member.
91
+ */
92
+ export const L1_ONE_BYTE_EDITED = {
93
+ ...L1_BASH_DANGEROUS,
94
+ description: "Block dangerous shell commands (rm -rf /, chmod 777, pipe-to-sh, fork bombs)".slice(0, -1),
95
+ };
96
+ //# sourceMappingURL=claude-hook-ownership.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Synchronous in-memory `PlatformSecureFs` fake for the transaction engine's
3
+ * fault matrix. Host-independent, no root, no real filesystem: directories,
4
+ * files, modes, and dev+ino identities live in Maps, and every failure branch
5
+ * (identity drift, ACL refusal, exclusive-create EEXIST, write/fsync fault,
6
+ * rename fault, ownership loss, capture refusal, post-commit hash drift) is a
7
+ * declarative toggle. Used only by tests.
8
+ */
9
+ import type { PlatformSecureFs } from "../secure-fs-transaction.js";
10
+ interface FakeFile {
11
+ bytes: Buffer;
12
+ mode: number;
13
+ }
14
+ /** Per-call fault predicates; every one defaults to "no fault". */
15
+ export interface FakeFaults {
16
+ /** Refuse revalidateIdentity for a path on its Nth (1-based) call. */
17
+ revalidateRefuse?: (target: string, callIndex: number) => boolean;
18
+ /** Refuse proveOwnershipAndMode for a path on its Nth call. */
19
+ ownershipRefuse?: (dirPath: string, callIndex: number) => boolean;
20
+ /** Refuse proveNoExtendedAcl for a path on its Nth call. */
21
+ aclRefuse?: (target: string, callIndex: number) => boolean;
22
+ /** Refuse captureFile for a path (simulate open/read failure). */
23
+ captureRefuse?: (target: string) => boolean;
24
+ /** Override the sha of a captured file (simulate post-commit drift). */
25
+ captureShaOverride?: (target: string) => string | undefined;
26
+ /** Refuse writeExclusive for a base name on its Nth call (EEXIST / fsync fault). */
27
+ writeRefuse?: (name: string, callIndex: number) => boolean;
28
+ /** Refuse renameInDir when the destination base name matches. */
29
+ renameRefuse?: (to: string) => boolean;
30
+ }
31
+ export interface FakeSecureFs extends PlatformSecureFs {
32
+ readonly dirs: Set<string>;
33
+ readonly files: Map<string, FakeFile>;
34
+ readonly dirModes: Map<string, number>;
35
+ faults: FakeFaults;
36
+ seedDir(dirPath: string): void;
37
+ seedFile(filePath: string, bytes: Buffer, mode?: number): void;
38
+ fileText(filePath: string): string | undefined;
39
+ hasBackup(dirPath: string): boolean;
40
+ }
41
+ export declare function makeFakeSecureFs(): FakeSecureFs;
42
+ export {};
43
+ //# sourceMappingURL=fake-secure-fs.d.ts.map
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Synchronous in-memory `PlatformSecureFs` fake for the transaction engine's
3
+ * fault matrix. Host-independent, no root, no real filesystem: directories,
4
+ * files, modes, and dev+ino identities live in Maps, and every failure branch
5
+ * (identity drift, ACL refusal, exclusive-create EEXIST, write/fsync fault,
6
+ * rename fault, ownership loss, capture refusal, post-commit hash drift) is a
7
+ * declarative toggle. Used only by tests.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import path from "node:path";
11
+ const ok = () => ({ ok: true });
12
+ const okValue = (value) => ({ ok: true, value });
13
+ const unsafe = (detail) => ({
14
+ ok: false,
15
+ refusal: "unsafe-parent-chain",
16
+ detail,
17
+ });
18
+ export function makeFakeSecureFs() {
19
+ const dirs = new Set();
20
+ const files = new Map();
21
+ const dirModes = new Map();
22
+ const inos = new Map();
23
+ let inoSeq = 100;
24
+ const revalidateCounts = new Map();
25
+ const ownershipCounts = new Map();
26
+ const aclCounts = new Map();
27
+ const writeCounts = new Map();
28
+ const inoFor = (p) => {
29
+ let ino = inos.get(p);
30
+ if (ino === undefined) {
31
+ ino = inoSeq++;
32
+ inos.set(p, ino);
33
+ }
34
+ return ino;
35
+ };
36
+ const bump = (m, key) => {
37
+ const next = (m.get(key) ?? 0) + 1;
38
+ m.set(key, next);
39
+ return next;
40
+ };
41
+ const handleFor = (p) => ({
42
+ path: p,
43
+ identity: { dev: 1, ino: inoFor(p) },
44
+ close: async () => { },
45
+ });
46
+ const isEmptyDir = (p) => {
47
+ const prefix = `${p}/`;
48
+ for (const f of files.keys())
49
+ if (f.startsWith(prefix))
50
+ return false;
51
+ for (const d of dirs)
52
+ if (d !== p && d.startsWith(prefix))
53
+ return false;
54
+ return true;
55
+ };
56
+ const fake = {
57
+ dirs,
58
+ files,
59
+ dirModes,
60
+ faults: {},
61
+ seedDir(dirPath) {
62
+ dirs.add(dirPath);
63
+ },
64
+ seedFile(filePath, bytes, mode = 0o644) {
65
+ files.set(filePath, { bytes, mode });
66
+ },
67
+ fileText(filePath) {
68
+ return files.get(filePath)?.bytes.toString("utf8");
69
+ },
70
+ hasBackup(dirPath) {
71
+ const prefix = `${dirPath}/`;
72
+ for (const f of files.keys()) {
73
+ if (f.startsWith(prefix) && f.includes(".javi-forge.bak."))
74
+ return true;
75
+ }
76
+ return false;
77
+ },
78
+ async openDirNoFollow(dirPath) {
79
+ if (!dirs.has(dirPath))
80
+ return unsafe(`openDir enoent ${dirPath}`);
81
+ return okValue(handleFor(dirPath));
82
+ },
83
+ async revalidateIdentity(target, held) {
84
+ const idx = bump(revalidateCounts, target);
85
+ if (fake.faults.revalidateRefuse?.(target, idx)) {
86
+ return unsafe(`identity drift ${target}`);
87
+ }
88
+ if (!dirs.has(target) && !files.has(target)) {
89
+ return unsafe(`identity missing ${target}`);
90
+ }
91
+ return inoFor(target) === held.ino
92
+ ? ok()
93
+ : unsafe(`identity drift ${target}`);
94
+ },
95
+ async proveOwnershipAndMode(dirPath) {
96
+ const idx = bump(ownershipCounts, dirPath);
97
+ if (fake.faults.ownershipRefuse?.(dirPath, idx)) {
98
+ return unsafe(`ownership ${dirPath}`);
99
+ }
100
+ return ok();
101
+ },
102
+ async proveNoExtendedAcl(target) {
103
+ const idx = bump(aclCounts, target);
104
+ if (fake.faults.aclRefuse?.(target, idx)) {
105
+ return { ok: false, refusal: "unsupported-posix-acl", detail: target };
106
+ }
107
+ return ok();
108
+ },
109
+ async createDirExclusive(parent, name, mode) {
110
+ const full = path.join(parent.path, name);
111
+ if (dirs.has(full) || files.has(full))
112
+ return unsafe(`EEXIST ${full}`);
113
+ dirs.add(full);
114
+ dirModes.set(full, mode);
115
+ return okValue(handleFor(full));
116
+ },
117
+ async captureFile(target) {
118
+ if (fake.faults.captureRefuse?.(target))
119
+ return unsafe(`capture ${target}`);
120
+ const file = files.get(target);
121
+ if (!file)
122
+ return unsafe(`capture enoent ${target}`);
123
+ const realSha = createHash("sha256").update(file.bytes).digest("hex");
124
+ const sha256 = fake.faults.captureShaOverride?.(target) ?? realSha;
125
+ return okValue({
126
+ bytes: file.bytes,
127
+ mode: file.mode,
128
+ identity: { dev: 1, ino: inoFor(target) },
129
+ sha256,
130
+ });
131
+ },
132
+ async writeExclusive(dir, name, bytes, mode) {
133
+ const idx = bump(writeCounts, name);
134
+ if (fake.faults.writeRefuse?.(name, idx))
135
+ return unsafe(`write ${name}`);
136
+ const full = path.join(dir.path, name);
137
+ if (files.has(full))
138
+ return unsafe(`EEXIST ${full}`);
139
+ files.set(full, { bytes, mode });
140
+ return ok();
141
+ },
142
+ async applyExactMode(target, mode) {
143
+ const file = files.get(target);
144
+ if (!file)
145
+ return unsafe(`applyMode enoent ${target}`);
146
+ file.mode = mode;
147
+ return ok();
148
+ },
149
+ async renameInDir(dir, from, to) {
150
+ if (fake.faults.renameRefuse?.(to))
151
+ return unsafe(`rename ${to}`);
152
+ const fromP = path.join(dir.path, from);
153
+ const toP = path.join(dir.path, to);
154
+ const file = files.get(fromP);
155
+ if (!file)
156
+ return unsafe(`rename enoent ${fromP}`);
157
+ files.set(toP, file);
158
+ files.delete(fromP);
159
+ inos.delete(toP); // fresh identity for the renamed-in target
160
+ return ok();
161
+ },
162
+ async unlinkIfIdentity(dir, name, _held) {
163
+ const full = path.join(dir.path, name);
164
+ if (!files.has(full))
165
+ return unsafe(`unlink enoent ${full}`);
166
+ files.delete(full);
167
+ return ok();
168
+ },
169
+ async rmdirIfIdentityEmpty(handle) {
170
+ if (!dirs.has(handle.path))
171
+ return unsafe(`rmdir enoent ${handle.path}`);
172
+ if (inoFor(handle.path) !== handle.identity.ino) {
173
+ return unsafe(`rmdir identity ${handle.path}`);
174
+ }
175
+ if (!isEmptyDir(handle.path))
176
+ return unsafe(`rmdir not-empty ${handle.path}`);
177
+ dirs.delete(handle.path);
178
+ return ok();
179
+ },
180
+ };
181
+ return fake;
182
+ }
183
+ //# sourceMappingURL=fake-secure-fs.js.map
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Read-only Claude PreToolUse ownership manager (Slice 2). Its only filesystem
3
+ * surface is `safeReadFile` plus one isolated no-follow `lstat` helper — it never
4
+ * writes, creates directories, or makes backups. It owns asset byte
5
+ * classification (always recompute the full-file SHA), the settings read+parse
6
+ * wrapper (identity delegated to `claude-hook-settings`), the Node `>=22` check,
7
+ * and the component-level doctor. Install/repair are declared but unimplemented
8
+ * Slice-3 seams — Slice 3 GROWS this file, it does not relocate this code.
9
+ */
10
+ import { type ClaudeHookComponentState, type SettingsClassification, type SettingsIdentityManifest } from "./claude-hook-settings.js";
11
+ import { type PlatformSecureFs } from "./secure-fs-transaction.js";
12
+ declare const COVERAGE: readonly ["Bash", "PowerShell", "Read", "Write", "Edit"];
13
+ export interface AssetManifestEntry {
14
+ name: string;
15
+ version: number;
16
+ sha256: string;
17
+ historical: string[];
18
+ }
19
+ export interface Manifest {
20
+ asset: AssetManifestEntry;
21
+ settingsEntries: SettingsIdentityManifest;
22
+ }
23
+ export interface ClaudeHookAssetClassification {
24
+ state: ClaudeHookComponentState;
25
+ version?: number;
26
+ sha256?: string;
27
+ detail?: string;
28
+ }
29
+ export interface ClaudeHookDoctorReport {
30
+ healthy: boolean;
31
+ settings: {
32
+ state: ClaudeHookComponentState;
33
+ version?: number;
34
+ canonicalSha256?: string;
35
+ detail: string;
36
+ };
37
+ asset: {
38
+ state: ClaudeHookComponentState;
39
+ version?: number;
40
+ sha256?: string;
41
+ detail: string;
42
+ };
43
+ node: {
44
+ available: boolean;
45
+ version?: string;
46
+ satisfiesMinimum: boolean;
47
+ };
48
+ matcherExact: boolean;
49
+ commandShapeExact: boolean;
50
+ assetSettingsConsistent: boolean;
51
+ coverage: typeof COVERAGE;
52
+ hostResidual: string;
53
+ remediation: readonly string[];
54
+ }
55
+ /**
56
+ * Classify the asset into one of nine states from observed bytes only. Never
57
+ * trusts a claimed hash: the full-file SHA is always recomputed and compared to
58
+ * the manifest.
59
+ */
60
+ export declare function classifyAssetState(assetPath: string, manifest: Manifest): Promise<ClaudeHookAssetClassification>;
61
+ /** Read `.claude/settings.json` and classify it (lstat → bounded read → legacy SHA → pure classifier). */
62
+ export declare function classifySettingsFile(settingsPath: string, currentAssetSha: string, identities: SettingsIdentityManifest): Promise<SettingsClassification>;
63
+ /** Node availability + `>=22` check from a version string (no spawn). */
64
+ export declare function detectNode(nodeVersion: string | undefined): {
65
+ available: boolean;
66
+ version?: string;
67
+ satisfiesMinimum: boolean;
68
+ };
69
+ /**
70
+ * Assemble the read-only component-level doctor report (no writes). `healthy` is
71
+ * exactly: both components `managed-current`, matcher and command shape exact,
72
+ * Node `>=22`. `assetSettingsConsistent` is a reported advisory, NOT part of it.
73
+ */
74
+ export declare function doctorClaudePreToolUse(projectDir: string, options?: {
75
+ manifest?: Manifest;
76
+ nodeVersion?: string;
77
+ }): Promise<ClaudeHookDoctorReport>;
78
+ export interface ClaudeHookMutationResult {
79
+ ok: boolean;
80
+ changed: string[];
81
+ backups: string[];
82
+ report: ClaudeHookDoctorReport;
83
+ errors: string[];
84
+ }
85
+ /** Injectable deps so tests drive `_run` with a fake `PlatformSecureFs`. */
86
+ export interface ClaudeHookRunDeps {
87
+ secureFs?: PlatformSecureFs | null;
88
+ clock?: () => Date;
89
+ nonce?: () => string;
90
+ manifest?: Manifest;
91
+ platform?: NodeJS.Platform;
92
+ }
93
+ /** Internal deps-taking entry; tests drive it with a fake `PlatformSecureFs`. */
94
+ export declare function _run(projectDir: string, mode: "install" | "repair", options: {
95
+ force?: boolean;
96
+ }, deps: ClaudeHookRunDeps): Promise<ClaudeHookMutationResult>;
97
+ export declare function installClaudePreToolUse(projectDir: string): Promise<ClaudeHookMutationResult>;
98
+ export declare function repairClaudePreToolUse(projectDir: string, options?: {
99
+ force?: boolean;
100
+ }): Promise<ClaudeHookMutationResult>;
101
+ export {};
102
+ //# sourceMappingURL=claude-hook-manager.d.ts.map