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.
- package/README.md +20 -3
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +11 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +67 -26
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/setup-check.d.ts +19 -0
- package/dist/commands/setup-check.d.ts.map +1 -0
- package/dist/commands/setup-check.js +48 -0
- package/dist/commands/setup-check.js.map +1 -0
- package/dist/commands/submit.js +6 -1
- package/dist/commands/submit.js.map +1 -1
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/ai-tracking.d.ts +55 -7
- package/dist/lib/ai-tracking.d.ts.map +1 -1
- package/dist/lib/ai-tracking.js +580 -36
- package/dist/lib/ai-tracking.js.map +1 -1
- package/dist/lib/capture-nudge.d.ts.map +1 -1
- package/dist/lib/capture-nudge.js +10 -8
- package/dist/lib/capture-nudge.js.map +1 -1
- package/dist/lib/environment-checks.d.ts +123 -0
- package/dist/lib/environment-checks.d.ts.map +1 -0
- package/dist/lib/environment-checks.js +324 -0
- package/dist/lib/environment-checks.js.map +1 -0
- package/package.json +1 -1
package/dist/lib/ai-tracking.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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
|
-
//
|
|
364
|
-
//
|
|
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
|
-
//
|
|
370
|
-
//
|
|
371
|
-
// `
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
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
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
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
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
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
|
|
392
|
-
*
|
|
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
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|