@indigoai-us/hq-cli 5.107.0 → 5.108.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.0] — 2026-09-03
6
+
7
+ ### Fixed
8
+
9
+ - Every `hq` command was reading the whole local sync database before it ran,
10
+ to pull a single timestamp out of it for the client-health heartbeat. On a
11
+ machine whose sync store had grown large this cost around 21 seconds of CPU
12
+ per command — including every `hq secrets` fetch — and the 1.2-second bound
13
+ the heartbeat claims could not stop it, because the work is synchronous and
14
+ the timer cannot fire while it runs. The timestamp is now observed at most
15
+ once every 15 minutes, once per machine rather than once per process, and
16
+ sync commands still refresh it every time. Measured on an affected machine, a
17
+ credential fetch went from ~22.6 s to ~1.4 s.
18
+
19
+ ## [5.107.1] — 2026-09-03
20
+
5
21
  ## [5.107.0] — 2026-09-03
6
22
 
7
23
  ### Added
@@ -17,7 +17,7 @@ import * as fs from "fs";
17
17
  import * as path from "path";
18
18
  import { share, sync, getStateDir, listJournals, loadCachedTokens, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, } from "@indigoai-us/hq-cloud";
19
19
  import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
20
- import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
20
+ import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintPresentationLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
21
21
  import { beginSyncHealthReport, } from "../utils/client-health.js";
22
22
  /**
23
23
  * Terminal-failure signal for the sync fanout runners. The runners
@@ -283,10 +283,13 @@ export async function pullAll(options, deps) {
283
283
  const nudgeExceedsSize = resolvedMode === "all" &&
284
284
  entry.companyUid !== undefined &&
285
285
  narrowNudgeExceedsSize(options.hqRoot, entry.slug);
286
- if (resolvedMode === "all" &&
286
+ const strictRefusal = resolvedMode === "all" &&
287
287
  nudgeExceedsSize &&
288
288
  isStrictRefusal(resolvedMode, narrowHintLevel) &&
289
- !options.modeAllOverride &&
289
+ !options.modeAllOverride;
290
+ const bannerLevel = resolveNarrowHintPresentationLevel(narrowHintLevel, strictRefusal);
291
+ if (strictRefusal &&
292
+ resolvedMode === "all" &&
290
293
  entry.companyUid) {
291
294
  // Emit the strict-level banner once, then mark the leg as errored
292
295
  // without invoking sync(). The operator either narrows the
@@ -295,7 +298,7 @@ export async function pullAll(options, deps) {
295
298
  emitNarrowHint({
296
299
  companyUid: entry.companyUid,
297
300
  syncMode: resolvedMode,
298
- level: narrowHintLevel,
301
+ level: bannerLevel,
299
302
  });
300
303
  const message = "Refusing to pull all-mode membership in strict mode. " +
301
304
  "Run `hq sync narrow --apply` to migrate, or re-run with --mode-all.";
@@ -316,7 +319,7 @@ export async function pullAll(options, deps) {
316
319
  emitNarrowHint({
317
320
  companyUid: entry.companyUid,
318
321
  syncMode: resolvedMode,
319
- level: narrowHintLevel,
322
+ level: bannerLevel,
320
323
  });
321
324
  }
322
325
  }
@@ -891,19 +894,22 @@ export function registerCloudCommands(program) {
891
894
  const nudgeExceedsSize = resolvedMode === "all" &&
892
895
  resolvedCompanyUid !== undefined &&
893
896
  narrowNudgeExceedsSize(options.hqRoot, options.company);
897
+ const strictRefusal = resolvedMode === "all" &&
898
+ nudgeExceedsSize &&
899
+ isStrictRefusal(resolvedMode, narrowHintLevel) &&
900
+ options.modeAll !== true;
901
+ const bannerLevel = resolveNarrowHintPresentationLevel(narrowHintLevel, strictRefusal);
894
902
  // Strict-mode refusal: matches runPullAll + runNowSingle behavior.
895
903
  // Default banner level is 'hint' which never triggers refusal —
896
904
  // wired now so a future release can flip the default to 'strict'
897
905
  // (still size-gated) without re-touching this command.
898
- if (resolvedMode === "all" &&
899
- nudgeExceedsSize &&
900
- isStrictRefusal(resolvedMode, narrowHintLevel) &&
901
- options.modeAll !== true &&
906
+ if (strictRefusal &&
907
+ resolvedMode === "all" &&
902
908
  resolvedCompanyUid) {
903
909
  emitNarrowHint({
904
910
  companyUid: resolvedCompanyUid,
905
911
  syncMode: resolvedMode,
906
- level: narrowHintLevel,
912
+ level: bannerLevel,
907
913
  });
908
914
  console.error(chalk.red("\n✗ Pull refused: strict narrow-hint mode is on and this " +
909
915
  "company's local folder has grown large. Run `hq sync narrow --apply` " +
@@ -941,7 +947,7 @@ export function registerCloudCommands(program) {
941
947
  emitNarrowHint({
942
948
  companyUid: resolvedCompanyUid,
943
949
  syncMode: resolvedMode,
944
- level: narrowHintLevel,
950
+ level: bannerLevel,
945
951
  });
946
952
  }
947
953
  await syncHealth.succeeded();
@@ -1406,15 +1412,18 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1406
1412
  const nudgeExceedsSize = resolvedMode === "all" &&
1407
1413
  resolvedCompanyUid !== undefined &&
1408
1414
  narrowNudgeExceedsSize(hqRoot, targetCompany);
1409
- if (resolvedMode === "all" &&
1415
+ const strictRefusal = resolvedMode === "all" &&
1410
1416
  nudgeExceedsSize &&
1411
1417
  isStrictRefusal(resolvedMode, narrowHintLevel) &&
1412
- !modeAllOverride &&
1418
+ !modeAllOverride;
1419
+ const bannerLevel = resolveNarrowHintPresentationLevel(narrowHintLevel, strictRefusal);
1420
+ if (strictRefusal &&
1421
+ resolvedMode === "all" &&
1413
1422
  resolvedCompanyUid) {
1414
1423
  emitNarrowHint({
1415
1424
  companyUid: resolvedCompanyUid,
1416
1425
  syncMode: resolvedMode,
1417
- level: narrowHintLevel,
1426
+ level: bannerLevel,
1418
1427
  });
1419
1428
  console.error(chalk.red("\n✗ Sync now refused: strict narrow-hint mode is on and this " +
1420
1429
  "company's local folder has grown large. Run `hq sync narrow --apply` " +
@@ -1468,7 +1477,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1468
1477
  emitNarrowHint({
1469
1478
  companyUid: resolvedCompanyUid,
1470
1479
  syncMode: resolvedMode,
1471
- level: narrowHintLevel,
1480
+ level: bannerLevel,
1472
1481
  });
1473
1482
  }
1474
1483
  console.log(chalk.green("\n✓ Sync now complete"));
@@ -52,6 +52,7 @@
52
52
  * first-class skip), and {@link registerMcpServers} is the pack-install routing
53
53
  * seam over it.
54
54
  */
