@indigoai-us/hq-cli 5.75.0 → 5.77.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 (49) hide show
  1. package/dist/commands/agents.js +8 -3
  2. package/dist/commands/files.d.ts +61 -0
  3. package/dist/commands/files.js +274 -0
  4. package/dist/commands/mcp-registration.d.ts +4 -5
  5. package/dist/commands/mcp-registration.js +5 -4
  6. package/dist/commands/outposts.d.ts +20 -4
  7. package/dist/commands/outposts.js +79 -10
  8. package/dist/commands/pack-install.d.ts +14 -17
  9. package/dist/commands/pack-install.js +53 -29
  10. package/dist/commands/pkg-install.js +3 -1
  11. package/dist/commands/run.d.ts +2 -0
  12. package/dist/commands/run.js +9 -3
  13. package/dist/commands/secrets.js +189 -87
  14. package/dist/run/hq-plugin.js +94 -31
  15. package/dist/utils/billing-gate.d.ts +15 -0
  16. package/dist/utils/billing-gate.js +35 -0
  17. package/dist/utils/sandbox-runner-client.d.ts +1 -0
  18. package/dist/utils/sandbox-runner-client.js +1 -0
  19. package/dist/utils/secrets-cache.d.ts +4 -5
  20. package/dist/utils/secrets-cache.js +5 -8
  21. package/package.json +3 -2
  22. package/pnpm-workspace.yaml +2 -0
  23. package/src/commands/agents.test.ts +41 -0
  24. package/src/commands/agents.ts +7 -3
  25. package/src/commands/files-recovery.test.ts +361 -0
  26. package/src/commands/files.ts +410 -0
  27. package/src/commands/mcp-registration.ts +9 -9
  28. package/src/commands/outposts.test.ts +155 -24
  29. package/src/commands/outposts.ts +199 -45
  30. package/src/commands/pack-install-secret-authorization.test.ts +115 -0
  31. package/src/commands/pack-install.test.ts +5 -1
  32. package/src/commands/pack-install.ts +67 -29
  33. package/src/commands/pkg-install.ts +3 -1
  34. package/src/commands/run.test.ts +45 -0
  35. package/src/commands/run.ts +20 -4
  36. package/src/commands/secrets.test.ts +366 -25
  37. package/src/commands/secrets.ts +222 -96
  38. package/src/run/hq-plugin.test.ts +186 -10
  39. package/src/run/hq-plugin.ts +102 -32
  40. package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
  41. package/src/utils/billing-gate.ts +46 -0
  42. package/src/utils/pack-contributions.test.ts +90 -31
  43. package/src/utils/sandbox-runner-client.test.ts +28 -0
  44. package/src/utils/sandbox-runner-client.ts +2 -0
  45. package/src/utils/secrets-cache.ts +5 -8
  46. package/test/commands/signals.test.ts +2 -2
  47. package/test/commands/sources.test.ts +2 -2
  48. package/test/helpers/vault-service-mock.ts +76 -17
  49. package/test/sources-signals/smoke.test.ts +2 -2
@@ -305,26 +305,21 @@ export declare function renderMcpServerLine(payloadDir: string, item: string): s
305
305
  */
306
306
  export declare function confirmMcp(pkg: PackManifest, payloadDir: string, allowMcp: boolean): Promise<boolean>;
307
307
  /**
308
- * Build the install-time {@link SecretResolver} bound to the HQ vault's local
309
- * secrets-cache (`~/.hq/secrets-cache/<scope>/<NAME>`, AES-256-GCM, 0600, TTL'd).
308
+ * Build an install-time {@link SecretResolver} from a fresh server authorization
309
+ * decision. The encrypted cache may identify one unambiguous scope, but its
310
+ * plaintext is never trusted or read to register an MCP server.
310
311
  *
311
- * Active-company resolution at install time is INDIRECT by design: `hq install`
312
- * has no `--company` flag and runs OFFLINE (no token), so we cannot resolve a
313
- * single active company UID the way `hq run` / `hq secrets` do (via
314
- * `getEntityUid` over the network). Instead we probe EVERY cached scope
315
- * (`cmp_*`/`prs_*` whichever has minted secrets locally) for the requested
316
- * name and return the first hit. This naturally resolves to whichever company
317
- * context just provisioned the secret (e.g. the one `/connect-shopify` minted
318
- * `VYG_API_KEY` under), without guessing, and works whether the vault scoped the
319
- * key under a company or person entity.
312
+ * With `--company`, the caller-selected company is resolved normally. Without
313
+ * it, exactly one cache scope may identify the intended company/person context;
314
+ * multiple scopes are ambiguous and fail closed. The requested names are then
315
+ * loaded through the same server-authorized endpoint as exec/env/run. Offline,
316
+ * expired-session, forbidden, not-found, script-lock, and malformed-response
317
+ * outcomes all produce an empty resolver, so MCP registration is deferred.
320
318
  *
321
- * On MISS across all scopes (no cache, expired TTL, or key never minted) it
322
- * returns `null` which is exactly what {@link registerMcpServers}'s
323
- * unresolvable-secret path keys off to defer that server gracefully. With no
324
- * cached scopes at all (`listSecretCacheScopes()` → `[]`) it simply returns
325
- * `null` for every name, the desired graceful-deferral behavior.
319
+ * The returned synchronous resolver contains only values from that fresh
320
+ * authorization response. No cached value is a fallback.
326
321
  */
