@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
@@ -3,7 +3,7 @@ import * as readline from "node:readline";
3
3
  import { spawn } from "node:child_process";
4
4
  import * as nodePath from "node:path";
5
5
  import { ensureCognitoToken } from "../utils/cognito-session.js";
6
- import { DEFAULT_SECRETS_CACHE_TTL_MS, readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
6
+ import { DEFAULT_SECRETS_CACHE_TTL_MS, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
7
7
  import { computeSha256 } from "../utils/integrity.js";
8
8
  import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
9
9
  import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
@@ -130,6 +130,7 @@ function promptSecretInteractively() {
130
130
  // large --only list is chunked client-side rather than 400'd whole by the
131
131
  // server (the legacy per-key GET path had no such cap).
132
132
  const MAX_BATCH_NAMES = 100;
133
+ const SECRET_LOAD_TIMEOUT_MS = 30_000;
133
134
  function parseSecretAclPrincipal(principal) {
134
135
  const p = principal.trim();
135
136
  if (p === "@all") {
@@ -232,10 +233,23 @@ function normalizeSecretTier(tier) {
232
233
  function normalizeScriptLockMode(mode) {
233
234
  return mode === "enforced" ? "enforced" : "off";
234
235
  }
235
- function normalizeCacheTtlMs(cacheTtlMs) {
236
- return typeof cacheTtlMs === "number"
237
- ? cacheTtlMs
238
- : DEFAULT_SECRETS_CACHE_TTL_MS;
236
+ function normalizeCacheTtlMs(metadata) {
237
+ // Controlled rows must never reach the offline cache even if a mixed-version
238
+ // or malformed response carries a positive TTL. The server is authoritative
239
+ // for access, but the client still has enough policy metadata to fail safe.
240
+ if (metadata.tier === "sensitive" ||
241
+ metadata.tier === "nuclear" ||
242
+ metadata.scriptLock?.mode === "enforced") {
243
+ return 0;
244
+ }
245
+ if (metadata.cacheTtlMs === undefined) {
246
+ return DEFAULT_SECRETS_CACHE_TTL_MS;
247
+ }
248
+ return typeof metadata.cacheTtlMs === "number" &&
249
+ Number.isFinite(metadata.cacheTtlMs) &&
250
+ metadata.cacheTtlMs > 0
251
+ ? metadata.cacheTtlMs
252
+ : 0;
239
253
  }
240
254
  function extractApiMessage(body, fallback) {
241
255
  const message = typeof body.message === "string" ? body.message : undefined;
@@ -243,6 +257,9 @@ function extractApiMessage(body, fallback) {
243
257
  return message ?? error ?? fallback;
244
258
  }
245
259
  async function buildSecretUsage(channel, scriptPath, scriptId, attestationLevel = "self-asserted-hash") {
260
+ if (scriptId && !scriptPath) {
261
+ throw new Error("--script-id requires --script");
262
+ }
246
263
  if (!scriptPath) {
247
264
  return { channel };
248
265
  }
@@ -308,11 +325,25 @@ function renderSandboxJobResult(job, secretNames) {
308
325
  }
309
326
  function normalizePolicyRecord(secretPath, data) {
310
327
  const policy = data.policy ?? { path: secretPath };
311
- const scripts = Array.isArray(policy.scripts)
312
- ? policy.scripts
313
- : Array.isArray(data.scripts)
314
- ? data.scripts
315
- : [];
328
+ const scripts = Array.isArray(policy.scriptLock?.approvedScripts)
329
+ ? policy.scriptLock.approvedScripts
330
+ .filter((script) => script !== null &&
331
+ typeof script === "object" &&
332
+ typeof script.scriptId === "string" &&
333
+ typeof script.path === "string" &&
334
+ typeof script.attestationLevel === "string" &&
335
+ !script.revokedAt)
336
+ .map((script) => ({
337
+ scriptId: script.scriptId,
338
+ scriptPath: script.path,
339
+ sha256: typeof script.sha256 === "string" ? script.sha256 : "",
340
+ attestationLevel: script.attestationLevel,
341
+ }))
342
+ : Array.isArray(policy.scripts)
343
+ ? policy.scripts.filter(isSecretPolicyScript)
344
+ : Array.isArray(data.scripts)
345
+ ? data.scripts.filter(isSecretPolicyScript)
346
+ : [];
316
347
  return {
317
348
  path: policy.path ?? secretPath,
318
349
  tier: normalizeSecretTier(policy.tier),
@@ -323,6 +354,15 @@ function normalizePolicyRecord(secretPath, data) {
323
354
  scripts,
324
355
  };
325
356
  }
357
+ function isSecretPolicyScript(value) {
358
+ if (!value || typeof value !== "object" || Array.isArray(value))
359
+ return false;
360
+ const script = value;
361
+ return (typeof script.scriptId === "string" &&
362
+ typeof script.scriptPath === "string" &&
363
+ typeof script.sha256 === "string" &&
364
+ typeof script.attestationLevel === "string");
365
+ }
326
366
  function renderPolicySummary(policy) {
327
367
  console.log(chalk.bold(`Policy: ${policy.path}`));
328
368
  console.log(` Tier: ${normalizeSecretTier(policy.tier)}`);
@@ -351,7 +391,7 @@ function renderPolicyScripts(scripts) {
351
391
  script.scriptId.padEnd(idWidth),
352
392
  script.scriptPath.padEnd(pathWidth),
353
393
  script.attestationLevel.padEnd(attestationWidth),
354
- script.sha256,
394
+ script.sha256 || "-",
355
395
  ].join(" "));
356
396
  }
357
397
  }
@@ -370,84 +410,136 @@ function renderPolicyScripts(scripts) {
370
410
  // `env` through it stops those callers from emitting the warning while keeping
371
411
  // identical UX and error text.
372
412
  //
373
- // Cache-first (so warm keys cost no request), chunked at MAX_BATCH_NAMES, and
374
- // throws on the FIRST unresolved key with the same `Failed to fetch secret
375
- // '<k>': <reason>` shape the per-key GET path used never swallows a failure.
413
+ // Every value use through exec/env/reveal is server-authorized, including when
414
+ // an encrypted disk-cache entry exists. The cache remains write-through for
415
+ // explicitly offline install-time consumers; these commands never trust it as
416
+ // an authorization decision. This means a policy, ACL, tier, or script-approval
417
+ // change takes effect on their next use even though there is no push revocation.
418
+ // Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
419
+ // with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
420
+ // path used — never swallows a failure.
376
421
  export async function loadRevealedSecrets(token, companyUid, keys, usage) {
377
422
  const resolved = new Map();
378
- const missing = [];
379
- for (const key of keys) {
380
- const cached = readCache(companyUid, key);
381
- if (cached !== null) {
382
- resolved.set(key, cached);
383
- }
384
- else if (!missing.includes(key)) {
385
- missing.push(key);
386
- }
387
- }
388
- for (let i = 0; i < missing.length; i += MAX_BATCH_NAMES) {
389
- const chunk = missing.slice(i, i + MAX_BATCH_NAMES);
390
- const res = await vaultApiFetch({
391
- token,
392
- path: `/secrets/${encodeURIComponent(companyUid)}/load`,
393
- method: "POST",
394
- body: usage ? { names: chunk, usage } : { names: chunk },
395
- });
396
- if (!res.ok) {
397
- const body = (await res.json().catch(() => ({})));
398
- const message = extractApiMessage(body, res.statusText);
399
- // High-security ("nuclear") refusal surfaced at the batch level (rather
400
- // than per-name): point the caller at the proxy and never leak plaintext.
401
- if (body.code === "high_security_denied" || body.highSecurity === true) {
402
- throw new Error("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.");
403
- }
404
- if (res.status >= 400 &&
405
- res.status < 500 &&
406
- typeof body.code === "string") {
407
- throw new Error(message);
408
- }
409
- throw new Error(`Failed to batch-load secrets: ${message}`);
410
- }
411
- const data = (await res.json());
412
- for (const s of data.secrets ?? []) {
413
- if (s.value == null) {
414
- throw new Error(`Secret '${s.name}' has no value (reveal may not be permitted).`);
423
+ const requested = [...new Set(keys)];
424
+ try {
425
+ for (let i = 0; i < requested.length; i += MAX_BATCH_NAMES) {
426
+ const chunk = requested.slice(i, i + MAX_BATCH_NAMES);
427
+ const chunkNames = new Set(chunk);
428
+ const res = await vaultApiFetch({
429
+ token,
430
+ path: `/secrets/${encodeURIComponent(companyUid)}/load`,
431
+ method: "POST",
432
+ body: usage ? { names: chunk, usage } : { names: chunk },
433
+ signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
434
+ });
435
+ if (!res.ok) {
436
+ const body = (await res.json().catch(() => ({})));
437
+ const message = extractApiMessage(body, res.statusText);
438
+ // High-security ("nuclear") refusal surfaced at the batch level (rather
439
+ // than per-name): point the caller at the proxy and never leak plaintext.
440
+ if (body.code === "high_security_denied" || body.highSecurity === true) {
441
+ throw new Error("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.");
442
+ }
443
+ if (res.status >= 400 &&
444
+ res.status < 500 &&
445
+ typeof body.code === "string") {
446
+ throw new Error(message);
447
+ }
448
+ throw new Error(`Failed to batch-load secrets: ${message}`);
415
449
  }
416
- const cacheTtlMs = normalizeCacheTtlMs(s.cacheTtlMs);
417
- if (cacheTtlMs > 0) {
418
- writeCache(companyUid, s.name, s.value, cacheTtlMs);
450
+ const data = (await res.json());
451
+ if (!Array.isArray(data.secrets) || !Array.isArray(data.errors)) {
452
+ throw new Error("Invalid secret load response from vault");
453
+ }
454
+ const errorsByName = new Map();
455
+ const seenNames = new Set();
456
+ for (const rawSecret of data.secrets) {
457
+ if (!rawSecret || typeof rawSecret !== "object") {
458
+ throw new Error("Invalid secret load response from vault");
459
+ }
460
+ const s = rawSecret;
461
+ if (typeof s.name !== "string" ||
462
+ !chunkNames.has(s.name) ||
463
+ seenNames.has(s.name) ||
464
+ (s.value != null && typeof s.value !== "string")) {
465
+ throw new Error("Invalid secret load response from vault");
466
+ }
467
+ seenNames.add(s.name);
468
+ if (s.value == null) {
469
+ errorsByName.set(s.name, {
470
+ code: "not_returned",
471
+ message: `Secret '${s.name}' has no value (reveal may not be permitted).`,
472
+ });
473
+ removeCacheEntry(companyUid, s.name);
474
+ continue;
475
+ }
476
+ const cacheTtlMs = normalizeCacheTtlMs(s);
477
+ if (cacheTtlMs > 0) {
478
+ writeCache(companyUid, s.name, s.value, cacheTtlMs);
479
+ }
480
+ else {
481
+ removeCacheEntry(companyUid, s.name);
482
+ }
483
+ resolved.set(s.name, s.value);
484
+ }
485
+ for (const rawError of data.errors) {
486
+ if (!rawError || typeof rawError !== "object") {
487
+ throw new Error("Invalid secret load response from vault");
488
+ }
489
+ const e = rawError;
490
+ if (typeof e.name !== "string" ||
491
+ !chunkNames.has(e.name) ||
492
+ seenNames.has(e.name) ||
493
+ typeof e.code !== "string" ||
494
+ (e.message !== undefined && typeof e.message !== "string")) {
495
+ throw new Error("Invalid secret load response from vault");
496
+ }
497
+ seenNames.add(e.name);
498
+ errorsByName.set(e.name, { code: e.code, message: e.message });
499
+ resolved.delete(e.name);
500
+ removeCacheEntry(companyUid, e.name);
501
+ }
502
+ // Any requested key in this chunk the server did not return is a per-key
503
+ // failure — surface it with the same prefix the single-GET path used so
504
+ // callers (and scripts grepping stderr) see no behavior change.
505
+ let firstFailure = null;
506
+ for (const key of chunk) {
507
+ if (resolved.has(key))
508
+ continue;
509
+ removeCacheEntry(companyUid, key);
510
+ const err = errorsByName.get(key);
511
+ // High-security ("nuclear") secret: the server refuses to vend it on the
512
+ // local-injection (batch-load) path — per-name code `high_security_denied`,
513
+ // no plaintext returned. Every caller of loadRevealedSecrets injects or
514
+ // prints the plaintext locally (`secrets get --reveal`, `secrets exec`,
515
+ // `secrets env`), so a high-security secret can NEVER be used here. Surface
516
+ // a clear, actionable error pointing at the proxy instead of a raw failure.
517
+ if (err?.code === "high_security_denied") {
518
+ firstFailure ??= new Error(`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.`);
519
+ continue;
520
+ }
521
+ const reason = err?.code === "not_found"
522
+ ? "Secret not found"
523
+ : err?.code === "forbidden"
524
+ ? err.message ?? "No read permission"
525
+ : err?.message ?? err?.code ?? "not returned by vault";
526
+ firstFailure ??= new Error(`Failed to fetch secret '${key}': ${reason}`);
527
+ }
528
+ if (firstFailure) {
529
+ throw firstFailure;
419
530
  }
420
- resolved.set(s.name, s.value);
421
- }
422
- const errorsByName = new Map();
423
- for (const e of data.errors ?? []) {
424
- errorsByName.set(e.name, { code: e.code, message: e.message });
425
531
  }
426
- // Any requested key in this chunk the server did not return is a per-key
427
- // failure — surface it with the same prefix the single-GET path used so
428
- // callers (and scripts grepping stderr) see no behavior change.
429
- for (const key of chunk) {
430
- if (resolved.has(key))
431
- continue;
432
- const err = errorsByName.get(key);
433
- // High-security ("nuclear") secret: the server refuses to vend it on the
434
- // local-injection (batch-load) path — per-name code `high_security_denied`,
435
- // no plaintext returned. Every caller of loadRevealedSecrets injects or
436
- // prints the plaintext locally (`secrets get --reveal`, `secrets exec`,
437
- // `secrets env`), so a high-security secret can NEVER be used here. Surface
438
- // a clear, actionable error pointing at the proxy instead of a raw failure.
439
- if (err?.code === "high_security_denied") {
440
- throw new Error(`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.`);
441
- }
442
- const reason = err?.code === "not_found"
443
- ? "Secret not found"
444
- : err?.code === "forbidden"
445
- ? err.message ?? "No read permission"
446
- : err?.message ?? err?.code ?? "not returned by vault";
447
- throw new Error(`Failed to fetch secret '${key}': ${reason}`);
532
+ return resolved;
533
+ }
534
+ catch (err) {
535
+ // A transport failure, stale session, malformed response, or a later chunk
536
+ // failure must not leave values written by this attempted operation available
537
+ // to offline cache readers. Evict the whole request before failing closed.
538
+ for (const key of requested) {
539
+ removeCacheEntry(companyUid, key);
448
540
  }
541
+ throw err;
449
542
  }
450
- return resolved;
451
543
  }
452
544
  export function registerSecretsCommand(program) {
453
545
  const secrets = program
@@ -846,6 +938,7 @@ export function registerSecretsCommand(program) {
846
938
  process.exit(1);
847
939
  }
848
940
  const data = (await res.json().catch(() => ({})));
941
+ removeCacheEntry(companyUid, secretPath);
849
942
  console.log(chalk.green(`Policy updated for '${secretPath}'.`));
850
943
  renderPolicySummary(normalizePolicyRecord(secretPath, data.policy ? data : { policy: { path: secretPath, tier, scriptLock } }));
851
944
  }
@@ -890,6 +983,7 @@ export function registerSecretsCommand(program) {
890
983
  console.error(chalk.red(`Failed to approve script: ${extractApiMessage(body, res.statusText)}`));
891
984
  process.exit(1);
892
985
  }
986
+ removeCacheEntry(companyUid, secretPath);
893
987
  console.log(chalk.green(`Approved script '${opts.id}' for '${secretPath}'.`));
894
988
  }
895
989
  catch (err) {
@@ -921,6 +1015,7 @@ export function registerSecretsCommand(program) {
921
1015
  console.error(chalk.red(`Failed to revoke script: ${extractApiMessage(body, res.statusText)}`));
922
1016
  process.exit(1);
923
1017
  }
1018
+ removeCacheEntry(companyUid, secretPath);
924
1019
  console.log(chalk.green(`Revoked script '${opts.id}' for '${secretPath}'.`));
925
1020
  }
926
1021
  catch (err) {
@@ -1044,9 +1139,14 @@ export function registerSecretsCommand(program) {
1044
1139
  renderSandboxJobResult(job, keys);
1045
1140
  const exitCode = typeof job.exitCode === "number" ? job.exitCode : undefined;
1046
1141
  if (job.success === false || (exitCode !== undefined && exitCode !== 0) || job.status === "failed") {
1047
- const code = exitCode && exitCode !== 0 ? exitCode : 1;
1048
- console.error(chalk.red(`Sandbox command failed with exit code ${code}.`));
1049
- process.exit(code);
1142
+ if (exitCode !== undefined && exitCode !== 0) {
1143
+ console.error(chalk.red(`Sandbox command failed with exit code ${exitCode}.`));
1144
+ process.exit(exitCode);
1145
+ }
1146
+ console.error(chalk.red(job.error !== undefined
1147
+ ? scrubSandboxOutput(job.error, keys)
1148
+ : "Sandbox execution failed before the command produced an exit code."));
1149
+ process.exit(1);
1050
1150
  }
1051
1151
  }
1052
1152
  catch (err) {
@@ -1059,6 +1159,7 @@ export function registerSecretsCommand(program) {
1059
1159
  .description("Run a command with secrets injected as env vars")
1060
1160
  .requiredOption("--only <keys>", "Secret names to inject (comma-separated; may be repeated) (required)", collectSecretNames)
1061
1161
  .option("--script <path>", "Attach local script identity for script-locked secrets")
1162
+ .option("--script-id <id>", "Stable script identifier approved by policy")
1062
1163
  .allowUnknownOption(true)
1063
1164
  .action(async (_opts, cmd) => {
1064
1165
  try {
@@ -1078,7 +1179,7 @@ export function registerSecretsCommand(program) {
1078
1179
  const keys = parseSecretNameList(_opts.only);
1079
1180
  const token = await ensureCognitoToken();
1080
1181
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1081
- const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("exec", _opts.script));
1182
+ const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("exec", _opts.script, _opts.scriptId));
1082
1183
  const secretEnv = {};
1083
1184
  for (const key of keys) {
1084
1185
  const value = revealed.get(key);
@@ -1115,6 +1216,7 @@ export function registerSecretsCommand(program) {
1115
1216
  .description("Print 'export KEY=VALUE' lines suitable for: source <(hq secrets env --only K1,K2)")
1116
1217
  .requiredOption("--only <keys>", "Secret names to print (comma-separated; may be repeated) (required)", collectSecretNames)
1117
1218
  .option("--script <path>", "Attach local script identity for script-locked secrets")
1219
+ .option("--script-id <id>", "Stable script identifier approved by policy")
1118
1220
  .action(async (opts) => {
1119
1221
  try {
1120
1222
  const redact = process.stdout.isTTY;
@@ -1124,7 +1226,7 @@ export function registerSecretsCommand(program) {
1124
1226
  const keys = parseSecretNameList(opts.only);
1125
1227
  const token = await ensureCognitoToken();
1126
1228
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1127
- const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("env", opts.script));
1229
+ const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("env", opts.script, opts.scriptId));
1128
1230
  for (const key of keys) {
1129
1231
  const value = revealed.get(key);
1130
1232
  // loadRevealedSecrets throws on any unresolved key, so a miss here is
@@ -1,9 +1,19 @@
1
1
  import { ResolutionError } from 'varlock/plugin-lib';
2
- import { DEFAULT_SECRETS_CACHE_TTL_MS, readCache, writeCache, } from '../utils/secrets-cache.js';
3
- function normalizeCacheTtlMs(cacheTtlMs) {
4
- return typeof cacheTtlMs === 'number'
5
- ? cacheTtlMs
6
- : DEFAULT_SECRETS_CACHE_TTL_MS;
2
+ import { DEFAULT_SECRETS_CACHE_TTL_MS, writeCache, removeCacheEntry, } from '../utils/secrets-cache.js';
3
+ function normalizeCacheTtlMs(secret) {
4
+ if (secret.tier === 'sensitive' ||
5
+ secret.tier === 'nuclear' ||
6
+ secret.scriptLock?.mode === 'enforced') {
7
+ return 0;
8
+ }
9
+ if (secret.cacheTtlMs === undefined) {
10
+ return DEFAULT_SECRETS_CACHE_TTL_MS;
11
+ }
12
+ return typeof secret.cacheTtlMs === 'number' &&
13
+ Number.isFinite(secret.cacheTtlMs) &&
14
+ secret.cacheTtlMs > 0
15
+ ? secret.cacheTtlMs
16
+ : 0;
7
17
  }
8
18
  export function installHqPlugin(graph /* EnvGraph */, opts) {
9
19
  const pluginState = {
@@ -41,9 +51,9 @@ export function installHqPlugin(graph /* EnvGraph */, opts) {
41
51
  impliesSensitive: true,
42
52
  argsSchema: { type: 'array', arrayMaxLength: 1 },
43
53
  resolve: async function () {
44
- // Cache-only read. `pluginState` is captured by this inner-class closure;
45
- // `prewarmHqSecrets(graph, opts, state)` populates `state.uid` and
46
- // `state.errorsByName` before `graph.resolveEnvValues()` calls us.
54
+ // `pluginState` is captured by this inner-class closure;
55
+ // `prewarmHqSecrets(graph, opts, state)` server-authorizes and populates
56
+ // the in-memory values before `graph.resolveEnvValues()` calls us.
47
57
  const explicit = this.arrArgs?.[0]?.staticValue;
48
58
  const secretName = (typeof explicit === 'string' && explicit) ? explicit : this._ownerKey;
49
59
  if (!secretName) {
@@ -66,11 +76,6 @@ export function installHqPlugin(graph /* EnvGraph */, opts) {
66
76
  }
67
77
  throw new ResolutionError(`Failed to load secret "${secretName}": ${err.message ?? err.code}`);
68
78
  }
69
- // Sentinel-check style throughout: `readCache` returns `string | null`
70
- // (verified at `hq/packages/hq-cli/src/utils/secrets-cache.ts:45`); `pluginState.uid`
71
- // is `string | null` per `PluginState`. Use `== null` (covers null AND undefined defensively)
72
- // for both — do not mix in truthy checks like `if (!x)`, which would silently swallow a
73
- // legitimate empty-string value if the contract ever loosened.
74
79
  if (pluginState.uid == null) {
75
80
  throw new ResolutionError('Internal error: prewarmHqSecrets was not called before resolveEnvValues');
76
81
  }
@@ -78,11 +83,7 @@ export function installHqPlugin(graph /* EnvGraph */, opts) {
78
83
  if (inMemory != null) {
79
84
  return inMemory;
80
85
  }
81
- const cached = readCache(pluginState.uid, secretName); // string | null
82
- if (cached == null) {
83
- throw new ResolutionError(`Internal error: pre-warm did not populate cache for "${secretName}"`);
84
- }
85
- return cached;
86
+ throw new ResolutionError(`Secret "${secretName}" was not returned by vault after server authorization`);
86
87
  },
87
88
  };
88
89
  // Captured during process(parent); used by resolve() to fall back to the var key.
@@ -144,22 +145,84 @@ export async function prewarmHqSecrets(graph /* EnvGraph */, opts, state) {
144
145
  if (uniqueNames.length > 100) {
145
146
  throw new Error(`hq run supports at most 100 hq() resolvers per schema; got ${uniqueNames.length}`);
146
147
  }
147
- const result = await opts.fetchBatch(uid, uniqueNames, opts.usage);
148
- for (const s of result.secrets) {
149
- if (s.value == null) {
150
- continue;
148
+ state.loadedSecretsByName.clear();
149
+ state.errorsByName = new Map();
150
+ let result;
151
+ try {
152
+ result = await opts.fetchBatch(uid, uniqueNames, opts.usage);
153
+ if (!Array.isArray(result.secrets) || !Array.isArray(result.errors)) {
154
+ throw new Error('Invalid secret load response from vault');
151
155
  }
152
- state.loadedSecretsByName.set(s.name, s.value);
153
- const cacheTtlMs = normalizeCacheTtlMs(s.cacheTtlMs);
154
- if (cacheTtlMs > 0) {
155
- writeCache(uid, s.name, s.value, cacheTtlMs);
156
+ const errorsByName = new Map();
157
+ const returnedNames = new Set();
158
+ const requestedNames = new Set(uniqueNames);
159
+ const seenNames = new Set();
160
+ for (const rawSecret of result.secrets) {
161
+ if (!rawSecret || typeof rawSecret !== 'object') {
162
+ throw new Error('Invalid secret load response from vault');
163
+ }
164
+ const s = rawSecret;
165
+ if (typeof s.name !== 'string' ||
166
+ !requestedNames.has(s.name) ||
167
+ seenNames.has(s.name) ||
168
+ (s.value != null && typeof s.value !== 'string')) {
169
+ throw new Error('Invalid secret load response from vault');
170
+ }
171
+ seenNames.add(s.name);
172
+ if (s.value == null) {
173
+ errorsByName.set(s.name, {
174
+ code: 'not_returned',
175
+ message: 'not returned by vault after server authorization',
176
+ });
177
+ removeCacheEntry(uid, s.name);
178
+ continue;
179
+ }
180
+ returnedNames.add(s.name);
181
+ state.loadedSecretsByName.set(s.name, s.value);
182
+ const cacheTtlMs = normalizeCacheTtlMs(s);
183
+ if (cacheTtlMs > 0) {
184
+ writeCache(uid, s.name, s.value, cacheTtlMs);
185
+ }
186
+ else {
187
+ removeCacheEntry(uid, s.name);
188
+ }
189
+ }
190
+ for (const rawError of result.errors) {
191
+ if (!rawError || typeof rawError !== 'object') {
192
+ throw new Error('Invalid secret load response from vault');
193
+ }
194
+ const e = rawError;
195
+ if (typeof e.name !== 'string' ||
196
+ !requestedNames.has(e.name) ||
197
+ seenNames.has(e.name) ||
198
+ typeof e.code !== 'string' ||
199
+ (e.message !== undefined && typeof e.message !== 'string')) {
200
+ throw new Error('Invalid secret load response from vault');
201
+ }
202
+ seenNames.add(e.name);
203
+ errorsByName.set(e.name, { code: e.code, message: e.message });
204
+ state.loadedSecretsByName.delete(e.name);
205
+ removeCacheEntry(uid, e.name);
156
206
  }
207
+ for (const name of uniqueNames) {
208
+ if (!returnedNames.has(name) && !errorsByName.has(name)) {
209
+ errorsByName.set(name, {
210
+ code: 'not_returned',
211
+ message: 'not returned by vault after server authorization',
212
+ });
213
+ removeCacheEntry(uid, name);
214
+ }
215
+ }
216
+ state.errorsByName = errorsByName;
217
+ state.uid = uid;
157
218
  }
158
- const errorsByName = new Map();
159
- for (const e of result.errors) {
160
- errorsByName.set(e.name, { code: e.code, message: e.message });
219
+ catch (err) {
220
+ state.loadedSecretsByName.clear();
221
+ state.errorsByName = new Map();
222
+ for (const name of uniqueNames) {
223
+ removeCacheEntry(uid, name);
224
+ }
225
+ throw err;
161
226
  }
162
- state.errorsByName = errorsByName;
163
- state.uid = uid;
164
227
  }
165
228
  //# sourceMappingURL=hq-plugin.js.map
@@ -12,6 +12,7 @@ export interface SandboxRunnerJob {
12
12
  jobId: string;
13
13
  status: SandboxRunnerState;
14
14
  output?: string;
15
+ error?: string;
15
16
  exitCode?: number;
16
17
  success?: boolean;
17
18
  }
@@ -36,6 +36,7 @@ function normalizeJob(body, jobIdFallback) {
36
36
  : jobIdFallback ?? requireString(body, "jobId"),
37
37
  status,
38
38
  output: typeof body.output === "string" ? body.output : undefined,
39
+ error: typeof body.error === "string" ? body.error : undefined,
39
40
  exitCode: typeof body.exitCode === "number" ? body.exitCode : undefined,
40
41
  success: typeof body.success === "boolean" ? body.success : undefined,
41
42
  };
@@ -3,11 +3,10 @@ export declare function readCache(companyUid: string, name: string): string | nu
3
3
  export declare function writeCache(companyUid: string, name: string, value: string, ttlMs?: number): void;
4
4
  /**
5
5
  * List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
6
- * secrets-cache directory on disk. Used by offline callers (e.g. install-time MCP
7
- * registration) that have no `--company` flag and no network token and so cannot
8
- * resolve a single active company UID up front: they instead probe every cached
9
- * scope for a given secret name. Returns `[]` when the cache root is absent or
10
- * unreadable (the desired graceful-deferral behavior — no scopes, no hits).
6
+ * secrets-cache directory on disk. Install-time MCP registration may use an
7
+ * exactly-one result as a scope hint before reauthorizing every value online; it
8
+ * never reads cached plaintext through this helper. Returns `[]` when the cache
9
+ * root is absent or unreadable.
11
10
  */
12
11
  export declare function listSecretCacheScopes(): string[];
13
12
  export declare function removeCacheEntry(companyUid: string, name: string): void;
@@ -140,11 +140,10 @@ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACH
140
140
  }
141
141
  /**
142
142
  * List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
143
- * secrets-cache directory on disk. Used by offline callers (e.g. install-time MCP
144
- * registration) that have no `--company` flag and no network token and so cannot
145
- * resolve a single active company UID up front: they instead probe every cached
146
- * scope for a given secret name. Returns `[]` when the cache root is absent or
147
- * unreadable (the desired graceful-deferral behavior — no scopes, no hits).
143
+ * secrets-cache directory on disk. Install-time MCP registration may use an
144
+ * exactly-one result as a scope hint before reauthorizing every value online; it
145
+ * never reads cached plaintext through this helper. Returns `[]` when the cache
146
+ * root is absent or unreadable.
148
147
  */
149
148
  export function listSecretCacheScopes() {
150
149
  try {
@@ -152,9 +151,7 @@ export function listSecretCacheScopes() {
152
151
  .readdirSync(CACHE_DIR, { withFileTypes: true })
153
152
  .filter((e) => e.isDirectory())
154
153
  .map((e) => e.name)
155
- // Only real entity scopes (cmp_*/prs_*); validateInputs in readCache also
156
- // rejects anything with `/` or `..`, so this is belt-and-suspenders.
157
- .filter((name) => !name.startsWith("."));
154
+ .filter((name) => /^(?:cmp|prs)_[A-Za-z0-9_-]+$/.test(name));
158
155
  }
159
156
  catch {
160
157
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.75.0",
3
+ "version": "5.76.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "build": "node scripts/generate-dsn.mjs && tsc && node scripts/chmod-bins.mjs",
12
12
  "prepublishOnly": "npm run build",
13
13
  "typecheck": "tsc --noEmit",
14
+ "gen:scan-golden": "node scripts/generate-scan-packages-table.mjs > src/utils/__fixtures__/scan-packages.generated-block.sh",
14
15
  "lint": "eslint .",
15
16
  "test": "vitest run",
16
17
  "test:db": "vitest run src/lib/db test/commands/db.test.ts test/commands/db-tenant-isolation.test.ts",
@@ -21,7 +22,7 @@
21
22
  },
22
23
  "dependencies": {
23
24
  "@aws-sdk/client-s3": "^3.1049.0",
24
- "@indigoai-us/hq-cloud": "^6.14.4",
25
+ "@indigoai-us/hq-cloud": "^6.14.14",
25
26
  "@indigoai-us/hq-onboarding": "^0.1.0",
26
27
  "@sentry/node": "^10.49.0",
27
28
  "better-sqlite3": "^12.11.1",
@@ -1,2 +1,4 @@
1
1
  allowBuilds:
2
2
  better-sqlite3: true
3
+ minimumReleaseAgeExclude:
4
+ - '@indigoai-us/hq-cloud@6.14.14'