@indigoai-us/hq-cli 5.75.0 → 5.76.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.
Files changed (39) hide show
  1. package/dist/commands/mcp-registration.d.ts +4 -5
  2. package/dist/commands/mcp-registration.js +5 -4
  3. package/dist/commands/outposts.d.ts +20 -4
  4. package/dist/commands/outposts.js +77 -8
  5. package/dist/commands/pack-install.d.ts +14 -17
  6. package/dist/commands/pack-install.js +53 -29
  7. package/dist/commands/pkg-install.js +3 -1
  8. package/dist/commands/run.d.ts +2 -0
  9. package/dist/commands/run.js +9 -3
  10. package/dist/commands/secrets.js +189 -87
  11. package/dist/run/hq-plugin.js +94 -31
  12. package/dist/utils/sandbox-runner-client.d.ts +1 -0
  13. package/dist/utils/sandbox-runner-client.js +1 -0
  14. package/dist/utils/secrets-cache.d.ts +4 -5
  15. package/dist/utils/secrets-cache.js +5 -8
  16. package/package.json +3 -2
  17. package/pnpm-workspace.yaml +2 -0
  18. package/src/commands/mcp-registration.ts +9 -9
  19. package/src/commands/outposts.test.ts +118 -24
  20. package/src/commands/outposts.ts +197 -43
  21. package/src/commands/pack-install-secret-authorization.test.ts +115 -0
  22. package/src/commands/pack-install.test.ts +5 -1
  23. package/src/commands/pack-install.ts +67 -29
  24. package/src/commands/pkg-install.ts +3 -1
  25. package/src/commands/run.test.ts +45 -0
  26. package/src/commands/run.ts +20 -4
  27. package/src/commands/secrets.test.ts +366 -25
  28. package/src/commands/secrets.ts +222 -96
  29. package/src/run/hq-plugin.test.ts +186 -10
  30. package/src/run/hq-plugin.ts +102 -32
  31. package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
  32. package/src/utils/pack-contributions.test.ts +90 -31
  33. package/src/utils/sandbox-runner-client.test.ts +28 -0
  34. package/src/utils/sandbox-runner-client.ts +2 -0
  35. package/src/utils/secrets-cache.ts +5 -8
  36. package/test/commands/signals.test.ts +2 -2
  37. package/test/commands/sources.test.ts +2 -2
  38. package/test/helpers/vault-service-mock.ts +76 -17
  39. package/test/sources-signals/smoke.test.ts +2 -2
@@ -6,7 +6,6 @@ import * as nodePath from "node:path";
6
6
  import { ensureCognitoToken } from "../utils/cognito-session.js";