327
- export declare function makeInstallSecretResolver(): SecretResolver;
322
+ export declare function makeInstallSecretResolver(secretNames: string[], company?: string): Promise<SecretResolver>;
328
323
  /**
329
324
  * Install the fetched payload to `<hqRoot>/core/packages/<pkg.name>/` (HQ
330
325
  * v12+ layout). The HQ template (`hq-core` / `hq-core-staging`) ships
@@ -381,6 +376,8 @@ export declare function runScanPackages(hqRoot: string, opts?: {
381
376
  }): void;
382
377
  export interface InstallPackOptions {
383
378
  allowHooks?: boolean;
379
+ /** Company context for online authorization of install-time MCP secret refs. */
380
+ company?: string;
384
381
  /**
385
382
  * US-010 install-time MCP trust prompt bypass (CI/ambient-trust). Mirrors
386
383
  * `allowHooks`: when set, `confirmMcp` skips the prompt and prints a yellow
@@ -49,9 +49,11 @@ import { findHqRoot } from '../utils/manifest.js';
49
49
  import { readHqVersion, routeContribution, listInstalledPacks } from '../utils/pack-contributions.js';
50
50
  import { CONTRIBUTION_TABLE, payloadFor } from '../utils/contribution-table.js';
51
51
  import { safeExtractTarball } from './safe-extract.js';
52
- import { vaultApiFetchPublic } from '../utils/vault-api.js';
52
+ import { getCompanyUid, vaultApiFetchPublic } from '../utils/vault-api.js';
53
+ import { ensureCognitoToken } from '../utils/cognito-session.js';
53
54
  import { redactSecrets, SECRET_REDACTION, registerMcpServers, McpManifestError, } from './mcp-registration.js';
54
- import { readCache, listSecretCacheScopes } from '../utils/secrets-cache.js';
55
+ import { listSecretCacheScopes } from '../utils/secrets-cache.js';
56
+ import { loadRevealedSecrets } from './secrets.js';
55
57
  const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
56
58
  const PACK_UPDATE_FETCH_TIMEOUT_MS = 3_000;
57
59
  const gitLsRemoteMemo = new Map();
@@ -1349,34 +1351,56 @@ function evalConditional(expr) {
1349
1351
  // key) still SUCCEEDS, skipping only the server whose secret is not yet present.
1350
1352
  // ---------------------------------------------------------------------------
1351
1353
  /**
1352
- * Build the install-time {@link SecretResolver} bound to the HQ vault's local
1353
- * secrets-cache (`~/.hq/secrets-cache/<scope>/<NAME>`, AES-256-GCM, 0600, TTL'd).
1354
+ * Build an install-time {@link SecretResolver} from a fresh server authorization
1355
+ * decision. The encrypted cache may identify one unambiguous scope, but its
1356
+ * plaintext is never trusted or read to register an MCP server.
1354
1357
  *
1355
- * Active-company resolution at install time is INDIRECT by design: `hq install`
1356
- * has no `--company` flag and runs OFFLINE (no token), so we cannot resolve a
1357
- * single active company UID the way `hq run` / `hq secrets` do (via
1358
- * `getEntityUid` over the network). Instead we probe EVERY cached scope
1359
- * (`cmp_*`/`prs_*` whichever has minted secrets locally) for the requested
1360
- * name and return the first hit. This naturally resolves to whichever company
1361
- * context just provisioned the secret (e.g. the one `/connect-shopify` minted
1362
- * `VYG_API_KEY` under), without guessing, and works whether the vault scoped the
1363
- * key under a company or person entity.
1358
+ * With `--company`, the caller-selected company is resolved normally. Without
1359
+ * it, exactly one cache scope may identify the intended company/person context;
1360
+ * multiple scopes are ambiguous and fail closed. The requested names are then
1361
+ * loaded through the same server-authorized endpoint as exec/env/run. Offline,
1362
+ * expired-session, forbidden, not-found, script-lock, and malformed-response
1363
+ * outcomes all produce an empty resolver, so MCP registration is deferred.
1364
1364
  *
1365
- * On MISS across all scopes (no cache, expired TTL, or key never minted) it
1366
- * returns `null` which is exactly what {@link registerMcpServers}'s
1367
- * unresolvable-secret path keys off to defer that server gracefully. With no
1368
- * cached scopes at all (`listSecretCacheScopes()` → `[]`) it simply returns
1369
- * `null` for every name, the desired graceful-deferral behavior.
1365
+ * The returned synchronous resolver contains only values from that fresh
1366
+ * authorization response. No cached value is a fallback.
1370
1367
  */
