litmus-cli 1.4.24 → 1.4.26

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.
@@ -1,14 +1,24 @@
1
- import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, copyFileSync, openSync, closeSync, unlinkSync, statSync, constants as fsConstants, } from "fs";
1
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, copyFileSync, openSync, closeSync, unlinkSync, statSync, realpathSync, chmodSync, constants as fsConstants, } from "fs";
2
2
  import path from "path";
3
3
  import os from "os";
4
+ import { createHash } from "crypto";
4
5
  const CLAUDE_DIR = path.join(os.homedir(), ".claude");
5
6
  const CLAUDE_SETTINGS = path.join(CLAUDE_DIR, "settings.json");
6
- const CODEX_DIR = path.join(os.homedir(), ".codex");
7
+ // Codex honours $CODEX_HOME over ~/.codex, and its hook-trust keys embed this
8
+ // path verbatim, so we must resolve it the same way or the pin never matches.
9
+ const CODEX_DIR = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex");
7
10
  const CODEX_HOOKS = path.join(CODEX_DIR, "hooks.json");
11
+ const CODEX_CONFIG = path.join(CODEX_DIR, "config.toml");
8
12
  const CURSOR_DIR = path.join(os.homedir(), ".cursor");
9
13
  const CURSOR_HOOKS = path.join(CURSOR_DIR, "hooks.json");
10
14
  const GEMINI_DIR = path.join(os.homedir(), ".gemini");
11
15
  const GEMINI_SETTINGS = path.join(GEMINI_DIR, "settings.json");
16
+ // Gemini's own override for the trust store, honoured so our entry lands in
17
+ // the file Gemini actually reads.
18
+ const GEMINI_TRUSTED_FOLDERS = process.env.GEMINI_CLI_TRUSTED_FOLDERS_PATH?.trim() || path.join(GEMINI_DIR, "trustedFolders.json");
19
+ // Copilot CLI resolves its home as $COPILOT_HOME, else ~/.copilot.
20
+ const COPILOT_DIR = process.env.COPILOT_HOME?.trim() || path.join(os.homedir(), ".copilot");
21
+ const COPILOT_CONFIG = path.join(COPILOT_DIR, "config.json");
12
22
  const LITMUS_DIR = path.join(os.homedir(), ".litmus");
13
23
  const ACTIVE_ASSESSMENTS = path.join(LITMUS_DIR, "active-assessments.json");
14
24
  const REGISTRY_LOCK = path.join(LITMUS_DIR, "registry.lock");
