@inetafrica/open-claudia 3.4.24 → 3.5.1

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.
@@ -15,12 +15,16 @@
15
15
  // it) slips through — accepted as best-effort; the dream's KPI pass reads the
16
16
  // guard log to spot chronic bypassing.
17
17
  //
18
- // Fail-open doctrine: any error in the gate itself must NEVER brick the
19
- // agent's shell — parse failures, fs errors, unknown payloads all allow.
18
+ // Fail direction: on owner and no-context turns any error in the gate itself
19
+ // must NEVER brick the agent's tools — parse failures, fs errors, unknown
20
+ // payloads all allow. On GUARDED (non-owner) turns the same failures deny:
21
+ // a check that could not run must not widen a staff member's reach.
20
22
 
21
23
  const fs = require("fs");
24
+ const os = require("os");
22
25
  const path = require("path");
23
26
  const CONFIG_DIR = require("../config-dir");
27
+ const { resolveConfigDir } = require("./config-dir");
24
28
 
25
29
  const GUARD_LOG = process.env.TOOL_GUARD_LOG || path.join(CONFIG_DIR, "tool-guard.jsonl");
26
30
  const SETTINGS_FILE = process.env.DENY_GATE_SETTINGS || path.join(CONFIG_DIR, "deny-gate.settings.json");
@@ -309,34 +313,408 @@ function externalControlPlaneDenial(command) {
309
313
  );
310
314
  }
311
315
 
