javi-forge 1.39.3 → 1.39.5

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.
@@ -11,6 +11,10 @@ export const SUPPORTED_TOOLS = Object.freeze(["Bash", "PowerShell", "Read", "Wri
11
11
  export const POLICY_REGISTRY = Object.freeze({ schemaVersion: 1, policyVersion: 2, diagnosticsMaxBytes: 240 });
12
12
  export function resolvePlatformSupport(platform = process.platform) { return platform === "linux" || platform === "win32" ? { supported: true } : { supported: false, reason: "unsupported-platform" }; }
13
13
  const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
14
+ // The global Codex targets are anchored to the hook process's current-user home,
15
+ // never to an event field or a command token. This is intentionally an exact,
16
+ // two-file allowlist rather than a whole-home or whole-.codex policy.
17
+ const HOST_HOME = os.homedir();
14
18
  // Per-agent adapter config (S0 core-extraction): every agent-specific input the guard needs
15
19
  // (the isManaged protected-path set, the project-dir source, the managed marker) is resolved by
16
20
  // the --agent selector instead of a baked-in literal. The pure evaluate*/utility engine stays
@@ -18,7 +22,8 @@ const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
18
22
  // byte-identical; codex is defined so the map is agent-generic but wired end-to-end only in a later slice.
19
23
  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"]) });
20
24
  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"]) });
21
- 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" }) });
25
+ const CODEX_GLOBAL_MANAGED_PATHS = Object.freeze([".codex/hooks.json", ".codex/config.toml"]);
26
+ export const AGENT_CONFIGS = Object.freeze({ claude: Object.freeze({ id: "claude", managedSet: CLAUDE_MANAGED_SET, globalManagedPaths: Object.freeze([]), projectDir: Object.freeze({ envVar: "CLAUDE_PROJECT_DIR", fallback: "asset-root" }), marker: MANAGED_MARKER }), codex: Object.freeze({ id: "codex", managedSet: CODEX_MANAGED_SET, globalManagedPaths: CODEX_GLOBAL_MANAGED_PATHS, projectDir: Object.freeze({ envVar: null, fallback: "cwd" }), marker: "// javi-forge-managed: codex-pretooluse v1" }) });
22
27
  // Fail-closed agent selector: a missing/unknown --agent means we cannot know what to protect, so refuse.
23
28
  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; }
24
29
  // Project root per agent: the env var when set (Claude = CLAUDE_PROJECT_DIR); otherwise the per-agent
@@ -135,7 +140,8 @@ export function isSensitivePolicyKey(key, platform = process.platform) {
135
140
  if (SENSITIVE_DIRECTORY_SUFFIXES.some((directory) => key.endsWith(directory) || key.includes(`${directory}/`))) return true;
136
141
  return platform === "win32" ? basename.toLowerCase() === "serviceaccountkey.json" : basename === "serviceAccountKey.json";
137
142
  }
138
- function isManaged(key, managedSet = CLAUDE_MANAGED_SET, projectRoot = PROJECT_ROOT) {
143
+ function isManaged(key, managedSet = CLAUDE_MANAGED_SET, projectRoot = PROJECT_ROOT, globalManagedPaths = []) {
144
+ if (globalManagedPaths.some((relative) => key === canonicalizePolicyPath(path.join(HOST_HOME, relative)))) return true;
139
145
  const project = canonicalizePolicyPath(projectRoot);
140
146
  if (!key.startsWith(`${project}/`) && key !== project) return false;
141
147
  const relative = key.slice(project.length + 1);
@@ -148,7 +154,7 @@ function isManaged(key, managedSet = CLAUDE_MANAGED_SET, projectRoot = PROJECT_R
148
154
  function evaluateFile(toolName, filePath, config = AGENT_CONFIGS.claude, projectRoot = PROJECT_ROOT) {
149
155
  const keys = policyPathKeys(filePath);
150
156
  if (keys.some((key) => isSensitivePolicyKey(key))) return { allowed: false, ruleId: "path.sensitive" };
151
- if (toolName !== "Read" && keys.some((key) => isManaged(key, config.managedSet, projectRoot))) return { allowed: false, ruleId: "path.managed-config" };
157
+ if (toolName !== "Read" && keys.some((key) => isManaged(key, config.managedSet, projectRoot, config.globalManagedPaths))) return { allowed: false, ruleId: "path.managed-config" };
152
158
  return { allowed: true };
153
159
  }
154
160
  function lex(command, powershell = false) {
@@ -753,7 +759,7 @@ function hasManagedLiteral(tokens, cwd, config = AGENT_CONFIGS.claude, projectRo
753
759
  return tokens.some((token) => {
754
760
  if ((token.startsWith("-") && !/^-(?:LiteralPath|Path):/i.test(token)) || !/[\\/.]/.test(token)) return false;
755
761
  try {
756
- return isManaged(canonicalizePolicyPath(token.replace(/^(?:-LiteralPath:|-Path:)/i, "").replace(/[;,]$/, ""), { base: cwd, projectRoot }), config.managedSet, projectRoot);
762
+ return isManaged(canonicalizePolicyPath(token.replace(/^(?:-LiteralPath:|-Path:)/i, "").replace(/[;,]$/, ""), { base: cwd, projectRoot }), config.managedSet, projectRoot, config.globalManagedPaths);
757
763
  } catch {
758
764
  return false;
759
765
  }
@@ -857,6 +863,7 @@ function evaluateBash(command, cwd, config = AGENT_CONFIGS.claude, projectRoot =
857
863
  if (reduced.ambiguity) return ambiguityDecision(reduced.ambiguity);
858
864
  const tokens = reduced.tokens;
859
865
  const executable = (tokens[0] ?? "").toLowerCase();
866
+ const executableName = path.basename(executable.replaceAll("\\", "/"));
860
867
  const identity = normalizeLiteralUtilityIdentity(tokens[0] ?? "");
861
868
  const rmOptions = tokens.filter((token) => token.startsWith("-")).join("");
862
869
  if ((executable === "rm" && /r/i.test(rmOptions) && /f/i.test(rmOptions) && tokens.some(isCriticalTarget)) || /^mkfs/.test(executable) || (executable === "dd" && tokens.some((token) => /^of=\/dev\/(?:sd|nvme|vd|disk)/.test(token)))) return { allowed: false, ruleId: "shell.destructive-root" };
@@ -880,8 +887,8 @@ function evaluateBash(command, cwd, config = AGENT_CONFIGS.claude, projectRoot =
880
887
  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" };
881
888
  if (tokens.some((token) => token === "<") && hasSensitiveLiteral(tokens, cwd, projectRoot)) return { allowed: false, ruleId: "shell.sensitive-read" };
882
889
  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" };
883
- 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" };
884
- 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" };
890
+ if (["rm", "mv", "cp", "install", "truncate", "touch", "chmod", "chown", "tee"].includes(executableName) && hasManagedLiteral(tokens.slice(1), cwd, config, projectRoot)) return { allowed: false, ruleId: "shell.managed-config-tamper" };
891
+ if ((/^(?:sed|perl)$/.test(executableName) && 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" };
885
892
  if (/^(?:powershell|pwsh)(?:\.exe)?$/i.test(executable) && tokens.some((token) => /^-(?:enc|encodedcommand)$/i.test(token))) return { allowed: false, ruleId: "shell.obfuscated-interpreter" };
886
893
  if (/^(?:bash|sh|zsh|dash|ksh)$/.test(executable)) {
887
894
  const flag = tokens.findIndex((token) => /^-[^-]*c[^-]*$/.test(token));
@@ -4,7 +4,7 @@
4
4
  "name": "javi-forge-skillguard-pre-tool-use.mjs",
5
5
  "version": 1,
6
6
  "policyVersion": 2,
7
- "sha256": "507a57a15f1b967103bb1eefe701a74813d5a9603fa7bcc15579a215b03621e3",
7
+ "sha256": "f55ece1aea76251ec005f2b731fa44a58b6245fef8150b58486ae062167a2377",
8
8
  "historical": [
9
9
  "78be7e6613c012280b7ad17886462ba166b63ebd031e34565d757b3a0796d7cc",
10
10
  "5dc2a5c31131f4ac7d8657c78b950de52776aad6eaefe78ea0d764a9963c4425",
@@ -13,7 +13,8 @@
13
13
  "54a270f28b068450b79547a88ec6f2d4854514392fd5f38ed1d6174ea093d7aa",
14
14
  "9a565cec31d9e091e3fb9420b86685f824733bc1ebe479f086b2b955aba6ef3e",
15
15
  "59fc4224975ad64cfc85bab50ec60d9bd4948070e9d41f42ab43e6d8231c19a1",
16
- "6edfbb31ce0551b27e38ae1ffd1daf9cc4bea54f2c86687c5124132af5b8c0af"
16
+ "6edfbb31ce0551b27e38ae1ffd1daf9cc4bea54f2c86687c5124132af5b8c0af",
17
+ "507a57a15f1b967103bb1eefe701a74813d5a9603fa7bcc15579a215b03621e3"
17
18
  ]
18
19
  },
19
20
  "settingsEntries": {
@@ -13,6 +13,8 @@ interface FakeFile {
13
13
  }
14
14
  /** Per-call fault predicates; every one defaults to "no fault". */
15
15
  export interface FakeFaults {
16
+ closeRefuse?: (dirPath: string) => boolean;
17
+ reuseFirstHandle?: (dirPath: string) => boolean;
16
18
  /** Refuse revalidateIdentity for a path on its Nth (1-based) call. */
17
19
  revalidateRefuse?: (target: string, callIndex: number) => boolean;
18
20
  /** Refuse proveOwnershipAndMode for a path on its Nth call. */
@@ -57,8 +59,11 @@ export interface FakeSecureFs extends PlatformSecureFs {
57
59
  readonly dirs: Set<string>;
58
60
  readonly files: Map<string, FakeFile>;
59
61
  readonly dirModes: Map<string, number>;
62
+ readonly events: string[];
63
+ readonly closeAttempts: string[];
60
64
  faults: FakeFaults;
61
65
  seedDir(dirPath: string): void;
66
+ shareDirIdentity(target: string, source: string): void;
62
67
  seedFile(filePath: string, bytes: Buffer, mode?: number): void;
63
68
  fileText(filePath: string): string | undefined;
64
69
  hasBackup(dirPath: string): boolean;
@@ -34,6 +34,9 @@ export function makeFakeSecureFs() {
34
34
  const endangeringCounts = new Map();
35
35
  const writeCounts = new Map();
36
36
  const managedCounts = new Map();
37
+ const events = [];
38
+ const closeAttempts = [];
39
+ let handleSeq = 0, firstHandle;
37
40
  const inoFor = (p) => {
38
41
  let ino = inos.get(p);
39
42
  if (ino === undefined) {
@@ -47,11 +50,23 @@ export function makeFakeSecureFs() {
47
50
  m.set(key, next);
48
51
  return next;
49
52
  };
50
- const handleFor = (p) => ({
51
- path: p,
52
- identity: { dev: 1, ino: inoFor(p) },
53
- close: async () => { },
54
- });
53
+ const handleFor = (p) => {
54
+ if (firstHandle && fake.faults.reuseFirstHandle?.(p))
55
+ return firstHandle;
56
+ const token = `${p}#${++handleSeq}`;
57
+ const handle = {
58
+ path: p,
59
+ identity: { dev: 1, ino: inoFor(p) },
60
+ close: async () => {
61
+ events.push(`close ${token}`);
62
+ closeAttempts.push(token);
63
+ if (fake.faults.closeRefuse?.(p))
64
+ throw new Error(`close failed ${p}`);
65
+ },
66
+ };
67
+ firstHandle ??= handle;
68
+ return handle;
69
+ };
55
70
  const isEmptyDir = (p) => {
56
71
  const prefix = `${p}/`;
57
72
  for (const f of files.keys())
@@ -66,10 +81,15 @@ export function makeFakeSecureFs() {
66
81
  dirs,
67
82
  files,
68
83
  dirModes,
84
+ events,
85
+ closeAttempts,
69
86
  faults: {},
70
87
  seedDir(dirPath) {
71
88
  dirs.add(dirPath);
72
89
  },
90
+ shareDirIdentity(target, source) {
91
+ inos.set(target, inoFor(source));
92
+ },
73
93
  seedFile(filePath, bytes, mode = 0o644) {
74
94
  files.set(filePath, { bytes, mode });
75
95
  },
@@ -94,6 +114,7 @@ export function makeFakeSecureFs() {
94
114
  return okValue(handleFor(dirPath));
95
115
  },
96
116
  async revalidateIdentity(target, held) {
117
+ events.push(`revalidate ${target}`);
97
118
  const idx = bump(revalidateCounts, target);
98
119
  if (fake.faults.revalidateRefuse?.(target, idx)) {
99
120
  return unsafe(`identity drift ${target}`);
@@ -135,6 +156,7 @@ export function makeFakeSecureFs() {
135
156
  },
136
157
  async createDirExclusive(parent, name, mode) {
137
158
  const full = path.join(parent.path, name);
159
+ events.push(`create ${full}`);
138
160
  if (dirs.has(full) || files.has(full))
139
161
  return unsafe(`EEXIST ${full}`);
140
162
  dirs.add(full);
@@ -199,6 +221,7 @@ export function makeFakeSecureFs() {
199
221
  return ok();
200
222
  },
201
223
  async rmdirIfIdentityEmpty(handle) {
224
+ events.push(`rmdir ${handle.path}`);
202
225
  if (!dirs.has(handle.path))
203
226
  return unsafe(`rmdir enoent ${handle.path}`);
204
227
  if (inoFor(handle.path) !== handle.identity.ino) {
@@ -120,11 +120,15 @@ export async function runTransaction(input) {
120
120
  // refuse ANY extended entry (lenient-gated THEN strict-managed); only ancestor-
121
121
  // only segments loosen. No `process.platform` here — role is expressed by which
122
122
  // dirs get proveManagedContainer'd (the managedContainers set).
123
+ function hold(dirPath, handle) {
124
+ heldByPath.set(dirPath, handle);
125
+ if (!heldOrder.includes(handle))
126
+ heldOrder.push(handle);
127
+ }
123
128
  async function gate(dirPath, handle) {
124
129
  // Transfer ownership before refusal-capable proofs so `finally` closes a
125
130
  // successfully opened handle even when either proof fails.
126
- heldByPath.set(dirPath, handle);
127
- heldOrder.push(handle);
131
+ hold(dirPath, handle);
128
132
  must(`ownership ${dirPath}`, await secureFs.proveOwnershipAndMode(dirPath));
129
133
  must(`acl ${dirPath}`, await secureFs.proveNoEndangeringAcl(dirPath));
130
134
  }
@@ -164,6 +168,7 @@ export async function runTransaction(input) {
164
168
  // Own the created segment before post-create validation so any refusal rolls
165
169
  // it back and closes its handle.
166
170
  createdDirs.push(created);
171
+ hold(fullPath, created);
167
172
  // Post-create identity revalidation + full gate on the new segment.
168
173
  must(`revalidate-created ${fullPath}`, await secureFs.revalidateIdentity(fullPath, created.identity));
169
174
  await gate(fullPath, created);
@@ -191,6 +196,8 @@ export async function runTransaction(input) {
191
196
  }
192
197
  return true;
193
198
  }
199
+ let workSucceeded = false;
200
+ const errors = [];
194
201
  try {
195
202
  // --- PREFLIGHT: gate the existing chain root..projectDir ---
196
203
  for (const dirPath of ancestorChain(projectDir)) {
@@ -240,7 +247,14 @@ export async function runTransaction(input) {
240
247
  must(`stage-mode ${tName}`, await secureFs.applyExactMode(path.join(dir.path, tName), prior ? prior.mode : 0o600));
241
248
  // Identity revalidation after each writeExclusive (JD-B-005).
242
249
  must(`revalidate-staged ${parentPath}`, await secureFs.revalidateIdentity(dir.path, dir.identity));
243
- staged.push({ dir, tempName: tName, target: component, prior });
250
+ const stagedEntry = {
251
+ dir,
252
+ tempName: tName,
253
+ target: component,
254
+ prior,
255
+ };
256
+ staged.push(stagedEntry);
257
+ stagedEntry.identity = must(`capture-staged ${tName}`, await secureFs.captureFile(path.join(dir.path, tName))).identity;
244
258
  }
245
259
  // --- PRE-FIRST-RENAME FULL-GATE RE-PROVE (JD-007) ---
246
260
  for (const handle of heldOrder) {
@@ -259,7 +273,8 @@ export async function runTransaction(input) {
259
273
  }
260
274
  }
261
275
  // --- COMMIT: asset first, settings second ---
262
- for (const entry of staged) {
276
+ while (staged.length > 0) {
277
+ const entry = staged[0];
263
278
  const base = path.basename(entry.target.path);
264
279
  must(`pre-rename ${entry.dir.path}`, await secureFs.revalidateIdentity(entry.dir.path, entry.dir.identity));
265
280
  const wroteHash = sha256(entry.target.desired);
@@ -273,6 +288,7 @@ export async function runTransaction(input) {
273
288
  wasAbsent: entry.target.wasAbsent,
274
289
  prior: entry.prior,
275
290
  });
291
+ staged.shift();
276
292
  }
277
293
  else if (renamed.mutation === "unknown") {
278
294
  unknownRenameTarget = entry.target.path;
@@ -280,35 +296,38 @@ export async function runTransaction(input) {
280
296
  must(`rename ${base}`, renamed);
281
297
  must(`post-rename ${entry.dir.path}`, await secureFs.revalidateIdentity(entry.dir.path, entry.dir.identity));
282
298
  }
283
- return {
284
- ok: true,
285
- committed: committed.map((c) => c.path),
286
- backups,
287
- errors: [],
288
- };
299
+ workSucceeded = true;
289
300
  }
290
301
  catch (error) {
291
- const errors = [
292
- error instanceof TxAbort ? error.message : String(error),
293
- ];
302
+ errors.push(error instanceof TxAbort ? error.message : String(error));
294
303
  if (unknownRenameTarget) {
295
304
  errors.push(`STOP: unknown rename outcome at ${unknownRenameTarget}; manual recovery; no rollback or directory cleanup`);
296
305
  }
297
306
  else {
298
- await rollback(committed, createdDirs, errors);
307
+ try {
308
+ await rollback(committed, staged, createdDirs, errors);
309
+ }
310
+ catch (rollbackError) {
311
+ errors.push(`rollback: ${String(rollbackError)}`);
312
+ }
299
313
  }
300
- return {
301
- ok: false,
302
- committed: committed.map((c) => c.path),
303
- backups,
304
- errors,
305
- };
306
314
  }
307
315
  finally {
308
- for (const handle of new Set([...createdDirs, ...heldOrder])) {
309
- await handle.close().catch(() => { });
316
+ for (const handle of heldOrder) {
317
+ try {
318
+ await handle.close();
319
+ }
320
+ catch (error) {
321
+ errors.push(`close ${handle.path}: ${String(error)}`);
322
+ }
310
323
  }
311
324
  }
325
+ return {
326
+ ok: workSucceeded && errors.length === 0,
327
+ committed: committed.map((c) => c.path),
328
+ backups,
329
+ errors,
330
+ };
312
331
  /** A rejected or malformed rename reply never proves absence of mutation. */
313
332
  async function observeRename(dir, from, to) {
314
333
  try {
@@ -340,7 +359,7 @@ export async function runTransaction(input) {
340
359
  }
341
360
  throw new TxAbort("backup", `no exclusive backup name after ${BACKUP_NONCE_CANDIDATES}`);
342
361
  }
343
- async function rollback(done, created, errors) {
362
+ async function rollback(done, pending, created, errors) {
344
363
  for (const entry of [...done].reverse()) {
345
364
  if (!(await gateStillValid())) {
346
365
  errors.push(`STOP: lost parent-chain proof; manual recovery at ${entry.path}`);
@@ -393,6 +412,14 @@ export async function runTransaction(input) {
393
412
  }
394
413
  }
395
414
  }
415
+ for (const entry of pending) {
416
+ const identity = entry.identity ??
417
+ must(`recapture-staged ${entry.tempName}`, await secureFs.captureFile(path.join(entry.dir.path, entry.tempName))).identity;
418
+ const removed = await secureFs.unlinkIfIdentity(entry.dir, entry.tempName, identity);
419
+ const detail = removed.detail ?? removed.refusal;
420
+ if (!removed.ok)
421
+ errors.push(`cleanup ${entry.tempName}: ${detail}`);
422
+ }
396
423
  // Remove only tx-created, identity-matched, still-empty segments, child-first.
397
424
  for (const handle of [...created].reverse()) {
398
425
  await secureFs.rmdirIfIdentityEmpty(handle);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.39.3",
3
+ "version": "1.39.5",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {