javi-forge 1.28.1 → 1.29.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-skillguard-pre-tool-use.mjs +828 -0
- package/assets/claude-hooks/manifest.json +1 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/lib/__fixtures__/claude-hook-ownership.d.ts +112 -0
- package/dist/lib/__fixtures__/claude-hook-ownership.js +96 -0
- package/dist/lib/claude-hook-manager.d.ts +88 -0
- package/dist/lib/claude-hook-manager.js +276 -0
- package/dist/lib/claude-hook-settings.d.ts +116 -0
- package/dist/lib/claude-hook-settings.js +283 -0
- package/package.json +1 -1
|
@@ -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}}
|
package/dist/constants.d.ts
CHANGED
|
@@ -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,88 @@
|
|
|
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
|
+
declare const COVERAGE: readonly ["Bash", "PowerShell", "Read", "Write", "Edit"];
|
|
12
|
+
export interface AssetManifestEntry {
|
|
13
|
+
name: string;
|
|
14
|
+
version: number;
|
|
15
|
+
sha256: string;
|
|
16
|
+
historical: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface Manifest {
|
|
19
|
+
asset: AssetManifestEntry;
|
|
20
|
+
settingsEntries: SettingsIdentityManifest;
|
|
21
|
+
}
|
|
22
|
+
export interface ClaudeHookAssetClassification {
|
|
23
|
+
state: ClaudeHookComponentState;
|
|
24
|
+
version?: number;
|
|
25
|
+
sha256?: string;
|
|
26
|
+
detail?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ClaudeHookDoctorReport {
|
|
29
|
+
healthy: boolean;
|
|
30
|
+
settings: {
|
|
31
|
+
state: ClaudeHookComponentState;
|
|
32
|
+
version?: number;
|
|
33
|
+
canonicalSha256?: string;
|
|
34
|
+
detail: string;
|
|
35
|
+
};
|
|
36
|
+
asset: {
|
|
37
|
+
state: ClaudeHookComponentState;
|
|
38
|
+
version?: number;
|
|
39
|
+
sha256?: string;
|
|
40
|
+
detail: string;
|
|
41
|
+
};
|
|
42
|
+
node: {
|
|
43
|
+
available: boolean;
|
|
44
|
+
version?: string;
|
|
45
|
+
satisfiesMinimum: boolean;
|
|
46
|
+
};
|
|
47
|
+
matcherExact: boolean;
|
|
48
|
+
commandShapeExact: boolean;
|
|
49
|
+
assetSettingsConsistent: boolean;
|
|
50
|
+
coverage: typeof COVERAGE;
|
|
51
|
+
hostResidual: string;
|
|
52
|
+
remediation: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Classify the asset into one of nine states from observed bytes only. Never
|
|
56
|
+
* trusts a claimed hash: the full-file SHA is always recomputed and compared to
|
|
57
|
+
* the manifest.
|
|
58
|
+
*/
|
|
59
|
+
export declare function classifyAssetState(assetPath: string, manifest: Manifest): Promise<ClaudeHookAssetClassification>;
|
|
60
|
+
/** Read `.claude/settings.json` and classify it (lstat → bounded read → legacy SHA → pure classifier). */
|
|
61
|
+
export declare function classifySettingsFile(settingsPath: string, currentAssetSha: string, identities: SettingsIdentityManifest): Promise<SettingsClassification>;
|
|
62
|
+
/** Node availability + `>=22` check from a version string (no spawn). */
|
|
63
|
+
export declare function detectNode(nodeVersion: string | undefined): {
|
|
64
|
+
available: boolean;
|
|
65
|
+
version?: string;
|
|
66
|
+
satisfiesMinimum: boolean;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Assemble the read-only component-level doctor report (no writes). `healthy` is
|
|
70
|
+
* exactly: both components `managed-current`, matcher and command shape exact,
|
|
71
|
+
* Node `>=22`. `assetSettingsConsistent` is a reported advisory, NOT part of it.
|
|
72
|
+
*/
|
|
73
|
+
export declare function doctorClaudePreToolUse(projectDir: string, options?: {
|
|
74
|
+
manifest?: Manifest;
|
|
75
|
+
nodeVersion?: string;
|
|
76
|
+
}): Promise<ClaudeHookDoctorReport>;
|
|
77
|
+
export interface ClaudeHookMutationResult {
|
|
78
|
+
ok: boolean;
|
|
79
|
+
changed: string[];
|
|
80
|
+
backups: string[];
|
|
81
|
+
errors: string[];
|
|
82
|
+
}
|
|
83
|
+
export declare function installClaudePreToolUse(_projectDir: string): Promise<ClaudeHookMutationResult>;
|
|
84
|
+
export declare function repairClaudePreToolUse(_projectDir: string, _options?: {
|
|
85
|
+
force?: boolean;
|
|
86
|
+
}): Promise<ClaudeHookMutationResult>;
|
|
87
|
+
export {};
|
|
88
|
+
//# sourceMappingURL=claude-hook-manager.d.ts.map
|
|
@@ -0,0 +1,276 @@
|
|
|
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 { createHash } from "node:crypto";
|
|
11
|
+
import { lstat } from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { CLAUDE_HOOK_ASSETS_DIR } from "../constants.js";
|
|
14
|
+
import { ASSET_MANAGED_MARKER, ASSET_NAME, } from "./__fixtures__/claude-hook-ownership.js";
|
|
15
|
+
import { classifySettingsEntry, isPlainObject, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, } from "./claude-hook-settings.js";
|
|
16
|
+
import { safeReadFile } from "./safe-read.js";
|
|
17
|
+
/** 1 MiB read budget, shared with the runtime's stdin envelope. */
|
|
18
|
+
const ASSET_MAX_BYTES = 1024 * 1024;
|
|
19
|
+
const NODE_MINIMUM_MAJOR = 22;
|
|
20
|
+
const READ_OPTS = {
|
|
21
|
+
maxBytes: ASSET_MAX_BYTES,
|
|
22
|
+
hardRejectOverBytes: ASSET_MAX_BYTES,
|
|
23
|
+
maxLineLength: Number.POSITIVE_INFINITY,
|
|
24
|
+
};
|
|
25
|
+
const COVERAGE = ["Bash", "PowerShell", "Read", "Write", "Edit"];
|
|
26
|
+
const HOST_RESIDUAL = "spawn/start/timeout failures continue through Claude permission flow";
|
|
27
|
+
const ASSET_SHA_TOKEN = /^[0-9a-f]{64}$/;
|
|
28
|
+
async function lstatNoFollow(target) {
|
|
29
|
+
try {
|
|
30
|
+
const stats = await lstat(target);
|
|
31
|
+
if (stats.isSymbolicLink())
|
|
32
|
+
return { kind: "symlink" };
|
|
33
|
+
if (!stats.isFile())
|
|
34
|
+
return { kind: "non-regular" };
|
|
35
|
+
return { kind: "file" };
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
const code = error.code;
|
|
39
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
40
|
+
return { kind: "enoent" };
|
|
41
|
+
return { kind: "error", detail: code ?? String(error) };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Hash the observed bytes as UTF-8, matching the manifest's raw-file hash for the managed asset. */
|
|
45
|
+
function sha256Of(content) {
|
|
46
|
+
return createHash("sha256")
|
|
47
|
+
.update(Buffer.from(content, "utf8"))
|
|
48
|
+
.digest("hex");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Classify the asset into one of nine states from observed bytes only. Never
|
|
52
|
+
* trusts a claimed hash: the full-file SHA is always recomputed and compared to
|
|
53
|
+
* the manifest.
|
|
54
|
+
*/
|
|
55
|
+
export async function classifyAssetState(assetPath, manifest) {
|
|
56
|
+
const stat = await lstatNoFollow(assetPath);
|
|
57
|
+
if (stat.kind === "enoent")
|
|
58
|
+
return { state: "absent" };
|
|
59
|
+
if (stat.kind === "symlink")
|
|
60
|
+
return { state: "symlink" };
|
|
61
|
+
if (stat.kind === "non-regular")
|
|
62
|
+
return { state: "non-regular" };
|
|
63
|
+
if (stat.kind === "error")
|
|
64
|
+
return { state: "non-regular", detail: stat.detail };
|
|
65
|
+
const read = await safeReadFile(assetPath, READ_OPTS);
|
|
66
|
+
if (!read.ok) {
|
|
67
|
+
if (read.reason === "not-found")
|
|
68
|
+
return { state: "absent" };
|
|
69
|
+
if (read.reason === "binary")
|
|
70
|
+
return { state: "foreign", detail: "binary" };
|
|
71
|
+
if (read.reason === "too-large") {
|
|
72
|
+
return { state: "foreign", detail: "exceeds asset budget" };
|
|
73
|
+
}
|
|
74
|
+
return { state: "non-regular", detail: read.reason };
|
|
75
|
+
}
|
|
76
|
+
if (!read.content.startsWith(`${ASSET_MANAGED_MARKER}\n`)) {
|
|
77
|
+
return { state: "foreign" };
|
|
78
|
+
}
|
|
79
|
+
const sha256 = sha256Of(read.content);
|
|
80
|
+
if (sha256 === manifest.asset.sha256) {
|
|
81
|
+
return {
|
|
82
|
+
state: "managed-current",
|
|
83
|
+
version: manifest.asset.version,
|
|
84
|
+
sha256,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (manifest.asset.historical.includes(sha256)) {
|
|
88
|
+
return {
|
|
89
|
+
state: "released-outdated",
|
|
90
|
+
version: manifest.asset.version,
|
|
91
|
+
sha256,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return { state: "edited-managed", sha256 };
|
|
95
|
+
}
|
|
96
|
+
/** Read `.claude/settings.json` and classify it (lstat → bounded read → legacy SHA → pure classifier). */
|
|
97
|
+
export async function classifySettingsFile(settingsPath, currentAssetSha, identities) {
|
|
98
|
+
const parsed = await readSettings(settingsPath);
|
|
99
|
+
if ("state" in parsed)
|
|
100
|
+
return parsed.classification;
|
|
101
|
+
return classifySettingsEntry(parsed.value, currentAssetSha, identities);
|
|
102
|
+
}
|
|
103
|
+
const done = (classification) => ({
|
|
104
|
+
state: true,
|
|
105
|
+
classification,
|
|
106
|
+
});
|
|
107
|
+
async function readSettings(settingsPath) {
|
|
108
|
+
const stat = await lstatNoFollow(settingsPath);
|
|
109
|
+
if (stat.kind === "enoent")
|
|
110
|
+
return done({ state: "absent" });
|
|
111
|
+
if (stat.kind === "symlink")
|
|
112
|
+
return done({ state: "symlink" });
|
|
113
|
+
if (stat.kind === "non-regular")
|
|
114
|
+
return done({ state: "non-regular" });
|
|
115
|
+
if (stat.kind === "error") {
|
|
116
|
+
return done({ state: "non-regular", detail: stat.detail });
|
|
117
|
+
}
|
|
118
|
+
const read = await safeReadFile(settingsPath, READ_OPTS);
|
|
119
|
+
if (!read.ok) {
|
|
120
|
+
if (read.reason === "not-found")
|
|
121
|
+
return done({ state: "absent" });
|
|
122
|
+
if (read.reason === "binary" || read.reason === "too-large") {
|
|
123
|
+
return done({ state: "malformed", detail: read.reason });
|
|
124
|
+
}
|
|
125
|
+
return done({ state: "non-regular", detail: read.reason });
|
|
126
|
+
}
|
|
127
|
+
if (sha256Of(read.content) === LEGACY_FILE_SHA256) {
|
|
128
|
+
return done({ state: "exact-legacy", detail: "whole-file" });
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
return { value: JSON.parse(read.content) };
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return done({ state: "malformed", detail: "invalid-json" });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Node availability + `>=22` check from a version string (no spawn). */
|
|
138
|
+
export function detectNode(nodeVersion) {
|
|
139
|
+
if (!nodeVersion)
|
|
140
|
+
return { available: false, satisfiesMinimum: false };
|
|
141
|
+
const major = Number.parseInt(nodeVersion.split(".")[0] ?? "", 10);
|
|
142
|
+
return {
|
|
143
|
+
available: true,
|
|
144
|
+
version: nodeVersion,
|
|
145
|
+
satisfiesMinimum: Number.isFinite(major) && major >= NODE_MINIMUM_MAJOR,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const NO_SIGNALS = {
|
|
149
|
+
matcherExact: false,
|
|
150
|
+
commandShapeExact: false,
|
|
151
|
+
assetSettingsConsistent: false,
|
|
152
|
+
};
|
|
153
|
+
/** Derive matcher/command/consistency signals from the marked handler, if any. */
|
|
154
|
+
function settingsSignals(value, classification, currentAssetSha) {
|
|
155
|
+
if (classification.groupIndex === undefined || !isPlainObject(value)) {
|
|
156
|
+
return NO_SIGNALS;
|
|
157
|
+
}
|
|
158
|
+
const hooks = value.hooks;
|
|
159
|
+
const groups = isPlainObject(hooks) && Array.isArray(hooks.PreToolUse)
|
|
160
|
+
? hooks.PreToolUse
|
|
161
|
+
: [];
|
|
162
|
+
const group = groups[classification.groupIndex];
|
|
163
|
+
if (!isPlainObject(group) || !Array.isArray(group.hooks))
|
|
164
|
+
return NO_SIGNALS;
|
|
165
|
+
const handler = group.hooks[classification.handlerIndex ?? -1];
|
|
166
|
+
if (!isPlainObject(handler))
|
|
167
|
+
return NO_SIGNALS;
|
|
168
|
+
const args = handler.args;
|
|
169
|
+
const commandShapeExact = handler.type === "command" &&
|
|
170
|
+
handler.command === "node" &&
|
|
171
|
+
Array.isArray(args) &&
|
|
172
|
+
args.length === 1 &&
|
|
173
|
+
args[0] === MANAGED_ASSET_ARG &&
|
|
174
|
+
handler.timeout === 30;
|
|
175
|
+
let assetSettingsConsistent = false;
|
|
176
|
+
if (typeof handler.statusMessage === "string" &&
|
|
177
|
+
handler.statusMessage.startsWith(MANAGED_STATUS_PREFIX)) {
|
|
178
|
+
const token = handler.statusMessage.slice(MANAGED_STATUS_PREFIX.length);
|
|
179
|
+
assetSettingsConsistent =
|
|
180
|
+
ASSET_SHA_TOKEN.test(token) && token === currentAssetSha;
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
matcherExact: group.matcher === MANAGED_MATCHER,
|
|
184
|
+
commandShapeExact,
|
|
185
|
+
assetSettingsConsistent,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const REMEDIATION = {
|
|
189
|
+
absent: "install the managed $ (Slice 3)",
|
|
190
|
+
"released-outdated": "upgrade the managed $ (Slice 3)",
|
|
191
|
+
"exact-legacy": "migrate the legacy $ (Slice 3)",
|
|
192
|
+
"edited-managed": "repair the managed $ with --force (Slice 3)",
|
|
193
|
+
foreign: "manually review the $",
|
|
194
|
+
symlink: "manually review the $",
|
|
195
|
+
"non-regular": "manually review the $",
|
|
196
|
+
malformed: "manually review the $",
|
|
197
|
+
};
|
|
198
|
+
function remediationFor(state, component) {
|
|
199
|
+
return REMEDIATION[state]?.replace("$", component);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Assemble the read-only component-level doctor report (no writes). `healthy` is
|
|
203
|
+
* exactly: both components `managed-current`, matcher and command shape exact,
|
|
204
|
+
* Node `>=22`. `assetSettingsConsistent` is a reported advisory, NOT part of it.
|
|
205
|
+
*/
|
|
206
|
+
export async function doctorClaudePreToolUse(projectDir, options) {
|
|
207
|
+
const manifest = options?.manifest ?? (await readManifest());
|
|
208
|
+
const currentAssetSha = manifest.asset.sha256;
|
|
209
|
+
const asset = await classifyAssetState(path.join(projectDir, ".claude", "hooks", ASSET_NAME), manifest);
|
|
210
|
+
const settingsRead = await readSettings(path.join(projectDir, ".claude", "settings.json"));
|
|
211
|
+
const settings = "state" in settingsRead
|
|
212
|
+
? settingsRead.classification
|
|
213
|
+
: classifySettingsEntry(settingsRead.value, currentAssetSha, manifest.settingsEntries);
|
|
214
|
+
const signals = "value" in settingsRead
|
|
215
|
+
? settingsSignals(settingsRead.value, settings, currentAssetSha)
|
|
216
|
+
: NO_SIGNALS;
|
|
217
|
+
const node = detectNode(options?.nodeVersion ?? process.versions.node);
|
|
218
|
+
const remediation = new Set();
|
|
219
|
+
if (asset.state !== "managed-current") {
|
|
220
|
+
const line = remediationFor(asset.state, "asset");
|
|
221
|
+
if (line)
|
|
222
|
+
remediation.add(line);
|
|
223
|
+
}
|
|
224
|
+
if (settings.state !== "managed-current") {
|
|
225
|
+
const line = remediationFor(settings.state, "settings");
|
|
226
|
+
if (line)
|
|
227
|
+
remediation.add(line);
|
|
228
|
+
}
|
|
229
|
+
if (!node.satisfiesMinimum)
|
|
230
|
+
remediation.add("install Node 22 or newer");
|
|
231
|
+
if (!signals.matcherExact)
|
|
232
|
+
remediation.add("restore the exact managed matcher");
|
|
233
|
+
if (!signals.commandShapeExact) {
|
|
234
|
+
remediation.add("restore the exact managed command shape");
|
|
235
|
+
}
|
|
236
|
+
const healthy = settings.state === "managed-current" &&
|
|
237
|
+
asset.state === "managed-current" &&
|
|
238
|
+
signals.matcherExact &&
|
|
239
|
+
signals.commandShapeExact &&
|
|
240
|
+
node.satisfiesMinimum;
|
|
241
|
+
return {
|
|
242
|
+
healthy,
|
|
243
|
+
settings: {
|
|
244
|
+
state: settings.state,
|
|
245
|
+
version: settings.version,
|
|
246
|
+
canonicalSha256: settings.canonicalSha256,
|
|
247
|
+
detail: settings.detail ?? settings.state,
|
|
248
|
+
},
|
|
249
|
+
asset: {
|
|
250
|
+
state: asset.state,
|
|
251
|
+
version: asset.version,
|
|
252
|
+
sha256: asset.sha256,
|
|
253
|
+
detail: asset.detail ?? asset.state,
|
|
254
|
+
},
|
|
255
|
+
node,
|
|
256
|
+
matcherExact: signals.matcherExact,
|
|
257
|
+
commandShapeExact: signals.commandShapeExact,
|
|
258
|
+
assetSettingsConsistent: signals.assetSettingsConsistent,
|
|
259
|
+
coverage: COVERAGE,
|
|
260
|
+
hostResidual: HOST_RESIDUAL,
|
|
261
|
+
remediation: [...remediation].sort(),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
async function readManifest() {
|
|
265
|
+
const read = await safeReadFile(path.join(CLAUDE_HOOK_ASSETS_DIR, "manifest.json"), READ_OPTS);
|
|
266
|
+
if (!read.ok)
|
|
267
|
+
throw new Error(`unreadable claude-hooks manifest: ${read.reason}`);
|
|
268
|
+
return JSON.parse(read.content);
|
|
269
|
+
}
|
|
270
|
+
export function installClaudePreToolUse(_projectDir) {
|
|
271
|
+
throw new Error("unimplemented: Slice 3 transaction");
|
|
272
|
+
}
|
|
273
|
+
export function repairClaudePreToolUse(_projectDir, _options) {
|
|
274
|
+
throw new Error("unimplemented: Slice 3 transaction");
|
|
275
|
+
}
|
|
276
|
+
//# sourceMappingURL=claude-hook-manager.js.map
|