1371
- export function makeInstallSecretResolver() {
1372
- return (name) => {
1373
- for (const scope of listSecretCacheScopes()) {
1374
- const value = readCache(scope, name);
1375
- if (value !== null)
1376
- return value;
1368
+ export async function makeInstallSecretResolver(secretNames, company) {
1369
+ const names = [...new Set(secretNames)];
1370
+ if (names.length === 0)
1371
+ return () => null;
1372
+ try {
1373
+ const token = await ensureCognitoToken({ interactive: false });
1374
+ let scopeUid;
1375
+ if (company) {
1376
+ scopeUid = await getCompanyUid(token, company);
1377
1377
  }
1378
- return null;
1379
- };
1378
+ else {
1379
+ const scopes = listSecretCacheScopes();
1380
+ if (scopes.length !== 1)
1381
+ return () => null;
1382
+ [scopeUid] = scopes;
1383
+ }
1384
+ const authorized = await loadRevealedSecrets(token, scopeUid, names);
1385
+ return (name) => authorized.get(name) ?? null;
1386
+ }
1387
+ catch {
1388
+ return () => null;
1389
+ }
1390
+ }
1391
+ const INSTALL_SECRET_REF_RE = /\$\{secret:([A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]+)*)\}/g;
1392
+ function collectManifestSecretNames(manifest) {
1393
+ const names = new Set();
1394
+ for (const value of [
1395
+ ...Object.values(manifest.headers ?? {}),
1396
+ ...Object.values(manifest.env ?? {}),
1397
+ ]) {
1398
+ INSTALL_SECRET_REF_RE.lastIndex = 0;
1399
+ for (let match = INSTALL_SECRET_REF_RE.exec(value); match; match = INSTALL_SECRET_REF_RE.exec(value)) {
1400
+ names.add(match[1]);
1401
+ }
1402
+ }
1403
+ return [...names];
1380
1404
  }
1381
1405
  /**
1382
1406
  * Load + shape-validate + parse a pack's per-server MCP manifest from disk.
@@ -1424,13 +1448,13 @@ function extractDeferredSecretName(message) {
1424
1448
  * - ANY OTHER error → RE-THROW (abort install). Only the
1425
1449
  * unresolvable-secret case is swallowed.
1426
1450
  */
1427
- function wireMcpServers(pkg, destDir) {
1428
- const resolveSecret = makeInstallSecretResolver();
1451
+ async function wireMcpServers(pkg, destDir, company) {
1429
1452
  const loadManifest = (name) => loadMcpManifestFrom(destDir, name);
1430
1453
  const registered = [];
1431
1454
  const skipped = [];
1432
1455
  for (const name of pkg.contributes.mcp ?? []) {
1433
1456
  try {
1457
+ const resolveSecret = await makeInstallSecretResolver(collectManifestSecretNames(loadManifest(name)), company);
1434
1458
  // Per-server call: registerMcpServers throws on the FIRST unresolvable
1435
1459
  // secret, so calling it one name at a time lets us catch + continue.
1436
1460
  registerMcpServers(pkg.name, [name], { loadManifest, resolveSecret });
@@ -1676,7 +1700,7 @@ export async function installPack(source, opts = {}) {
1676
1700
  // Per-server secret-deferral keeps a fresh install (key not yet minted) at
1677
1701
  // exit 0, deferring only the unresolvable server (see wireMcpServers).
1678
1702
  if (Array.isArray(pkg.contributes.mcp) && pkg.contributes.mcp.length > 0) {
1679
- const { registered, skipped } = wireMcpServers(pkg, destDir);
1703
+ const { registered, skipped } = await wireMcpServers(pkg, destDir, opts.company);
1680
1704
  // One-line summary (server NAMES only — never resolved secret VALUES).
1681
1705
  say(chalk.dim(` MCP servers: registered [${registered.join(', ')}]; ` +
1682
1706
  `skipped [${skipped.join(', ')}].`));
@@ -29,7 +29,7 @@ export function registerPackageInstallCommand(parent) {
29
29
  .command('install <source>')
30
30
  .description('Install a package. Sources: bare slug (registry, Cognito-gated), ' +
31
31
  '@scope/name[@ver] (npm pack), git URL[#ref], or local path.')
32
- .option('--company <co>', 'Scope the package to a specific company (registry flow only)')
32
+ .option('--company <co>', 'Scope package secret authorization to a specific company')
33
33
  .option('--allow-hooks', 'Skip the hooks confirmation prompt (content-pack flow)')
34
34
  .option('--allow-mcp', 'Skip the MCP server confirmation prompt (content-pack flow)')
35
35
  .option('--branch', 'Follow a ref instead of SHA-pinning (git content-pack flow)')
@@ -37,6 +37,7 @@ export function registerPackageInstallCommand(parent) {
37
37
  try {
38
38
  if (sourceMatchesPackPattern(source)) {
39
39
  await installPack(source, {
40
+ company: opts.company,
40
41
  allowHooks: opts.allowHooks,
41
42
  allowMcp: opts.allowMcp,
42
43
  followBranch: opts.branch,
@@ -48,6 +49,7 @@ export function registerPackageInstallCommand(parent) {
48
49
  // would fail at registry setup. Resolve it through the marketplace
49
50
  // listings transport — the live install path — instead.
50
51
  await installPack(`${MARKETPLACE_PREFIX}${source}`, {
52
+ company: opts.company,
51
53
  allowHooks: opts.allowHooks,
52
54
  allowMcp: opts.allowMcp,
53
55
  followBranch: opts.branch,
@@ -1,3 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import type { SecretUsage } from './secrets.js';
3
+ export declare function buildRunUsage(scriptPath?: string, scriptId?: string): Promise<SecretUsage | undefined>;
2
4
  export declare function registerRunCommand(program: Command): void;
3
5
  //# sourceMappingURL=run.d.ts.map
@@ -7,7 +7,11 @@ import { computeSha256 } from '../utils/integrity.js';
7
7
  import { vaultApiFetch, getCompanyUid } from '../utils/vault-api.js';
8
8
  import { discoverSchemas } from '../run/discover-schemas.js';
9
9
  import { installHqPlugin, prewarmHqSecrets } from '../run/hq-plugin.js';
10
- async function buildRunUsage(scriptPath) {
10
+ const SECRET_LOAD_TIMEOUT_MS = 30_000;
11
+ export async function buildRunUsage(scriptPath, scriptId) {
12
+ if (scriptId && !scriptPath) {
13
+ throw new Error('--script-id requires --script');
14
+ }
11
15
  if (!scriptPath) {
12
16
  return undefined;
13
17
  }
@@ -15,7 +19,7 @@ async function buildRunUsage(scriptPath) {
15
19
  return {
16
20
  channel: 'run',
17
21
  script: {
18
- scriptId: resolvedPath,
22
+ scriptId: scriptId ?? resolvedPath,
19
23
  path: resolvedPath,
20
24
  sha256: await computeSha256(resolvedPath),
21
25
  attestationLevel: 'self-asserted-hash',
@@ -29,6 +33,7 @@ export function registerRunCommand(program) {
29
33
  .option('--company <slug>', 'Company slug (overrides @hqCompany in schema)')
30
34
  .option('--schema <path>', 'Explicit schema path (skips walk-up discovery)')
31
35
  .option('--script <path>', 'Attach local script identity for script-locked secrets')
36
+ .option('--script-id <id>', 'Stable script identifier approved by policy')
32
37
  .option('--check', 'Resolve schema and validate vars without executing the command')
33
38
  .allowUnknownOption(true)
34
39
  .action(async (opts) => {
@@ -68,13 +73,14 @@ export function registerRunCommand(program) {
68
73
  }
69
74
  const token = await ensureCognitoToken();
70
75
  const uid = await getCompanyUid(token, slug);
71
- const usage = await buildRunUsage(opts.script);
76
+ const usage = await buildRunUsage(opts.script, opts.scriptId);
72
77
  const fetchBatch = async (companyUid, names, requestUsage) => {
73
78
  const res = await vaultApiFetch({
74
79
  token,
75
80
  path: `/secrets/${encodeURIComponent(companyUid)}/load`,
76
81
  method: 'POST',
77
82
  body: requestUsage ? { names, usage: requestUsage } : { names },
83
+ signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
78
84
  });
79
85
  if (!res.ok) {
80
86
  const body = await res.json().catch(() => ({}));
@@ -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