316
+ // ── guarded-speaker read gate ──────────────────────────────────────────────
317
+ // Claude Code's own file-reading tools never pass through a shell, so on a
318
+ // guarded turn they could open the bot's keyring, every session transcript,
319
+ // or ~/.ssh. On a guarded turn a read whose target resolves inside a
320
+ // protected root is denied, and a search (Grep/Glob/LS) is denied when its
321
+ // root is a protected root, inside one, or ABOVE one — searching $HOME or /
322
+ // walks straight through them. Owner and no-context turns never get here.
323
+ const READ_FILE_TOOLS = new Set(["Read", "NotebookRead"]);
324
+ const READ_SEARCH_TOOLS = new Set(["Grep", "Glob", "LS"]); // LS/NotebookRead: older Claude Code builds
325
+ const READ_TOOLS = new Set([...READ_FILE_TOOLS, ...READ_SEARCH_TOOLS]);
326
+
327
+ // Credential stores under the user's home. One list feeds both the read gate
328
+ // and the guarded shell check below, so the two cannot drift apart.
329
+ const HOME_SECRET_PATHS = [
330
+ ".claude", // Claude Code credentials and every session transcript (the owner's private chats)
331
+ ".claude.json", // Claude Code account state and MCP server config (can carry API keys)
332
+ ".codex", // Codex auth.json and session rollouts
333
+ ".ssh", // SSH private keys
334
+ ".aws", // AWS access keys
335
+ ".kube", // Kubernetes cluster credentials
336
+ ".docker", // container registry auth (config.json)
337
+ ".config/gcloud", // Google Cloud credentials
338
+ ".netrc", // plaintext host passwords (curl, git, ftp)
339
+ ".git-credentials", // git credential-store tokens
340
+ ".npmrc", // npm registry tokens
341
+ ];
342
+
343
+ function homeDirs() {
344
+ const out = [];
345
+ for (const get of [() => os.homedir(), () => os.userInfo().homedir]) {
346
+ try {
347
+ const home = get();
348
+ if (home && !out.includes(path.resolve(home))) out.push(path.resolve(home));
349
+ } catch (e) { /* no resolvable home */ }
350
+ }
351
+ return out;
352
+ }
353
+
354
+ // Every root a guarded turn may not read. Absolute paths; symlinks are
355
+ // resolved at match time, on both sides.
356
+ function protectedReadRoots() {
357
+ const roots = [];
358
+ const add = (value) => {
359
+ const raw = String(value || "").trim();
360
+ if (!raw) return;
361
+ const abs = path.resolve(raw);
362
+ if (!roots.includes(abs)) roots.push(abs);
363
+ };
364
+ // Open Claudia's own state: keyring, vault, approvals, grants, transcripts,
365
+ // people, identities, audit logs, crons/jobs, tool state — and the packs
366
+ // (procedure notes), which Phase 1 serves through the bot instead.
367
+ add(resolveConfigDir());
368
+ add(CONFIG_DIR);
369
+ for (const home of homeDirs()) {
370
+ // The default config dir: another bot on this host, or this bot's state
371
+ // from before OPEN_CLAUDIA_CONFIG_DIR moved it.
372
+ add(path.join(home, ".open-claudia"));
373
+ for (const rel of HOME_SECRET_PATHS) add(path.join(home, rel));
374
+ }
375
+ // The same Claude Code / Codex stores when relocated for this process.
376
+ add(process.env.CLAUDE_CONFIG_DIR);
377
+ add(process.env.CODEX_HOME);
378
+ // Linux: /proc/<pid>/environ exposes other processes' environment, which
379
+ // for the bot process means its channel and provider tokens.
380
+ add("/proc");
381
+ // The bot env file named in the system prompt. The rest of the installed
382
+ // package is public source and stays readable; any other .env file inside
383
+ // it is matched by name (isPackageEnvFile).
384
+ add(path.join(PACKAGE_ROOT, ".env"));
385
+ return roots;
386
+ }
387
+
388
+ // Inbound chat attachments are saved under the config dir (files/, media/)
389
+ // and every photo/document turn tells the agent to Read them there, so a
390
+ // guarded turn keeps reading single files there exactly as today. Never a
391
+ // search root: a Grep/Glob over these folders would sweep every attachment
392
+ // from every room.
393
+ function attachmentDirs() {
394
+ const out = [];
395
+ for (const dir of [resolveConfigDir(), CONFIG_DIR]) {
396
+ for (const sub of ["files", "media"]) {
397
+ const p = path.join(path.resolve(dir), sub);
398
+ if (!out.includes(p)) out.push(p);
399
+ }
400
+ }
401
+ return out;
402
+ }
403
+
404
+ const CASE_INSENSITIVE_PATHS = process.platform === "darwin" || process.platform === "win32";
405
+
406
+ function comparablePath(value) {
407
+ const s = String(value).normalize("NFC");
408
+ return CASE_INSENSITIVE_PATHS ? s.toLowerCase() : s;
409
+ }
410
+
411
+ // Prefix match on whole path segments: /a/b contains /a/b/c, not /a/bc.
412
+ function isSameOrInside(child, parent) {
413
+ const c = comparablePath(child);
414
+ const p = comparablePath(parent);
415
+ if (c === p) return true;
416
+ return c.startsWith(p.endsWith(path.sep) ? p : p + path.sep);
417
+ }
418
+
419
+ // Where the OS actually lands for an absolute path: symlinks followed and a
420
+ // `..` applied AFTER them, as open(2) does. fs.realpathSync.native, because
421
+ // the JS realpath folds `..` lexically first (so `link/../x` would escape).
422
+ // A path that does not exist yet resolves through its deepest existing
423
+ // ancestor.
424
+ function physicalPath(rawAbs) {
425
+ const parts = String(rawAbs).split(path.sep);
426
+ for (let keep = parts.length; keep > 0; keep -= 1) {
427
+ const head = parts.slice(0, keep).join(path.sep) || path.sep;
428
+ try {
429
+ const real = fs.realpathSync.native(head);
430
+ const rest = parts.slice(keep).filter(Boolean);
431
+ return rest.length ? path.join(real, ...rest) : real;
432
+ } catch (e) { /* missing or unreadable — try the parent */ }
433
+ }
434
+ return path.resolve(rawAbs);
435
+ }
436
+
437
+ function expandHome(raw) {
438
+ const value = String(raw);
439
+ const home = os.homedir();
440
+ if (value === "~" || value === "$HOME" || value === "${HOME}") return home;
441
+ for (const prefix of ["~/", "$HOME/", "${HOME}/"]) {
442
+ if (value.startsWith(prefix)) return home + path.sep + value.slice(prefix.length);
443
+ }
444
+ return value;
445
+ }
446
+
447
+ // Both views of a tool path: lexical (what a caller that normalises first
448
+ // opens) and physical (what open(2) reaches). A check must pass on both.
449
+ function pathForms(raw, cwd) {
450
+ const expanded = expandHome(raw);
451
+ const rawAbs = path.isAbsolute(expanded) ? expanded : cwd + path.sep + expanded;
452
+ const forms = [path.resolve(cwd, expanded), physicalPath(rawAbs)];
453
+ return forms.filter((form, index) => forms.indexOf(form) === index);
454
+ }
455
+
456
+ function protectedRootForms() {
457
+ const forms = [];
458
+ for (const root of protectedReadRoots()) {
459
+ for (const form of [root, physicalPath(root)]) if (!forms.includes(form)) forms.push(form);
460
+ }
461
+ return forms;
462
+ }
463
+
464
+ const PACKAGE_ENV_FILE_RE = /^\.env(?:\..+)?$/i;
465
+
466
+ function isPackageEnvFile(form) {
467
+ if (!PACKAGE_ENV_FILE_RE.test(path.basename(form))) return false;
468
+ return [PACKAGE_ROOT, physicalPath(PACKAGE_ROOT)].some((root) => isSameOrInside(form, root));
469
+ }
470
+
471
+ // True when reading the file at `raw` (relative to `cwd`) would reach
472
+ // protected state. Attachment folders are exempt unless the path escapes
473
+ // them, or they themselves resolve onto protected state.
474
+ function isProtectedReadPath(raw, cwd = process.cwd()) {
475
+ const base = path.resolve(String(cwd || process.cwd()));
476
+ const roots = protectedRootForms();
477
+ const exempt = attachmentDirs()
478
+ .flatMap((dir) => [dir, physicalPath(dir)])
479
+ .filter((dir) => !roots.some((root) => isSameOrInside(root, dir)));
480
+ return pathForms(raw, base).some((form) => isPackageEnvFile(form)
481
+ || (roots.some((root) => isSameOrInside(form, root))
482
+ && !exempt.some((dir) => isSameOrInside(form, dir))));
483
+ }
484
+
485
+ // True when a search rooted at `raw` would enter protected state: the root is
486
+ // a protected root, inside one, or an ancestor of one.
487
+ function isProtectedSearchRoot(raw, cwd = process.cwd()) {
488
+ const base = path.resolve(String(cwd || process.cwd()));
489
+ return searchFormsProtected(pathForms(raw, base));
490
+ }
491
+
492
+ function searchFormsProtected(forms) {
493
+ const roots = protectedRootForms();
494
+ return forms.some((form) => isPackageEnvFile(form)
495
+ || roots.some((root) => isSameOrInside(form, root) || isSameOrInside(root, form)));
496
+ }
497
+
498
+ // Glob: where the walk actually starts. The literal (wildcard-free) leading
499
+ // segments of the pattern, taken from the pattern itself when it is absolute
500
+ // (or ~-anchored), else from the search root; every `..` after the first
501
+ // wildcard may climb one level higher, so the base climbs with it.
502
+ const GLOB_MAGIC_RE = /[*?[\]{}()!+@\\]/;
503
+
504
+ function globSearchBases(pattern, searchRoot) {
505
+ const expanded = expandHome(pattern);
506
+ const literal = [];
507
+ let climb = 0;
508
+ let wild = false;
509
+ for (const segment of expanded.split("/")) {
510
+ if (!wild && !GLOB_MAGIC_RE.test(segment)) { literal.push(segment); continue; }
511
+ wild = true;
512
+ if (segment === "..") climb += 1;
513
+ }
514
+ const prefix = literal.join("/");
515
+ const start = path.isAbsolute(expanded)
516
+ ? (prefix || path.sep)
517
+ : (prefix ? searchRoot + path.sep + prefix : searchRoot);
518
+ return pathForms(start, searchRoot).map((form) => {
519
+ let out = form;
520
+ for (let i = 0; i < climb; i += 1) out = path.dirname(out);
521
+ return out;
522
+ });
523
+ }
524
+
525
+ function isRegularFile(raw, cwd) {
526
+ try { return fs.statSync(path.resolve(cwd, expandHome(raw))).isFile(); }
527
+ catch (e) { return false; }
528
+ }
529
+
530
+ // Guarded-turn verdict for one read-tool call:
531
+ // { malformed } | { deny, target, search }.
532
+ function evaluateGuardedRead(toolName, input, cwd) {
533
+ if (!input || typeof input !== "object") return { malformed: true };
534
+ const base = path.resolve(typeof cwd === "string" && cwd ? cwd : process.cwd());
535
+ if (READ_FILE_TOOLS.has(toolName)) {
536
+ const target = toolName === "NotebookRead" ? input.notebook_path : input.file_path;
537
+ if (typeof target !== "string" || !target) return { malformed: true };
538
+ return { deny: isProtectedReadPath(target, base), target, search: false };
539
+ }
540
+ if (input.path != null && typeof input.path !== "string") return { malformed: true };
541
+ if (toolName === "LS" && !input.path) return { malformed: true };
542
+ if (toolName === "Glob" && (typeof input.pattern !== "string" || !input.pattern)) return { malformed: true };
543
+ const root = input.path || base;
544
+ // A Grep over one file is a read of that file.
545
+ if (toolName === "Grep" && isRegularFile(root, base)) {
546
+ return { deny: isProtectedReadPath(root, base), target: root, search: false };
547
+ }
548
+ if (isProtectedSearchRoot(root, base)) return { deny: true, target: root, search: true };
549
+ if (toolName === "Glob"
550
+ && searchFormsProtected(globSearchBases(input.pattern, path.resolve(base, expandHome(root))))) {
551
+ return { deny: true, target: input.pattern, search: true };
552
+ }
553
+ return { deny: false, target: root, search: true };
554
+ }
555
+
556
+ function protectedReadDenial(toolName, verdict, speaker) {
557
+ const who = speaker && speaker.name ? ` (${speaker.name})` : "";
558
+ const target = String(verdict.target).slice(0, 200);
559
+ return verdict.search
560
+ ? `⛔ External-speaker guard: ${toolName} over ${target} is blocked on this turn — it would reach the bot's private state or credentials, and the speaker${who} is not the owner.\nSearch a narrower folder inside the project instead.`
561
+ : `⛔ External-speaker guard: ${toolName} of ${target} is blocked on this turn — it is part of the bot's private state or credentials, and the speaker${who} is not the owner.\nAnswer without it.`;
562
+ }
563
+
564
+ // ── guarded-speaker secret reads from the shell ────────────────────────────
565
+ // A deliberately small list of obvious ways a shell reads another process's
566
+ // environment (the bot's carries its tokens) or a credential store. Their own
567
+ // environment (`env`, `printenv`) is left alone. Phase 2 replaces shell text
568
+ // matching with tool-only staff turns; until then this closes the literal
569
+ // forms of what the read gate above denies to Read/Grep/Glob.
570
+ const KEYCHAIN_RE = /\bsecurity\s+(?:find-generic-password|find-internet-password|dump-keychain|export)\b/;
571
+ const PS_RE = /(?:^|[|;&(`'"\n])\s*(?:sudo\s+)?(?:[^\s|;&()`'"]*\/)?ps(?=\s|$)([^|;&\n)`]*)/g;
572
+
573
+ // ps prints other processes' environment with BSD `e` (ps eww, ps auxe) or
574
+ // macOS -E. Linux `-e` only selects all processes and stays free.
575
+ function psShowsEnvironment(cmd) {
576
+ for (const match of cmd.matchAll(PS_RE)) {
577
+ const args = match[1].split(/\s+/).map((arg) => arg.replace(/^['"]+|['"]+$/g, "")).filter(Boolean);
578
+ let valueNext = false;
579
+ for (const arg of args) {
580
+ if (valueNext) { valueNext = false; continue; }
581
+ if (/^-[A-Za-z]*E/.test(arg)) return true;
582
+ if (/^-[A-Za-z]*[oOpUuGgtCq]$/.test(arg)) { valueNext = true; continue; }
583
+ if (/^[A-Za-z]+$/.test(arg) && arg.includes("e")) return true;
584
+ }
585
+ }
586
+ return false;
587
+ }
588
+
589
+ function credentialPathAnchors() {
590
+ const anchors = [];
591
+ const prefixes = ["~/", "$HOME/", "${HOME}/", ...homeDirs().map((home) => home + path.sep)];
592
+ for (const prefix of prefixes) for (const rel of HOME_SECRET_PATHS) anchors.push(prefix + rel);
593
+ for (const moved of [process.env.CLAUDE_CONFIG_DIR, process.env.CODEX_HOME]) {
594
+ if (moved && String(moved).trim()) anchors.push(path.resolve(String(moved).trim()));
595
+ }
596
+ return anchors;
597
+ }
598
+
599
+ function mentionsCredentialPath(cmd) {
600
+ return credentialPathAnchors().some((anchor) => {
601
+ for (let at = cmd.indexOf(anchor); at !== -1; at = cmd.indexOf(anchor, at + 1)) {
602
+ const next = cmd[at + anchor.length];
603
+ if (next === undefined || !/[A-Za-z0-9_-]/.test(next)) return true;
604
+ }
605
+ return false;
606
+ });
607
+ }
608
+
609
+ // Words a guarded turn's command may not contain at all (Phase 0 review,
610
+ // HIGH): the path-anchored rules above only see one spelling of a read, so the
611
+ // names themselves are refused — process environments and argument blocks, the
612
+ // keyring, the bot's own config dir, and the credential stores above as path
613
+ // components — and so is any per-process /proc path, as the read gate already
614
+ // refuses /proc to Read/Grep/Glob. This narrows the shell; it is not a
615
+ // boundary. Phase 2 takes the shell away from staff turns.
616
+ const SECRET_WORD_RE = /\b(?:environ|procargs\d*|keyring)\b/i;
617
+ const PROC_PROCESS_RE = /\/proc\/(?:\d|self\b|thread-self\b|[$*?[{])/;
618
+ const SECRET_COMPONENT_RE = new RegExp(
619
+ `(?:^|[\\s/'"=:(\`])(?:\\.open|${HOME_SECRET_PATHS.map((rel) => rel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?![A-Za-z0-9_-])`,
620
+ );
621
+
622
+ // Pure: null, or the id of the secret-read pattern the command matches.
623
+ function secretReadRule(command) {
624
+ const cmd = String(command || "");
625
+ if (!cmd.trim() || isAuthorizedChannelCommand(cmd)) return null;
626
+ if (cmd.includes("/proc/") && cmd.includes("environ")) return "proc-environ";
627
+ if (psShowsEnvironment(cmd)) return "ps-environment";
628
+ if (KEYCHAIN_RE.test(cmd)) return "os-keychain";
629
+ if (mentionsCredentialPath(cmd)) return "credential-store";
630
+ if (PROC_PROCESS_RE.test(cmd)) return "proc-process";
631
+ if (SECRET_WORD_RE.test(cmd) || SECRET_COMPONENT_RE.test(cmd)) return "secret-word";
632
+ return null;
633
+ }
634
+
635
+ function externalSecretReadDenial(command) {
636
+ const rule = secretReadRule(command);
637
+ if (!rule) return null;
638
+ const speaker = guardedSpeakerFromEnv();
639
+ if (!speaker) return null;
640
+ return {
641
+ rule,
642
+ message:
643
+ `⛔ External-speaker guard: this command reads another process's environment or a credential store, and the speaker${speaker.name ? ` (${speaker.name})` : ""} is not the owner.\n` +
644
+ `Answer without it.`,
645
+ };
646
+ }
647
+
648
+ // A hook call that could not be checked. Owner and no-context turns fail
649
+ // OPEN as they always have; a guarded turn fails CLOSED.
650
+ function hookFailure(toolName, reason) {
651
+ let speaker = null;
652
+ try { speaker = guardedSpeakerFromEnv(); }
653
+ catch (e) { speaker = process.env.OC_CHANNEL_ADAPTER && process.env.OC_CHANNEL_ID ? { name: "" } : null; }
654
+ if (!speaker) return { exit: 0, stderr: "" };
655
+ logGuardEvent({ kind: "deny", rule: "external-guard-failure", tool: String(toolName || "").slice(0, 60), reason });
656
+ return {
657
+ exit: 2,
658
+ stderr: `⛔ External-speaker guard: this ${toolName || "tool"} call could not be checked, so it is blocked on this turn (the speaker is not the owner).`,
659
+ };
660
+ }
661
+
662
+ // Claude sends the shell command as a string; accept an argv array too.
663
+ function shellCommandOf(input) {
664
+ if (!input || typeof input !== "object") return null;
665
+ if (typeof input.command === "string") return input.command;
666
+ if (Array.isArray(input.command) && input.command.every((part) => typeof part === "string")) {
667
+ return input.command.join(" ");
668
+ }
669
+ return null;
670
+ }
671
+
312
672
  // Hook entry, pure enough to test: takes the PreToolUse stdin payload (object
313
673
  // or JSON string), returns { exit, stderr }. exit 2 = deny (stderr is fed back
314
- // to the model); exit 0 = allow. Anything unexpected → allow (fail-open).
674
+ // to the model); exit 0 = allow. Anything unexpected → allow on owner and
675
+ // no-context turns (fail-open), deny on guarded turns (fail-closed).
315
676
  function runHook(payload) {
316
677
  let data = payload;
317
678
  try {
318
679
  if (typeof payload === "string") data = JSON.parse(payload);
319
680
  } catch (e) {
320
- return { exit: 0, stderr: "" };
681
+ return hookFailure("", "unparseable payload");
321
682
  }
683
+ let toolName = "";
322
684
  try {
685
+ if (!data || typeof data !== "object" || typeof data.tool_name !== "string" || !data.tool_name) {
686
+ return hookFailure("", "malformed payload");
687
+ }
688
+ toolName = data.tool_name;
323
689
  // File-mutation tools are gated by SPEAKER, not by command content — and
324
690
  // deliberately outside the relaxed tooling-mode bypass below: tooling mode
325
691
  // is an owner workflow preference, this is a security control.
326
- if (data && FILE_MUTATION_TOOLS.has(data.tool_name)) {
327
- const denial = externalFileMutationDenial(data.tool_name);
692
+ if (FILE_MUTATION_TOOLS.has(toolName)) {
693
+ const denial = externalFileMutationDenial(toolName);
328
694
  if (!denial) return { exit: 0, stderr: "" };
329
695
  const input = data.tool_input || {};
330
696
  logGuardEvent({
331
697
  kind: "deny",
332
698
  rule: "external-file-write",
333
- tool: data.tool_name,
699
+ tool: toolName,
334
700
  file: String(input.file_path || input.notebook_path || input.command || "").slice(0, 300),
335
701
  });
336
702
  return { exit: 2, stderr: denial };
337
703
  }
338
- if (!data || data.tool_name !== "Bash") return { exit: 0, stderr: "" };
339
- const command = data.tool_input && data.tool_input.command;
704
+ // Read tools: speaker first, so owner and no-context turns pass through
705
+ // untouched (no path work, no log line).
706
+ if (READ_TOOLS.has(toolName)) {
707
+ const speaker = guardedSpeakerFromEnv();
708
+ if (!speaker) return { exit: 0, stderr: "" };
709
+ const verdict = evaluateGuardedRead(toolName, data.tool_input, data.cwd);
710
+ if (verdict.malformed) return hookFailure(toolName, "malformed payload");
711
+ if (!verdict.deny) return { exit: 0, stderr: "" };
712
+ logGuardEvent({ kind: "deny", rule: "external-protected-read", tool: toolName, target: String(verdict.target).slice(0, 300) });
713
+ return { exit: 2, stderr: protectedReadDenial(toolName, verdict, speaker) };
714
+ }
715
+ if (toolName !== "Bash") return { exit: 0, stderr: "" };
716
+ const command = shellCommandOf(data.tool_input);
717
+ if (command === null) return hookFailure(toolName, "malformed payload");
340
718
  // Control-plane references are speaker-gated ahead of the tooling-mode
341
719
  // switch — a security control, not a workflow preference.
342
720
  const cpDenial = externalControlPlaneDenial(command);
@@ -344,6 +722,11 @@ function runHook(payload) {
344
722
  logGuardEvent({ kind: "deny", rule: "external-control-plane", command: String(command).slice(0, 300) });
345
723
  return { exit: 2, stderr: cpDenial };
346
724
  }
725
+ const secretDenial = externalSecretReadDenial(command);
726
+ if (secretDenial) {
727
+ logGuardEvent({ kind: "deny", rule: "external-secret-read", pattern: secretDenial.rule, command: String(command).slice(0, 300) });
728
+ return { exit: 2, stderr: secretDenial.message };
729
+ }
347
730
  // Relaxed tooling mode: the gate observes but never blocks. The event is
348
731
  // still logged (as relaxed-pass, not deny) so the dream KPI can report
349
732
  // what WOULD have been denied — visibility survives the mode switch.
@@ -357,7 +740,7 @@ function runHook(payload) {
357
740
  ? { exit: 2, stderr: policy.message }
358
741
  : { exit: 0, stderr: "" };
359
742
  } catch (e) {
360
- return { exit: 0, stderr: "" };
743
+ return hookFailure(toolName, "guard error");
361
744
  }
362
745
  }
363
746
 
@@ -373,8 +756,9 @@ function ensureHookSettings() {
373
756
  }
374
757
 
375
758
  module.exports = {
376
- RULES, RAW_OP_PATTERNS, GUARD_LOG, SETTINGS_FILE, FILE_MUTATION_TOOLS,
759
+ RULES, RAW_OP_PATTERNS, GUARD_LOG, SETTINGS_FILE, FILE_MUTATION_TOOLS, READ_TOOLS, HOME_SECRET_PATHS,
377
760
  isAuthorizedChannelCommand, matchRule, denyMessage, evaluateCommandPolicy, logGuardEvent, runHook, hookSettings, ensureHookSettings,
378
761
  externalFileMutationDenial, externalControlPlaneDenial, guardedSpeakerFromEnv,
762
+ protectedReadRoots, isProtectedReadPath, isProtectedSearchRoot, secretReadRule,
379
763
  matchRawOp, noteRawOp, readGuardEvents,
380
764
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.4.24",
3
+ "version": "3.5.1",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  "setup": "node setup.js",
12
12
  "start": "node bot.js",
13
13
  "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-upgrade-path.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-kazee-context-media.js && node test-kazee-typing-join.js && node test-kazee-inbound-dedup.js && node test-typing-phase-push.js && node test-socket-reconnect.js && node test-speech-loopback.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-call-speech.js && node test-kazee-call-session.js",
14
- "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && node test-codex-rollout.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-mandate-hygiene.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-external-rule-scopes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-cards.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-people-owner-seed.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-typing-heartbeat.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-stream-preview-purpose.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-union.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-subst-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-section-budgets.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-dedup-adjudicator.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-prospective.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-regressions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cost-report.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-mining.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-certification.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-apprenticeship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-attest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-verify-contact.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-loopback-owner-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-intro-adoption.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-grants.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-room-provider-settings.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-store-durability.js"
14
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && node test-codex-rollout.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-transcript-room-scope.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-mandate-hygiene.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-external-rule-scopes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-cards.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-people-owner-seed.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-typing-heartbeat.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-stream-preview-purpose.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-union.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-subst-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-section-budgets.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-dedup-adjudicator.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-prospective.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-regressions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cost-report.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-mining.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-certification.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-apprenticeship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-attest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-attest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-bot-endpoint.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cli-owner-gates.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-verify-contact.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-loopback-owner-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-watch-check-env.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-intro-adoption.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-grants.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-room-provider-settings.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-store-durability.js"
15
15
  },
16
16
  "files": [
17
17
  "bot.js",
@@ -136,9 +136,34 @@ class ProjectTranscripts {
136
136
  }
137
137
  }
138
138
 
139
+ // Who a transcript file belongs to, for the room-scoping guard in the search
140
+ // CLIs. Every project transcript is keyed by (canonicalUserId, projectPath) and
141
+ // records the owning canonicalUserId in its sidecar `.json` (and on each JSONL
142
+ // entry). Reading it back lets a non-owner CLI confirm a transcript it was
143
+ // pointed at is actually the caller's own — an env-forged OC_TRANSCRIPT_PATH
144
+ // aimed at someone else's file resolves to a different canonicalUserId and is
145
+ // refused. Returns null when ownership cannot be established (fail closed).
146
+ function transcriptOwnerOf(transcriptPath) {
147
+ const file = String(transcriptPath || "");
148
+ if (!file) return null;
149
+ const sidecar = file.endsWith(".jsonl") ? `${file.slice(0, -6)}.json` : `${file}.json`;
150
+ try {
151
+ const meta = JSON.parse(fs.readFileSync(sidecar, "utf8"));
152
+ if (meta && meta.canonicalUserId) return String(meta.canonicalUserId);
153
+ } catch (e) { /* fall through to the JSONL header */ }
154
+ try {
155
+ const raw = fs.readFileSync(file, "utf8");
156
+ const newline = raw.indexOf("\n");
157
+ const first = JSON.parse(newline === -1 ? raw : raw.slice(0, newline));
158
+ if (first && first.canonicalUserId) return String(first.canonicalUserId);
159
+ } catch (e) { /* unreadable → unknown owner */ }
160
+ return null;
161
+ }
162
+
139
163
  module.exports = {
140
164
  ProjectTranscripts,
141
165
  normalizeProjectPath,
142
166
  projectHash,
143
167
  truthy,
168
+ transcriptOwnerOf,
144
169
  };
@@ -124,6 +124,28 @@ const approvals = require("./core/approvals");
124
124
  "legacy approvals with an undisplayed suffix require a fresh decision",
125
125
  );
126
126
 
127
+ // ── an approval carries EXACTLY the tier on the owner's card ──
128
+ // A write approval never redeems for a run that now resolves destructive
129
+ // (and the refusal leaves the token intact); the tier a redemption exports
130
+ // is the record's own, and a record without one falls back to the run's
131
+ // tier — never to "destructive" by default.
132
+ const writeRec = approvals.create({ tool: "fleet", verb: "restart", tier: "write", command: CMD, channelId: "c1", adapter: "tg" });
133
+ approvals.decide(writeRec.id, "approved", "c1");
134
+ const stretched = approvals.consume(writeRec.id, CMD, { tier: "destructive" });
135
+ assert.strictEqual(stretched.ok, false, "a write approval cannot redeem a destructive run");
136
+ assert.ok(/covers a write run, but this run is destructive/.test(stretched.reason));
137
+ assert.strictEqual(approvals.read(writeRec.id).consumedAt, undefined, "the tier refusal does not burn the token");
138
+ const fits = approvals.consume(writeRec.id, CMD, { tier: "write" });
139
+ assert.strictEqual(fits.ok, true, "the approved tier redeems");
140
+ assert.strictEqual(approvals.approvedTier(fits.record, "destructive"), "write", "the record's tier is what the run carries");
141
+ const untiered = approvals.create({ tool: "fleet", command: CMD, channelId: "c1", adapter: "tg" });
142
+ assert.strictEqual(untiered.tier, "", "no tier given → none recorded (not destructive)");
143
+ approvals.decide(untiered.id, "approved", "c1");
144
+ const legacyRun = approvals.consume(untiered.id, CMD, { tier: "destructive" });
145
+ assert.strictEqual(legacyRun.ok, true, "a tierless record binds nothing extra");
146
+ assert.strictEqual(approvals.approvedTier(legacyRun.record, "write"), "write", "a tierless record exports the run's own tier");
147
+ assert.strictEqual(approvals.approvedTier({ tier: "reply" }, "read-only"), "read-only", "non-tool tiers never widen a run");
148
+
127
149
  const quotedCommand = approvals.formatApprovedCommand(["open-claudia", "tool", "run", "fleet", "purge", "node one", "it's-safe"]);
128
150
  assert.strictEqual(quotedCommand, "open-claudia tool run fleet purge 'node one' 'it'\"'\"'s-safe'");
129
151