javi-forge 1.35.1 → 1.36.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 +97 -29
- package/assets/claude-hooks/manifest.json +1 -1
- package/dist/cli/dispatch/hooks.d.ts +9 -2
- package/dist/cli/dispatch/hooks.js +22 -8
- package/dist/commands/codex-hooks.d.ts +26 -0
- package/dist/commands/codex-hooks.js +104 -0
- package/dist/lib/__fixtures__/claude-hook-ownership.d.ts +6 -0
- package/dist/lib/__fixtures__/claude-hook-ownership.js +7 -1
- package/dist/lib/agent-adapter.d.ts +56 -0
- package/dist/lib/agent-adapter.js +88 -0
- package/dist/lib/claude-hook-manager.js +3 -2
- package/dist/lib/claude-hook-settings.d.ts +3 -3
- package/dist/lib/claude-hook-settings.js +3 -3
- package/dist/lib/codex-hook-manager.d.ts +161 -0
- package/dist/lib/codex-hook-manager.js +531 -0
- package/dist/lib/secure-fs-transaction.d.ts +25 -5
- package/dist/lib/secure-fs-transaction.js +44 -19
- package/package.json +1 -1
|
@@ -10,6 +10,22 @@ export const INPUT_LIMIT_BYTES = 1_048_576;
|
|
|
10
10
|
export const SUPPORTED_TOOLS = Object.freeze(["Bash", "PowerShell", "Read", "Write", "Edit"]);
|
|
11
11
|
export const POLICY_REGISTRY = Object.freeze({ schemaVersion: 1, policyVersion: 1, diagnosticsMaxBytes: 240 });
|
|
12
12
|
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
13
|
+
// Per-agent adapter config (S0 core-extraction): every agent-specific input the guard needs
|
|
14
|
+
// (the isManaged protected-path set, the project-dir source, the managed marker) is resolved by
|
|
15
|
+
// the --agent selector instead of a baked-in literal. The pure evaluate*/utility engine stays
|
|
16
|
+
// agent-independent. Claude's entry holds today's EXACT values so its observable behavior is
|
|
17
|
+
// byte-identical; codex is defined so the map is agent-generic but wired end-to-end only in a later slice.
|
|
18
|
+
const CLAUDE_MANAGED_SET = Object.freeze({ exact: Object.freeze([".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"]) });
|
|
19
|
+
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"]) });
|
|
20
|
+
export const AGENT_CONFIGS = Object.freeze({ claude: Object.freeze({ id: "claude", managedSet: CLAUDE_MANAGED_SET, projectDir: Object.freeze({ envVar: "CLAUDE_PROJECT_DIR", fallback: "asset-root" }), marker: MANAGED_MARKER }), codex: Object.freeze({ id: "codex", managedSet: CODEX_MANAGED_SET, projectDir: Object.freeze({ envVar: null, fallback: "cwd" }), marker: "// javi-forge-managed: codex-pretooluse v1" }) });
|
|
21
|
+
// Fail-closed agent selector: a missing/unknown --agent means we cannot know what to protect, so refuse.
|
|
22
|
+
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; }
|
|
23
|
+
// Project root per agent: the env var when set (Claude = CLAUDE_PROJECT_DIR); otherwise the per-agent
|
|
24
|
+
// fallback decides. "asset-root" anchors to the asset-relative PROJECT_ROOT so Claude is byte-identical
|
|
25
|
+
// to the pre-extraction guard whether the env var is set OR unset (closing the cwd-divergence gap);
|
|
26
|
+
// "cwd" uses the envelope cwd (codex, whose user-global asset has no asset-relative project root and no
|
|
27
|
+
// env var). Fail-safe: an unknown/missing fallback anchors to the stricter PROJECT_ROOT, never looser.
|
|
28
|
+
function resolveProjectRoot(config, cwd) { const envVar = config.projectDir.envVar; if (envVar) { const value = process.env[envVar]; if (typeof value === "string" && value.length > 0) return value; } if (config.projectDir.fallback === "cwd") return cwd ?? PROJECT_ROOT; return PROJECT_ROOT; }
|
|
13
29
|
const POSIX_ABSOLUTE = /^\//;
|
|
14
30
|
const WINDOWS_DRIVE = /^[a-zA-Z]:[\\/]/;
|
|
15
31
|
const WINDOWS_UNC = /^\\\\[^\\]+\\[^\\]+/;
|
|
@@ -59,9 +75,8 @@ function nativeRealpath(input) {
|
|
|
59
75
|
fail("path-resolution-failed");
|
|
60
76
|
}
|
|
61
77
|
}
|
|
62
|
-
export function
|
|
78
|
+
export function lexicalizePolicyPath(input, options = {}) {
|
|
63
79
|
if (typeof input !== "string" || input.includes("\0")) fail("invalid-event");
|
|
64
|
-
const platform = options.platform ?? process.platform;
|
|
65
80
|
let expanded = input;
|
|
66
81
|
if (options.base) {
|
|
67
82
|
expanded = expanded.replace(/^\$\{CLAUDE_PROJECT_DIR\}|^\$CLAUDE_PROJECT_DIR/, options.projectRoot ?? PROJECT_ROOT);
|
|
@@ -70,6 +85,11 @@ export function canonicalizePolicyPath(input, options = {}) {
|
|
|
70
85
|
expanded = path.resolve(options.base, expanded);
|
|
71
86
|
}
|
|
72
87
|
}
|
|
88
|
+
return expanded;
|
|
89
|
+
}
|
|
90
|
+
export function canonicalizePolicyPath(input, options = {}) {
|
|
91
|
+
const platform = options.platform ?? process.platform;
|
|
92
|
+
let expanded = lexicalizePolicyPath(input, options);
|
|
73
93
|
// Strip Windows device aliases (\??\, \\?\, \\.\) BEFORE native realpath:
|
|
74
94
|
// on a real win32 host nativeRealpath resolves an unstripped \??\ against the
|
|
75
95
|
// current drive (D:\??\C:\...), so the alias must be canonicalized first or a
|
|
@@ -114,21 +134,21 @@ export function isSensitivePolicyKey(key, platform = process.platform) {
|
|
|
114
134
|
if (SENSITIVE_DIRECTORY_SUFFIXES.some((directory) => key.endsWith(directory) || key.includes(`${directory}/`))) return true;
|
|
115
135
|
return platform === "win32" || platform === "darwin" ? basename.toLowerCase() === "serviceaccountkey.json" : basename === "serviceAccountKey.json";
|
|
116
136
|
}
|
|
117
|
-
function isManaged(key) {
|
|
118
|
-
const project = canonicalizePolicyPath(
|
|
137
|
+
function isManaged(key, managedSet = CLAUDE_MANAGED_SET, projectRoot = PROJECT_ROOT) {
|
|
138
|
+
const project = canonicalizePolicyPath(projectRoot);
|
|
119
139
|
if (!key.startsWith(`${project}/`) && key !== project) return false;
|
|
120
140
|
const relative = key.slice(project.length + 1);
|
|
121
141
|
// On case-insensitive platforms lexicalNormalize folds the key to lowercase,
|
|
122
142
|
// so the mixed-case CLAUDE.md literals must be matched case-insensitively too
|
|
123
143
|
// (the other literals are already lowercase). Otherwise CLAUDE.md and
|
|
124
144
|
// .claude/CLAUDE.md lose managed-config protection on macOS/Windows.
|
|
125
|
-
const foldedClaudeMd = (process.platform === "win32" || process.platform === "darwin") && (relative
|
|
126
|
-
return foldedClaudeMd ||
|
|
145
|
+
const foldedClaudeMd = (process.platform === "win32" || process.platform === "darwin") && managedSet.caseFoldExact.includes(relative);
|
|
146
|
+
return foldedClaudeMd || managedSet.exact.includes(relative) || managedSet.prefixes.some((prefix) => relative.startsWith(prefix));
|
|
127
147
|
}
|
|
128
|
-
function evaluateFile(toolName, filePath) {
|
|
148
|
+
function evaluateFile(toolName, filePath, config = AGENT_CONFIGS.claude, projectRoot = PROJECT_ROOT) {
|
|
129
149
|
const keys = policyPathKeys(filePath);
|
|
130
150
|
if (keys.some((key) => isSensitivePolicyKey(key))) return { allowed: false, ruleId: "path.sensitive" };
|
|
131
|
-
if (toolName !== "Read" && keys.some(isManaged)) return { allowed: false, ruleId: "path.managed-config" };
|
|
151
|
+
if (toolName !== "Read" && keys.some((key) => isManaged(key, config.managedSet, projectRoot))) return { allowed: false, ruleId: "path.managed-config" };
|
|
132
152
|
return { allowed: true };
|
|
133
153
|
}
|
|
134
154
|
function lex(command, powershell = false) {
|
|
@@ -628,21 +648,21 @@ function reduceWrappers(input, powershell = false) {
|
|
|
628
648
|
}
|
|
629
649
|
return { tokens };
|
|
630
650
|
}
|
|
631
|
-
function hasSensitiveLiteral(tokens, cwd) {
|
|
651
|
+
function hasSensitiveLiteral(tokens, cwd, projectRoot = PROJECT_ROOT) {
|
|
632
652
|
return tokens.some((token) => {
|
|
633
653
|
if ((token.startsWith("-") && !/^-(?:LiteralPath|Path):/i.test(token)) || !/[\\/.~$]/.test(token)) return false;
|
|
634
654
|
try {
|
|
635
|
-
return isSensitivePolicyKey(canonicalizePolicyPath(token.replace(/^(?:-LiteralPath:|-Path:)/i, "").replace(/[;,]$/, ""), { base: cwd, projectRoot
|
|
655
|
+
return isSensitivePolicyKey(canonicalizePolicyPath(token.replace(/^(?:-LiteralPath:|-Path:)/i, "").replace(/[;,]$/, ""), { base: cwd, projectRoot }));
|
|
636
656
|
} catch {
|
|
637
657
|
return false;
|
|
638
658
|
}
|
|
639
659
|
});
|
|
640
660
|
}
|
|
641
|
-
function hasManagedLiteral(tokens, cwd) {
|
|
661
|
+
function hasManagedLiteral(tokens, cwd, config = AGENT_CONFIGS.claude, projectRoot = PROJECT_ROOT) {
|
|
642
662
|
return tokens.some((token) => {
|
|
643
663
|
if ((token.startsWith("-") && !/^-(?:LiteralPath|Path):/i.test(token)) || !/[\\/.]/.test(token)) return false;
|
|
644
664
|
try {
|
|
645
|
-
return isManaged(canonicalizePolicyPath(token.replace(/^(?:-LiteralPath:|-Path:)/i, "").replace(/[;,]$/, ""), { base: cwd, projectRoot
|
|
665
|
+
return isManaged(canonicalizePolicyPath(token.replace(/^(?:-LiteralPath:|-Path:)/i, "").replace(/[;,]$/, ""), { base: cwd, projectRoot }), config.managedSet, projectRoot);
|
|
646
666
|
} catch {
|
|
647
667
|
return false;
|
|
648
668
|
}
|
|
@@ -681,9 +701,9 @@ function bashSubstitutions(command) {
|
|
|
681
701
|
}
|
|
682
702
|
return bodies;
|
|
683
703
|
}
|
|
684
|
-
function evaluateBash(command, cwd, depth = 0) {
|
|
704
|
+
function evaluateBash(command, cwd, config = AGENT_CONFIGS.claude, projectRoot = PROJECT_ROOT, depth = 0) {
|
|
685
705
|
if (depth > 4) return { allowed: false, ruleId: "shell.obfuscated-interpreter" };
|
|
686
|
-
try { for (const body of bashSubstitutions(command)) { const nested = evaluateBash(body, cwd, depth + 1); if (!nested.allowed) return nested; } } catch { return { allowed: false, ruleId: "shell.obfuscated-interpreter" }; }
|
|
706
|
+
try { for (const body of bashSubstitutions(command)) { const nested = evaluateBash(body, cwd, config, projectRoot, depth + 1); if (!nested.allowed) return nested; } } catch { return { allowed: false, ruleId: "shell.obfuscated-interpreter" }; }
|
|
687
707
|
if (/^\s*:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:\s*$/.test(command)) return { allowed: false, ruleId: "shell.destructive-root" };
|
|
688
708
|
let parsed;
|
|
689
709
|
try { parsed = lex(command); } catch { return { allowed: false, ruleId: "shell.obfuscated-interpreter" }; }
|
|
@@ -712,20 +732,20 @@ function evaluateBash(command, cwd, depth = 0) {
|
|
|
712
732
|
if (decision) return decision;
|
|
713
733
|
} else if (/^(?:curl|wget)$/.test(executable) && shellSink) return { allowed: false, ruleId: "shell.pipe-to-shell" };
|
|
714
734
|
}
|
|
715
|
-
if (["cat", "less", "more", "head", "tail", "bat", "grep", "rg", "sed", "awk", "source", ".", "cp", "install"].includes(executable) && hasSensitiveLiteral(tokens.slice(1), cwd)) return { allowed: false, ruleId: "shell.sensitive-read" };
|
|
716
|
-
if (tokens.some((token) => token === "<") && hasSensitiveLiteral(tokens, cwd)) return { allowed: false, ruleId: "shell.sensitive-read" };
|
|
735
|
+
if (["cat", "less", "more", "head", "tail", "bat", "grep", "rg", "sed", "awk", "source", ".", "cp", "install"].includes(executable) && hasSensitiveLiteral(tokens.slice(1), cwd, projectRoot)) return { allowed: false, ruleId: "shell.sensitive-read" };
|
|
736
|
+
if (tokens.some((token) => token === "<") && hasSensitiveLiteral(tokens, cwd, projectRoot)) return { allowed: false, ruleId: "shell.sensitive-read" };
|
|
717
737
|
if (executable === "git" && tokens[1]?.toLowerCase() === "push" && tokens.some((token) => ["-f", "--force", "--force-with-lease"].includes(token.toLowerCase()))) return { allowed: false, ruleId: "shell.force-push" };
|
|
718
|
-
if (["rm", "mv", "cp", "install", "truncate", "touch", "chmod", "chown", "tee"].includes(executable) && hasManagedLiteral(tokens.slice(1), cwd)) return { allowed: false, ruleId: "shell.managed-config-tamper" };
|
|
719
|
-
if ((/^(?:sed|perl)$/.test(executable) && tokens.some((token) => token.startsWith("-i")) && hasManagedLiteral(tokens, cwd)) || (tokens.includes(">") && hasManagedLiteral(tokens, cwd))) return { allowed: false, ruleId: "shell.managed-config-tamper" };
|
|
738
|
+
if (["rm", "mv", "cp", "install", "truncate", "touch", "chmod", "chown", "tee"].includes(executable) && hasManagedLiteral(tokens.slice(1), cwd, config, projectRoot)) return { allowed: false, ruleId: "shell.managed-config-tamper" };
|
|
739
|
+
if ((/^(?:sed|perl)$/.test(executable) && tokens.some((token) => token.startsWith("-i")) && hasManagedLiteral(tokens, cwd, config, projectRoot)) || (tokens.includes(">") && hasManagedLiteral(tokens, cwd, config, projectRoot))) return { allowed: false, ruleId: "shell.managed-config-tamper" };
|
|
720
740
|
if (/^(?:powershell|pwsh)(?:\.exe)?$/i.test(executable) && tokens.some((token) => /^-(?:enc|encodedcommand)$/i.test(token))) return { allowed: false, ruleId: "shell.obfuscated-interpreter" };
|
|
721
741
|
if (/^(?:bash|sh|zsh|dash|ksh)$/.test(executable)) {
|
|
722
742
|
const flag = tokens.findIndex((token) => /^-[^-]*c[^-]*$/.test(token));
|
|
723
|
-
if (flag >= 0) { const body = tokens[flag + 1]; if (!body || /\$(?!\()/.test(body)) return { allowed: false, ruleId: "shell.obfuscated-interpreter" }; const nested = evaluateBash(body, cwd, depth + 1); if (!nested.allowed) return nested; }
|
|
743
|
+
if (flag >= 0) { const body = tokens[flag + 1]; if (!body || /\$(?!\()/.test(body)) return { allowed: false, ruleId: "shell.obfuscated-interpreter" }; const nested = evaluateBash(body, cwd, config, projectRoot, depth + 1); if (!nested.allowed) return nested; }
|
|
724
744
|
}
|
|
725
745
|
}
|
|
726
746
|
return { allowed: true };
|
|
727
747
|
}
|
|
728
|
-
function evaluatePowerShell(command, cwd) {
|
|
748
|
+
function evaluatePowerShell(command, cwd, config = AGENT_CONFIGS.claude, projectRoot = PROJECT_ROOT) {
|
|
729
749
|
const parsed = lex(command, true);
|
|
730
750
|
for (let index = 0; index < parsed.commands.length; index++) {
|
|
731
751
|
const reduced = reduceWrappers(parsed.commands[index], true);
|
|
@@ -734,23 +754,69 @@ function evaluatePowerShell(command, cwd) {
|
|
|
734
754
|
const executable = (tokens[0] ?? "").toLowerCase();
|
|
735
755
|
if ((["remove-item", "rm", "del", "erase", "rmdir", "rd"].includes(executable) && tokens.some((token) => /^-(?:r|recurse)$/i.test(token)) && tokens.some((token) => /^-(?:fo|force)$/i.test(token)) && tokens.some((token) => /^(?:[a-z]:\\?|[/~]|\$HOME)$/i.test(token))) || ["format-volume", "clear-disk", "initialize-disk"].includes(executable)) return { allowed: false, ruleId: "powershell.destructive-root" };
|
|
736
756
|
if (parsed.separators[index] === "|" && /^(?:invoke-webrequest|iwr|curl|wget|invoke-restmethod|irm)$/.test(executable) && /^(?:invoke-expression|iex)$/.test(((reduceWrappers(parsed.commands[index + 1] ?? [], true).tokens ?? [])[0] ?? "").toLowerCase())) return { allowed: false, ruleId: "powershell.pipe-to-shell" };
|
|
737
|
-
if (["get-content", "gc", "cat", "type", "select-string", "copy-item", "cp", "copy"].includes(executable) && hasSensitiveLiteral(tokens.slice(1), cwd)) return { allowed: false, ruleId: "powershell.sensitive-read" };
|
|
757
|
+
if (["get-content", "gc", "cat", "type", "select-string", "copy-item", "cp", "copy"].includes(executable) && hasSensitiveLiteral(tokens.slice(1), cwd, projectRoot)) return { allowed: false, ruleId: "powershell.sensitive-read" };
|
|
738
758
|
if (executable === "git" && tokens[1]?.toLowerCase() === "push" && tokens.some((token) => ["-f", "--force", "--force-with-lease"].includes(token.toLowerCase()))) return { allowed: false, ruleId: "powershell.force-push" };
|
|
739
|
-
if (["set-content", "add-content", "out-file", "clear-content", "remove-item", "move-item", "copy-item", "rename-item", "new-item"].includes(executable) && hasManagedLiteral(tokens.slice(1), cwd)) return { allowed: false, ruleId: "powershell.managed-config-tamper" };
|
|
740
|
-
if (tokens.includes(">") && hasManagedLiteral(tokens, cwd)) return { allowed: false, ruleId: "powershell.managed-config-tamper" };
|
|
759
|
+
if (["set-content", "add-content", "out-file", "clear-content", "remove-item", "move-item", "copy-item", "rename-item", "new-item"].includes(executable) && hasManagedLiteral(tokens.slice(1), cwd, config, projectRoot)) return { allowed: false, ruleId: "powershell.managed-config-tamper" };
|
|
760
|
+
if (tokens.includes(">") && hasManagedLiteral(tokens, cwd, config, projectRoot)) return { allowed: false, ruleId: "powershell.managed-config-tamper" };
|
|
741
761
|
if (/^(?:powershell|pwsh)(?:\.exe)?$/i.test(executable) && tokens.some((token) => /^-(?:enc|encodedcommand)$/i.test(token))) return { allowed: false, ruleId: "powershell.obfuscated-interpreter" };
|
|
742
762
|
}
|
|
743
763
|
return { allowed: true };
|
|
744
764
|
}
|
|
745
|
-
|
|
746
|
-
|
|
765
|
+
// Codex file-write shim (S1, security-critical): Codex delivers file writes as
|
|
766
|
+
// tool_name:"apply_patch" with the target path(s) inside the tool_input.command patch
|
|
767
|
+
// text and NO file_path field, so evaluateFile's managed-config protection cannot fire
|
|
768
|
+
// without first parsing the header paths out of the patch. This is a WRITE-class surface:
|
|
769
|
+
// a parse miss = a managed-config file-write bypass, so every unresolved shape fails
|
|
770
|
+
// closed (throw -> main() catch -> denyAndExit, exit 2). Grammar VERIFIED against a REAL
|
|
771
|
+
// captured codex-cli 0.147.0 envelope (2026-08-18): first line is `*** Begin Patch`,
|
|
772
|
+
// closed by `*** End Patch`, with `*** Add File:` / `*** Update File:` / `*** Delete File:`
|
|
773
|
+
// headers AND `*** Move to:` for a rename target -- the last one CONFIRMED emitted (a
|
|
774
|
+
// rename can retarget a benign source onto a managed path, so its destination is checked too).
|
|
775
|
+
export function parseApplyPatchPaths(command) {
|
|
776
|
+
if (typeof command !== "string" || command.includes("\0")) fail("invalid-event");
|
|
777
|
+
const lines = command.split(/\r?\n/);
|
|
778
|
+
if ((lines[0] ?? "").trim() !== "*** Begin Patch") fail("invalid-event"); // header-less patch -> cannot prove safe
|
|
779
|
+
let seenEnd = false;
|
|
780
|
+
const paths = [];
|
|
781
|
+
for (let index = 1; index < lines.length; index++) {
|
|
782
|
+
const line = lines[index];
|
|
783
|
+
if (line.trim() === "*** End Patch") { seenEnd = true; break; }
|
|
784
|
+
const file = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/.exec(line);
|
|
785
|
+
if (file) { paths.push(file[1].trim()); continue; }
|
|
786
|
+
const move = /^\*\*\* Move to: (.+)$/.exec(line); // rename target (verified present)
|
|
787
|
+
if (move) { paths.push(move[1].trim()); continue; }
|
|
788
|
+
// body lines (+/-/space/@@/index) carry no header path and are ignored
|
|
789
|
+
}
|
|
790
|
+
if (!seenEnd) fail("invalid-event"); // truncated patch could hide a managed write
|
|
791
|
+
if (paths.length === 0) fail("invalid-event"); // zero extractable paths -> fail closed
|
|
792
|
+
for (const target of paths) if (target.length === 0 || target.includes("\0")) fail("invalid-event");
|
|
793
|
+
return paths;
|
|
794
|
+
}
|
|
795
|
+
export function evaluateEvent(input, config = AGENT_CONFIGS.claude) {
|
|
796
|
+
if (!isObject(input) || input.hook_event_name !== "PreToolUse" || !isObject(input.tool_input)) fail("invalid-event");
|
|
797
|
+
const applyPatch = input.tool_name === "apply_patch";
|
|
798
|
+
if (!applyPatch && !SUPPORTED_TOOLS.includes(input.tool_name)) fail("invalid-event");
|
|
799
|
+
const cwd = typeof input.cwd === "string" && isAbsolutePolicyPath(input.cwd) ? input.cwd : PROJECT_ROOT;
|
|
800
|
+
const projectRoot = resolveProjectRoot(config, cwd);
|
|
801
|
+
if (applyPatch) {
|
|
802
|
+
if (typeof input.tool_input.command !== "string") fail("invalid-event");
|
|
803
|
+
// apply_patch is a WRITE tool (not "Read"), so the managed-config rule fires. Each header
|
|
804
|
+
// path is made absolute LEXICALLY (no realpath) vs the envelope cwd and handed to evaluateFile
|
|
805
|
+
// exactly like Write/Edit's raw file_path, so policyPathKeys owns the dual lexical+realpath
|
|
806
|
+
// canonicalization. Realpath-ing here first would collapse a symlinked managed dir onto its
|
|
807
|
+
// target and drop the protective lexical key -> managed-config write bypass.
|
|
808
|
+
for (const target of parseApplyPatchPaths(input.tool_input.command)) {
|
|
809
|
+
const decision = evaluateFile("apply_patch", lexicalizePolicyPath(target, { base: cwd, projectRoot }), config, projectRoot);
|
|
810
|
+
if (!decision.allowed) return decision;
|
|
811
|
+
}
|
|
812
|
+
return { allowed: true };
|
|
813
|
+
}
|
|
747
814
|
if (input.tool_name === "Bash" || input.tool_name === "PowerShell") {
|
|
748
815
|
if (typeof input.tool_input.command !== "string") fail("invalid-event");
|
|
749
|
-
|
|
750
|
-
return input.tool_name === "Bash" ? evaluateBash(input.tool_input.command, cwd) : evaluatePowerShell(input.tool_input.command, cwd);
|
|
816
|
+
return input.tool_name === "Bash" ? evaluateBash(input.tool_input.command, cwd, config, projectRoot) : evaluatePowerShell(input.tool_input.command, cwd, config, projectRoot);
|
|
751
817
|
}
|
|
752
818
|
if (typeof input.tool_input.file_path !== "string" || !isAbsolutePolicyPath(input.tool_input.file_path)) fail("invalid-event");
|
|
753
|
-
return evaluateFile(input.tool_name, input.tool_input.file_path);
|
|
819
|
+
return evaluateFile(input.tool_name, input.tool_input.file_path, config, projectRoot);
|
|
754
820
|
}
|
|
755
821
|
export function parseAndEvaluateInput(input) {
|
|
756
822
|
if (!Buffer.isBuffer(input) || input.length === 0) fail("invalid-json");
|
|
@@ -769,6 +835,7 @@ function diagnostic(error) {
|
|
|
769
835
|
const messages = {
|
|
770
836
|
"invalid-json": "input is not a valid JSON object",
|
|
771
837
|
"invalid-event": "input does not match the supported event schema",
|
|
838
|
+
"invalid-config": "missing or unknown --agent selector (expected --agent=<id>)",
|
|
772
839
|
"oversized-input": "stdin exceeds 1048576 bytes",
|
|
773
840
|
"missing-policy": "embedded policy registry is unavailable",
|
|
774
841
|
"internal-error": "policy evaluation could not complete",
|
|
@@ -830,6 +897,7 @@ export function readBoundedStdin(stream = process.stdin) {
|
|
|
830
897
|
}
|
|
831
898
|
export async function main() {
|
|
832
899
|
try {
|
|
900
|
+
const config = resolveAgentConfig(process.argv);
|
|
833
901
|
const fault = process.argv.find((arg) => arg.startsWith("--javi-forge-test-fault="))?.split("=")[1];
|
|
834
902
|
if (fault === "missing-policy") throw new Error("missing-policy");
|
|
835
903
|
if (POLICY_REGISTRY.schemaVersion !== 1 || POLICY_REGISTRY.policyVersion !== 1) throw new Error("missing-policy");
|
|
@@ -841,7 +909,7 @@ export async function main() {
|
|
|
841
909
|
throw new Error("invalid-json");
|
|
842
910
|
}
|
|
843
911
|
if (fault === "evaluator-throw") throw new Error("internal-error");
|
|
844
|
-
const decision = evaluateEvent(parsed);
|
|
912
|
+
const decision = evaluateEvent(parsed, config);
|
|
845
913
|
if (!decision.allowed) denyAndExit(denialDiagnostic(parsed.tool_name, decision));
|
|
846
914
|
process.exitCode = 0;
|
|
847
915
|
} catch (error) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"schemaVersion":1,"asset":{"name":"javi-forge-skillguard-pre-tool-use.mjs","version":1,"policyVersion":1,"sha256":"
|
|
1
|
+
{"schemaVersion":1,"asset":{"name":"javi-forge-skillguard-pre-tool-use.mjs","version":1,"policyVersion":1,"sha256":"9a565cec31d9e091e3fb9420b86685f824733bc1ebe479f086b2b955aba6ef3e","historical":["78be7e6613c012280b7ad17886462ba166b63ebd031e34565d757b3a0796d7cc","5dc2a5c31131f4ac7d8657c78b950de52776aad6eaefe78ea0d764a9963c4425","0c9aa8fa26b389f4892782f83104f0792c2b71ebca39c18c02706e2185e22b40","3581862f0567cce75a58b693c9ade80d39ee7d58add11537a34a8461c47c1ed4","54a270f28b068450b79547a88ec6f2d4854514392fd5f38ed1d6174ea093d7aa"]},"settingsEntries":{"current":{"version":1,"canonicalSha256":"b1341803cd076091edfcb473494514df1a23bd33372374ca1de6b75258e52e06"},"historical":[{"version":1,"canonicalSha256":"038c59a91bf8967f6908afed74c465f1e7030254e11e4f8738975d6d708424d4"}]},"installerHelpers":{"windowsSecureObject":{"name":"javi-forge-windows-secure-object.ps1","sha256":"2289ef6ac6b039ec74dc3ea0894413e243ff9bea963f04008a356b3838f9b8dd"}}}
|
|
@@ -6,9 +6,16 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Subcommands:
|
|
8
8
|
* - `hooks run <pre-commit|pre-push>` → runHook, exits with its code.
|
|
9
|
-
* - `hooks <install|doctor|repair> claude [--force]` →
|
|
10
|
-
* exits with its code (
|
|
9
|
+
* - `hooks <install|doctor|repair> <claude|codex> [--force]` → the matching
|
|
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
|
+
*
|
|
13
|
+
* The valid agent target set is decided by `isAgentId` (backed by the
|
|
14
|
+
* `AGENT_ADAPTERS` registry — the single source of agent truth). Each id maps to
|
|
15
|
+
* a lazy loader for its console command runner; the map is typed
|
|
16
|
+
* `Record<AgentId, …>`, so a new registry agent cannot be silently dropped
|
|
17
|
+
* (omitting its loader is a compile error). Lazy imports keep cold-start minimal
|
|
18
|
+
* on the commit/push hot path.
|
|
12
19
|
*/
|
|
13
20
|
import type { CLI } from "./types.js";
|
|
14
21
|
export declare function handleHooks(cli: CLI): Promise<void>;
|
|
@@ -6,11 +6,24 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Subcommands:
|
|
8
8
|
* - `hooks run <pre-commit|pre-push>` → runHook, exits with its code.
|
|
9
|
-
* - `hooks <install|doctor|repair> claude [--force]` →
|
|
10
|
-
* exits with its code (
|
|
9
|
+
* - `hooks <install|doctor|repair> <claude|codex> [--force]` → the matching
|
|
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
|
+
*
|
|
13
|
+
* The valid agent target set is decided by `isAgentId` (backed by the
|
|
14
|
+
* `AGENT_ADAPTERS` registry — the single source of agent truth). Each id maps to
|
|
15
|
+
* a lazy loader for its console command runner; the map is typed
|
|
16
|
+
* `Record<AgentId, …>`, so a new registry agent cannot be silently dropped
|
|
17
|
+
* (omitting its loader is a compile error). Lazy imports keep cold-start minimal
|
|
18
|
+
* on the commit/push hot path.
|
|
12
19
|
*/
|
|
20
|
+
import { isAgentId } from "../../lib/agent-adapter.js";
|
|
13
21
|
import { HOOKS_HELP_TEXT } from "../help.js";
|
|
22
|
+
/** Agent registry: id → lazy loader for its console command runner. */
|
|
23
|
+
const AGENT_COMMAND_LOADERS = {
|
|
24
|
+
claude: async () => (await import("../../commands/claude-hooks.js")).runClaudeHookCommand,
|
|
25
|
+
codex: async () => (await import("../../commands/codex-hooks.js")).runCodexHookCommand,
|
|
26
|
+
};
|
|
14
27
|
export async function handleHooks(cli) {
|
|
15
28
|
if (cli.flags.help === true) {
|
|
16
29
|
console.log(HOOKS_HELP_TEXT);
|
|
@@ -28,14 +41,15 @@ export async function handleHooks(cli) {
|
|
|
28
41
|
}
|
|
29
42
|
const sub = cli.input[1];
|
|
30
43
|
if (sub === "install" || sub === "doctor" || sub === "repair") {
|
|
31
|
-
|
|
32
|
-
|
|
44
|
+
const agent = cli.input[2];
|
|
45
|
+
// Validity is decided by the agent registry (isAgentId), the single source
|
|
46
|
+
// of truth; the loader map is exhaustive over AgentId by type.
|
|
47
|
+
if (!isAgentId(agent)) {
|
|
48
|
+
console.error(`Usage: javi-forge hooks ${sub} <claude|codex>`);
|
|
33
49
|
process.exit(1);
|
|
34
50
|
}
|
|
35
|
-
const
|
|
36
|
-
process.exit(await
|
|
37
|
-
force: cli.flags.force === true,
|
|
38
|
-
}));
|
|
51
|
+
const run = await AGENT_COMMAND_LOADERS[agent]();
|
|
52
|
+
process.exit(await run(sub, process.cwd(), { force: cli.flags.force === true }));
|
|
39
53
|
}
|
|
40
54
|
// No subcommand → show usage (exit 0). An unknown subcommand is a typo →
|
|
41
55
|
// show usage but exit 1 rather than run nothing silently.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `javi-forge hooks <install|doctor|repair> codex` — console-only renderer for
|
|
3
|
+
* the Codex PreToolUse guard library (agent-agnostic slice 2). It wires the
|
|
4
|
+
* install/doctor/repair lib fns to human output + exit codes; it adds NO new
|
|
5
|
+
* security logic and never touches `runTransaction`/secure-fs directly.
|
|
6
|
+
*
|
|
7
|
+
* Doctor exit code follows the effective-execution verdict (runnable → 0,
|
|
8
|
+
* blocked → 1, inconclusive → 2), independent of any component health — the same
|
|
9
|
+
* honest-execution contract as the Claude renderer. The UNTRUSTED state is a
|
|
10
|
+
* `blocked` verdict (an untrusted Codex hook is silently skipped), so a fresh
|
|
11
|
+
* install correctly reports blocked until the user grants trust.
|
|
12
|
+
*/
|
|
13
|
+
import { doctorCodexPreToolUse, installCodexPreToolUse, repairCodexPreToolUse } from "../lib/codex-hook-manager.js";
|
|
14
|
+
export type CodexHookSub = "install" | "doctor" | "repair";
|
|
15
|
+
export interface CodexHookCmdDeps {
|
|
16
|
+
install?: typeof installCodexPreToolUse;
|
|
17
|
+
doctor?: typeof doctorCodexPreToolUse;
|
|
18
|
+
repair?: typeof repairCodexPreToolUse;
|
|
19
|
+
homeDir?: string;
|
|
20
|
+
log?: (msg: string) => void;
|
|
21
|
+
logError?: (msg: string) => void;
|
|
22
|
+
}
|
|
23
|
+
export declare function runCodexHookCommand(sub: CodexHookSub, _cwd: string, opts: {
|
|
24
|
+
force?: boolean;
|
|
25
|
+
}, deps?: CodexHookCmdDeps): Promise<number>;
|
|
26
|
+
//# sourceMappingURL=codex-hooks.d.ts.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `javi-forge hooks <install|doctor|repair> codex` — console-only renderer for
|
|
3
|
+
* the Codex PreToolUse guard library (agent-agnostic slice 2). It wires the
|
|
4
|
+
* install/doctor/repair lib fns to human output + exit codes; it adds NO new
|
|
5
|
+
* security logic and never touches `runTransaction`/secure-fs directly.
|
|
6
|
+
*
|
|
7
|
+
* Doctor exit code follows the effective-execution verdict (runnable → 0,
|
|
8
|
+
* blocked → 1, inconclusive → 2), independent of any component health — the same
|
|
9
|
+
* honest-execution contract as the Claude renderer. The UNTRUSTED state is a
|
|
10
|
+
* `blocked` verdict (an untrusted Codex hook is silently skipped), so a fresh
|
|
11
|
+
* install correctly reports blocked until the user grants trust.
|
|
12
|
+
*/
|
|
13
|
+
import { doctorCodexPreToolUse, installCodexPreToolUse, repairCodexPreToolUse, } from "../lib/codex-hook-manager.js";
|
|
14
|
+
function renderWarnings(warnings, log) {
|
|
15
|
+
if (warnings.length === 0)
|
|
16
|
+
return;
|
|
17
|
+
log("warnings:");
|
|
18
|
+
for (const w of warnings)
|
|
19
|
+
log(` ${w}`);
|
|
20
|
+
}
|
|
21
|
+
function renderMutation(verb, result, log, logError) {
|
|
22
|
+
if (result.ok) {
|
|
23
|
+
log(`${verb} codex: ok`);
|
|
24
|
+
if (result.changed.length > 0) {
|
|
25
|
+
log("changed:");
|
|
26
|
+
for (const p of result.changed)
|
|
27
|
+
log(` ${p}`);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
log("changed: nothing (already up to date)");
|
|
31
|
+
}
|
|
32
|
+
log(`trust: ${result.report.trust.state}`);
|
|
33
|
+
if (result.report.trust.state === "untrusted") {
|
|
34
|
+
log(` → ${result.report.trust.grantCommand}`);
|
|
35
|
+
}
|
|
36
|
+
renderWarnings(result.warnings, log);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
logError(`${verb} codex: refused`);
|
|
40
|
+
for (const e of result.errors)
|
|
41
|
+
logError(` ${e}`);
|
|
42
|
+
renderWarnings(result.warnings, log);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
function renderDoctor(report, log) {
|
|
46
|
+
log(`doctor codex: ${report.healthy ? "healthy" : "unhealthy"}`);
|
|
47
|
+
log(` hooks.json: ${report.hooksJson.state}`);
|
|
48
|
+
log(` config: [features] hooks=${report.config.featuresHooks} (readable: ${report.config.readable})`);
|
|
49
|
+
log(` trust: ${report.trust.state}`);
|
|
50
|
+
log(` asset: ${report.asset.state}`);
|
|
51
|
+
log(` node: ${report.node.version ?? "unavailable"} (min-satisfied: ${report.node.satisfiesMinimum})`);
|
|
52
|
+
const onPath = report.nodeOnPath;
|
|
53
|
+
const onPathDetail = onPath.status === "resolved"
|
|
54
|
+
? ` ${onPath.version}`
|
|
55
|
+
: onPath.status === "unknown"
|
|
56
|
+
? ` — ${onPath.detail}`
|
|
57
|
+
: "";
|
|
58
|
+
log(` node-on-PATH: ${onPath.status}${onPathDetail} (heuristic: this process' PATH)`);
|
|
59
|
+
log(` execution: ${report.execution.status}`);
|
|
60
|
+
if (report.execution.blockers.length > 0) {
|
|
61
|
+
log(" blockers:");
|
|
62
|
+
for (const b of report.execution.blockers)
|
|
63
|
+
log(` - ${b}`);
|
|
64
|
+
}
|
|
65
|
+
if (report.execution.unknownSources.length > 0) {
|
|
66
|
+
log(" unknown-sources:");
|
|
67
|
+
for (const u of report.execution.unknownSources)
|
|
68
|
+
log(` - ${u}`);
|
|
69
|
+
}
|
|
70
|
+
if (report.execution.residual.length > 0) {
|
|
71
|
+
log(" execution-residual:");
|
|
72
|
+
for (const r of report.execution.residual)
|
|
73
|
+
log(` - ${r}`);
|
|
74
|
+
}
|
|
75
|
+
if (report.remediation.length > 0) {
|
|
76
|
+
log(" remediation:");
|
|
77
|
+
for (const r of report.remediation)
|
|
78
|
+
log(` - ${r}`);
|
|
79
|
+
}
|
|
80
|
+
if (report.execution.status === "blocked")
|
|
81
|
+
return 1;
|
|
82
|
+
if (report.execution.status === "inconclusive")
|
|
83
|
+
return 2;
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
export async function runCodexHookCommand(sub, _cwd, opts, deps = {}) {
|
|
87
|
+
const log = deps.log ?? ((m) => console.log(m));
|
|
88
|
+
const logError = deps.logError ?? ((m) => console.error(m));
|
|
89
|
+
const install = deps.install ?? installCodexPreToolUse;
|
|
90
|
+
const doctor = deps.doctor ?? doctorCodexPreToolUse;
|
|
91
|
+
const repair = deps.repair ?? repairCodexPreToolUse;
|
|
92
|
+
// Codex config is user-global (~/.codex). `cwd` is accepted for CLI symmetry
|
|
93
|
+
// with the Claude command; the home dir is what the manager operates on.
|
|
94
|
+
const home = deps.homeDir;
|
|
95
|
+
if (sub === "install") {
|
|
96
|
+
return renderMutation("install", await install(home), log, logError);
|
|
97
|
+
}
|
|
98
|
+
if (sub === "repair") {
|
|
99
|
+
const result = await repair(home, { force: opts.force === true });
|
|
100
|
+
return renderMutation("repair", result, log, logError);
|
|
101
|
+
}
|
|
102
|
+
return renderDoctor(await doctor(home), log);
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=codex-hooks.js.map
|
|
@@ -24,6 +24,12 @@ export declare const LEGACY_FILE_SHA256 = "b4638222ecddc2daac6ec3339596d853a6269
|
|
|
24
24
|
export declare const MANAGED_MATCHER = "Bash|PowerShell|Read|Write|Edit";
|
|
25
25
|
/** Single project-local MJS argument, kept as a literal placeholder path. */
|
|
26
26
|
export declare const MANAGED_ASSET_ARG = "${CLAUDE_PROJECT_DIR}/.claude/hooks/javi-forge-skillguard-pre-tool-use.mjs";
|
|
27
|
+
/**
|
|
28
|
+
* The agent selector the install writer appends after the asset path. The runtime
|
|
29
|
+
* fails closed without it (no agent config = cannot know what to protect), so it is
|
|
30
|
+
* part of the managed command shape and the canonical settings identity.
|
|
31
|
+
*/
|
|
32
|
+
export declare const MANAGED_AGENT_ARG = "--agent=claude";
|
|
27
33
|
/** Exact asset filename under `.claude/hooks/`. */
|
|
28
34
|
export declare const ASSET_NAME = "javi-forge-skillguard-pre-tool-use.mjs";
|
|
29
35
|
/** Exact first-line comment marking the managed asset. */
|
|
@@ -25,6 +25,12 @@ export const LEGACY_FILE_SHA256 = "b4638222ecddc2daac6ec3339596d853a626906bbd123
|
|
|
25
25
|
export const MANAGED_MATCHER = "Bash|PowerShell|Read|Write|Edit";
|
|
26
26
|
/** Single project-local MJS argument, kept as a literal placeholder path. */
|
|
27
27
|
export const MANAGED_ASSET_ARG = "${CLAUDE_PROJECT_DIR}/.claude/hooks/javi-forge-skillguard-pre-tool-use.mjs";
|
|
28
|
+
/**
|
|
29
|
+
* The agent selector the install writer appends after the asset path. The runtime
|
|
30
|
+
* fails closed without it (no agent config = cannot know what to protect), so it is
|
|
31
|
+
* part of the managed command shape and the canonical settings identity.
|
|
32
|
+
*/
|
|
33
|
+
export const MANAGED_AGENT_ARG = "--agent=claude";
|
|
28
34
|
/** Exact asset filename under `.claude/hooks/`. */
|
|
29
35
|
export const ASSET_NAME = "javi-forge-skillguard-pre-tool-use.mjs";
|
|
30
36
|
/** Exact first-line comment marking the managed asset. */
|
|
@@ -44,7 +50,7 @@ export function managedHandler(assetSha = SAMPLE_ASSET_SHA256) {
|
|
|
44
50
|
return {
|
|
45
51
|
type: "command",
|
|
46
52
|
command: "node",
|
|
47
|
-
args: [MANAGED_ASSET_ARG],
|
|
53
|
+
args: [MANAGED_ASSET_ARG, MANAGED_AGENT_ARG],
|
|
48
54
|
timeout: 30,
|
|
49
55
|
statusMessage: managedStatusMessage(assetSha),
|
|
50
56
|
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent adapter registry (agent-agnostic slice 2). One descriptor per host
|
|
3
|
+
* (`claude`, `codex`) capturing the per-agent facts the SkillGuard installer and
|
|
4
|
+
* doctor need: config paths, the protected managed set, the project-root source,
|
|
5
|
+
* the settings-schema validator (SHARED — both hosts use the identical hooks
|
|
6
|
+
* schema), the managed marker, the deny protocol, and the trust model.
|
|
7
|
+
*
|
|
8
|
+
* This descriptor is additive: Codex is routed through it, while Claude keeps its
|
|
9
|
+
* existing, byte-identical runtime path (the descriptor only DESCRIBES Claude; it
|
|
10
|
+
* does not change how the Claude command executes). The CLI dispatch decides the
|
|
11
|
+
* valid `hooks <sub> <agent>` target set through `isAgentId` (backed by the
|
|
12
|
+
* `AGENT_ADAPTERS` keys) — this registry is the single source of agent truth, and
|
|
13
|
+
* a new agent added here cannot be silently dropped: the dispatch's command-loader
|
|
14
|
+
* map is typed `Record<AgentId, …>`, so omitting a loader is a compile error.
|
|
15
|
+
*/
|
|
16
|
+
import { validateSettingsShape } from "./claude-hook-settings.js";
|
|
17
|
+
export type AgentId = "claude" | "codex";
|
|
18
|
+
export type TrustState = "trusted" | "untrusted" | "unknown";
|
|
19
|
+
export interface TrustDescriptor {
|
|
20
|
+
/** Detect whether the installed hook is TRUSTED from the host config text. */
|
|
21
|
+
detect(configText: string, hooksFile: string): TrustState;
|
|
22
|
+
/** The exact step a human runs to grant trust (no non-interactive path exists). */
|
|
23
|
+
grantCommand(hooksFile: string): string;
|
|
24
|
+
}
|
|
25
|
+
export interface AgentAdapter {
|
|
26
|
+
id: AgentId;
|
|
27
|
+
/** Resolve the two host config files from a base dir (project root or home). */
|
|
28
|
+
configPaths(baseDir: string): {
|
|
29
|
+
hooksFile: string;
|
|
30
|
+
settingsFile: string;
|
|
31
|
+
};
|
|
32
|
+
/** Relative protected paths the guard refuses writes to (from AGENT_CONFIGS). */
|
|
33
|
+
managedSet: readonly string[];
|
|
34
|
+
/** Project-root source: an env var, or null → the envelope `cwd` (Codex). */
|
|
35
|
+
projectDir: {
|
|
36
|
+
envVar: string | null;
|
|
37
|
+
};
|
|
38
|
+
/** SHARED settings-schema validator — the hooks container shape is identical. */
|
|
39
|
+
settingsSchema: typeof validateSettingsShape;
|
|
40
|
+
/** The managed asset marker for this host. */
|
|
41
|
+
marker: string;
|
|
42
|
+
/** Deny protocol emitted by the shared `.mjs` — the same on both hosts. */
|
|
43
|
+
emitDeny: "exit2+stderr";
|
|
44
|
+
/** Hook-trust model, or null when the host has none (Claude). */
|
|
45
|
+
trust: TrustDescriptor | null;
|
|
46
|
+
}
|
|
47
|
+
export declare const claudeAdapter: AgentAdapter;
|
|
48
|
+
export declare const codexAdapter: AgentAdapter;
|
|
49
|
+
export declare const AGENT_ADAPTERS: Record<AgentId, AgentAdapter>;
|
|
50
|
+
/**
|
|
51
|
+
* Whether a raw CLI token names a known agent adapter. Backed by the
|
|
52
|
+
* `AGENT_ADAPTERS` registry so there is ONE source deciding valid targets: adding
|
|
53
|
+
* an adapter to the registry automatically widens the accepted CLI set.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isAgentId(value: unknown): value is AgentId;
|
|
56
|
+
//# sourceMappingURL=agent-adapter.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent adapter registry (agent-agnostic slice 2). One descriptor per host
|
|
3
|
+
* (`claude`, `codex`) capturing the per-agent facts the SkillGuard installer and
|
|
4
|
+
* doctor need: config paths, the protected managed set, the project-root source,
|
|
5
|
+
* the settings-schema validator (SHARED — both hosts use the identical hooks
|
|
6
|
+
* schema), the managed marker, the deny protocol, and the trust model.
|
|
7
|
+
*
|
|
8
|
+
* This descriptor is additive: Codex is routed through it, while Claude keeps its
|
|
9
|
+
* existing, byte-identical runtime path (the descriptor only DESCRIBES Claude; it
|
|
10
|
+
* does not change how the Claude command executes). The CLI dispatch decides the
|
|
11
|
+
* valid `hooks <sub> <agent>` target set through `isAgentId` (backed by the
|
|
12
|
+
* `AGENT_ADAPTERS` keys) — this registry is the single source of agent truth, and
|
|
13
|
+
* a new agent added here cannot be silently dropped: the dispatch's command-loader
|
|
14
|
+
* map is typed `Record<AgentId, …>`, so omitting a loader is a compile error.
|
|
15
|
+
*/
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { ASSET_NAME } from "./__fixtures__/claude-hook-ownership.js";
|
|
18
|
+
import { validateSettingsShape } from "./claude-hook-settings.js";
|
|
19
|
+
import { codexConfigPaths, codexTrustGrantCommand, hasCodexTrustEntry, } from "./codex-hook-manager.js";
|
|
20
|
+
const CLAUDE_MANAGED_SET = [
|
|
21
|
+
".claude/settings.json",
|
|
22
|
+
".claude/settings.local.json",
|
|
23
|
+
".claude/CLAUDE.md",
|
|
24
|
+
"CLAUDE.md",
|
|
25
|
+
".javi-forge/ci.yaml",
|
|
26
|
+
".claude/hooks/",
|
|
27
|
+
".claude/agents/",
|
|
28
|
+
".claude/skills/",
|
|
29
|
+
];
|
|
30
|
+
const CODEX_MANAGED_SET = [
|
|
31
|
+
".codex/hooks.json",
|
|
32
|
+
".claude/settings.json",
|
|
33
|
+
".claude/settings.local.json",
|
|
34
|
+
".claude/CLAUDE.md",
|
|
35
|
+
"CLAUDE.md",
|
|
36
|
+
".javi-forge/ci.yaml",
|
|
37
|
+
".claude/hooks/",
|
|
38
|
+
".claude/agents/",
|
|
39
|
+
".claude/skills/",
|
|
40
|
+
];
|
|
41
|
+
export const claudeAdapter = {
|
|
42
|
+
id: "claude",
|
|
43
|
+
configPaths(projectDir) {
|
|
44
|
+
return {
|
|
45
|
+
hooksFile: path.join(projectDir, ".claude", "hooks", ASSET_NAME),
|
|
46
|
+
settingsFile: path.join(projectDir, ".claude", "settings.json"),
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
managedSet: CLAUDE_MANAGED_SET,
|
|
50
|
+
projectDir: { envVar: "CLAUDE_PROJECT_DIR" },
|
|
51
|
+
settingsSchema: validateSettingsShape,
|
|
52
|
+
marker: "// javi-forge-managed: claude-pretooluse v1",
|
|
53
|
+
emitDeny: "exit2+stderr",
|
|
54
|
+
trust: null,
|
|
55
|
+
};
|
|
56
|
+
export const codexAdapter = {
|
|
57
|
+
id: "codex",
|
|
58
|
+
configPaths(homeDir) {
|
|
59
|
+
const paths = codexConfigPaths(homeDir);
|
|
60
|
+
return { hooksFile: paths.hooksFile, settingsFile: paths.configFile };
|
|
61
|
+
},
|
|
62
|
+
managedSet: CODEX_MANAGED_SET,
|
|
63
|
+
projectDir: { envVar: null },
|
|
64
|
+
settingsSchema: validateSettingsShape,
|
|
65
|
+
marker: "// javi-forge-managed: codex-pretooluse v1",
|
|
66
|
+
emitDeny: "exit2+stderr",
|
|
67
|
+
trust: {
|
|
68
|
+
detect(configText, hooksFile) {
|
|
69
|
+
return hasCodexTrustEntry(configText, hooksFile)
|
|
70
|
+
? "trusted"
|
|
71
|
+
: "untrusted";
|
|
72
|
+
},
|
|
73
|
+
grantCommand: codexTrustGrantCommand,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
export const AGENT_ADAPTERS = {
|
|
77
|
+
claude: claudeAdapter,
|
|
78
|
+
codex: codexAdapter,
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Whether a raw CLI token names a known agent adapter. Backed by the
|
|
82
|
+
* `AGENT_ADAPTERS` registry so there is ONE source deciding valid targets: adding
|
|
83
|
+
* an adapter to the registry automatically widens the accepted CLI set.
|
|
84
|
+
*/
|
|
85
|
+
export function isAgentId(value) {
|
|
86
|
+
return typeof value === "string" && Object.hasOwn(AGENT_ADAPTERS, value);
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=agent-adapter.js.map
|