@@ -145,10 +155,32 @@ export function isOurCursorEntry(entry) {
145
155
  // atomic, so a crash mid-write leaves either the old file or the new file —
146
156
  // never a truncated mix. Avoids leaving the candidate's ~/.claude/settings.json
147
157
  // in an unparseable state if the CLI is killed mid-write.
158
+ //
159
+ // Two things a plain tmp+rename would silently change on a file the candidate
160
+ // manages themselves: a symlinked config (dotfiles repos) would have the LINK
161
+ // replaced by a regular file, leaving their repo copy stale; and a file they
162
+ // deliberately chmod'ed (Codex writes trust state 0600, Gemini writes
163
+ // trustedFolders.json 0600) would come back with the umask default. Resolve
164
+ // the link and copy the mode so the rewrite is invisible except for content.
148
165
  function atomicWriteFileSync(target, content) {
149
- const tmp = `${target}.litmus-tmp-${process.pid}-${Date.now()}`;
150
- writeFileSync(tmp, content);
151
- renameSync(tmp, target);
166
+ let dest = target;
167
+ let mode;
168
+ try {
169
+ if (existsSync(target)) {
170
+ dest = realpathSync(target);
171
+ mode = statSync(dest).mode & 0o777;
172
+ }
173
+ }
174
+ catch { /* fall back to writing the path as given */ }
175
+ const tmp = `${dest}.litmus-tmp-${process.pid}-${Date.now()}`;
176
+ writeFileSync(tmp, content, mode === undefined ? undefined : { mode });
177
+ if (mode !== undefined) {
178
+ try {
179
+ chmodSync(tmp, mode);
180
+ }
181
+ catch { /* best effort */ }
182
+ }
183
+ renameSync(tmp, dest);
152
184
  }
153
185
  function atomicCopyFileSync(src, target) {
154
186
  const tmp = `${target}.litmus-tmp-${process.pid}-${Date.now()}`;
@@ -358,42 +390,222 @@ export function uninstallUserScopeClaudeHook() {
358
390
  // text DIRECTLY in `last_assistant_message` (plus `transcript_path` to the
359
391
  // rollout JSONL, which we don't need). Timeout in seconds, default 600.
360
392
  //
361
- // ── Codex BLOCKS third-party hooks until the candidate trusts them (ENG-951) ──
393
+ // ── Codex BLOCKS third-party hooks until they are trusted (ENG-951) ──
394
+ //
395
+ // Writing hooks.json is necessary but NOT sufficient: Codex skips any
396
+ // non-managed hook whose definition has not been trusted, and says nothing
397
+ // beyond a startup line pointing at `/hooks`. Until now we left that to the
398
+ // candidate ("accept the trust prompt"), and it was the single biggest
399
+ // silent-capture loss on Codex cohorts (ENG-1860).
400
+ //
401
+ // The trust store is per hook, in the candidate's own `~/.codex/config.toml`:
362
402
  //
363
- // Writing this file is necessary but NOT sufficient: Codex will not run a hook
364
- // it has not been told to trust, so capture stays off until the candidate
365
- // approves it. The comment here used to call that "a review on first run, and
366
- // that gate is surfaced by Codex itself", which understates it — nothing about
367
- // our install makes the hook live.
403
+ // [hooks.state."<hooks.json path>:<event_key>:<group_index>:<handler_index>"]
404
+ // trusted_hash = "sha256:<hex>"
368
405
  //
369
- // Mechanism, read from a real `~/.codex/config.toml` rather than from docs:
370
- // trust is recorded per hook entry under a `[hooks.state."<id>"]` table as
371
- // `trusted_hash = "sha256:..."`, alongside per-project `[projects."<path>"]
372
- // trust_level` entries. Because the pin is a hash of the entry, CHANGING our
373
- // command invalidates it — so a candidate who re-runs `litmus init` after their
374
- // logger path moves has to approve the hook again, and their previous approval
375
- // does not carry over. Both of our entries (prompt and `Stop`) sit behind this
376
- // gate, each with its own per-entry pin.
406
+ // Codex compares `trusted_hash` against a hash it derives from the hook's
407
+ // normalized definition. We now compute that same hash and pin it ourselves at
408
+ // install time, exactly as accepting the `/hooks` review would, so a Codex
409
+ // candidate needs no trust step at all. Same idea as pre-seeding
410
+ // `~/.codex/rules/*.rules` to pre-approve a command: the approval lives in the
411
+ // candidate's own config, and Codex reads it at session start so a Codex
412
+ // that was already open when `litmus init` ran must be restarted before
413
+ // capture begins. `litmus init` says so.
377
414
  //
378
- // We do NOT write that trust store. It is a consent control OpenAI added to
379
- // stop third parties running hooks without the user's approval, it applies at
380
- // user scope across all of their Codex usage rather than just the assessment,
381
- // and the format is undocumented. `litmus init` tells the candidate instead.
415
+ // Derivation, read from openai/codex `codex-rs/hooks/src/engine/discovery.rs`
416
+ // (`hook_hash`, `hook_key`) and `codex-rs/config/src/fingerprint.rs`
417
+ // (`version_for_toml`), and verified against codex-cli 0.153.4's app-server
418
+ // `hooks/list` (our pinned hash == its `currentHash`; `trustStatus` flips
419
+ // from `untrusted` to `trusted`):
382
420
  //
383
- // Not yet verified, and deliberately not asserted in candidate-facing copy: the
384
- // exact UI by which Codex asks for approval, and the `[hooks.state]` key format
385
- // for a user-scope hooks.json entry (the live examples were plugin-sourced).
386
- // That key is what a `litmus status` trust check would need.
421
+ // key = `${hooks.json absolute path}:${event_key}:${group_index}:0`
422
+ // event_key is snake_case ("user_prompt_submit", "stop");
423
+ // group_index is the entry's index within that event's array.
424
+ // hash = "sha256:" + sha256(compact JSON, keys sorted recursively, of
425
+ // { event_name: event_key,
426
+ // hooks: [{ type: "command", command, timeout, async: false }] })
427
+ // `matcher` is dropped for UserPromptSubmit/Stop (Codex forces it
428
+ // to None for those events); `commandWindows`/`statusMessage` are
429
+ // absent because we don't set them; `timeout` is our explicit
430
+ // value so normalization leaves it alone.
431
+ //
432
+ // Because the pin is a hash of the COMMAND, a logger path move re-derives it
433
+ // on the next `litmus init` — fine, since we rewrite the pin too. Because the
434
+ // KEY embeds the group index, a candidate adding their own UserPromptSubmit
435
+ // hook in front of ours orphans the pin until the next init; Codex then shows
436
+ // ours as needing review rather than silently running it, which is the right
437
+ // failure direction.
438
+ //
439
+ // We touch config.toml as TEXT, not through a TOML parser: the CLI has no TOML
440
+ // dependency, and the file is the candidate's whole Codex config (model, MCP
441
+ // servers, auth prefs), so a serializer round-trip could reorder or reformat
442
+ // things they care about. We only ever remove our own `[hooks.state."…"]`
443
+ // tables and append fresh ones. If the key already appears in a form we don't
444
+ // own (e.g. an inline `"…" = { trusted_hash = … }`), appending would make the
445
+ // TOML invalid and take ALL of their config down, so we refuse and fall back
446
+ // to the notice.
387
447
  const CODEX_PROMPT_EVENT = "UserPromptSubmit";
388
448
  const CODEX_HOOK_EVENTS = [CODEX_PROMPT_EVENT, "Stop"];
449
+ const CODEX_EVENT_KEY = {
450
+ UserPromptSubmit: "user_prompt_submit",
451
+ Stop: "stop",
452
+ };
453
+ function canonicalJson(value) {
454
+ if (Array.isArray(value))
455
+ return value.map(canonicalJson);
456
+ if (value && typeof value === "object") {
457
+ const out = {};
458
+ for (const k of Object.keys(value).sort()) {
459
+ out[k] = canonicalJson(value[k]);
460
+ }
461
+ return out;
462
+ }
463
+ return value;
464
+ }
465
+ /**
466
+ * Codex's trust hash for one of our command hooks — see the block comment
467
+ * above for the derivation. Exported for the golden-vector test.
468
+ */
469
+ export function codexHookTrustHash(event, command, timeoutSec) {
470
+ const identity = {
471
+ event_name: CODEX_EVENT_KEY[event],
472
+ hooks: [{ type: "command", command, timeout: timeoutSec, async: false }],
473
+ };
474
+ const json = JSON.stringify(canonicalJson(identity));
475
+ return "sha256:" + createHash("sha256").update(json).digest("hex");
476
+ }
477
+ // Codex embeds the hooks.json path in every trust key, spelled the way its
478
+ // `find_codex_home` spells it: a $CODEX_HOME value is CANONICALIZED (realpath,
479
+ // with the Windows `\\?\` prefix stripped), while the default `~/.codex` is
480
+ // `home_dir().join(".codex")` taken as-is. Mirror that exactly, in both
481
+ // directions — measured against codex-cli 0.153.4 and 0.154.0 via app-server
482
+ // `hooks/list`: with CODEX_HOME under macOS's `/var -> /private/var`, a key
483
+ // spelled from the raw path reads `untrusted` beside a matching hash, and
484
+ // canonicalizing the DEFAULT would miss the same way on a symlinked home.
485
+ function codexHooksKeyPath() {
486
+ if (!process.env.CODEX_HOME?.trim())
487
+ return CODEX_HOOKS;
488
+ let dir = CODEX_DIR;
489
+ try {
490
+ dir = realpathSync.native(CODEX_DIR);
491
+ }
492
+ catch {
493
+ try {
494
+ dir = realpathSync(CODEX_DIR);
495
+ }
496
+ catch { /* not created yet — Codex refuses such a CODEX_HOME itself */ }
497
+ }
498
+ return path.join(dir, "hooks.json");
499
+ }
500
+ /** Codex's `hooks.state` key for our entry at `groupIndex` of `event`. */
501
+ export function codexHookStateKey(event, groupIndex) {
502
+ return `${codexHooksKeyPath()}:${CODEX_EVENT_KEY[event]}:${groupIndex}:0`;
503
+ }
504
+ // TOML basic-string escaping: backslash, quote, and control characters. Keys
505
+ // are filesystem paths, so on Windows every separator is written `\\` — which
506
+ // is why everything that looks for our tables matches on the ESCAPED form,
507
+ // never on the raw path. (The first CI run matched the raw path: on Windows
508
+ // it never found its own tables, so every init stacked a new pair and
509
+ // uninstall removed nothing.)
510
+ function tomlEscape(s) {
511
+ return s.replace(/[\\"\x00-\x1f\x7f]/g, ch => {
512
+ if (ch === "\\")
513
+ return "\\\\";
514
+ if (ch === '"')
515
+ return '\\"';
516
+ return "\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0");
517
+ });
518
+ }
519
+ function tomlBasicString(s) {
520
+ return '"' + tomlEscape(s) + '"';
521
+ }
522
+ function escapeRegExp(s) {
523
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
524
+ }
525
+ // One `[hooks.state."<key>"]` table — basic (escaped) or literal (single-
526
+ // quoted) header string — from its header line through the line before the
527
+ // next table header, or EOF. `basic` and `literal` are regex sources for the
528
+ // key as it appears inside each quote style.
529
+ function codexStateTableRegex(basic, literal) {
530
+ return new RegExp(`^[ \\t]*\\[hooks\\.state\\.(?:"${basic}"|'${literal}')\\][^\\n]*\\n?(?:(?![ \\t]*\\[)[^\\n]*(?:\\n|$))*`, "gm");
531
+ }
532
+ function codexStateTableForKey(key) {
533
+ return codexStateTableRegex(escapeRegExp(tomlEscape(key)), escapeRegExp(key));
534
+ }
535
+ // Any other appearance of the key — an inline table, a dotted key under
536
+ // `[hooks.state]`, anything we can't safely append a table beside.
537
+ function codexKeyAppearsElsewhere(toml, key) {
538
+ const rest = toml.replace(codexStateTableForKey(key), "");
539
+ return rest.includes(tomlEscape(key)) || rest.includes(key);
540
+ }
541
+ // Remove every `[hooks.state."<our hooks.json>:…"]` table whose trusted_hash
542
+ // is one of `hashes`. Tables pinning anything else — a candidate's own hook —
543
+ // are left exactly as they were.
544
+ function stripOurCodexTrustTables(toml, hashes) {
545
+ // Both spellings of our hooks.json path, so a pin written while the dir
546
+ // resolved one way is still found if it resolves the other way later.
547
+ const prefixes = [...new Set([codexHooksKeyPath(), CODEX_HOOKS])].map(p => p + ":");
548
+ const table = codexStateTableRegex(`(?:${prefixes.map(p => escapeRegExp(tomlEscape(p))).join("|")})(?:[^"\\\\\\n]|\\\\.)*`, `(?:${prefixes.map(escapeRegExp).join("|")})[^'\\n]*`);
549
+ return toml.replace(table, block => {
550
+ const m = /^\s*trusted_hash\s*=\s*"(sha256:[0-9a-f]+)"/m.exec(block);
551
+ return m && hashes.has(m[1]) ? "" : block;
552
+ });
553
+ }
554
+ /**
555
+ * Pin `trusted_hash` for each of our entries in `~/.codex/config.toml`.
556
+ * Throws rather than write when it can't do so safely; the caller treats a
557
+ * throw as "trust not pinned" and falls back to telling the candidate.
558
+ */
559
+ function writeCodexHookTrust(pins) {
560
+ let toml = existsSync(CODEX_CONFIG) ? readFileSync(CODEX_CONFIG, "utf8") : "";
561
+ for (const { key } of pins) {
562
+ if (codexKeyAppearsElsewhere(toml, key)) {
563
+ throw new Error(`${CODEX_CONFIG} already defines ${key} in a form we don't manage; refusing to append.`);
564
+ }
565
+ // Drop the previous pin at this key (its hash may have changed).
566
+ toml = toml.replace(codexStateTableForKey(key), "");
567
+ }
568
+ // Drop OUR pins left at other group indices by an earlier install, so a
569
+ // shifted entry doesn't leave a stale table behind.
570
+ toml = stripOurCodexTrustTables(toml, new Set(pins.map(p => p.hash)));
571
+ toml = toml.replace(/\s+$/, "");
572
+ const blocks = pins.map(({ key, hash }) => `[hooks.state.${tomlBasicString(key)}]\ntrusted_hash = ${tomlBasicString(hash)}\n`);
573
+ atomicWriteFileSync(CODEX_CONFIG, (toml ? toml + "\n\n" : "") + blocks.join("\n"));
574
+ }
575
+ /**
576
+ * Remove every `[hooks.state."…"]` table under our hooks.json path that pins
577
+ * OUR command hash. Keyed on the hash rather than the state key so it finds
578
+ * our pins at any group index, and never touches a pin for someone else's hook.
579
+ */
580
+ function removeCodexHookTrust() {
581
+ if (!existsSync(CODEX_CONFIG))
582
+ return;
583
+ let toml;
584
+ try {
585
+ toml = readFileSync(CODEX_CONFIG, "utf8");
586
+ }
587
+ catch {
588
+ return;
589
+ }
590
+ const command = buildHookEntry(USER_SCOPE_LOGGER, "codex").hooks[0];
591
+ const ourHashes = new Set(CODEX_HOOK_EVENTS.map(ev => codexHookTrustHash(ev, command.command, command.timeout)));
592
+ const next = stripOurCodexTrustTables(toml, ourHashes);
593
+ if (next === toml)
594
+ return;
595
+ try {
596
+ atomicWriteFileSync(CODEX_CONFIG, next.replace(/\n{3,}/g, "\n\n"));
597
+ }
598
+ catch { /* unwritable, give up silently */ }
599
+ }
389
600
  /**
390
601
  * Install `UserPromptSubmit` + `Stop` hooks in `~/.codex/hooks.json` pointing
391
- * at the absolute logger path. Idempotent. Same nested matcher/hooks shape as
392
- * Claude Code Codex's hooks system mirrors Claude's design.
602
+ * at the absolute logger path, then pin their trust in `~/.codex/config.toml`
603
+ * so Codex runs them without a review step. Idempotent. Same nested
604
+ * matcher/hooks shape as Claude Code — Codex's hooks system mirrors Claude's.
393
605
  *
394
- * Returning without throwing means the FILE is written, not that capture works:
395
- * Codex blocks untrusted hooks (see the block comment above), so the candidate
396
- * still has to approve it. `litmus init` says so prominently.
606
+ * `trustPinned: false` means hooks.json is in place but Codex will still ask
607
+ * the candidate to review the hooks the caller surfaces that. Throws only
608
+ * when hooks.json itself can't be written.
397
609
  */
398
610
  export function installUserScopeCodexHook() {
399
611
  mkdirSync(CODEX_DIR, { recursive: true });
@@ -410,6 +622,7 @@ export function installUserScopeCodexHook() {
410
622
  }
411
623
  }
412
624
  const hooks = (data.hooks ?? (data.hooks = {}));
625
+ const pins = [];
413
626
  for (const event of CODEX_HOOK_EVENTS) {
414
627
  const existingValue = hooks[event];
415
628
  if (existingValue !== undefined && !Array.isArray(existingValue)) {
@@ -422,11 +635,24 @@ export function installUserScopeCodexHook() {
422
635
  }
423
636
  const existing = Array.isArray(existingValue) ? existingValue : [];
424
637
  const others = existing.filter(e => !isOurEntry(e));
425
- hooks[event] = [...others, buildHookEntry(USER_SCOPE_LOGGER, "codex")];
638
+ const entry = buildHookEntry(USER_SCOPE_LOGGER, "codex");
639
+ hooks[event] = [...others, entry];
640
+ pins.push({
641
+ key: codexHookStateKey(event, others.length),
642
+ hash: codexHookTrustHash(event, entry.hooks[0].command, entry.hooks[0].timeout),
643
+ });
426
644
  }
427
645
  atomicWriteFileSync(CODEX_HOOKS, JSON.stringify(data, null, 2) + "\n");
646
+ try {
647
+ writeCodexHookTrust(pins);
648
+ return { trustPinned: true };
649
+ }
650
+ catch {
651
+ return { trustPinned: false };
652
+ }
428
653
  }
429
654
  export function uninstallUserScopeCodexHook() {
655
+ removeCodexHookTrust();
430
656
  if (!existsSync(CODEX_HOOKS))
431
657
  return;
432
658
  let data;
@@ -961,16 +1187,33 @@ function readRegistryUnlocked() {
961
1187
  return null;
962
1188
  }
963
1189
  }
1190
+ /**
1191
+ * Register an assessment. `added` is true when the path was not registered
1192
+ * before this call AND the registration was written — a fresh assessment at
1193
+ * that folder, as opposed to a re-run of init / doctor / the watcher on one
1194
+ * already in flight. A registration that could not be persisted reports
1195
+ * `added: false`: the next run would find the path missing again and read as
1196
+ * fresh, and the folder-trust installers would then drop the ownership marks
1197
+ * this run wrote (see FolderTrustOptions), leaving the folder trusted after
1198
+ * submit. Not-fresh is the safe reading — the marks stay and submit still
1199
+ * removes exactly the entries we created.
1200
+ */
964
1201
  export function addActiveAssessment(assessmentPath) {
965
- withRegistryLock(() => {
1202
+ return withRegistryLock(() => {
966
1203
  mkdirSync(LITMUS_DIR, { recursive: true });
967
1204
  const list = readRegistryUnlocked() ?? [];
968
- if (!list.includes(assessmentPath))
1205
+ const added = !list.includes(assessmentPath);
1206
+ if (added)
969
1207
  list.push(assessmentPath);
970
1208
  try {
971
1209
  atomicWriteFileSync(ACTIVE_ASSESSMENTS, JSON.stringify(list, null, 2) + "\n");
972
1210
  }
973
- catch { /* unwritable — fall back to cwd-only capture */ }
1211
+ catch {
1212
+ // Unwritable — fall back to cwd-only capture, and do not claim a fresh
1213
+ // registration we could not record.
1214
+ return { added: false };
1215
+ }
1216
+ return { added };
974
1217
  });
975
1218
  }
976
1219
  /**
@@ -1041,4 +1284,305 @@ export function removeActiveAssessment(assessmentPath) {
1041
1284
  return filtered.length === 0;
1042
1285
  });
1043
1286
  }
1287
+ // ── Ownership ledger ──
1288
+ // A candidate may already trust the assessment folder's location (a parent
1289
+ // they marked TRUST_FOLDER in Gemini, a folder they told Copilot to remember)
1290
+ // before `litmus init` ever runs. Then the trust functions above find nothing
1291
+ // to write — and `litmus submit` must NOT take that entry away, or finishing
1292
+ // an assessment costs them a trust prompt they had already answered. So the
1293
+ // installer records which entries IT created, per assessment folder and tool,
1294
+ // in ~/.litmus/folder-trust.json, and untrust removes an entry only when the
1295
+ // ledger says it is ours. The ledger lives beside the active-assessments
1296
+ // registry rather than in the assessment folder because submit deletes that
1297
+ // folder before releasing tracking.
1298
+ const FOLDER_TRUST_LEDGER = path.join(LITMUS_DIR, "folder-trust.json");
1299
+ function ledgerKey(dir) {
1300
+ const real = resolveFolder(dir);
1301
+ return process.platform === "win32" || process.platform === "darwin" ? real.toLowerCase() : real;
1302
+ }
1303
+ function readFolderTrustLedger() {
1304
+ if (!existsSync(FOLDER_TRUST_LEDGER))
1305
+ return {};
1306
+ const parsed = readJsonObjectOrNull(FOLDER_TRUST_LEDGER);
1307
+ return (parsed ?? {});
1308
+ }
1309
+ function writeFolderTrustLedger(ledger) {
1310
+ mkdirSync(LITMUS_DIR, { recursive: true });
1311
+ if (Object.keys(ledger).length === 0) {
1312
+ try {
1313
+ unlinkSync(FOLDER_TRUST_LEDGER);
1314
+ }
1315
+ catch { /* already gone */ }
1316
+ return;
1317
+ }
1318
+ atomicWriteFileSync(FOLDER_TRUST_LEDGER, JSON.stringify(ledger, null, 2) + "\n");
1319
+ }
1320
+ // The ledger and both trust stores are read-modify-write files shared by
1321
+ // every concurrent `litmus init` / `litmus submit` on the machine, so all of
1322
+ // the folder-trust entry points below run under the same `withRegistryLock()`
1323
+ // the active-assessments registry uses. These helpers assume the caller holds
1324
+ // it (the lock is not re-entrant).
1325
+ function markFolderTrustOwnedLocked(dir, tool) {
1326
+ try {
1327
+ const ledger = readFolderTrustLedger();
1328
+ const key = ledgerKey(dir);
1329
+ ledger[key] = { ...(ledger[key] ?? {}), [tool]: true };
1330
+ writeFolderTrustLedger(ledger);
1331
+ }
1332
+ catch { /* a missing mark only means submit leaves the entry in place */ }
1333
+ }
1334
+ function isFolderTrustOwnedLocked(dir, tool) {
1335
+ try {
1336
+ return readFolderTrustLedger()[ledgerKey(dir)]?.[tool] === true;
1337
+ }
1338
+ catch {
1339
+ return false;
1340
+ }
1341
+ }
1342
+ // Cleared only AFTER the trust store no longer holds our entry, so a failed
1343
+ // rewrite leaves the mark in place and the next submit/doctor can retry.
1344
+ function clearFolderTrustOwnedLocked(dir, tool) {
1345
+ try {
1346
+ const ledger = readFolderTrustLedger();
1347
+ const key = ledgerKey(dir);
1348
+ if (!ledger[key])
1349
+ return;
1350
+ delete ledger[key][tool];
1351
+ if (Object.keys(ledger[key]).length === 0)
1352
+ delete ledger[key];
1353
+ writeFolderTrustLedger(ledger);
1354
+ }
1355
+ catch { /* stale mark: harmless, the next release retries */ }
1356
+ }
1357
+ // Gemini's normalizePath: absolute, realpath if it exists, lowercased on the
1358
+ // case-insensitive platforms. Writing the same form Gemini itself writes keeps
1359
+ // one entry per folder instead of one per spelling.
1360
+ function geminiFolderKey(dir) {
1361
+ const real = resolveFolder(dir);
1362
+ return process.platform === "win32" || process.platform === "darwin" ? real.toLowerCase() : real;
1363
+ }
1364
+ // Realpath when the folder exists. When it doesn't — `litmus submit` deletes
1365
+ // the assessment folder BEFORE releasing tracking — realpath the parent and
1366
+ // re-attach the basename, so a symlinked ancestor (macOS's /var → /private/var,
1367
+ // a dotfiles-managed ~/code) still resolves to the key we wrote at init.
1368
+ function resolveFolder(dir) {
1369
+ const abs = path.resolve(dir);
1370
+ try {
1371
+ return realpathSync(abs);
1372
+ }
1373
+ catch { /* gone or never created */ }
1374
+ try {
1375
+ return path.join(realpathSync(path.dirname(abs)), path.basename(abs));
1376
+ }
1377
+ catch {
1378
+ return abs;
1379
+ }
1380
+ }
1381
+ function readJsonObjectOrNull(file) {
1382
+ try {
1383
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
1384
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1385
+ }
1386
+ catch {
1387
+ return null;
1388
+ }
1389
+ }
1390
+ /**
1391
+ * Mark `assessmentDir` trusted for the Gemini CLI. Returns `trusted: false`
1392
+ * (and touches nothing) when the file can't be parsed or when the candidate
1393
+ * has explicitly marked this folder DO_NOT_TRUST — that is their decision.
1394
+ */
1395
+ export function trustAssessmentFolderForGemini(assessmentDir, opts = {}) {
1396
+ return withRegistryLock(() => trustAssessmentFolderForGeminiLocked(assessmentDir, opts));
1397
+ }
1398
+ function trustAssessmentFolderForGeminiLocked(assessmentDir, opts) {
1399
+ const key = geminiFolderKey(assessmentDir);
1400
+ let config = {};
1401
+ if (existsSync(GEMINI_TRUSTED_FOLDERS)) {
1402
+ const parsed = readJsonObjectOrNull(GEMINI_TRUSTED_FOLDERS);
1403
+ if (!parsed)
1404
+ return { trusted: false };
1405
+ config = parsed;
1406
+ }
1407
+ const current = config[key];
1408
+ if (current === "TRUST_FOLDER" || current === "TRUST_PARENT") {
1409
+ if (opts.freshAssessment)
1410
+ clearFolderTrustOwnedLocked(assessmentDir, "gemini");
1411
+ return { trusted: true };
1412
+ }
1413
+ if (current === "DO_NOT_TRUST") {
1414
+ if (opts.freshAssessment)
1415
+ clearFolderTrustOwnedLocked(assessmentDir, "gemini");
1416
+ return { trusted: false };
1417
+ }
1418
+ config[key] = "TRUST_FOLDER";
1419
+ try {
1420
+ mkdirSync(path.dirname(GEMINI_TRUSTED_FOLDERS), { recursive: true });
1421
+ const created = !existsSync(GEMINI_TRUSTED_FOLDERS);
1422
+ atomicWriteFileSync(GEMINI_TRUSTED_FOLDERS, JSON.stringify(config, null, 2) + "\n");
1423
+ // Gemini creates this file 0600; match it when we are the one creating it.
1424
+ if (created) {
1425
+ try {
1426
+ chmodSync(GEMINI_TRUSTED_FOLDERS, 0o600);
1427
+ }
1428
+ catch { /* best effort */ }
1429
+ }
1430
+ markFolderTrustOwnedLocked(assessmentDir, "gemini");
1431
+ return { trusted: true };
1432
+ }
1433
+ catch {
1434
+ return { trusted: false };
1435
+ }
1436
+ }
1437
+ /** Drop the Gemini trust entry for `assessmentDir` — only if init created it. */
1438
+ export function untrustAssessmentFolderForGemini(assessmentDir) {
1439
+ withRegistryLock(() => {
1440
+ if (!isFolderTrustOwnedLocked(assessmentDir, "gemini"))
1441
+ return;
1442
+ if (removeGeminiTrustEntry(assessmentDir))
1443
+ clearFolderTrustOwnedLocked(assessmentDir, "gemini");
1444
+ });
1445
+ }
1446
+ // True when the store no longer carries our entry — removed now, or already
1447
+ // absent. False when we could not read or rewrite it, so the mark must stay.
1448
+ function removeGeminiTrustEntry(assessmentDir) {
1449
+ if (!existsSync(GEMINI_TRUSTED_FOLDERS))
1450
+ return true;
1451
+ const config = readJsonObjectOrNull(GEMINI_TRUSTED_FOLDERS);
1452
+ if (!config)
1453
+ return false;
1454
+ const key = geminiFolderKey(assessmentDir);
1455
+ if (config[key] !== "TRUST_FOLDER")
1456
+ return true;
1457
+ delete config[key];
1458
+ try {
1459
+ atomicWriteFileSync(GEMINI_TRUSTED_FOLDERS, JSON.stringify(config, null, 2) + "\n");
1460
+ return true;
1461
+ }
1462
+ catch {
1463
+ return false;
1464
+ }
1465
+ }
1466
+ // Copilot writes `// …` header lines above the JSON in config.json ("This file
1467
+ // is managed automatically."). Split them off so they survive the round-trip.
1468
+ function splitCopilotConfig(text) {
1469
+ const lines = text.split("\n");
1470
+ let i = 0;
1471
+ while (i < lines.length && (lines[i].trim() === "" || lines[i].trimStart().startsWith("//")))
1472
+ i++;
1473
+ return { header: lines.slice(0, i).join("\n"), body: lines.slice(i).join("\n") };
1474
+ }
1475
+ function copilotFolderKey(dir) {
1476
+ return resolveFolder(dir);
1477
+ }
1478
+ // Both sides resolved the same way, compared case-insensitively where the
1479
+ // filesystem is.
1480
+ function samePath(a, b) {
1481
+ const ci = process.platform === "win32" || process.platform === "darwin";
1482
+ const na = resolveFolder(a), nb = resolveFolder(b);
1483
+ return ci ? na.toLowerCase() === nb.toLowerCase() : na === nb;
1484
+ }
1485
+ /**
1486
+ * Add `assessmentDir` to Copilot CLI's `trustedFolders`. Returns
1487
+ * `trusted: false` (and touches nothing) when config.json can't be parsed or
1488
+ * `trustedFolders` isn't the array we expect.
1489
+ */
1490
+ export function trustAssessmentFolderForCopilot(assessmentDir, opts = {}) {
1491
+ return withRegistryLock(() => trustAssessmentFolderForCopilotLocked(assessmentDir, opts));
1492
+ }
1493
+ function trustAssessmentFolderForCopilotLocked(assessmentDir, opts) {
1494
+ const key = copilotFolderKey(assessmentDir);
1495
+ let header = "";
1496
+ let config = {};
1497
+ if (existsSync(COPILOT_CONFIG)) {
1498
+ let text;
1499
+ try {
1500
+ text = readFileSync(COPILOT_CONFIG, "utf8");
1501
+ }
1502
+ catch {
1503
+ return { trusted: false };
1504
+ }
1505
+ const split = splitCopilotConfig(text);
1506
+ header = split.header;
1507
+ let parsed;
1508
+ try {
1509
+ parsed = JSON.parse(split.body);
1510
+ }
1511
+ catch {
1512
+ return { trusted: false };
1513
+ }
1514
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1515
+ return { trusted: false };
1516
+ config = parsed;
1517
+ }
1518
+ const existing = config.trustedFolders;
1519
+ if (existing !== undefined && !Array.isArray(existing))
1520
+ return { trusted: false };
1521
+ const folders = Array.isArray(existing) ? existing : [];
1522
+ if (folders.some(f => typeof f === "string" && samePath(f, key))) {
1523
+ if (opts.freshAssessment)
1524
+ clearFolderTrustOwnedLocked(assessmentDir, "copilot");
1525
+ return { trusted: true };
1526
+ }
1527
+ config.trustedFolders = [...folders, key];
1528
+ try {
1529
+ mkdirSync(COPILOT_DIR, { recursive: true });
1530
+ const out = (header ? header + "\n" : "") + JSON.stringify(config, null, 2) + "\n";
1531
+ atomicWriteFileSync(COPILOT_CONFIG, out);
1532
+ markFolderTrustOwnedLocked(assessmentDir, "copilot");
1533
+ return { trusted: true };
1534
+ }
1535
+ catch {
1536
+ return { trusted: false };
1537
+ }
1538
+ }
1539
+ /** Remove `assessmentDir` from Copilot CLI's `trustedFolders` — only if init added it. */
1540
+ export function untrustAssessmentFolderForCopilot(assessmentDir) {
1541
+ withRegistryLock(() => {
1542
+ if (!isFolderTrustOwnedLocked(assessmentDir, "copilot"))
1543
+ return;
1544
+ if (removeCopilotTrustEntry(assessmentDir))
1545
+ clearFolderTrustOwnedLocked(assessmentDir, "copilot");
1546
+ });
1547
+ }
1548
+ // Same contract as removeGeminiTrustEntry.
1549
+ function removeCopilotTrustEntry(assessmentDir) {
1550
+ if (!existsSync(COPILOT_CONFIG))
1551
+ return true;
1552
+ let text;
1553
+ try {
1554
+ text = readFileSync(COPILOT_CONFIG, "utf8");
1555
+ }
1556
+ catch {
1557
+ return false;
1558
+ }
1559
+ const { header, body } = splitCopilotConfig(text);
1560
+ let config;
1561
+ try {
1562
+ const parsed = JSON.parse(body);
1563
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1564
+ return false;
1565
+ config = parsed;
1566
+ }
1567
+ catch {
1568
+ return false;
1569
+ }
1570
+ const folders = config.trustedFolders;
1571
+ if (!Array.isArray(folders))
1572
+ return true;
1573
+ const kept = folders.filter(f => !(typeof f === "string" && samePath(f, assessmentDir)));
1574
+ if (kept.length === folders.length)
1575
+ return true;
1576
+ if (kept.length === 0)
1577
+ delete config.trustedFolders;
1578
+ else
1579
+ config.trustedFolders = kept;
1580
+ try {
1581
+ atomicWriteFileSync(COPILOT_CONFIG, (header ? header + "\n" : "") + JSON.stringify(config, null, 2) + "\n");
1582
+ return true;
1583
+ }
1584
+ catch {
1585
+ return false;
1586
+ }
1587
+ }
1044
1588
  //# sourceMappingURL=ai-tracking.js.map