javi-forge 1.35.1 → 1.37.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/README.md CHANGED
@@ -372,6 +372,11 @@ npx javi-forge doctor
372
372
  - **Project Manifest** — Reads `.javi-forge/manifest.json`
373
373
  - **Installed Modules** — engram, obsidian-brain, memory-simple, ghagga
374
374
 
375
+
376
+ ## macOS CI-Local support
377
+
378
+ macOS is deprecated and unsupported for new CI-Local install/startup. Pin a supported release or migrate your workflow. Existing installed guards are not removed in 1.x; Darwin code removal is planned separately for 2.0.
379
+
375
380
  ## Requirements
376
381
 
377
382
  - **Node.js** >= 22 (required by ink 7; previous versions ran on >= 18)
@@ -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"}}}
@@ -34,21 +34,26 @@ cp -r lib /path/to/new-project/
34
34
 
35
35
  # En el nuevo proyecto
36
36
  cd /path/to/new-project
37
- ./.ci-local/install.sh # Linux/Mac/WSL
37
+ ./.ci-local/install.sh # Linux/WSL
38
38
  # o
39
39
  .\.ci-local\install.ps1 # Windows (PowerShell 7+)
40
40
  ```
41
41
 
42
- > **Importante:** CI-Local depende de `lib/common.sh` (Linux/Mac) y/o `lib/common.psm1` (Windows). Copiá ambos junto con el directorio `ci-local/`.
42
+ > **Importante:** CI-Local depende de `lib/common.sh` (Linux/WSL) y/o `lib/common.psm1` (Windows). Copiá ambos junto con el directorio `ci-local/`. macOS está deprecated y se rechaza para instalaciones o arranques nuevos.
43
43
 
44
44
  El installer (ambas variantes) falla con mensaje claro si `javi-forge` no está en el PATH.
45
45
 
46
+
47
+ ## macOS deprecation
48
+
49
+ macOS is deprecated and unsupported for new CI-Local install/startup. Pin a supported release or migrate your workflow. Existing installed guards are not removed in 1.x; Darwin code removal is planned separately for 2.0.
50
+
46
51
  ### Soporte cross-platform
47
52
 
48
53
  | Plataforma | Installer | Runner | Hooks | Requisitos |
49
54
  |---|---|---|---|---|
50
55
  | Linux | `install.sh` | `ci-local.sh` | bash | bash, perl, docker (opcional) |
51
- | macOS | `install.sh` | `ci-local.sh` | bash | bash, perl, docker (opcional) |
56
+ | macOS | Unsupported: pin a supported release or migrate | Refused before startup | Existing installed guards retained | No new install or startup |
52
57
  | WSL | `install.sh` | `ci-local.sh` | bash | bash, perl, docker (opcional) |
53
58
  | Windows nativo | `install.ps1` | `ci-local.ps1` | bash | PowerShell 7+, Git for Windows (MSYS2 bash), docker (opcional) |
54
59
 
@@ -117,7 +122,7 @@ Si tu modelo de amenaza requiere defensa contra adversarios motivados, usá
117
122
  .\.ci-local\ci-local.ps1 shell # Shell en entorno CI
118
123
  .\.ci-local\ci-local.ps1 detect # Ver stack detectado
119
124
 
120
- # Linux/Mac
125
+ # Linux/WSL
121
126
  ./.ci-local/ci-local.sh quick
122
127
  ./.ci-local/ci-local.sh full
123
128
  ./.ci-local/ci-local.sh shell
@@ -164,9 +169,9 @@ Developer workflow:
164
169
  ```
165
170
  .ci-local/
166
171
  ├── ci-local.ps1 # Script principal (Windows)
167
- ├── ci-local.sh # Script principal (Linux/Mac)
172
+ ├── ci-local.sh # Script principal (Linux/WSL)
168
173
  ├── install.ps1 # Instalador Windows
169
- ├── install.sh # Instalador Linux/Mac
174
+ ├── install.sh # Instalador Linux/WSL
170
175
  ├── semgrep.yml # Reglas de seguridad