55
+ import { type FlagReader } from '../lib/flag-registry.js';
55
56
  /** Base for every MCP-registration error; carries a stable machine-checkable `code`. */
56
57
  export declare abstract class McpRegistrationError extends Error {
57
58
  abstract readonly code: string;
@@ -595,6 +596,10 @@ export declare function registerServer(opts: RegisterServerOptions): RegisterSer
595
596
  * when neither a url nor a command is present.
596
597
  */
597
598
  export declare function manifestTarget(manifest: McpManifest): string;
599
+ /** Resolve the gate once at the start of an MCP registration operation. */
600
+ export declare function isMcpRegistrationEnabled(flagReader?: FlagReader): boolean;
601
+ /** Freeze a registration decision so multi-server work cannot split on refresh. */
602
+ export declare function captureMcpRegistrationDecision(flagReader?: FlagReader): FlagReader;
598
603
  /**
599
604
  * Register one pack's MCP servers into the shared agent configs (the public seam
600
605
  * `pack-install` routes `wire:'merge'` keys to). For each declared server name it
@@ -609,6 +614,7 @@ export declare function manifestTarget(manifest: McpManifest): string;
609
614
  * @param names the bare server names from `contributes.mcp`
610
615
  * @param options manifest loader + secret resolver + injectable env (all optional;
611
616
  * without a loader this throws, since US-007 has no payload-dir context)
617
+ *
612
618
  * @returns one {@link RegisterServerResult} per server, in `names` order
613
619
  */
614
620
  export declare function registerMcpServers(pkg: string, names: string[], options?: {
@@ -621,6 +627,8 @@ export declare function registerMcpServers(pkg: string, names: string[], options
621
627
  /** Lock tuning + backup stamp passthrough (tests). */
622
628
  lock?: AcquireLockOptions;
623
629
  stamp?: string;
630
+ /** Test seam: held registry snapshot reader. */
631
+ flagReader?: FlagReader;
624
632
  }): RegisterServerResult[];
625
633
  /** smol-toml's value-table type (its `parse` return + `stringify` input). */
626
634
  type TomlTable = Record<string, unknown>;
@@ -57,6 +57,7 @@ import * as fs from 'fs';
57
57
  import * as os from 'os';
58
58
  import * as path from 'path';
59
59
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
60
+ import { resolveFlagGate, resolveProcessFlagGate, } from '../lib/flag-registry.js';
60
61
  // ---------------------------------------------------------------------------
61
62
  // Named error classes (no bare catch-all anywhere in this module).
62
63
  //
@@ -1225,6 +1226,29 @@ export function manifestTarget(manifest) {
1225
1226
  }
1226
1227
  return manifest.url ?? '';
1227
1228
  }
1229
+ /** Registry vocabulary for the legacy MCP operator kill switch. */
1230
+ const MCP_REGISTRATION_LOOKUP = {
1231
+ globalEnvVar: 'HQ_DISABLE_MCP_REGISTRATION',
1232
+ globalValueSemantics: {
1233
+ onValues: [],
1234
+ offValues: ['1'],
1235
+ unrecognizedValue: true,
1236
+ unsetValue: true,
1237
+ },
1238
+ fallback: true,
1239
+ };
1240
+ const mcpRegistrationLegacyFallback = () => process.env.HQ_DISABLE_MCP_REGISTRATION !== '1';
1241
+ /** Resolve the gate once at the start of an MCP registration operation. */
1242
+ export function isMcpRegistrationEnabled(flagReader) {
1243
+ return flagReader
1244
+ ? resolveFlagGate(flagReader, 'cli.mcp-registration', MCP_REGISTRATION_LOOKUP, mcpRegistrationLegacyFallback)
1245
+ : resolveProcessFlagGate('cli.mcp-registration', MCP_REGISTRATION_LOOKUP, mcpRegistrationLegacyFallback);
1246
+ }
1247
+ /** Freeze a registration decision so multi-server work cannot split on refresh. */
1248
+ export function captureMcpRegistrationDecision(flagReader) {
1249
+ const enabled = isMcpRegistrationEnabled(flagReader);
1250
+ return { isEnabled: () => enabled };
1251
+ }
1228
1252
  /**
1229
1253
  * Register one pack's MCP servers into the shared agent configs (the public seam
1230
1254
  * `pack-install` routes `wire:'merge'` keys to). For each declared server name it
@@ -1239,6 +1263,7 @@ export function manifestTarget(manifest) {
1239
1263
  * @param names the bare server names from `contributes.mcp`
1240
1264
  * @param options manifest loader + secret resolver + injectable env (all optional;
1241
1265
  * without a loader this throws, since US-007 has no payload-dir context)
1266
+ *
1242
1267
  * @returns one {@link RegisterServerResult} per server, in `names` order
1243
1268
  */
1244
1269
  export function registerMcpServers(pkg, names, options) {
@@ -1250,14 +1275,14 @@ export function registerMcpServers(pkg, names, options) {
1250
1275
  // ORDERING: this is checked FIRST, BEFORE the loadManifest programmer-error guard
1251
1276
  // below. The kill-switch is a USER/OPERATOR condition; the missing-loadManifest
1252
1277
  // throw is a PROGRAMMER error. An operator who set the kill-switch must never hit a
1253
- // spurious McpManifestError, so the operator path wins. We read process.env directly
1254
- // (not options.env that's the SafeWriteEnv home-path injector, NOT process env);
1255
- // tests set/unset process.env.HQ_DISABLE_MCP_REGISTRATION around the call.
1278
+ // spurious McpManifestError, so the operator path wins. Its legacy environment
1279
+ // predicate remains the outage fallback; `options.env` is the SafeWriteEnv
1280
+ // home-path injector, not process env.
1256
1281
  //
1257
1282
  // Returns an empty RegisterServerResult[] (`[]`) — the correct "no servers
1258
1283
  // registered" semantic — so callers (pack-install) consume it gracefully. The skip
1259
1284
  // NOTICE is emitted exactly once per registerMcpServers call.
1260
- if (process.env.HQ_DISABLE_MCP_REGISTRATION === '1') {
1285
+ if (!isMcpRegistrationEnabled(options?.flagReader)) {
1261
1286
  process.stderr.write('MCP registration skipped (HQ_DISABLE_MCP_REGISTRATION=1)\n');
1262
1287
  return [];
1263
1288
  }
@@ -52,7 +52,7 @@ import { safeExtractTarball } from './safe-extract.js';
52
52
  import { getCompanyUid, vaultApiFetch, vaultApiFetchPublic, } from '../utils/vault-api.js';
53
53
  import { ensureCognitoToken } from '../utils/cognito-session.js';
54
54
  import { formatApiKeyCapabilityDenial, peekHqApiKey, } from '../utils/resolve-vault-credential.js';
55
- import { redactSecrets, SECRET_REDACTION, registerMcpServers, McpManifestError, } from './mcp-registration.js';
55
+ import { redactSecrets, SECRET_REDACTION, captureMcpRegistrationDecision, registerMcpServers, McpManifestError, } from './mcp-registration.js';
56
56
  import { listSecretCacheScopes } from '../utils/secrets-cache.js';
57
57
  import { loadRevealedSecrets } from './secrets.js';
58
58
  const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
@@ -1492,13 +1492,21 @@ async function wireMcpServers(pkg, destDir, company) {
1492
1492
  const loadManifest = (name) => loadMcpManifestFrom(destDir, name);
1493
1493
  const registered = [];
1494
1494
  const skipped = [];
1495
+ // A refresh may arrive while secret resolution awaits. Freeze the gate before
1496
+ // the loop so an install never writes only an arbitrary prefix of a pack.
1497
+ const flagReader = captureMcpRegistrationDecision();
1495
1498
  for (const name of pkg.contributes.mcp ?? []) {
1496
1499
  try {
1497
1500
  const resolveSecret = await makeInstallSecretResolver(collectManifestSecretNames(loadManifest(name)), company);
1498
1501
  // Per-server call: registerMcpServers throws on the FIRST unresolvable
1499
1502
  // secret, so calling it one name at a time lets us catch + continue.
1500
- registerMcpServers(pkg.name, [name], { loadManifest, resolveSecret });
1501
- registered.push(name);
1503
+ const results = registerMcpServers(pkg.name, [name], {
1504
+ loadManifest,
1505
+ resolveSecret,
1506
+ flagReader,
1507
+ });
1508
+ if (results.length > 0)
1509
+ registered.push(name);
1502
1510
  }
1503
1511
  catch (e) {
1504
1512
  const isUnresolvableSecret = e instanceof McpManifestError && /cannot resolve \$\{secret:/.test(e.message);
@@ -11,5 +11,20 @@ export declare function removeStaleHqWorktrees(hqRoot: string, now?: number, dea
11
11
  * malformed configurations remain untouched and receive recovery guidance.
12
12
  */
13
13
  export declare function repairExtremeHookDrift(hqRoot: string, allowRepair?: boolean): void;
14
+ /**
15
+ * Stop the generated project settings from silently downgrading an operator who
16
+ * chose Bypass mode globally.
17
+ *
18
+ * Claude Code resolves `permissions.defaultMode` project-over-user, so ANY value
19
+ * in the HQ root's `.claude/settings.json` — including HQ's own shipped "plan" —
20
+ * outranks `bypassPermissions` in `~/.claude/settings.json`. The operator sets
21
+ * bypass once at user scope and then every HQ session boots in some other mode
22
+ * with no indication why. When that user-scope choice is present, drop the
23
+ * project key so their own mode is what actually applies.
24
+ *
25
+ * `.claude/settings.local.json` is the operator's deliberate per-machine layer
26
+ * and outranks both scopes; it is never touched.
27
+ */
28
+ export declare function dropProjectDefaultModeForBypassUser(hqRoot: string): void;
14
29
  export declare function registerReindexCommand(program: Command): void;
15
30
  //# sourceMappingURL=reindex.d.ts.map
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import * as fs from 'node:fs';
28
+ import * as os from 'node:os';
28
29
  import * as path from 'node:path';
29
30
  import * as yaml from 'js-yaml';
30
31
  import { reindex, rescue } from '@indigoai-us/hq-cloud';
@@ -32,6 +33,7 @@ import { trustHqRuntimeHooks } from '../utils/hook-trust.js';
32
33
  import { findHqRoot } from '../utils/manifest.js';
33
34
  import { guardLargeFiles } from '../utils/large-file-guard.js';
34
35
  const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse'];
36
+ const BYPASS_MODE = 'bypassPermissions';
35
37
  const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh');
36
38
  const WORKTREE_STALE_AFTER_MS = 12 * 60 * 60 * 1_000;
37
39
  const WORKTREE_GIT_TIMEOUT_MS = 2_000;
@@ -470,6 +472,119 @@ export function repairExtremeHookDrift(hqRoot, allowRepair = true) {
470
472
  printHookHealthWarning(hqRoot, 'hook configuration repair could not run');
471
473
  }
472
474
  }
475
+ /**
476
+ * Resolve the user-scope Claude settings file — CLAUDE_CONFIG_DIR when the
477
+ * operator relocated their Claude config, otherwise ~/.claude/settings.json.
478
+ */
479
+ function userClaudeSettingsPath() {
480
+ const configDir = process.env.CLAUDE_CONFIG_DIR?.trim();
481
+ if (configDir)
482
+ return path.join(path.resolve(configDir), 'settings.json');
483
+ const home = process.env.HOME?.trim() || os.homedir();
484
+ if (!home)
485
+ return undefined;
486
+ return path.join(home, '.claude', 'settings.json');
487
+ }
488
+ /** Parse a settings file into a plain object, or undefined when it is absent or malformed. */
489
+ function readSettingsObject(file) {
490
+ let raw;
491
+ try {
492
+ raw = fs.readFileSync(file, 'utf8');
493
+ }
494
+ catch {
495
+ // Absent (or unreadable) user/project settings simply means there is no
496
+ // override to reconcile. Hook health reports a broken project file already.
497
+ return undefined;
498
+ }
499
+ try {
500
+ const parsed = JSON.parse(raw);
501
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
502
+ return undefined;
503
+ return parsed;
504
+ }
505
+ catch {
506
+ return undefined;
507
+ }
508
+ }
509
+ function permissionsObject(settings) {
510
+ const permissions = settings?.permissions;
511
+ if (!permissions || typeof permissions !== 'object' || Array.isArray(permissions))
512
+ return undefined;
513
+ return permissions;
514
+ }
515
+ function declaredDefaultMode(settings) {
516
+ const mode = permissionsObject(settings)?.defaultMode;
517
+ return typeof mode === 'string' ? mode : undefined;
518
+ }
519
+ /**
520
+ * Replace a file through a same-directory temporary file and a rename, so a
521
+ * failed or interrupted write can never leave a truncated `.claude/settings.json`
522
+ * behind — that file carries every hook registration HQ depends on.
523
+ */
524
+ function writeFileAtomically(target, contents) {
525
+ const temporary = path.join(path.dirname(target), `.${path.basename(target)}.reindex-${process.pid}.tmp`);
526
+ try {
527
+ fs.writeFileSync(temporary, contents);
528
+ // Keep whatever mode the operator had on the original; a fresh temp file
529
+ // would otherwise hand back umask defaults on every rewrite.
530
+ try {
531
+ fs.chmodSync(temporary, fs.statSync(target).mode & 0o777);
532
+ }
533
+ catch {
534
+ // A missing or unstattable original just means the default mode applies.
535
+ }
536
+ fs.renameSync(temporary, target);
537
+ }
538
+ catch (err) {
539
+ try {
540
+ fs.rmSync(temporary, { force: true });
541
+ }
542
+ catch {
543
+ // Best-effort cleanup; the original file is still intact either way.
544
+ }
545
+ throw err;
546
+ }
547
+ }
548
+ /**
549
+ * Stop the generated project settings from silently downgrading an operator who
550
+ * chose Bypass mode globally.
551
+ *
552
+ * Claude Code resolves `permissions.defaultMode` project-over-user, so ANY value
553
+ * in the HQ root's `.claude/settings.json` — including HQ's own shipped "plan" —
554
+ * outranks `bypassPermissions` in `~/.claude/settings.json`. The operator sets
555
+ * bypass once at user scope and then every HQ session boots in some other mode
556
+ * with no indication why. When that user-scope choice is present, drop the
557
+ * project key so their own mode is what actually applies.
558
+ *
559
+ * `.claude/settings.local.json` is the operator's deliberate per-machine layer
560
+ * and outranks both scopes; it is never touched.
561
+ */
562
+ export function dropProjectDefaultModeForBypassUser(hqRoot) {
563
+ // As with the hook repair: `hq reindex` can run from an arbitrary directory,
564
+ // and only a real HQ root's settings are ours to rewrite.
565
+ if (!isHqRoot(hqRoot))
566
+ return;
567
+ const userSettingsPath = userClaudeSettingsPath();
568
+ if (!userSettingsPath)
569
+ return;
570
+ if (declaredDefaultMode(readSettingsObject(userSettingsPath)) !== BYPASS_MODE)
571
+ return;
572
+ const settingsPath = path.join(hqRoot, '.claude', 'settings.json');
573
+ const settings = readSettingsObject(settingsPath);
574
+ const permissions = permissionsObject(settings);
575
+ const projectMode = declaredDefaultMode(settings);
576
+ if (!settings || !permissions || projectMode === undefined || projectMode === BYPASS_MODE)
577
+ return;
578
+ delete permissions.defaultMode;
579
+ try {
580
+ writeFileAtomically(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
581
+ }
582
+ catch (err) {
583
+ console.warn(`reindex: could not drop permissions.defaultMode "${projectMode}" from ${settingsPath} — ${err instanceof Error ? err.message : String(err)}\nHQ sessions will keep booting in "${projectMode}" instead of the bypassPermissions mode set in ${userSettingsPath}.`);
584
+ return;
585
+ }
586
+ console.log(`reindex: dropped permissions.defaultMode "${projectMode}" from .claude/settings.json — ${userSettingsPath} sets ${BYPASS_MODE}`);
587
+ }
473
588
  /**
474
589
  * Keep files above GitHub's 100MB limit out of the HQ repo, and say so.
475
590
  *
@@ -528,6 +643,11 @@ export function registerReindexCommand(program) {
528
643
  const { status } = reindex({ repoRoot: opts.repoRoot });
529
644
  repairExtremeHookDrift(hqRoot, status === 0);
530
645
  if (status === 0) {
646
+ // Only safe once reindex actually ran: a refused --from-hook reindex
647
+ // means a sync/rescue holds the shared operation lock and may be
648
+ // rewriting .claude/settings.json right now, and this is an unlocked
649
+ // whole-file read-modify-write.
650
+ dropProjectDefaultModeForBypassUser(hqRoot);
531
651
  await trustHqRuntimeHooks(hqRoot, undefined, {
532
652
  rootAliases: [literalHqRoot],
533
653
  // A lifecycle hook must never block the agent waiting on another HQ
@@ -28,6 +28,7 @@ export interface SecretMetadata {
28
28
  export interface SecretLoadSuccessRow extends SecretMetadata {
29
29
  name: string;
30
30
  value?: string;
31
+ version?: number;
31
32
  }
32
33
  export interface SecretLoadResponse {
33
34
  secrets: SecretLoadSuccessRow[];
@@ -4,7 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import { writeSync } from "node:fs";
5
5
  import * as nodePath from "node:path";
6
6
  import { ensureCognitoToken } from "../utils/cognito-session.js";
7
- import { DEFAULT_SECRETS_CACHE_TTL_MS, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
7
+ import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
8
8
  import { computeSha256 } from "../utils/integrity.js";
9
9
  import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
10
10
  import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
@@ -545,7 +545,12 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
545
545
  }
546
546
  const resolved = new Map();
547
547
  const requested = [...new Set(keys)];
548
+ const cachedCandidates = new Set(requested.filter((name) => hasCacheEntry(companyUid, name)));
548
549
  try {
550
+ // The batch endpoint is the only non-capturing authorization path that also
551
+ // evaluates the supplied usage metadata. It covers warm and cold names in
552
+ // bounded requests, then a warm name may use its locally authenticated
553
+ // plaintext only when the returned version and current policy TTL agree.
549
554
  for (let i = 0; i < requested.length; i += MAX_BATCH_NAMES) {
550
555
  const chunk = requested.slice(i, i + MAX_BATCH_NAMES);
551
556
  const chunkNames = new Set(chunk);
@@ -572,6 +577,9 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
572
577
  typeof body.code === "string") {
573
578
  throw new Error(message);
574
579
  }
580
+ if (res.status === 403) {
581
+ throw new Error(`Failed to fetch secret '${chunk[0]}': No read permission`);
582
+ }
575
583
  throw new Error(`Failed to batch-load secrets: ${message}`);
576
584
  }
577
585
  const data = (await res.json());
@@ -601,8 +609,26 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
601
609
  continue;
602
610
  }
603
611
  const cacheTtlMs = normalizeCacheTtlMs(s);
612
+ const cached = cachedCandidates.has(s.name) &&
613
+ cacheTtlMs > 0 &&
614
+ typeof s.version === "number" &&
615
+ Number.isSafeInteger(s.version) &&
616
+ s.version > 0
617
+ ? readCache(companyUid, s.name, s.version, cacheTtlMs)
618
+ : null;
619
+ if (cached !== null) {
620
+ resolved.set(s.name, cached);
621
+ continue;
622
+ }
604
623
  if (cacheTtlMs > 0) {
605
- writeCache(companyUid, s.name, s.value, cacheTtlMs);
624
+ if (typeof s.version === "number" &&
625
+ Number.isSafeInteger(s.version) &&
626
+ s.version > 0) {
627
+ writeCache(companyUid, s.name, s.value, cacheTtlMs, s.version);
628
+ }
629
+ else {
630
+ writeCache(companyUid, s.name, s.value, cacheTtlMs);
631
+ }
606
632
  }
607
633
  else {
608
634
  removeCacheEntry(companyUid, s.name);
@@ -20,6 +20,7 @@
20
20
  *
21
21
  * Exit codes: 0 = allow, 2 = block.
22
22
  */
23
+ import { type FlagReader } from "../flag-registry.js";
23
24
  /**
24
25
  * Worst-case inner deadline (seconds) declared in a command: a `timeout` /
25
26
  * `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
@@ -50,9 +51,11 @@ export interface TimeoutGuardDeps {
50
51
  getEmail?: () => string | undefined;
51
52
  env?: NodeJS.ProcessEnv;
52
53
  stderr?: NodeJS.WritableStream;
54
+ /** Test seam: held registry snapshot reader. */
55
+ flagReader?: FlagReader;
53
56
  }
54
57
  /** Whether the guard is active for this identity (rollout gate). */
55
- export declare function isGatedUser(email: string | undefined): boolean;
58
+ export declare function isGatedUser(email: string | undefined, flagReader?: FlagReader): boolean;
56
59
  /**
57
60
  * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
58
61
  * return the exit code (0 allow / 2 block). Never throws — a parse failure or a
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
24
24
  import { peekIdToken } from "../../utils/id-token.js";
25
+ import { resolveFlagGate, resolveProcessFlagGate, } from "../flag-registry.js";
25
26
  // Observed Claude Code Bash-tool bounds. The tool `timeout` (ms) raises the
26
27
  // deadline up to the max; with none declared the default applies.
27
28
  const HARNESS_DEFAULT_MS = 120_000; // 2 min
@@ -165,8 +166,16 @@ function defaultEmail() {
165
166
  }
166
167
  }
167
168
  /** Whether the guard is active for this identity (rollout gate). */
168
- export function isGatedUser(email) {
169
- return typeof email === "string" && email.toLowerCase().endsWith(GATE_DOMAIN);
169
+ export function isGatedUser(email, flagReader) {
170
+ const legacyFallback = () => typeof email === "string" && email.toLowerCase().endsWith(GATE_DOMAIN);
171
+ const lookup = {
172
+ defaultEmailDomains: ["getindigo.ai"],
173
+ personEmail: email,
174
+ fallback: legacyFallback(),
175
+ };
176
+ return flagReader
177
+ ? resolveFlagGate(flagReader, "cli.foreground-timeout-guard", lookup, legacyFallback)
178
+ : resolveProcessFlagGate("cli.foreground-timeout-guard", lookup, legacyFallback);
170
179
  }
171
180
  /**
172
181
  * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
@@ -194,7 +203,7 @@ export function timeoutGuardCommand(stdin, deps = {}) {
194
203
  return 0;
195
204
  // Rollout gate: only act for @getindigo.ai identities.
196
205
  const getEmail = deps.getEmail ?? defaultEmail;
197
- if (!isGatedUser(getEmail()))
206
+ if (!isGatedUser(getEmail(), deps.flagReader))
198
207
  return 0;
199
208
  const runInBackground = ti.run_in_background === true;
200
209
  const toolTimeoutMs = typeof ti.timeout === "number" ? ti.timeout : undefined;
@@ -0,0 +1,3 @@
1
+ /** One unref'd registry refresh for the short-lived Node CLI process. */
2
+ export {};
3
+ //# sourceMappingURL=flag-registry-worker.d.ts.map
@@ -0,0 +1,26 @@
1
+ /** One unref'd registry refresh for the short-lived Node CLI process. */
2
+ import { parentPort, workerData } from "node:worker_threads";
3
+ import { loadCachedTokens } from "@indigoai-us/hq-cloud";
4
+ import { createFlagClient } from "@indigoai-us/hq-flags-client";
5
+ const endpoint = workerData.endpoint;
6
+ async function refresh() {
7
+ if (!parentPort || typeof endpoint !== "string" || endpoint.trim() === "")
8
+ return;
9
+ try {
10
+ const client = createFlagClient({
11
+ endpoint,
12
+ getToken: () => loadCachedTokens()?.idToken ?? "",
13
+ refreshIntervalMs: 0,
14
+ onError: () => { },
15
+ });
16
+ await client.ready();
17
+ const snapshot = client.snapshot();
18
+ if (snapshot)
19
+ parentPort.postMessage(snapshot);
20
+ }
21
+ catch {
22
+ // The parent is deliberately fail-open and has the legacy fallback.
23
+ }
24
+ }
25
+ void refresh();
26
+ //# sourceMappingURL=flag-registry-worker.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Process-wide, fail-open bridge to the HQ flag registry.
3
+ *
4
+ * Command gates read an in-memory snapshot synchronously. The first refresh is
5
+ * isolated in an unref'd worker because an unawaited Node fetch can otherwise
6
+ * keep a short-lived, offline CLI alive until the client's network deadline.
7
+ */
8
+ import { type WorkerOptions } from "node:worker_threads";
9
+ import { type FlagClient, type FlagLookupOptions } from "@indigoai-us/hq-flags-client";
10
+ /** Dedicated endpoint configuration; this is intentionally not HQ_PRO_API_URL. */
11
+ export declare const FLAG_REGISTRY_ENDPOINT_ENV = "HQ_FLAGS_API_URL";
12
+ /** The synchronous capability that command gate call sites need. */
13
+ export type FlagReader = Pick<FlagClient, "isEnabled">;
14
+ interface WorkerLike {
15
+ on(event: "message", listener: (value: unknown) => void): unknown;
16
+ on(event: "error", listener: () => void): unknown;
17
+ unref(): void;
18
+ }
19
+ export interface FlagRegistryDependencies {
20
+ endpoint?: string;
21
+ createWorker?: (filename: URL, options: WorkerOptions) => WorkerLike;
22
+ }
23
+ /**
24
+ * Begin one registry refresh without awaiting it or retaining the CLI process.
25
+ *
26
+ * The worker owns cached-token I/O, token use, and the potentially slow fetch.
27
+ * Its response is accepted only after the client has validated it as a flag
28
+ * snapshot. Consequently, every command path is an immediate in-memory read:
29
+ * a missing, unauthenticated, slow, or offline worker leaves `heldSnapshot`
30
+ * null and the caller's exact legacy fallback decides the gate.
31
+ */
32
+ export declare function kickFlagRegistryReadiness(dependencies?: FlagRegistryDependencies): FlagReader | null;
33
+ /**
34
+ * Evaluate a registry-backed gate without changing its outage default.
35
+ * `isEnabled` itself is synchronous and snapshot-only; any reader failure
36
+ * falls through to the exact legacy predicate owned by the caller.
37
+ */
38
+ export declare function resolveFlagGate(reader: FlagReader | null | undefined, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
39
+ /** Resolve a process gate against the shared snapshot reader, when configured. */
40
+ export declare function resolveProcessFlagGate(flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
41
+ /** Test-only reset for process-global readiness state. */
42
+ export declare function _resetFlagRegistryForTests(): void;
43
+ export {};
44
+ //# sourceMappingURL=flag-registry.d.ts.map