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.
@@ -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 canonicalizePolicyPath(input, options = {}) {
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(PROJECT_ROOT);
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 === "claude.md" || relative === ".claude/claude.md");
126
- return foldedClaudeMd || relative === ".claude/settings.json" || relative === ".claude/settings.local.json" || relative === ".claude/CLAUDE.md" || relative === "CLAUDE.md" || relative === ".javi-forge/ci.yaml" || relative.startsWith(".claude/hooks/") || relative.startsWith(".claude/agents/") || relative.startsWith(".claude/skills/");
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: PROJECT_ROOT }));
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: PROJECT_ROOT }));
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
- export function evaluateEvent(input) {
746
- if (!isObject(input) || input.hook_event_name !== "PreToolUse" || !SUPPORTED_TOOLS.includes(input.tool_name) || !isObject(input.tool_input)) fail("invalid-event");
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
- const cwd = typeof input.cwd === "string" && isAbsolutePolicyPath(input.cwd) ? input.cwd : PROJECT_ROOT;
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":"5dc2a5c31131f4ac7d8657c78b950de52776aad6eaefe78ea0d764a9963c4425","historical":["78be7e6613c012280b7ad17886462ba166b63ebd031e34565d757b3a0796d7cc"]},"settingsEntries":{"current":{"version":1,"canonicalSha256":"038c59a91bf8967f6908afed74c465f1e7030254e11e4f8738975d6d708424d4"},"historical":[]},"installerHelpers":{"windowsSecureObject":{"name":"javi-forge-windows-secure-object.ps1","sha256":"2289ef6ac6b039ec74dc3ea0894413e243ff9bea963f04008a356b3838f9b8dd"}}}
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]` → runClaudeHookCommand,
10
- * exits with its code (wrong/missing target → usage + exit 1).
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]` → runClaudeHookCommand,
10
- * exits with its code (wrong/missing target → usage + exit 1).
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
- if (cli.input[2] !== "claude") {
32
- console.error(`Usage: javi-forge hooks ${sub} claude`);
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 { runClaudeHookCommand } = await import("../../commands/claude-hooks.js");
36
- process.exit(await runClaudeHookCommand(sub, process.cwd(), {
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