171
176
  ├── README.md # Esta guía
172
177
  ├── hooks/
@@ -21,6 +21,30 @@ param(
21
21
  [string]$Mode = 'full'
22
22
  )
23
23
 
24
+ function Invoke-CiLocalMain {
25
+ param(
26
+ [Parameter(Mandatory)][string]$Platform,
27
+ [ref]$ExitCode
28
+ )
29
+
30
+ if ($Platform -eq 'Darwin') {
31
+ Write-Host 'macOS is deprecated and unsupported for new CI-Local install/startup. Pin a supported release or migrate. Existing installed guards are not removed; Darwin code removal is planned separately for 2.0.'
32
+ if ($PSBoundParameters.ContainsKey('ExitCode')) {
33
+ $ExitCode.Value = 1
34
+ return
35
+ }
36
+ return 1
37
+ }
38
+
39
+ Invoke-CiLocalStartupBody -Platform $Platform
40
+ if ($PSBoundParameters.ContainsKey('ExitCode')) {
41
+ $ExitCode.Value = 0
42
+ }
43
+ }
44
+
45
+ function Invoke-CiLocalStartupBody {
46
+ param([string]$Platform)
47
+
24
48
  # See install.ps1 for the rationale behind 7.2 minimum (ResolveLinkTarget).
25
49
  if ($PSVersionTable.PSEdition -ne 'Core' -or $PSVersionTable.PSVersion -lt [Version]'7.2') {
26
50
  Write-Host 'ERROR: ci-local.ps1 requires PowerShell 7.2+ (pwsh).' -ForegroundColor Red
@@ -441,3 +465,13 @@ Write-Host ''
441
465
  Write-Host 'CI Local completed successfully!' -ForegroundColor Green
442
466
  Write-Host ' Safe to push - CI should pass.' -ForegroundColor Green
443
467
  Write-Host ''
468
+ }
469
+
470
+ if ($MyInvocation.InvocationName -ne '.') {
471
+ $platform = if ($IsMacOS) { 'Darwin' } else { 'Windows' }
472
+ $exitCode = 0
473
+ & ${function:Invoke-CiLocalMain} -Platform $platform -ExitCode ([ref]$exitCode)
474
+ if ($exitCode -ne 0) {
475
+ exit $exitCode
476
+ }
477
+ }
@@ -11,7 +11,20 @@
11
11
  # ./ci-local.sh detect # Mostrar stack detectado
12
12
  # =============================================================================
13
13
 
14
- set -e
14
+ function ci_local_main() {
15
+ local platform="${1:?platform required}"
16
+ shift
17
+
18
+ if [ "$platform" = "Darwin" ]; then
19
+ printf '%s\n' 'macOS is deprecated and unsupported for new CI-Local install/startup. Pin a supported release or migrate. Existing installed guards are not removed; Darwin code removal is planned separately for 2.0.'
20
+ return 1
21
+ fi
22
+
23
+ ci_local_startup_body "$@"
24
+ }
25
+
26
+ function ci_local_startup_body() {
27
+ set -e
15
28
 
16
29
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
17
30
  PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
@@ -390,3 +403,8 @@ esac
390
403
 
391
404
  echo -e "\n${GREEN}CI Local completed successfully!${NC}"
392
405
  echo -e "${GREEN} Safe to push - CI should pass.${NC}\n"
406
+ }
407
+
408
+ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
409
+ ci_local_main "$(/usr/bin/uname -s)" "$@"
410
+ fi
@@ -14,6 +14,20 @@
14
14
  [CmdletBinding()]
15
15
  param()
16
16
 
17
+ function Invoke-CiLocalMain {
18
+ param([Parameter(Mandatory)][string]$Platform)
19
+
20
+ if ($Platform -eq 'Darwin') {
21
+ Write-Host 'macOS is deprecated and unsupported for new CI-Local install/startup. Pin a supported release or migrate. Existing installed guards are not removed; Darwin code removal is planned separately for 2.0.'
22
+ return 1
23
+ }
24
+
25
+ Invoke-CiLocalStartupBody -Platform $Platform
26
+ }
27
+
28
+ function Invoke-CiLocalStartupBody {
29
+ param([string]$Platform)
30
+
17
31
  # Defense-in-depth: #Requires is parsed by some hosts AFTER the script body.
18
32
  # 7.2 is the minimum because FileSystemInfo.ResolveLinkTarget($true) is .NET 6+,
19
33
  # which shipped with pwsh 7.2. Earlier pwsh 7.x runs on .NET 5 and the method
@@ -365,3 +379,12 @@ try {
365
379
  } finally {
366
380
  Pop-Location
367
381
  }
382
+ }
383
+
384
+ if ($MyInvocation.InvocationName -ne '.') {
385
+ $platform = if ($IsMacOS) { 'Darwin' } else { 'Windows' }
386
+ $exitCode = & ${function:Invoke-CiLocalMain} -Platform $platform
387
+ if ($exitCode -is [int] -and $exitCode -ne 0) {
388
+ exit $exitCode
389
+ }
390
+ }
@@ -3,7 +3,19 @@
3
3
  # CI-LOCAL: Installation Script
4
4
  # =============================================================================
5
5
 
6
- set -e
6
+ function ci_local_main() {
7
+ local platform="${1:?platform required}"
8
+
9
+ if [ "$platform" = "Darwin" ]; then
10
+ printf '%s\n' 'macOS is deprecated and unsupported for new CI-Local install/startup. Pin a supported release or migrate. Existing installed guards are not removed; Darwin code removal is planned separately for 2.0.'
11
+ return 1
12
+ fi
13
+
14
+ ci_local_startup_body "$platform"
15
+ }
16
+
17
+ function ci_local_startup_body() {
18
+ set -e
7
19
 
8
20
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
21
  PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
@@ -193,3 +205,8 @@ echo -e " - pre-commit: AI check + lint + security"
193
205
  echo -e " - commit-msg: Block AI attribution"
194
206
  echo -e " - pre-push: CI simulation in Docker"
195
207
  echo -e ""
208
+ }
209
+
210
+ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
211
+ ci_local_main "$(/usr/bin/uname -s)"
212
+ fi
@@ -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.
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { render } from "ink";
11
11
  import React from "react";
12
+ import { resolvePlatformSupport } from "../../lib/platform-support.js";
12
13
  import AnalyzeUI from "../../ui/AnalyzeUI.js";
13
14
  import App from "../../ui/App.js";
14
15
  import { CIProvider as CIContextProvider } from "../../ui/CIContext.js";
@@ -49,6 +50,12 @@ export function handlePlugin(cli, ctx) {
49
50
  React.createElement(Plugin, { action: action, target: target, dryRun: cli.flags.dryRun, codex: cli.flags.codex, force: cli.flags.force })), { stdin: ctx.inkStdin });
50
51
  }
51
52
  export function handleInitDefault(cli, ctx) {
53
+ const platformSupport = resolvePlatformSupport(process.platform);
54
+ if (platformSupport) {
55
+ console.error(`${platformSupport.refusalCode}: ${platformSupport.guidance}`);
56
+ process.exitCode = 1;
57
+ return;
58
+ }
52
59
  const presetStack = VALID_STACKS.includes(cli.flags.stack)
53
60
  ? cli.flags.stack
54
61
  : undefined;
@@ -59,6 +59,8 @@ function renderMutation(verb, result, log, logError) {
59
59
  }
60
60
  function renderDoctor(report, log) {
61
61
  log(`doctor claude: ${report.healthy ? "healthy" : "unhealthy"}`);
62
+ if (report.platformSupport)
63
+ log(` platform-support: ${report.platformSupport.state} — ${report.platformSupport.guidance}`);
62
64
  log(` settings: ${report.settings.state} — ${report.settings.detail}`);
63
65
  log(` asset: ${report.asset.state} — ${report.asset.detail}`);
64
66
  log(` node: ${report.node.version ?? "unavailable"} (min-satisfied: ${report.node.satisfiesMinimum})`);
@@ -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