7
7
  import {
8
8
  DEFAULT_SECRETS_CACHE_TTL_MS,
9
- readCache,
10
9
  writeCache,
11
10
  removeCacheEntry,
12
11
  clearAllCache,
@@ -169,6 +168,7 @@ function promptSecretInteractively(): Promise<string> {
169
168
  // large --only list is chunked client-side rather than 400'd whole by the
170
169
  // server (the legacy per-key GET path had no such cap).
171
170
  const MAX_BATCH_NAMES = 100;
171
+ const SECRET_LOAD_TIMEOUT_MS = 30_000;
172
172
 
173
173
  export type SecretTier = "standard" | "sensitive" | "nuclear";
174
174
  export type SecretScriptLockMode = "off" | "enforced";
@@ -230,12 +230,21 @@ interface SecretPolicyScript {
230
230
  attestationLevel: string;
231
231
  }
232
232
 
233
+ interface SecretPolicyApprovedScript {
234
+ scriptId: string;
235
+ path: string;
236
+ sha256?: string;
237
+ attestationLevel: string;
238
+ revokedAt?: string;
239
+ }
240
+
233
241
  interface SecretPolicyRecord {
234
242
  path: string;
235
243
  tier?: SecretTier;
236
244
  scriptLock?: {
237
245
  mode?: SecretScriptLockMode;
238
246
  requiredAttestation?: string;
247
+ approvedScripts?: SecretPolicyApprovedScript[];
239
248
  };
240
249
  scripts?: SecretPolicyScript[];
241
250
  }
@@ -402,10 +411,25 @@ function normalizeScriptLockMode(mode?: string): SecretScriptLockMode {
402
411
  return mode === "enforced" ? "enforced" : "off";
403
412
  }
404
413
 
405
- function normalizeCacheTtlMs(cacheTtlMs?: number): number {
406
- return typeof cacheTtlMs === "number"
407
- ? cacheTtlMs
408
- : DEFAULT_SECRETS_CACHE_TTL_MS;
414
+ function normalizeCacheTtlMs(metadata: SecretMetadata): number {
415
+ // Controlled rows must never reach the offline cache even if a mixed-version
416
+ // or malformed response carries a positive TTL. The server is authoritative
417
+ // for access, but the client still has enough policy metadata to fail safe.
418
+ if (
419
+ metadata.tier === "sensitive" ||
420
+ metadata.tier === "nuclear" ||
421
+ metadata.scriptLock?.mode === "enforced"
422
+ ) {
423
+ return 0;
424
+ }
425
+ if (metadata.cacheTtlMs === undefined) {
426
+ return DEFAULT_SECRETS_CACHE_TTL_MS;
427
+ }
428
+ return typeof metadata.cacheTtlMs === "number" &&
429
+ Number.isFinite(metadata.cacheTtlMs) &&
430
+ metadata.cacheTtlMs > 0
431
+ ? metadata.cacheTtlMs
432
+ : 0;
409
433
  }
410
434
 
411
435
  function extractApiMessage(
@@ -423,6 +447,9 @@ async function buildSecretUsage(
423
447
  scriptId?: string,
424
448
  attestationLevel = "self-asserted-hash",
425
449
  ): Promise<SecretUsage> {
450
+ if (scriptId && !scriptPath) {
451
+ throw new Error("--script-id requires --script");
452
+ }
426
453
  if (!scriptPath) {
427
454
  return { channel };
428
455
  }
@@ -506,11 +533,28 @@ function normalizePolicyRecord(
506
533
  data: SecretPolicyResponse,
507
534
  ): SecretPolicyRecord & { scripts: SecretPolicyScript[] } {
508
535
  const policy = data.policy ?? { path: secretPath };
509
- const scripts = Array.isArray(policy.scripts)
510
- ? policy.scripts
511
- : Array.isArray(data.scripts)
512
- ? data.scripts
513
- : [];
536
+ const scripts = Array.isArray(policy.scriptLock?.approvedScripts)
537
+ ? policy.scriptLock.approvedScripts
538
+ .filter(
539
+ (script) =>
540
+ script !== null &&
541
+ typeof script === "object" &&
542
+ typeof script.scriptId === "string" &&
543
+ typeof script.path === "string" &&
544
+ typeof script.attestationLevel === "string" &&
545
+ !script.revokedAt,
546
+ )
547
+ .map((script) => ({
548
+ scriptId: script.scriptId,
549
+ scriptPath: script.path,
550
+ sha256: typeof script.sha256 === "string" ? script.sha256 : "",
551
+ attestationLevel: script.attestationLevel,
552
+ }))
553
+ : Array.isArray(policy.scripts)
554
+ ? policy.scripts.filter(isSecretPolicyScript)
555
+ : Array.isArray(data.scripts)
556
+ ? data.scripts.filter(isSecretPolicyScript)
557
+ : [];
514
558
  return {
515
559
  path: policy.path ?? secretPath,
516
560
  tier: normalizeSecretTier(policy.tier),
@@ -522,6 +566,17 @@ function normalizePolicyRecord(
522
566
  };
523
567
  }
524
568
 
569
+ function isSecretPolicyScript(value: unknown): value is SecretPolicyScript {
570
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
571
+ const script = value as Partial<SecretPolicyScript>;
572
+ return (
573
+ typeof script.scriptId === "string" &&
574
+ typeof script.scriptPath === "string" &&
575
+ typeof script.sha256 === "string" &&
576
+ typeof script.attestationLevel === "string"
577
+ );
578
+ }
579
+
525
580
  function renderPolicySummary(policy: SecretPolicyRecord): void {
526
581
  console.log(chalk.bold(`Policy: ${policy.path}`));
527
582
  console.log(` Tier: ${normalizeSecretTier(policy.tier)}`);
@@ -563,7 +618,7 @@ function renderPolicyScripts(scripts: SecretPolicyScript[]): void {
563
618
  script.scriptId.padEnd(idWidth),
564
619
  script.scriptPath.padEnd(pathWidth),
565
620
  script.attestationLevel.padEnd(attestationWidth),
566
- script.sha256,
621
+ script.sha256 || "-",
567
622
  ].join(" "),
568
623
  );
569
624
  }
@@ -584,9 +639,14 @@ function renderPolicyScripts(scripts: SecretPolicyScript[]): void {
584
639
  // `env` through it stops those callers from emitting the warning while keeping
585
640
  // identical UX and error text.
586
641
  //
587
- // Cache-first (so warm keys cost no request), chunked at MAX_BATCH_NAMES, and
588
- // throws on the FIRST unresolved key with the same `Failed to fetch secret
589
- // '<k>': <reason>` shape the per-key GET path used never swallows a failure.
642
+ // Every value use through exec/env/reveal is server-authorized, including when
643
+ // an encrypted disk-cache entry exists. The cache remains write-through for
644
+ // explicitly offline install-time consumers; these commands never trust it as
645
+ // an authorization decision. This means a policy, ACL, tier, or script-approval
646
+ // change takes effect on their next use even though there is no push revocation.
647
+ // Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
648
+ // with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
649
+ // path used — never swallows a failure.
590
650
  export async function loadRevealedSecrets(
591
651
  token: string,
592
652
  companyUid: string,
@@ -594,92 +654,139 @@ export async function loadRevealedSecrets(
594
654
  usage?: SecretUsage,
595
655
  ): Promise<Map<string, string>> {
596
656
  const resolved = new Map<string, string>();
597
- const missing: string[] = [];
598
- for (const key of keys) {
599
- const cached = readCache(companyUid, key);
600
- if (cached !== null) {
601
- resolved.set(key, cached);
602
- } else if (!missing.includes(key)) {
603
- missing.push(key);
604
- }
605
- }
606
-
607
- for (let i = 0; i < missing.length; i += MAX_BATCH_NAMES) {
608
- const chunk = missing.slice(i, i + MAX_BATCH_NAMES);
609
- const res = await vaultApiFetch({
610
- token,
611
- path: `/secrets/${encodeURIComponent(companyUid)}/load`,
612
- method: "POST",
613
- body: usage ? { names: chunk, usage } : { names: chunk },
614
- });
615
- if (!res.ok) {
616
- const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
617
- const message = extractApiMessage(body, res.statusText);
618
- // High-security ("nuclear") refusal surfaced at the batch level (rather
619
- // than per-name): point the caller at the proxy and never leak plaintext.
620
- if (body.code === "high_security_denied" || body.highSecurity === true) {
657
+ const requested = [...new Set(keys)];
658
+ try {
659
+ for (let i = 0; i < requested.length; i += MAX_BATCH_NAMES) {
660
+ const chunk = requested.slice(i, i + MAX_BATCH_NAMES);
661
+ const chunkNames = new Set(chunk);
662
+ const res = await vaultApiFetch({
663
+ token,
664
+ path: `/secrets/${encodeURIComponent(companyUid)}/load`,
665
+ method: "POST",
666
+ body: usage ? { names: chunk, usage } : { names: chunk },
667
+ signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
668
+ });
669
+ if (!res.ok) {
670
+ const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
671
+ const message = extractApiMessage(body, res.statusText);
672
+ // High-security ("nuclear") refusal surfaced at the batch level (rather
673
+ // than per-name): point the caller at the proxy and never leak plaintext.
674
+ if (body.code === "high_security_denied" || body.highSecurity === true) {
675
+ throw new Error(
676
+ "A requested secret is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.",
677
+ );
678
+ }
679
+ if (
680
+ res.status >= 400 &&
681
+ res.status < 500 &&
682
+ typeof body.code === "string"
683
+ ) {
684
+ throw new Error(message);
685
+ }
621
686
  throw new Error(
622
- "A requested secret is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.",
687
+ `Failed to batch-load secrets: ${message}`,
623
688
  );
624
689
  }
625
- if (
626
- res.status >= 400 &&
627
- res.status < 500 &&
628
- typeof body.code === "string"
629
- ) {
630
- throw new Error(message);
690
+ const data = (await res.json()) as Partial<SecretLoadResponse>;
691
+ if (!Array.isArray(data.secrets) || !Array.isArray(data.errors)) {
692
+ throw new Error("Invalid secret load response from vault");
631
693
  }
632
- throw new Error(
633
- `Failed to batch-load secrets: ${message}`,
634
- );
635
- }
636
- const data = (await res.json()) as SecretLoadResponse;
694
+ const errorsByName = new Map<string, { code: string; message?: string }>();
695
+ const seenNames = new Set<string>();
637
696
 
638
- for (const s of data.secrets ?? []) {
639
- if (s.value == null) {
640
- throw new Error(
641
- `Secret '${s.name}' has no value (reveal may not be permitted).`,
642
- );
697
+ for (const rawSecret of data.secrets) {
698
+ if (!rawSecret || typeof rawSecret !== "object") {
699
+ throw new Error("Invalid secret load response from vault");
700
+ }
701
+ const s = rawSecret as SecretLoadSuccessRow;
702
+ if (
703
+ typeof s.name !== "string" ||
704
+ !chunkNames.has(s.name) ||
705
+ seenNames.has(s.name) ||
706
+ (s.value != null && typeof s.value !== "string")
707
+ ) {
708
+ throw new Error("Invalid secret load response from vault");
709
+ }
710
+ seenNames.add(s.name);
711
+ if (s.value == null) {
712
+ errorsByName.set(s.name, {
713
+ code: "not_returned",
714
+ message: `Secret '${s.name}' has no value (reveal may not be permitted).`,
715
+ });
716
+ removeCacheEntry(companyUid, s.name);
717
+ continue;
718
+ }
719
+ const cacheTtlMs = normalizeCacheTtlMs(s);
720
+ if (cacheTtlMs > 0) {
721
+ writeCache(companyUid, s.name, s.value, cacheTtlMs);
722
+ } else {
723
+ removeCacheEntry(companyUid, s.name);
724
+ }
725
+ resolved.set(s.name, s.value);
726
+ }
727
+
728
+ for (const rawError of data.errors) {
729
+ if (!rawError || typeof rawError !== "object") {
730
+ throw new Error("Invalid secret load response from vault");
731
+ }
732
+ const e = rawError as { name?: unknown; code?: unknown; message?: unknown };
733
+ if (
734
+ typeof e.name !== "string" ||
735
+ !chunkNames.has(e.name) ||
736
+ seenNames.has(e.name) ||
737
+ typeof e.code !== "string" ||
738
+ (e.message !== undefined && typeof e.message !== "string")
739
+ ) {
740
+ throw new Error("Invalid secret load response from vault");
741
+ }
742
+ seenNames.add(e.name);
743
+ errorsByName.set(e.name, { code: e.code, message: e.message });
744
+ resolved.delete(e.name);
745
+ removeCacheEntry(companyUid, e.name);
746
+ }
747
+ // Any requested key in this chunk the server did not return is a per-key
748
+ // failure — surface it with the same prefix the single-GET path used so
749
+ // callers (and scripts grepping stderr) see no behavior change.
750
+ let firstFailure: Error | null = null;
751
+ for (const key of chunk) {
752
+ if (resolved.has(key)) continue;
753
+ removeCacheEntry(companyUid, key);
754
+ const err = errorsByName.get(key);
755
+ // High-security ("nuclear") secret: the server refuses to vend it on the
756
+ // local-injection (batch-load) path — per-name code `high_security_denied`,
757
+ // no plaintext returned. Every caller of loadRevealedSecrets injects or
758
+ // prints the plaintext locally (`secrets get --reveal`, `secrets exec`,
759
+ // `secrets env`), so a high-security secret can NEVER be used here. Surface
760
+ // a clear, actionable error pointing at the proxy instead of a raw failure.
761
+ if (err?.code === "high_security_denied") {
762
+ firstFailure ??= new Error(
763
+ `Secret '${key}' is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.`,
764
+ );
765
+ continue;
766
+ }
767
+ const reason =
768
+ err?.code === "not_found"
769
+ ? "Secret not found"
770
+ : err?.code === "forbidden"
771
+ ? err.message ?? "No read permission"
772
+ : err?.message ?? err?.code ?? "not returned by vault";
773
+ firstFailure ??= new Error(`Failed to fetch secret '${key}': ${reason}`);
643
774
  }
644
- const cacheTtlMs = normalizeCacheTtlMs(s.cacheTtlMs);
645
- if (cacheTtlMs > 0) {
646
- writeCache(companyUid, s.name, s.value, cacheTtlMs);
775
+ if (firstFailure) {
776
+ throw firstFailure;
647
777
  }
648
- resolved.set(s.name, s.value);
649
778
  }
650
779
 
651
- const errorsByName = new Map<string, { code: string; message?: string }>();
652
- for (const e of data.errors ?? []) {
653
- errorsByName.set(e.name, { code: e.code, message: e.message });
654
- }
655
- // Any requested key in this chunk the server did not return is a per-key
656
- // failure surface it with the same prefix the single-GET path used so
657
- // callers (and scripts grepping stderr) see no behavior change.
658
- for (const key of chunk) {
659
- if (resolved.has(key)) continue;
660
- const err = errorsByName.get(key);
661
- // High-security ("nuclear") secret: the server refuses to vend it on the
662
- // local-injection (batch-load) path — per-name code `high_security_denied`,
663
- // no plaintext returned. Every caller of loadRevealedSecrets injects or
664
- // prints the plaintext locally (`secrets get --reveal`, `secrets exec`,
665
- // `secrets env`), so a high-security secret can NEVER be used here. Surface
666
- // a clear, actionable error pointing at the proxy instead of a raw failure.
667
- if (err?.code === "high_security_denied") {
668
- throw new Error(
669
- `Secret '${key}' is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.`,
670
- );
671
- }
672
- const reason =
673
- err?.code === "not_found"
674
- ? "Secret not found"
675
- : err?.code === "forbidden"
676
- ? err.message ?? "No read permission"
677
- : err?.message ?? err?.code ?? "not returned by vault";
678
- throw new Error(`Failed to fetch secret '${key}': ${reason}`);
780
+ return resolved;
781
+ } catch (err) {
782
+ // A transport failure, stale session, malformed response, or a later chunk
783
+ // failure must not leave values written by this attempted operation available
784
+ // to offline cache readers. Evict the whole request before failing closed.
785
+ for (const key of requested) {
786
+ removeCacheEntry(companyUid, key);
679
787
  }
788
+ throw err;
680
789
  }
681
-
682
- return resolved;
683
790
  }
684
791
 
685
792
  export function registerSecretsCommand(program: Command): void {
@@ -1228,6 +1335,7 @@ export function registerSecretsCommand(program: Command): void {
1228
1335
  }
1229
1336
 
1230
1337
  const data = (await res.json().catch(() => ({}))) as SecretPolicyResponse;
1338
+ removeCacheEntry(companyUid, secretPath);
1231
1339
  console.log(chalk.green(`Policy updated for '${secretPath}'.`));
1232
1340
  renderPolicySummary(
1233
1341
  normalizePolicyRecord(
@@ -1302,6 +1410,7 @@ export function registerSecretsCommand(program: Command): void {
1302
1410
  process.exit(1);
1303
1411
  }
1304
1412
 
1413
+ removeCacheEntry(companyUid, secretPath);
1305
1414
  console.log(chalk.green(`Approved script '${opts.id}' for '${secretPath}'.`));
1306
1415
  } catch (err) {
1307
1416
  console.error(
@@ -1345,6 +1454,7 @@ export function registerSecretsCommand(program: Command): void {
1345
1454
  process.exit(1);
1346
1455
  }
1347
1456
 
1457
+ removeCacheEntry(companyUid, secretPath);
1348
1458
  console.log(chalk.green(`Revoked script '${opts.id}' for '${secretPath}'.`));
1349
1459
  } catch (err) {
1350
1460
  console.error(
@@ -1505,9 +1615,18 @@ export function registerSecretsCommand(program: Command): void {
1505
1615
  renderSandboxJobResult(job, keys);
1506
1616
  const exitCode = typeof job.exitCode === "number" ? job.exitCode : undefined;
1507
1617
  if (job.success === false || (exitCode !== undefined && exitCode !== 0) || job.status === "failed") {
1508
- const code = exitCode && exitCode !== 0 ? exitCode : 1;
1509
- console.error(chalk.red(`Sandbox command failed with exit code ${code}.`));
1510
- process.exit(code);
1618
+ if (exitCode !== undefined && exitCode !== 0) {
1619
+ console.error(chalk.red(`Sandbox command failed with exit code ${exitCode}.`));
1620
+ process.exit(exitCode);
1621
+ }
1622
+ console.error(
1623
+ chalk.red(
1624
+ job.error !== undefined
1625
+ ? scrubSandboxOutput(job.error, keys)
1626
+ : "Sandbox execution failed before the command produced an exit code.",
1627
+ ),
1628
+ );
1629
+ process.exit(1);
1511
1630
  }
1512
1631
  } catch (err) {
1513
1632
  console.error(
@@ -1523,8 +1642,12 @@ export function registerSecretsCommand(program: Command): void {
1523
1642
  .description("Run a command with secrets injected as env vars")
1524
1643
  .requiredOption("--only <keys>", "Secret names to inject (comma-separated; may be repeated) (required)", collectSecretNames)
1525
1644
  .option("--script <path>", "Attach local script identity for script-locked secrets")
1645
+ .option("--script-id <id>", "Stable script identifier approved by policy")
1526
1646
  .allowUnknownOption(true)
1527
- .action(async (_opts: { only: string[]; script?: string }, cmd: Command) => {
1647
+ .action(async (
1648
+ _opts: { only: string[]; script?: string; scriptId?: string },
1649
+ cmd: Command,
1650
+ ) => {
1528
1651
  try {
1529
1652
  const rawArgs = cmd.args;
1530
1653
  const dashIndex = process.argv.indexOf("--");
@@ -1552,7 +1675,7 @@ export function registerSecretsCommand(program: Command): void {
1552
1675
  token,
1553
1676
  companyUid,
1554
1677
  keys,
1555
- await buildSecretUsage("exec", _opts.script),
1678
+ await buildSecretUsage("exec", _opts.script, _opts.scriptId),
1556
1679
  );
1557
1680
 
1558
1681
  const secretEnv: Record<string, string> = {};
@@ -1597,7 +1720,10 @@ export function registerSecretsCommand(program: Command): void {
1597
1720
  .description("Print 'export KEY=VALUE' lines suitable for: source <(hq secrets env --only K1,K2)")
1598
1721
  .requiredOption("--only <keys>", "Secret names to print (comma-separated; may be repeated) (required)", collectSecretNames)
1599
1722
  .option("--script <path>", "Attach local script identity for script-locked secrets")
1600
- .action(async (opts: { only: string[]; script?: string }) => {
1723
+ .option("--script-id <id>", "Stable script identifier approved by policy")
1724
+ .action(async (
1725
+ opts: { only: string[]; script?: string; scriptId?: string },
1726
+ ) => {
1601
1727
  try {
1602
1728
  const redact = process.stdout.isTTY;
1603
1729
  if (redact) {
@@ -1620,7 +1746,7 @@ export function registerSecretsCommand(program: Command): void {
1620
1746
  token,
1621
1747
  companyUid,
1622
1748
  keys,
1623
- await buildSecretUsage("env", opts.script),
1749
+ await buildSecretUsage("env", opts.script, opts.scriptId),
1624
1750
  );
1625
1751
 
1626
1752
  for (const key of keys) {
@@ -9,20 +9,14 @@ import {
9
9
  type InstallHqPluginOpts,
10
10
  type PluginState,
11
11
  } from './hq-plugin.js';
12
+ import { removeCacheEntry, writeCache } from '../utils/secrets-cache.js';
12
13
 
13
- // Prevent any test run from writing to ~/.hq/secrets-cache/. Each test uses a
14
- // unique uid (random suffix) so cross-test contamination in the in-memory store
15
- // is not possible even without clearing between tests.
14
+ // Prevent any test run from writing to ~/.hq/secrets-cache/.
16
15
  vi.mock('../utils/secrets-cache.js', () => {
17
- const store = new Map<string, string>();
18
16
  return {
19
17
  DEFAULT_SECRETS_CACHE_TTL_MS: 300000,
20
- readCache: (uid: string, name: string): string | null =>
21
- store.get(`${uid}\0${name}`) ?? null,
22
- writeCache: (uid: string, name: string, value: string): void => {
23
- store.set(`${uid}\0${name}`, value);
24
- },
25
- removeCacheEntry: (): void => {},
18
+ writeCache: vi.fn((): void => {}),
19
+ removeCacheEntry: vi.fn((): void => {}),
26
20
  clearAllCache: (): { removed: number } => ({ removed: 0 }),
27
21
  };
28
22
  });
@@ -30,6 +24,7 @@ vi.mock('../utils/secrets-cache.js', () => {
30
24
  let tmpDir: string;
31
25
 
32
26
  beforeEach(() => {
27
+ vi.clearAllMocks();
33
28
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-plugin-test-'));
34
29
  });
35
30
 
@@ -102,6 +97,187 @@ describe('hq-plugin', () => {
102
97
  await graph.resolveEnvValues();
103
98
 
104
99
  expect(graph.getResolvedEnvObject().FOO).toBe('memory-only-value');
100
+ expect(removeCacheEntry).toHaveBeenCalledWith(uid, 'FOO');
101
+ });
102
+
103
+ it('controlled policy metadata overrides a malformed positive cache TTL', async () => {
104
+ const schemaPath = path.join(tmpDir, '.env.schema');
105
+ fs.writeFileSync(schemaPath, `# @hqCompany("test")\n\nFOO=hq()\n`);
106
+
107
+ const uid = `test-uid-${Math.random().toString(36).slice(2)}`;
108
+ const mocks = makeMocks({
109
+ resolveCompanyUid: async () => uid,
110
+ fetchBatch: async () => ({
111
+ secrets: [{
112
+ name: 'FOO',
113
+ value: 'memory-only-value',
114
+ tier: 'sensitive',
115
+ scriptLock: { mode: 'enforced' },
116
+ cacheTtlMs: 300000,
117
+ }],
118
+ errors: [],
119
+ }),
120
+ });
121
+
122
+ let state!: PluginState;
123
+ const graph = await internal.loadEnvGraph({
124
+ entryFilePaths: [schemaPath],
125
+ afterInit: async (g) => {
126
+ state = installHqPlugin(g, mocks);
127
+ },
128
+ });
129
+ await prewarmHqSecrets(graph, mocks, state);
130
+ await graph.resolveEnvValues();
131
+
132
+ expect(graph.getResolvedEnvObject().FOO).toBe('memory-only-value');
133
+ expect(writeCache).not.toHaveBeenCalled();
134
+ expect(removeCacheEntry).toHaveBeenCalledWith(uid, 'FOO');
135
+ });
136
+
137
+ it('fails closed and evicts cache when the server omits a requested secret', async () => {
138
+ const schemaPath = path.join(tmpDir, '.env.schema');
139
+ fs.writeFileSync(schemaPath, `# @hqCompany("test")\n\nFOO=hq()\n`);
140
+
141
+ const uid = `test-uid-${Math.random().toString(36).slice(2)}`;
142
+ const mocks = makeMocks({
143
+ resolveCompanyUid: async () => uid,
144
+ fetchBatch: async () => ({ secrets: [], errors: [] }),
145
+ });
146
+
147
+ let state!: PluginState;
148
+ const graph = await internal.loadEnvGraph({
149
+ entryFilePaths: [schemaPath],
150
+ afterInit: async (g) => {
151
+ state = installHqPlugin(g, mocks);
152
+ },
153
+ });
154
+ await prewarmHqSecrets(graph, mocks, state);
155
+ await graph.resolveEnvValues();
156
+
157
+ const fooErrors = (graph as any).configSchema.FOO.errors as Array<{
158
+ message: string;
159
+ }>;
160
+ expect(fooErrors.some((e) => e.message.includes('not returned by vault'))).toBe(true);
161
+ expect(removeCacheEntry).toHaveBeenCalledWith(uid, 'FOO');
162
+ });
163
+
164
+ it('fails closed and evicts cache when the server returns metadata without plaintext', async () => {
165
+ const schemaPath = path.join(tmpDir, '.env.schema');
166
+ fs.writeFileSync(schemaPath, `# @hqCompany("test")\n\nFOO=hq()\n`);
167
+
168
+ const uid = `test-uid-${Math.random().toString(36).slice(2)}`;
169
+ const mocks = makeMocks({
170
+ resolveCompanyUid: async () => uid,
171
+ fetchBatch: async () => ({
172
+ secrets: [{ name: 'FOO', cacheTtlMs: 0 }],
173
+ errors: [],
174
+ }),
175
+ });
176
+
177
+ let state!: PluginState;
178
+ const graph = await internal.loadEnvGraph({
179
+ entryFilePaths: [schemaPath],
180
+ afterInit: async (g) => {
181
+ state = installHqPlugin(g, mocks);
182
+ },
183
+ });
184
+ await prewarmHqSecrets(graph, mocks, state);
185
+ await graph.resolveEnvValues();
186
+
187
+ const fooErrors = (graph as any).configSchema.FOO.errors as Array<{
188
+ message: string;
189
+ }>;
190
+ expect(fooErrors.some((e) => e.message.includes('not returned by vault'))).toBe(true);
191
+ expect(removeCacheEntry).toHaveBeenCalledWith(uid, 'FOO');
192
+ });
193
+
194
+ it('evicts every requested cache entry when batch authorization throws', async () => {
195
+ const schemaPath = path.join(tmpDir, '.env.schema');
196
+ fs.writeFileSync(
197
+ schemaPath,
198
+ `# @hqCompany("test")\n\nFOO=hq()\nBAR=hq()\n`,
199
+ );
200
+
201
+ const uid = `test-uid-${Math.random().toString(36).slice(2)}`;
202
+ const mocks = makeMocks({
203
+ resolveCompanyUid: async () => uid,
204
+ fetchBatch: async () => {
205
+ throw new Error('Forbidden: batch authorization denied');
206
+ },
207
+ });
208
+
209
+ let state!: PluginState;
210
+ const graph = await internal.loadEnvGraph({
211
+ entryFilePaths: [schemaPath],
212
+ afterInit: async (g) => {
213
+ state = installHqPlugin(g, mocks);
214
+ },
215
+ });
216
+
217
+ await expect(prewarmHqSecrets(graph, mocks, state)).rejects.toThrow(
218
+ 'Forbidden: batch authorization denied',
219
+ );
220
+ expect(removeCacheEntry).toHaveBeenCalledWith(uid, 'FOO');
221
+ expect(removeCacheEntry).toHaveBeenCalledWith(uid, 'BAR');
222
+ });
223
+
224
+ it('rejects mixed success/error rows, clears memory, and evicts cache', async () => {
225
+ const schemaPath = path.join(tmpDir, '.env.schema');
226
+ fs.writeFileSync(schemaPath, `# @hqCompany("test")\n\nFOO=hq()\n`);
227
+
228
+ const uid = `test-uid-${Math.random().toString(36).slice(2)}`;
229
+ const mocks = makeMocks({
230
+ resolveCompanyUid: async () => uid,
231
+ fetchBatch: async () => ({
232
+ secrets: [{ name: 'FOO', value: 'sentinel-secret-value' }],
233
+ errors: [{ name: 'FOO', code: 'forbidden', message: 'Denied' }],
234
+ }),
235
+ });
236
+
237
+ let state!: PluginState;
238
+ const graph = await internal.loadEnvGraph({
239
+ entryFilePaths: [schemaPath],
240
+ afterInit: async (g) => {
241
+ state = installHqPlugin(g, mocks);
242
+ },
243
+ });
244
+
245
+ await expect(prewarmHqSecrets(graph, mocks, state)).rejects.toThrow(
246
+ 'Invalid secret load response from vault',
247
+ );
248
+ expect(state.loadedSecretsByName.size).toBe(0);
249
+ expect(removeCacheEntry).toHaveBeenCalledWith(uid, 'FOO');
250
+ });
251
+
252
+ it('rejects malformed response envelopes and evicts every requested entry', async () => {
253
+ const schemaPath = path.join(tmpDir, '.env.schema');
254
+ fs.writeFileSync(
255
+ schemaPath,
256
+ `# @hqCompany("test")\n\nFOO=hq()\nBAR=hq()\n`,
257
+ );
258
+
259
+ const uid = `test-uid-${Math.random().toString(36).slice(2)}`;
260
+ const mocks = makeMocks({
261
+ resolveCompanyUid: async () => uid,
262
+ fetchBatch: async () => ({
263
+ secrets: [],
264
+ errors: undefined as unknown as [],
265
+ }),
266
+ });
267
+
268
+ let state!: PluginState;
269
+ const graph = await internal.loadEnvGraph({
270
+ entryFilePaths: [schemaPath],
271
+ afterInit: async (g) => {
272
+ state = installHqPlugin(g, mocks);
273
+ },
274
+ });
275
+
276
+ await expect(prewarmHqSecrets(graph, mocks, state)).rejects.toThrow(
277
+ 'Invalid secret load response from vault',
278
+ );
279
+ expect(removeCacheEntry).toHaveBeenCalledWith(uid, 'FOO');
280
+ expect(removeCacheEntry).toHaveBeenCalledWith(uid, 'BAR');
105
281
  });
106
282
 
107
283
  it('missing-company-error: throws when no @hqCompany and no companyOverride', async () => {