javi-forge 1.41.0 → 1.42.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 +31 -8
- package/assets/claude-hooks/manifest.json +3 -2
- package/dist/cli/dispatch/hooks.d.ts +1 -1
- package/dist/cli/dispatch/hooks.js +3 -2
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +10 -2
- package/dist/commands/cursor-hooks.d.ts +15 -0
- package/dist/commands/cursor-hooks.js +44 -0
- package/dist/lib/agent-adapter.d.ts +4 -3
- package/dist/lib/agent-adapter.js +33 -1
- package/dist/lib/cursor-hook-manager.d.ts +75 -0
- package/dist/lib/cursor-hook-manager.js +379 -0
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@ export const MANAGED_MARKER = "// javi-forge-managed: claude-pretooluse v1";
|
|
|
9
9
|
export const INPUT_LIMIT_BYTES = 1_048_576;
|
|
10
10
|
export const SUPPORTED_TOOLS = Object.freeze(["Bash", "PowerShell", "Read", "Write", "Edit"]);
|
|
11
11
|
export function mapGrokToolName(toolName, platform = process.platform) { return toolName === "run_terminal_command" ? (platform === "win32" ? "PowerShell" : "Bash") : toolName === "read_file" ? "Read" : toolName === "search_replace" ? "Edit" : toolName; }
|
|
12
|
+
export function mapCursorToolName(toolName, platform = process.platform) { return toolName === "Shell" ? (platform === "win32" ? "PowerShell" : "Bash") : toolName === "Delete" ? "Edit" : toolName; }
|
|
12
13
|
export const POLICY_REGISTRY = Object.freeze({ schemaVersion: 1, policyVersion: 2, diagnosticsMaxBytes: 240 });
|
|
13
14
|
export function resolvePlatformSupport(platform = process.platform) { return platform === "linux" || platform === "win32" ? { supported: true } : { supported: false, reason: "unsupported-platform" }; }
|
|
14
15
|
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
@@ -26,9 +27,11 @@ const CLAUDE_MANAGED_SET = Object.freeze({ exact: Object.freeze([".claude/settin
|
|
|
26
27
|
const CODEX_MANAGED_SET = Object.freeze({ exact: Object.freeze([".codex/hooks.json", ".claude/settings.json", ".claude/settings.local.json", ".claude/CLAUDE.md", "CLAUDE.md", ".javi-forge/ci.yaml"]), prefixes: Object.freeze([".claude/hooks/", ".claude/agents/", ".claude/skills/"]), caseFoldExact: Object.freeze(["claude.md", ".claude/claude.md"]) });
|
|
27
28
|
const OPENCODE_MANAGED_SET = Object.freeze({ exact: Object.freeze(["opencode.json", "opencode.jsonc", ".opencode/opencode.json", ".opencode/opencode.jsonc", ".opencode/AGENTS.md", "AGENTS.md", ".javi-forge/ci.yaml"]), prefixes: Object.freeze([".opencode/plugins/", ".opencode/agents/", ".opencode/skills/", ".agents/skills/", ".claude/hooks/", ".claude/agents/", ".claude/skills/"]), caseFoldExact: Object.freeze(["agents.md", ".opencode/agents.md", "claude.md", ".claude/claude.md"]) });
|
|
28
29
|
const GROK_MANAGED_SET = Object.freeze({ exact: Object.freeze([".grok/config.toml", "AGENTS.md", ".claude/settings.json", ".claude/settings.local.json", ".claude/CLAUDE.md", "CLAUDE.md", ".javi-forge/ci.yaml"]), prefixes: Object.freeze([".grok/hooks/", ".claude/hooks/", ".claude/agents/", ".claude/skills/"]), caseFoldExact: Object.freeze(["agents.md", "claude.md", ".claude/claude.md"]) });
|
|
30
|
+
const CURSOR_MANAGED_SET = Object.freeze({ exact: Object.freeze([".cursor/hooks.json", ".cursor/rules/", "AGENTS.md", ".claude/settings.json", ".claude/settings.local.json", ".claude/CLAUDE.md", "CLAUDE.md", ".javi-forge/ci.yaml"]), prefixes: Object.freeze([".cursor/hooks/", ".cursor/rules/", ".claude/hooks/", ".claude/agents/", ".claude/skills/"]), caseFoldExact: Object.freeze(["agents.md", "claude.md", ".claude/claude.md"]) });
|
|
29
31
|
const CODEX_GLOBAL_MANAGED_PATHS = Object.freeze([".codex/hooks.json", ".codex/config.toml"]);
|
|
30
32
|
const GROK_GLOBAL_MANAGED_PATHS = Object.freeze([path.join(GROK_HOME_ROOT, "hooks", "javi-forge-skillguard-pre-tool-use.json"), path.join(GROK_HOME_ROOT, "hooks", "javi-forge-skillguard-pre-tool-use.mjs")]);
|
|
31
|
-
|
|
33
|
+
const CURSOR_GLOBAL_MANAGED_PATHS = Object.freeze([path.join(HOST_HOME, ".cursor", "hooks.json"), path.join(HOST_HOME, ".cursor", "hooks", "javi-forge-skillguard-pre-tool-use.mjs")]);
|
|
34
|
+
export const AGENT_CONFIGS = Object.freeze({ claude: Object.freeze({ id: "claude", managedSet: CLAUDE_MANAGED_SET, globalManagedPaths: Object.freeze([]), projectDir: Object.freeze({ envVar: "CLAUDE_PROJECT_DIR", fallback: "asset-root" }), marker: MANAGED_MARKER }), codex: Object.freeze({ id: "codex", managedSet: CODEX_MANAGED_SET, globalManagedPaths: CODEX_GLOBAL_MANAGED_PATHS, projectDir: Object.freeze({ envVar: null, fallback: "cwd" }), marker: "// javi-forge-managed: codex-pretooluse v1" }), opencode: Object.freeze({ id: "opencode", managedSet: OPENCODE_MANAGED_SET, globalManagedPaths: Object.freeze([]), projectDir: Object.freeze({ envVar: null, fallback: "cwd" }), marker: "// javi-forge-managed: opencode-skillguard v1" }), grok: Object.freeze({ id: "grok", managedSet: GROK_MANAGED_SET, globalManagedPaths: GROK_GLOBAL_MANAGED_PATHS, projectDir: Object.freeze({ envVar: null, fallback: "cwd" }), marker: "javi-forge-managed: grok-pretooluse v1" }), cursor: Object.freeze({ id: "cursor", managedSet: CURSOR_MANAGED_SET, globalManagedPaths: CURSOR_GLOBAL_MANAGED_PATHS, projectDir: Object.freeze({ envVar: "CURSOR_PROJECT_DIR", fallback: "cwd" }), marker: "// javi-forge-managed: cursor-pretooluse v1" }) });
|
|
32
35
|
// Fail-closed agent selector: a missing/unknown --agent means we cannot know what to protect, so refuse.
|
|
33
36
|
function resolveAgentConfig(argv) { const arg = argv.find((value) => typeof value === "string" && value.startsWith("--agent=")); const id = arg === undefined ? undefined : arg.slice("--agent=".length); const config = id === undefined ? undefined : AGENT_CONFIGS[id]; if (!config) fail("invalid-config"); return config; }
|
|
34
37
|
// Project root per agent: the env var when set (Claude = CLAUDE_PROJECT_DIR); otherwise the per-agent
|
|
@@ -956,15 +959,18 @@ export function parseApplyPatchPaths(command) {
|
|
|
956
959
|
return paths;
|
|
957
960
|
}
|
|
958
961
|
export function evaluateEvent(input, config = AGENT_CONFIGS.claude) {
|
|
959
|
-
if (!isObject(input)
|
|
962
|
+
if (!isObject(input)) fail("invalid-event");
|
|
963
|
+
const eventName = input.hook_event_name;
|
|
964
|
+
if (eventName !== "PreToolUse" && !(config.id === "cursor" && eventName === "preToolUse")) fail("invalid-event");
|
|
960
965
|
const rawToolName = typeof input.tool_name === "string" ? input.tool_name : input.toolName;
|
|
961
|
-
const toolName = config.id === "grok" && typeof rawToolName === "string" ? mapGrokToolName(rawToolName) : rawToolName;
|
|
966
|
+
const toolName = config.id === "grok" && typeof rawToolName === "string" ? mapGrokToolName(rawToolName) : config.id === "cursor" && typeof rawToolName === "string" ? mapCursorToolName(rawToolName) : rawToolName;
|
|
962
967
|
const toolInput = isObject(input.tool_input) ? input.tool_input : input.toolInput;
|
|
963
968
|
if (typeof toolName !== "string" || !isObject(toolInput)) fail("invalid-event");
|
|
964
969
|
if (input.toolInputTruncated === true || input.tool_input_truncated === true) return { allowed: false, ruleId: "tool-input-truncated" };
|
|
965
970
|
const applyPatch = toolName === "apply_patch";
|
|
966
971
|
if (!applyPatch && !SUPPORTED_TOOLS.includes(toolName)) fail("invalid-event");
|
|
967
|
-
const
|
|
972
|
+
const toolWorkingDirectory = isObject(toolInput) && typeof toolInput.working_directory === "string" ? toolInput.working_directory : undefined;
|
|
973
|
+
const cwd = typeof input.cwd === "string" && isAbsolutePolicyPath(input.cwd) ? input.cwd : typeof toolWorkingDirectory === "string" && isAbsolutePolicyPath(toolWorkingDirectory) ? toolWorkingDirectory : PROJECT_ROOT;
|
|
968
974
|
const projectRoot = resolveProjectRoot(config, cwd);
|
|
969
975
|
if (applyPatch) {
|
|
970
976
|
if (typeof toolInput.command !== "string") fail("invalid-event");
|
|
@@ -983,7 +989,7 @@ export function evaluateEvent(input, config = AGENT_CONFIGS.claude) {
|
|
|
983
989
|
if (typeof toolInput.command !== "string") fail("invalid-event");
|
|
984
990
|
return toolName === "Bash" ? evaluateBash(toolInput.command, cwd, config, projectRoot) : evaluatePowerShell(toolInput.command, cwd, config, projectRoot);
|
|
985
991
|
}
|
|
986
|
-
const filePath = typeof toolInput.file_path === "string" ? toolInput.file_path : typeof toolInput.filePath === "string" ? toolInput.filePath : config.id === "grok" && typeof toolInput.path === "string" ? lexicalizePolicyPath(toolInput.path, { base: cwd, projectRoot }) : undefined;
|
|
992
|
+
const filePath = typeof toolInput.file_path === "string" ? toolInput.file_path : typeof toolInput.filePath === "string" ? toolInput.filePath : (config.id === "grok" || config.id === "cursor") && typeof toolInput.path === "string" ? lexicalizePolicyPath(toolInput.path, { base: cwd, projectRoot }) : undefined;
|
|
987
993
|
if (typeof filePath !== "string" || !isAbsolutePolicyPath(filePath)) fail("invalid-event");
|
|
988
994
|
return evaluateFile(toolName, filePath, config, projectRoot);
|
|
989
995
|
}
|
|
@@ -1032,6 +1038,20 @@ function denyAndExit(message) {
|
|
|
1032
1038
|
process.exit(2);
|
|
1033
1039
|
}
|
|
1034
1040
|
}
|
|
1041
|
+
function cursorDecisionAndExit(permission, message) {
|
|
1042
|
+
try {
|
|
1043
|
+
const body = permission === "allow" ? { permission: "allow" } : { permission: "deny", user_message: message, agent_message: message };
|
|
1044
|
+
fs.writeSync(1, `${JSON.stringify(body)}\n`);
|
|
1045
|
+
} finally {
|
|
1046
|
+
process.stdin.destroy();
|
|
1047
|
+
process.stdin.unref?.();
|
|
1048
|
+
process.exit(permission === "allow" ? 0 : 2);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
function failClosed(message, config) {
|
|
1052
|
+
if (config?.id === "cursor") cursorDecisionAndExit("deny", message);
|
|
1053
|
+
denyAndExit(message);
|
|
1054
|
+
}
|
|
1035
1055
|
export function readBoundedStdin(stream = process.stdin) {
|
|
1036
1056
|
return new Promise((resolve, reject) => {
|
|
1037
1057
|
const chunks = [];
|
|
@@ -1083,11 +1103,14 @@ export async function main() {
|
|
|
1083
1103
|
if (fault === "evaluator-throw") throw new Error("internal-error");
|
|
1084
1104
|
const decision = evaluateEvent(parsed, config);
|
|
1085
1105
|
const rawToolName = typeof parsed.tool_name === "string" ? parsed.tool_name : parsed.toolName;
|
|
1086
|
-
const toolName = config.id === "grok" && typeof rawToolName === "string" ? mapGrokToolName(rawToolName) : rawToolName;
|
|
1087
|
-
if (!decision.allowed)
|
|
1106
|
+
const toolName = config.id === "grok" && typeof rawToolName === "string" ? mapGrokToolName(rawToolName) : config.id === "cursor" && typeof rawToolName === "string" ? mapCursorToolName(rawToolName) : rawToolName;
|
|
1107
|
+
if (!decision.allowed) failClosed(denialDiagnostic(toolName, decision), config);
|
|
1108
|
+
if (config.id === "cursor") cursorDecisionAndExit("allow", "");
|
|
1088
1109
|
process.exitCode = 0;
|
|
1089
1110
|
} catch (error) {
|
|
1090
|
-
|
|
1111
|
+
let config;
|
|
1112
|
+
try { config = resolveAgentConfig(process.argv); } catch {}
|
|
1113
|
+
failClosed(diagnostic(error), config);
|
|
1091
1114
|
}
|
|
1092
1115
|
}
|
|
1093
1116
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"name": "javi-forge-skillguard-pre-tool-use.mjs",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"policyVersion": 2,
|
|
7
|
-
"sha256": "
|
|
7
|
+
"sha256": "9c175bb1daed3055420cc6b1d7b4f90a7779da3525b23315e2a66767b8de96dc",
|
|
8
8
|
"historical": [
|
|
9
9
|
"78be7e6613c012280b7ad17886462ba166b63ebd031e34565d757b3a0796d7cc",
|
|
10
10
|
"5dc2a5c31131f4ac7d8657c78b950de52776aad6eaefe78ea0d764a9963c4425",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"f55ece1aea76251ec005f2b731fa44a58b6245fef8150b58486ae062167a2377",
|
|
19
19
|
"6fe1ef55363e6e3f38253ca94b17ec50c942352c00d8431cd2ff2f6858c094b6",
|
|
20
20
|
"b79ee8dc3df525810b07215927590366ed2669255666fdc817e9637cf0e10cf2",
|
|
21
|
-
"a352b5b6c29fcdeecf2a014ab0e271cab9373119460134d65134f16a19222e47"
|
|
21
|
+
"a352b5b6c29fcdeecf2a014ab0e271cab9373119460134d65134f16a19222e47",
|
|
22
|
+
"044f6816d18a2894532fde52a145f10347f617cbb361fc10075d04f885327d22"
|
|
22
23
|
]
|
|
23
24
|
},
|
|
24
25
|
"settingsEntries": {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Subcommands:
|
|
8
8
|
* - `hooks run <pre-commit|pre-push>` → runHook, exits with its code.
|
|
9
|
-
* - `hooks <install|doctor|repair> <claude|codex|opencode> [--force]` → the matching
|
|
9
|
+
* - `hooks <install|doctor|repair> <claude|codex|opencode|grok|cursor> [--force]` → the matching
|
|
10
10
|
* agent command, exits with its code (unknown/missing agent → usage + exit 1).
|
|
11
11
|
* Any other subcommand or a missing name → usage + exit 1.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Subcommands:
|
|
8
8
|
* - `hooks run <pre-commit|pre-push>` → runHook, exits with its code.
|
|
9
|
-
* - `hooks <install|doctor|repair> <claude|codex|opencode> [--force]` → the matching
|
|
9
|
+
* - `hooks <install|doctor|repair> <claude|codex|opencode|grok|cursor> [--force]` → the matching
|
|
10
10
|
* agent command, exits with its code (unknown/missing agent → usage + exit 1).
|
|
11
11
|
* Any other subcommand or a missing name → usage + exit 1.
|
|
12
12
|
*
|
|
@@ -25,6 +25,7 @@ const AGENT_COMMAND_LOADERS = {
|
|
|
25
25
|
codex: async () => (await import("../../commands/codex-hooks.js")).runCodexHookCommand,
|
|
26
26
|
opencode: async () => (await import("../../commands/opencode-hooks.js")).runOpenCodeHookCommand,
|
|
27
27
|
grok: async () => (await import("../../commands/grok-hooks.js")).runGrokHookCommand,
|
|
28
|
+
cursor: async () => (await import("../../commands/cursor-hooks.js")).runCursorHookCommand,
|
|
28
29
|
};
|
|
29
30
|
export async function handleHooks(cli) {
|
|
30
31
|
if (cli.flags.help === true) {
|
|
@@ -47,7 +48,7 @@ export async function handleHooks(cli) {
|
|
|
47
48
|
// Validity is decided by the agent registry (isAgentId), the single source
|
|
48
49
|
// of truth; the loader map is exhaustive over AgentId by type.
|
|
49
50
|
if (!isAgentId(agent)) {
|
|
50
|
-
console.error(`Usage: javi-forge hooks ${sub} <claude|codex|opencode|grok>`);
|
|
51
|
+
console.error(`Usage: javi-forge hooks ${sub} <claude|codex|opencode|grok|cursor>`);
|
|
51
52
|
process.exit(1);
|
|
52
53
|
}
|
|
53
54
|
const run = await AGENT_COMMAND_LOADERS[agent]();
|
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 $ javi-forge hooks <install|doctor|repair> <claude|codex|opencode|grok> [--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 install codex Install the managed Codex PreToolUse guard (~/.codex/)\n doctor codex Report Codex hook execution readiness and trust boundary\n repair codex Repair the managed Codex guard; --force overwrites edits\n install opencode Install the managed OpenCode global plugin (~/.config/opencode/plugins/)\n doctor opencode Report OpenCode plugin and policy file currency (informational)\n repair opencode Repair the OpenCode plugin pair; --force overwrites edits\n install grok Install the Grok Build global PreToolUse hook (~/.grok/hooks/)\n doctor grok Report Grok hook registration and policy file currency (informational)\n repair grok Repair the Grok hook pair; --force overwrites edits\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 doctor codex exits 0 when runnable, 1 when blocked, and 2 when inconclusive;\n it does not prove provider trust or runtime execution. Use repair codex\n --force only to overwrite an edited managed asset.\n doctor opencode is informational and inspects installed files only; it does\n not prove OpenCode discovered, loaded, or executed the plugin.\n doctor grok is informational and inspects the global hook registration and\n adjacent policy runtime only; it does not prove Grok loaded or executed them.\n Linux: install/repair claude and
|
|
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|codex|opencode|grok|cursor> [--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 install codex Install the managed Codex PreToolUse guard (~/.codex/)\n doctor codex Report Codex hook execution readiness and trust boundary\n repair codex Repair the managed Codex guard; --force overwrites edits\n install opencode Install the managed OpenCode global plugin (~/.config/opencode/plugins/)\n doctor opencode Report OpenCode plugin and policy file currency (informational)\n repair opencode Repair the OpenCode plugin pair; --force overwrites edits\n install grok Install the Grok Build global PreToolUse hook (~/.grok/hooks/)\n doctor grok Report Grok hook registration and policy file currency (informational)\n repair grok Repair the Grok hook pair; --force overwrites edits\n install cursor Install the Cursor global preToolUse hook (~/.cursor/)\n doctor cursor Report Cursor hook registration and policy file currency (informational)\n repair cursor Repair the Cursor hook pair; --force overwrites edits\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 doctor codex exits 0 when runnable, 1 when blocked, and 2 when inconclusive;\n it does not prove provider trust or runtime execution. Use repair codex\n --force only to overwrite an edited managed asset.\n doctor opencode is informational and inspects installed files only; it does\n not prove OpenCode discovered, loaded, or executed the plugin.\n doctor grok is informational and inspects the global hook registration and\n adjacent policy runtime only; it does not prove Grok loaded or executed them.\n doctor cursor is informational and inspects the global hook registration and\n adjacent policy runtime only; it does not prove Cursor loaded or executed them.\n Linux: install/repair claude, grok, and cursor need the acl package (getfacl) to\n prove the parent chain \u2014 apt install acl / apk add acl / dnf install acl.\n Without it\n they refuse fail-closed; an already-installed guard keeps firing, and\n doctor claude reports the acl capability as its own row.\n Claude Code spawns the guard with node from ITS path, so node must resolve\n there, not only inside javi-forge.\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 $ javi-forge hooks install codex\n $ javi-forge hooks doctor codex\n $ javi-forge hooks repair codex --force\n $ javi-forge hooks install opencode\n $ javi-forge hooks doctor opencode\n $ javi-forge hooks repair opencode --force\n $ javi-forge hooks install grok\n $ javi-forge hooks doctor grok\n $ javi-forge hooks repair grok --force\n $ javi-forge hooks install cursor\n $ javi-forge hooks doctor cursor\n $ javi-forge hooks repair cursor --force\n";
|
|
23
23
|
export declare const FLAGS_SCHEMA: {
|
|
24
24
|
readonly help: {
|
|
25
25
|
readonly type: "boolean";
|
package/dist/cli/help.js
CHANGED
|
@@ -167,7 +167,7 @@ export const CI_HELP_TEXT = `
|
|
|
167
167
|
export const HOOKS_HELP_TEXT = `
|
|
168
168
|
Usage
|
|
169
169
|
$ javi-forge hooks run <pre-commit|pre-push>
|
|
170
|
-
$ javi-forge hooks <install|doctor|repair> <claude|codex|opencode|grok> [--force]
|
|
170
|
+
$ javi-forge hooks <install|doctor|repair> <claude|codex|opencode|grok|cursor> [--force]
|
|
171
171
|
|
|
172
172
|
Run the sections enabled under hooks: in .javi-forge/ci.yaml, in a fixed
|
|
173
173
|
cheap→expensive order, fail-fast. With no hooks: config the default is the
|
|
@@ -188,6 +188,9 @@ export const HOOKS_HELP_TEXT = `
|
|
|
188
188
|
install grok Install the Grok Build global PreToolUse hook (~/.grok/hooks/)
|
|
189
189
|
doctor grok Report Grok hook registration and policy file currency (informational)
|
|
190
190
|
repair grok Repair the Grok hook pair; --force overwrites edits
|
|
191
|
+
install cursor Install the Cursor global preToolUse hook (~/.cursor/)
|
|
192
|
+
doctor cursor Report Cursor hook registration and policy file currency (informational)
|
|
193
|
+
repair cursor Repair the Cursor hook pair; --force overwrites edits
|
|
191
194
|
|
|
192
195
|
Notes
|
|
193
196
|
A blocking section failure exits non-zero and blocks the commit/push.
|
|
@@ -203,7 +206,9 @@ export const HOOKS_HELP_TEXT = `
|
|
|
203
206
|
not prove OpenCode discovered, loaded, or executed the plugin.
|
|
204
207
|
doctor grok is informational and inspects the global hook registration and
|
|
205
208
|
adjacent policy runtime only; it does not prove Grok loaded or executed them.
|
|
206
|
-
|
|
209
|
+
doctor cursor is informational and inspects the global hook registration and
|
|
210
|
+
adjacent policy runtime only; it does not prove Cursor loaded or executed them.
|
|
211
|
+
Linux: install/repair claude, grok, and cursor need the acl package (getfacl) to
|
|
207
212
|
prove the parent chain — apt install acl / apk add acl / dnf install acl.
|
|
208
213
|
Without it
|
|
209
214
|
they refuse fail-closed; an already-installed guard keeps firing, and
|
|
@@ -226,6 +231,9 @@ export const HOOKS_HELP_TEXT = `
|
|
|
226
231
|
$ javi-forge hooks install grok
|
|
227
232
|
$ javi-forge hooks doctor grok
|
|
228
233
|
$ javi-forge hooks repair grok --force
|
|
234
|
+
$ javi-forge hooks install cursor
|
|
235
|
+
$ javi-forge hooks doctor cursor
|
|
236
|
+
$ javi-forge hooks repair cursor --force
|
|
229
237
|
`;
|
|
230
238
|
export const FLAGS_SCHEMA = {
|
|
231
239
|
// `--help` is handled manually (autoHelp is disabled at the entrypoint so
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Console renderer for the Cursor global SkillGuard hook manager. */
|
|
2
|
+
import { doctorCursorSkillGuard, installCursorSkillGuard, repairCursorSkillGuard } from "../lib/cursor-hook-manager.js";
|
|
3
|
+
export type CursorHookSub = "install" | "doctor" | "repair";
|
|
4
|
+
export interface CursorHookCmdDeps {
|
|
5
|
+
install?: typeof installCursorSkillGuard;
|
|
6
|
+
doctor?: typeof doctorCursorSkillGuard;
|
|
7
|
+
repair?: typeof repairCursorSkillGuard;
|
|
8
|
+
homeDir?: string;
|
|
9
|
+
log?: (msg: string) => void;
|
|
10
|
+
logError?: (msg: string) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function runCursorHookCommand(sub: CursorHookSub, _cwd: string, opts: {
|
|
13
|
+
force?: boolean;
|
|
14
|
+
}, deps?: CursorHookCmdDeps): Promise<number>;
|
|
15
|
+
//# sourceMappingURL=cursor-hooks.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Console renderer for the Cursor global SkillGuard hook manager. */
|
|
2
|
+
import { doctorCursorSkillGuard, installCursorSkillGuard, repairCursorSkillGuard, } from "../lib/cursor-hook-manager.js";
|
|
3
|
+
function renderMutation(verb, result, log, logError) {
|
|
4
|
+
if (!result.ok) {
|
|
5
|
+
logError(`${verb} cursor: refused`);
|
|
6
|
+
for (const error of result.errors)
|
|
7
|
+
logError(` ${error}`);
|
|
8
|
+
for (const backup of result.backups)
|
|
9
|
+
logError(` backup: ${backup}`);
|
|
10
|
+
return 1;
|
|
11
|
+
}
|
|
12
|
+
log(`${verb} cursor: ok`);
|
|
13
|
+
if (result.changed.length === 0)
|
|
14
|
+
log("changed: nothing (already up to date)");
|
|
15
|
+
else
|
|
16
|
+
for (const changed of result.changed)
|
|
17
|
+
log(`changed: ${changed}`);
|
|
18
|
+
for (const backup of result.backups)
|
|
19
|
+
log(`backup: ${backup}`);
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
function renderDoctor(report, log) {
|
|
23
|
+
log(`doctor cursor: ${report.healthy ? "healthy" : "unhealthy"}`);
|
|
24
|
+
log(` hooks.json: ${report.hooksJson.state}`);
|
|
25
|
+
log(` policy: ${report.policy.state}`);
|
|
26
|
+
log(" runtime: not verified (installed files only)");
|
|
27
|
+
for (const remediation of report.remediation)
|
|
28
|
+
log(` remediation: ${remediation}`);
|
|
29
|
+
// Informational: installed files cannot prove Cursor discovered or invoked them.
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
export async function runCursorHookCommand(sub, _cwd, opts, deps = {}) {
|
|
33
|
+
const log = deps.log ?? ((message) => console.log(message));
|
|
34
|
+
const logError = deps.logError ?? ((message) => console.error(message));
|
|
35
|
+
const home = deps.homeDir;
|
|
36
|
+
if (sub === "install")
|
|
37
|
+
return renderMutation("install", await (deps.install ?? installCursorSkillGuard)(home), log, logError);
|
|
38
|
+
if (sub === "repair")
|
|
39
|
+
return renderMutation("repair", await (deps.repair ?? repairCursorSkillGuard)(home, {
|
|
40
|
+
force: opts.force === true,
|
|
41
|
+
}), log, logError);
|
|
42
|
+
return renderDoctor(await (deps.doctor ?? doctorCursorSkillGuard)(home), log);
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=cursor-hooks.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent adapter registry (agent-agnostic slice 2). One descriptor per host
|
|
3
|
-
* (`claude`, `codex`, `opencode`) capturing the per-agent facts the SkillGuard installer and
|
|
3
|
+
* (`claude`, `codex`, `opencode`, `grok`, `cursor`) capturing the per-agent facts the SkillGuard installer and
|
|
4
4
|
* doctor need: config paths, the protected managed set, the project-root source,
|
|
5
5
|
* the host registration schema, the managed marker, the deny protocol, and the
|
|
6
6
|
* trust model.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* map is typed `Record<AgentId, …>`, so omitting a loader is a compile error.
|
|
15
15
|
*/
|
|
16
16
|
import { validateSettingsShape } from "./claude-hook-settings.js";
|
|
17
|
-
export type AgentId = "claude" | "codex" | "opencode" | "grok";
|
|
17
|
+
export type AgentId = "claude" | "codex" | "opencode" | "grok" | "cursor";
|
|
18
18
|
export type TrustState = "trusted" | "untrusted" | "unknown";
|
|
19
19
|
export interface TrustDescriptor {
|
|
20
20
|
/** Detect whether the installed hook is TRUSTED from the host config text. */
|
|
@@ -40,7 +40,7 @@ export interface AgentAdapter {
|
|
|
40
40
|
/** The managed asset marker for this host. */
|
|
41
41
|
marker: string;
|
|
42
42
|
/** Deny protocol emitted by the host integration. */
|
|
43
|
-
emitDeny: "exit2+stderr" | "throw-error";
|
|
43
|
+
emitDeny: "exit2+stderr" | "throw-error" | "json-stdout";
|
|
44
44
|
/** Hook-trust model, or null when the host has none (Claude). */
|
|
45
45
|
trust: TrustDescriptor | null;
|
|
46
46
|
}
|
|
@@ -48,6 +48,7 @@ export declare const claudeAdapter: AgentAdapter;
|
|
|
48
48
|
export declare const codexAdapter: AgentAdapter;
|
|
49
49
|
export declare const opencodeAdapter: AgentAdapter;
|
|
50
50
|
export declare const grokAdapter: AgentAdapter;
|
|
51
|
+
export declare const cursorAdapter: AgentAdapter;
|
|
51
52
|
export declare const AGENT_ADAPTERS: Record<AgentId, AgentAdapter>;
|
|
52
53
|
/**
|
|
53
54
|
* Whether a raw CLI token names a known agent adapter. Backed by the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent adapter registry (agent-agnostic slice 2). One descriptor per host
|
|
3
|
-
* (`claude`, `codex`, `opencode`) capturing the per-agent facts the SkillGuard installer and
|
|
3
|
+
* (`claude`, `codex`, `opencode`, `grok`, `cursor`) capturing the per-agent facts the SkillGuard installer and
|
|
4
4
|
* doctor need: config paths, the protected managed set, the project-root source,
|
|
5
5
|
* the host registration schema, the managed marker, the deny protocol, and the
|
|
6
6
|
* trust model.
|
|
@@ -67,6 +67,21 @@ const GROK_MANAGED_SET = [
|
|
|
67
67
|
".claude/agents/",
|
|
68
68
|
".claude/skills/",
|
|
69
69
|
];
|
|
70
|
+
const CURSOR_MANAGED_SET = [
|
|
71
|
+
".cursor/hooks.json",
|
|
72
|
+
".cursor/hooks/javi-forge-skillguard-pre-tool-use.mjs",
|
|
73
|
+
"AGENTS.md",
|
|
74
|
+
".claude/settings.json",
|
|
75
|
+
".claude/settings.local.json",
|
|
76
|
+
".claude/CLAUDE.md",
|
|
77
|
+
"CLAUDE.md",
|
|
78
|
+
".javi-forge/ci.yaml",
|
|
79
|
+
".cursor/hooks/",
|
|
80
|
+
".cursor/rules/",
|
|
81
|
+
".claude/hooks/",
|
|
82
|
+
".claude/agents/",
|
|
83
|
+
".claude/skills/",
|
|
84
|
+
];
|
|
70
85
|
export const claudeAdapter = {
|
|
71
86
|
id: "claude",
|
|
72
87
|
configPaths(projectDir) {
|
|
@@ -134,11 +149,28 @@ export const grokAdapter = {
|
|
|
134
149
|
emitDeny: "exit2+stderr",
|
|
135
150
|
trust: null,
|
|
136
151
|
};
|
|
152
|
+
export const cursorAdapter = {
|
|
153
|
+
id: "cursor",
|
|
154
|
+
configPaths(homeDir) {
|
|
155
|
+
const cursorDir = path.join(homeDir, ".cursor");
|
|
156
|
+
return {
|
|
157
|
+
hooksFile: path.join(cursorDir, "hooks.json"),
|
|
158
|
+
settingsFile: path.join(cursorDir, "hooks", "javi-forge-skillguard-pre-tool-use.mjs"),
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
managedSet: CURSOR_MANAGED_SET,
|
|
162
|
+
projectDir: { envVar: "CURSOR_PROJECT_DIR" },
|
|
163
|
+
settingsSchema: null,
|
|
164
|
+
marker: "// javi-forge-managed: cursor-pretooluse v1",
|
|
165
|
+
emitDeny: "json-stdout",
|
|
166
|
+
trust: null,
|
|
167
|
+
};
|
|
137
168
|
export const AGENT_ADAPTERS = {
|
|
138
169
|
claude: claudeAdapter,
|
|
139
170
|
codex: codexAdapter,
|
|
140
171
|
opencode: opencodeAdapter,
|
|
141
172
|
grok: grokAdapter,
|
|
173
|
+
cursor: cursorAdapter,
|
|
142
174
|
};
|
|
143
175
|
/**
|
|
144
176
|
* Whether a raw CLI token names a known agent adapter. Backed by the
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor SkillGuard hook ownership manager. Cursor reads per-user hooks from
|
|
3
|
+
* ~/.cursor/hooks.json and hook scripts from ~/.cursor/hooks/, so installation is
|
|
4
|
+
* a two-file transaction: a merged preToolUse registration plus the shared
|
|
5
|
+
* policy runtime copied beside it. Doctor reports installed bytes/registration
|
|
6
|
+
* only; it cannot prove Cursor loaded or executed the hook in a live session.
|
|
7
|
+
*/
|
|
8
|
+
import type { AssetManifestEntry } from "./claude-hook-manager.js";
|
|
9
|
+
import type { ClaudeHookComponentState } from "./claude-hook-settings.js";
|
|
10
|
+
import { type PlatformSecureFs } from "./secure-fs-transaction.js";
|
|
11
|
+
export declare const CURSOR_HOOKS_NAME = "hooks.json";
|
|
12
|
+
export declare const CURSOR_POLICY_NAME = "javi-forge-skillguard-pre-tool-use.mjs";
|
|
13
|
+
export declare const CURSOR_HOOK_MARKER = "javi-forge-managed: cursor-pretooluse v1";
|
|
14
|
+
export declare const CURSOR_POLICY_MARKER = "// javi-forge-managed: claude-pretooluse v1";
|
|
15
|
+
export declare const CURSOR_HOOK_VERSION = 1;
|
|
16
|
+
export declare const CURSOR_TIMEOUT = 30;
|
|
17
|
+
export declare const CURSOR_TOOL_MATCHER = "Shell|Read|Write|Delete";
|
|
18
|
+
export declare const SHIPPED_CURSOR_POLICY: string;
|
|
19
|
+
export interface CursorConfigPaths {
|
|
20
|
+
cursorDir: string;
|
|
21
|
+
hooksDir: string;
|
|
22
|
+
hooksFile: string;
|
|
23
|
+
policyFile: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function cursorConfigPaths(homeDir: string): CursorConfigPaths;
|
|
26
|
+
export interface CursorAssetClassification {
|
|
27
|
+
state: ClaudeHookComponentState;
|
|
28
|
+
sha256?: string;
|
|
29
|
+
version?: number;
|
|
30
|
+
detail?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function classifyCursorAsset(target: string, marker: string, manifest: AssetManifestEntry): Promise<CursorAssetClassification>;
|
|
33
|
+
export declare function expectedCursorHooksJson(policyFile: string): string;
|
|
34
|
+
export declare function mergeCursorHooks(existing: unknown, policyFile: string): Record<string, unknown>;
|
|
35
|
+
export declare function classifyCursorHooksJson(value: unknown, policyFile: string): CursorAssetClassification;
|
|
36
|
+
interface CursorManifest {
|
|
37
|
+
policy: AssetManifestEntry;
|
|
38
|
+
}
|
|
39
|
+
export interface CursorHookDoctorReport {
|
|
40
|
+
healthy: boolean;
|
|
41
|
+
hooksJson: CursorAssetClassification;
|
|
42
|
+
policy: CursorAssetClassification;
|
|
43
|
+
remediation: string[];
|
|
44
|
+
/** Installed files and registration only; no Cursor execution is observed. */
|
|
45
|
+
runtimeEvidence: "not-implemented";
|
|
46
|
+
}
|
|
47
|
+
export interface CursorDoctorOptions {
|
|
48
|
+
manifest?: CursorManifest;
|
|
49
|
+
policySource?: string;
|
|
50
|
+
}
|
|
51
|
+
export declare function doctorCursorSkillGuard(homeDir?: string, options?: CursorDoctorOptions): Promise<CursorHookDoctorReport>;
|
|
52
|
+
interface CursorMutationSuccess {
|
|
53
|
+
ok: boolean;
|
|
54
|
+
changed: string[];
|
|
55
|
+
backups: string[];
|
|
56
|
+
errors: string[];
|
|
57
|
+
warnings: string[];
|
|
58
|
+
report: CursorHookDoctorReport;
|
|
59
|
+
}
|
|
60
|
+
export type CursorHookMutationResult = CursorMutationSuccess;
|
|
61
|
+
export interface CursorHookRunDeps extends CursorDoctorOptions {
|
|
62
|
+
secureFs?: PlatformSecureFs | null;
|
|
63
|
+
clock?: () => Date;
|
|
64
|
+
nonce?: () => string;
|
|
65
|
+
doctor?: typeof doctorCursorSkillGuard;
|
|
66
|
+
}
|
|
67
|
+
export declare function _runCursor(homeDir: string, mode: "install" | "repair", options: {
|
|
68
|
+
force?: boolean;
|
|
69
|
+
}, deps?: CursorHookRunDeps): Promise<CursorHookMutationResult>;
|
|
70
|
+
export declare function installCursorSkillGuard(homeDir?: string, deps?: CursorHookRunDeps): Promise<CursorHookMutationResult>;
|
|
71
|
+
export declare function repairCursorSkillGuard(homeDir?: string, options?: {
|
|
72
|
+
force?: boolean;
|
|
73
|
+
}, deps?: CursorHookRunDeps): Promise<CursorHookMutationResult>;
|
|
74
|
+
export {};
|
|
75
|
+
//# sourceMappingURL=cursor-hook-manager.d.ts.map
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor SkillGuard hook ownership manager. Cursor reads per-user hooks from
|
|
3
|
+
* ~/.cursor/hooks.json and hook scripts from ~/.cursor/hooks/, so installation is
|
|
4
|
+
* a two-file transaction: a merged preToolUse registration plus the shared
|
|
5
|
+
* policy runtime copied beside it. Doctor reports installed bytes/registration
|
|
6
|
+
* only; it cannot prove Cursor loaded or executed the hook in a live session.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { CLAUDE_HOOK_ASSETS_DIR } from "../constants.js";
|
|
13
|
+
import { safeReadFile } from "./safe-read.js";
|
|
14
|
+
import { selectSecureFs } from "./secure-fs-posix.js";
|
|
15
|
+
import { runTransaction, } from "./secure-fs-transaction.js";
|
|
16
|
+
const MAX_BYTES = 1024 * 1024;
|
|
17
|
+
const READ_OPTS = {
|
|
18
|
+
maxBytes: MAX_BYTES,
|
|
19
|
+
hardRejectOverBytes: MAX_BYTES,
|
|
20
|
+
maxLineLength: Number.POSITIVE_INFINITY,
|
|
21
|
+
};
|
|
22
|
+
export const CURSOR_HOOKS_NAME = "hooks.json";
|
|
23
|
+
export const CURSOR_POLICY_NAME = "javi-forge-skillguard-pre-tool-use.mjs";
|
|
24
|
+
export const CURSOR_HOOK_MARKER = "javi-forge-managed: cursor-pretooluse v1";
|
|
25
|
+
export const CURSOR_POLICY_MARKER = "// javi-forge-managed: claude-pretooluse v1";
|
|
26
|
+
export const CURSOR_HOOK_VERSION = 1;
|
|
27
|
+
export const CURSOR_TIMEOUT = 30;
|
|
28
|
+
export const CURSOR_TOOL_MATCHER = "Shell|Read|Write|Delete";
|
|
29
|
+
export const SHIPPED_CURSOR_POLICY = path.join(CLAUDE_HOOK_ASSETS_DIR, CURSOR_POLICY_NAME);
|
|
30
|
+
export function cursorConfigPaths(homeDir) {
|
|
31
|
+
const cursorDir = path.join(homeDir, ".cursor");
|
|
32
|
+
const hooksDir = path.join(cursorDir, "hooks");
|
|
33
|
+
return {
|
|
34
|
+
cursorDir,
|
|
35
|
+
hooksDir,
|
|
36
|
+
hooksFile: path.join(cursorDir, CURSOR_HOOKS_NAME),
|
|
37
|
+
policyFile: path.join(hooksDir, CURSOR_POLICY_NAME),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async function fileKind(target) {
|
|
41
|
+
try {
|
|
42
|
+
const stat = await lstat(target);
|
|
43
|
+
if (stat.isSymbolicLink())
|
|
44
|
+
return { kind: "symlink" };
|
|
45
|
+
return stat.isFile() ? { kind: "file" } : { kind: "non-regular" };
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const code = error.code;
|
|
49
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
50
|
+
return { kind: "absent" };
|
|
51
|
+
return { kind: "error", detail: code ?? String(error) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function sha256(content) {
|
|
55
|
+
return createHash("sha256").update(content).digest("hex");
|
|
56
|
+
}
|
|
57
|
+
function isObject(value) {
|
|
58
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
export async function classifyCursorAsset(target, marker, manifest) {
|
|
61
|
+
const stat = await fileKind(target);
|
|
62
|
+
if (stat.kind === "absent")
|
|
63
|
+
return { state: "absent" };
|
|
64
|
+
if (stat.kind === "symlink")
|
|
65
|
+
return { state: "symlink" };
|
|
66
|
+
if (stat.kind !== "file")
|
|
67
|
+
return { state: "non-regular", detail: stat.detail };
|
|
68
|
+
const read = await safeReadFile(target, READ_OPTS);
|
|
69
|
+
if (!read.ok) {
|
|
70
|
+
if (read.reason === "not-found")
|
|
71
|
+
return { state: "absent" };
|
|
72
|
+
if (read.reason === "binary" || read.reason === "too-large")
|
|
73
|
+
return { state: "foreign", detail: read.reason };
|
|
74
|
+
return { state: "non-regular", detail: read.reason };
|
|
75
|
+
}
|
|
76
|
+
if (!read.content.startsWith(`${marker}\n`))
|
|
77
|
+
return { state: "foreign" };
|
|
78
|
+
const observed = sha256(read.content);
|
|
79
|
+
if (observed === manifest.sha256)
|
|
80
|
+
return {
|
|
81
|
+
state: "managed-current",
|
|
82
|
+
sha256: observed,
|
|
83
|
+
version: manifest.version,
|
|
84
|
+
};
|
|
85
|
+
if (manifest.historical.includes(observed))
|
|
86
|
+
return {
|
|
87
|
+
state: "released-outdated",
|
|
88
|
+
sha256: observed,
|
|
89
|
+
version: manifest.version,
|
|
90
|
+
};
|
|
91
|
+
return { state: "edited-managed", sha256: observed };
|
|
92
|
+
}
|
|
93
|
+
function expectedCursorCommand(policyFile) {
|
|
94
|
+
return `${JSON.stringify(process.execPath)} ${JSON.stringify(policyFile)} --agent=cursor`;
|
|
95
|
+
}
|
|
96
|
+
function buildCursorHook(policyFile) {
|
|
97
|
+
return {
|
|
98
|
+
command: expectedCursorCommand(policyFile),
|
|
99
|
+
type: "command",
|
|
100
|
+
matcher: CURSOR_TOOL_MATCHER,
|
|
101
|
+
timeout: CURSOR_TIMEOUT,
|
|
102
|
+
failClosed: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export function expectedCursorHooksJson(policyFile) {
|
|
106
|
+
return `${JSON.stringify({
|
|
107
|
+
"javi-forge-managed": CURSOR_HOOK_MARKER,
|
|
108
|
+
version: CURSOR_HOOK_VERSION,
|
|
109
|
+
hooks: { preToolUse: [buildCursorHook(policyFile)] },
|
|
110
|
+
}, null, 2)}\n`;
|
|
111
|
+
}
|
|
112
|
+
const CURSOR_CMD_RE = /(?:^|\s)(?:"[^"]*node[^"]*"|node)\s+(?:"[^"]*javi-forge-skillguard-pre-tool-use\.mjs"|\S*javi-forge-skillguard-pre-tool-use\.mjs)\s+--agent=cursor(?:\s|$)/;
|
|
113
|
+
function preToolUseHandlers(value) {
|
|
114
|
+
if (!isObject(value))
|
|
115
|
+
return null;
|
|
116
|
+
const hooks = value.hooks;
|
|
117
|
+
if (hooks === undefined)
|
|
118
|
+
return [];
|
|
119
|
+
if (!isObject(hooks))
|
|
120
|
+
return null;
|
|
121
|
+
const groups = hooks.preToolUse;
|
|
122
|
+
if (groups === undefined)
|
|
123
|
+
return [];
|
|
124
|
+
if (!Array.isArray(groups))
|
|
125
|
+
return null;
|
|
126
|
+
const handlers = [];
|
|
127
|
+
for (const group of groups) {
|
|
128
|
+
if (!isObject(group))
|
|
129
|
+
return null;
|
|
130
|
+
handlers.push(group);
|
|
131
|
+
}
|
|
132
|
+
return handlers;
|
|
133
|
+
}
|
|
134
|
+
export function mergeCursorHooks(existing, policyFile) {
|
|
135
|
+
const container = isObject(existing)
|
|
136
|
+
? structuredClone(existing)
|
|
137
|
+
: {};
|
|
138
|
+
if (!isObject(container.hooks))
|
|
139
|
+
container.hooks = {};
|
|
140
|
+
const hooks = container.hooks;
|
|
141
|
+
const groups = Array.isArray(hooks.preToolUse) ? hooks.preToolUse : [];
|
|
142
|
+
hooks.preToolUse = [
|
|
143
|
+
...groups.filter((group) => !(isObject(group) &&
|
|
144
|
+
typeof group.command === "string" &&
|
|
145
|
+
CURSOR_CMD_RE.test(group.command))),
|
|
146
|
+
buildCursorHook(policyFile),
|
|
147
|
+
];
|
|
148
|
+
container["javi-forge-managed"] = CURSOR_HOOK_MARKER;
|
|
149
|
+
container.version = CURSOR_HOOK_VERSION;
|
|
150
|
+
return container;
|
|
151
|
+
}
|
|
152
|
+
export function classifyCursorHooksJson(value, policyFile) {
|
|
153
|
+
const handlers = preToolUseHandlers(value);
|
|
154
|
+
if (handlers === null)
|
|
155
|
+
return { state: "malformed" };
|
|
156
|
+
const expected = buildCursorHook(policyFile);
|
|
157
|
+
const ours = handlers.filter((handler) => typeof handler.command === "string" &&
|
|
158
|
+
CURSOR_CMD_RE.test(handler.command));
|
|
159
|
+
if (ours.some((handler) => JSON.stringify(handler) === JSON.stringify(expected) &&
|
|
160
|
+
isObject(value) &&
|
|
161
|
+
value["javi-forge-managed"] === CURSOR_HOOK_MARKER &&
|
|
162
|
+
value.version === CURSOR_HOOK_VERSION)) {
|
|
163
|
+
return { state: "managed-current", version: CURSOR_HOOK_VERSION };
|
|
164
|
+
}
|
|
165
|
+
if (ours.length > 0)
|
|
166
|
+
return { state: "released-outdated" };
|
|
167
|
+
if (handlers.length > 0)
|
|
168
|
+
return { state: "foreign" };
|
|
169
|
+
return { state: "absent" };
|
|
170
|
+
}
|
|
171
|
+
async function readManifest() {
|
|
172
|
+
const read = await safeReadFile(path.join(CLAUDE_HOOK_ASSETS_DIR, "manifest.json"), READ_OPTS);
|
|
173
|
+
if (!read.ok)
|
|
174
|
+
throw new Error(`unreadable claude-hooks manifest: ${read.reason}`);
|
|
175
|
+
const manifest = JSON.parse(read.content);
|
|
176
|
+
return { policy: manifest.asset };
|
|
177
|
+
}
|
|
178
|
+
async function readJsonFile(target) {
|
|
179
|
+
const stat = await fileKind(target);
|
|
180
|
+
if (stat.kind === "absent")
|
|
181
|
+
return { ok: false, state: "absent" };
|
|
182
|
+
if (stat.kind === "symlink")
|
|
183
|
+
return { ok: false, state: "symlink" };
|
|
184
|
+
if (stat.kind !== "file")
|
|
185
|
+
return { ok: false, state: "non-regular", detail: stat.detail };
|
|
186
|
+
const read = await safeReadFile(target, READ_OPTS);
|
|
187
|
+
if (!read.ok) {
|
|
188
|
+
if (read.reason === "not-found")
|
|
189
|
+
return { ok: false, state: "absent" };
|
|
190
|
+
if (read.reason === "binary" || read.reason === "too-large")
|
|
191
|
+
return { ok: false, state: "foreign", detail: read.reason };
|
|
192
|
+
return { ok: false, state: "non-regular", detail: read.reason };
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
return { ok: true, value: JSON.parse(read.content) };
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return { ok: false, state: "malformed", detail: "invalid-json" };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export async function doctorCursorSkillGuard(homeDir = os.homedir(), options = {}) {
|
|
202
|
+
const manifest = options.manifest ?? (await readManifest());
|
|
203
|
+
const paths = cursorConfigPaths(homeDir);
|
|
204
|
+
const hooksRead = await readJsonFile(paths.hooksFile);
|
|
205
|
+
const hooksJson = hooksRead.ok
|
|
206
|
+
? classifyCursorHooksJson(hooksRead.value, paths.policyFile)
|
|
207
|
+
: { state: hooksRead.state, detail: hooksRead.detail };
|
|
208
|
+
const policy = await classifyCursorAsset(paths.policyFile, CURSOR_POLICY_MARKER, manifest.policy);
|
|
209
|
+
const remediation = [];
|
|
210
|
+
for (const [name, asset] of [
|
|
211
|
+
["hook registration", hooksJson],
|
|
212
|
+
["policy runtime", policy],
|
|
213
|
+
]) {
|
|
214
|
+
if (asset.state === "absent" ||
|
|
215
|
+
asset.state === "released-outdated" ||
|
|
216
|
+
asset.state === "foreign") {
|
|
217
|
+
remediation.push(`install the Cursor ${name} with: javi-forge hooks install cursor`);
|
|
218
|
+
}
|
|
219
|
+
else if (asset.state === "edited-managed") {
|
|
220
|
+
remediation.push(`repair the edited Cursor ${name} with: javi-forge hooks repair cursor --force`);
|
|
221
|
+
}
|
|
222
|
+
else if (asset.state !== "managed-current") {
|
|
223
|
+
remediation.push(`manually review the Cursor ${name} (${asset.state}); it will not be overwritten`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
healthy: hooksJson.state === "managed-current" &&
|
|
228
|
+
policy.state === "managed-current",
|
|
229
|
+
hooksJson,
|
|
230
|
+
policy,
|
|
231
|
+
remediation: [...new Set(remediation)],
|
|
232
|
+
runtimeEvidence: "not-implemented",
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function hookWritePlan(state, force, name) {
|
|
236
|
+
if (state === "absent" ||
|
|
237
|
+
state === "released-outdated" ||
|
|
238
|
+
state === "foreign")
|
|
239
|
+
return { write: true };
|
|
240
|
+
if (state === "managed-current")
|
|
241
|
+
return { write: false };
|
|
242
|
+
if (state === "edited-managed" && force)
|
|
243
|
+
return { write: true };
|
|
244
|
+
if (state === "edited-managed")
|
|
245
|
+
return {
|
|
246
|
+
write: false,
|
|
247
|
+
refusal: `refuse edited ${name}; rerun repair with --force`,
|
|
248
|
+
};
|
|
249
|
+
return {
|
|
250
|
+
write: false,
|
|
251
|
+
refusal: `refuse ${name} in state ${state} — manual review`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function policyWritePlan(state, force, name) {
|
|
255
|
+
if (state === "absent" || state === "released-outdated")
|
|
256
|
+
return { write: true };
|
|
257
|
+
if (state === "managed-current")
|
|
258
|
+
return { write: false };
|
|
259
|
+
if (state === "edited-managed" && force)
|
|
260
|
+
return { write: true };
|
|
261
|
+
if (state === "edited-managed")
|
|
262
|
+
return {
|
|
263
|
+
write: false,
|
|
264
|
+
refusal: `refuse edited ${name}; rerun repair with --force`,
|
|
265
|
+
};
|
|
266
|
+
return {
|
|
267
|
+
write: false,
|
|
268
|
+
refusal: `refuse ${name} in state ${state} — manual review`,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function serialize(container) {
|
|
272
|
+
return Buffer.from(`${JSON.stringify(container, null, 2)}\n`, "utf8");
|
|
273
|
+
}
|
|
274
|
+
export async function _runCursor(homeDir, mode, options, deps = {}) {
|
|
275
|
+
const manifest = deps.manifest ?? (await readManifest());
|
|
276
|
+
const paths = cursorConfigPaths(homeDir);
|
|
277
|
+
const doctorFn = deps.doctor ?? doctorCursorSkillGuard;
|
|
278
|
+
const doctor = () => doctorFn(homeDir, {
|
|
279
|
+
manifest,
|
|
280
|
+
policySource: deps.policySource,
|
|
281
|
+
});
|
|
282
|
+
const force = mode === "repair" && options.force === true;
|
|
283
|
+
const hooksRead = await readJsonFile(paths.hooksFile);
|
|
284
|
+
const hooksState = hooksRead.ok
|
|
285
|
+
? classifyCursorHooksJson(hooksRead.value, paths.policyFile)
|
|
286
|
+
: { state: hooksRead.state, detail: hooksRead.detail };
|
|
287
|
+
const policyState = await classifyCursorAsset(paths.policyFile, CURSOR_POLICY_MARKER, manifest.policy);
|
|
288
|
+
const hookPlan = hookWritePlan(hooksState.state, force, "Cursor hook registration");
|
|
289
|
+
const policyPlan = policyWritePlan(policyState.state, force, "Cursor policy runtime");
|
|
290
|
+
if (hookPlan.refusal || policyPlan.refusal) {
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
changed: [],
|
|
294
|
+
backups: [],
|
|
295
|
+
errors: [hookPlan.refusal ?? policyPlan.refusal ?? "refused"],
|
|
296
|
+
warnings: [],
|
|
297
|
+
report: await doctor(),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (!hookPlan.write && !policyPlan.write) {
|
|
301
|
+
return {
|
|
302
|
+
ok: true,
|
|
303
|
+
changed: [],
|
|
304
|
+
backups: [],
|
|
305
|
+
errors: [],
|
|
306
|
+
warnings: [],
|
|
307
|
+
report: await doctor(),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const secureFs = deps.secureFs !== undefined
|
|
311
|
+
? deps.secureFs
|
|
312
|
+
: selectSecureFs(process.platform);
|
|
313
|
+
if (!secureFs) {
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
changed: [],
|
|
317
|
+
backups: [],
|
|
318
|
+
errors: ["windows-secure-object-unavailable"],
|
|
319
|
+
warnings: [],
|
|
320
|
+
report: await doctor(),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const policySource = deps.policySource ?? SHIPPED_CURSOR_POLICY;
|
|
324
|
+
const policyBytes = policyPlan.write ? await readFile(policySource) : null;
|
|
325
|
+
if (policyBytes && sha256(policyBytes) !== manifest.policy.sha256) {
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
changed: [],
|
|
329
|
+
backups: [],
|
|
330
|
+
errors: [
|
|
331
|
+
"refuse packaged Cursor policy asset with manifest hash mismatch",
|
|
332
|
+
],
|
|
333
|
+
warnings: [],
|
|
334
|
+
report: await doctor(),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const existing = hooksRead.ok ? hooksRead.value : undefined;
|
|
338
|
+
const hookBytes = hookPlan.write
|
|
339
|
+
? serialize(mergeCursorHooks(existing, paths.policyFile))
|
|
340
|
+
: null;
|
|
341
|
+
const components = [
|
|
342
|
+
{
|
|
343
|
+
path: paths.hooksFile,
|
|
344
|
+
desired: hookBytes,
|
|
345
|
+
capturePrior: hookPlan.write && hooksState.state !== "absent",
|
|
346
|
+
forceBackup: force && hooksState.state === "edited-managed",
|
|
347
|
+
wasAbsent: hooksState.state === "absent",
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
path: paths.policyFile,
|
|
351
|
+
desired: policyBytes,
|
|
352
|
+
capturePrior: policyPlan.write && policyState.state !== "absent",
|
|
353
|
+
forceBackup: force && policyState.state === "edited-managed",
|
|
354
|
+
wasAbsent: policyState.state === "absent",
|
|
355
|
+
},
|
|
356
|
+
];
|
|
357
|
+
const tx = await runTransaction({
|
|
358
|
+
secureFs,
|
|
359
|
+
clock: deps.clock ?? (() => new Date()),
|
|
360
|
+
nonce: deps.nonce ?? (() => randomBytes(4).toString("hex")),
|
|
361
|
+
projectDir: homeDir,
|
|
362
|
+
layout: { containers: [paths.cursorDir, paths.hooksDir], components },
|
|
363
|
+
});
|
|
364
|
+
return {
|
|
365
|
+
ok: tx.ok,
|
|
366
|
+
changed: tx.committed,
|
|
367
|
+
backups: tx.backups,
|
|
368
|
+
errors: tx.errors,
|
|
369
|
+
warnings: [],
|
|
370
|
+
report: await doctor(),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
export function installCursorSkillGuard(homeDir, deps = {}) {
|
|
374
|
+
return _runCursor(homeDir ?? os.homedir(), "install", {}, deps);
|
|
375
|
+
}
|
|
376
|
+
export function repairCursorSkillGuard(homeDir, options, deps = {}) {
|
|
377
|
+
return _runCursor(homeDir ?? os.homedir(), "repair", options ?? {}, deps);
|
|
378
|
+
}
|
|
379
|
+
//# sourceMappingURL=cursor-hook-manager.js.map
|