@indigoai-us/hq-cli 5.342.1 → 5.342.3

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.
@@ -0,0 +1,459 @@
1
+ import chalk from "chalk";
2
+ import { writeSync } from "node:fs";
3
+ import * as nodePath from "node:path";
4
+ import { cognitoCachePrincipalUid } from "./utils/cognito-session.js";
5
+ import { isAuthError } from "./utils/auth-error.js";
6
+ import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, removeCacheEntry, writeCache, } from "./utils/secrets-cache.js";
7
+ import { computeSha256 } from "./utils/integrity.js";
8
+ import { SECRET_NAME_PATTERN } from "./commands/_patterns.js";
9
+ import { finishEnvExportsAndExit } from "./commands/secrets-env-exit.js";
10
+ import { getEntityUid, vaultApiFetch } from "./utils/vault-api.js";
11
+ import { HQ_API_KEY_PREFIX, resolveVaultCredentialForCapability, } from "./utils/resolve-vault-credential.js";
12
+ import { CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE, isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError, } from "./utils/secret-load-availability.js";
13
+ const MAX_BATCH_NAMES = 100;
14
+ const SECRET_LOAD_TIMEOUT_MS = 30_000;
15
+ /**
16
+ * A browser login is safe only when a person can answer it. This mirrors the
17
+ * repository's existing terminal convention in `canOfferTeamUpgrade`: both
18
+ * stdin and stderr must be TTYs, and CI is always non-interactive.
19
+ */
20
+ export function secretsCredentialOptions() {
21
+ return {
22
+ interactive: process.stdin.isTTY === true &&
23
+ process.stderr.isTTY === true &&
24
+ !process.env.CI,
25
+ };
26
+ }
27
+ export function scopeOpts(opts) {
28
+ if (opts.personal && opts.company) {
29
+ console.error(chalk.red("Error: --personal cannot be combined with --company."));
30
+ process.exit(1);
31
+ }
32
+ return { personal: !!opts.personal, companySlug: opts.company };
33
+ }
34
+ function shellSingleQuote(value) {
35
+ return "'" + value.replace(/'/g, "'\\''") + "'";
36
+ }
37
+ export function highSecuritySandboxOnlyMessage(secretName) {
38
+ const sandboxExample = secretName
39
+ ? `hq secrets sandbox --only ${secretName} -- <command>`
40
+ : "hq secrets sandbox --only <secret> -- <command>";
41
+ return `High-security secrets can run only in the sandbox (use \`${sandboxExample}\`) or through the HQ secret proxy. Local injection and script locks (\`--script\`) are not supported.`;
42
+ }
43
+ export function normalizeSecretTier(tier) {
44
+ return tier === "sensitive" || tier === "nuclear" ? tier : "standard";
45
+ }
46
+ export function normalizeScriptLockMode(mode) {
47
+ return mode === "enforced" ? "enforced" : "off";
48
+ }
49
+ function normalizeCacheTtlMs(metadata) {
50
+ // Controlled rows must never reach the offline cache even if a mixed-version
51
+ // or malformed response carries a positive TTL. The server is authoritative
52
+ // for access, but the client still has enough policy metadata to fail safe.
53
+ if (metadata.tier === "sensitive" ||
54
+ metadata.tier === "nuclear" ||
55
+ metadata.scriptLock?.mode === "enforced") {
56
+ return 0;
57
+ }
58
+ if (metadata.cacheTtlMs === undefined) {
59
+ return DEFAULT_SECRETS_CACHE_TTL_MS;
60
+ }
61
+ return typeof metadata.cacheTtlMs === "number" &&
62
+ Number.isFinite(metadata.cacheTtlMs) &&
63
+ metadata.cacheTtlMs > 0
64
+ ? metadata.cacheTtlMs
65
+ : 0;
66
+ }
67
+ export function extractApiMessage(body, fallback) {
68
+ const message = typeof body.message === "string" ? body.message : undefined;
69
+ const error = typeof body.error === "string" ? body.error : undefined;
70
+ return message ?? error ?? fallback;
71
+ }
72
+ export async function buildSecretUsage(channel, scriptPath, scriptId, attestationLevel = "self-asserted-hash") {
73
+ if (scriptId && !scriptPath) {
74
+ throw new Error("--script-id requires --script");
75
+ }
76
+ if (!scriptPath) {
77
+ return { channel };
78
+ }
79
+ const resolvedPath = nodePath.resolve(scriptPath);
80
+ return {
81
+ channel,
82
+ script: {
83
+ scriptId: scriptId ?? resolvedPath,
84
+ path: resolvedPath,
85
+ sha256: await computeSha256(resolvedPath),
86
+ attestationLevel,
87
+ },
88
+ };
89
+ }
90
+ export function parseSecretNameList(input) {
91
+ const keys = input ?? [];
92
+ if (keys.length === 0) {
93
+ console.error(chalk.red("Error: --only requires at least one secret name."));
94
+ process.exit(1);
95
+ }
96
+ for (const key of keys) {
97
+ if (!SECRET_NAME_PATTERN.test(key)) {
98
+ console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$`));
99
+ process.exit(1);
100
+ }
101
+ }
102
+ return keys;
103
+ }
104
+ // HQ-4H — load + decrypt secrets through the BATCH-LOAD endpoint
105
+ // (`POST /secrets/{companyUid}/load`) instead of one single-secret GET per key.
106
+ //
107
+ // Every single-secret GET that 404s makes vault-service emit an unattributed
108
+ // "Secret not found" Sentry *warning* — each `response(404, …)` is captured at
109
+ // the response boundary (hq-pro `_error-response.ts`). That capture is the
110
+ // right signal for `hq secrets get <name>` ("a caller genuinely needed THIS
111
+ // secret and it was missing"), but it is pure noise for a multi-key load where
112
+ // a missing / renamed / rotated key is a per-key outcome `exec`/`env` already
113
+ // report cleanly to the user. The batch-load endpoint returns 200 with a
114
+ // per-name `errors[]` array and never captures — the same path `hq run` uses,
115
+ // which is exactly why `hq run` never contributed to HQ-4H. Routing `exec` and
116
+ // `env` through it stops those callers from emitting the warning while keeping
117
+ // identical UX and error text.
118
+ //
119
+ // Every value use through exec/env/reveal is server-authorized when online,
120
+ // including when an encrypted disk-cache entry exists. Only explicitly opted-in
121
+ // exec/env callers may use principal-bound values during classified outages;
122
+ // install authorization and reveal remain online-only. This means a policy,
123
+ // ACL, tier, or script-approval change takes effect on the next online use.
124
+ // Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
125
+ // with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
126
+ // path used — never swallows a failure.
127
+ async function loadRevealedSecretsViaApiKey(token, keys) {
128
+ const resolved = new Map();
129
+ const requested = [...new Set(keys)];
130
+ const cacheScope = "__api_key__";
131
+ for (const name of requested) {
132
+ const res = await vaultApiFetch({
133
+ token,
134
+ path: "/v1/keys/secrets/fetch",
135
+ method: "POST",
136
+ body: { name },
137
+ signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
138
+ });
139
+ const body = (await res.json().catch(() => ({})));
140
+ if (!res.ok) {
141
+ if (res.status === 404) {
142
+ throw new Error(`Failed to fetch secret '${name}': Secret not found`);
143
+ }
144
+ if (res.status === 403) {
145
+ const message = typeof body.error === "string"
146
+ ? body.error
147
+ : typeof body.message === "string"
148
+ ? body.message
149
+ : "No read permission";
150
+ if (body.highSecurity === true) {
151
+ throw new Error(highSecuritySandboxOnlyMessage(name));
152
+ }
153
+ throw new Error(`Failed to fetch secret '${name}': ${message}`);
154
+ }
155
+ if (res.status === 401) {
156
+ throw new Error(`Failed to fetch secret '${name}': Invalid or missing API key`);
157
+ }
158
+ throw new Error(`Failed to fetch secret '${name}': ${extractApiMessage(body, res.statusText)}`);
159
+ }
160
+ const secret = typeof body.secret === "object" && body.secret !== null
161
+ ? body.secret
162
+ : null;
163
+ if (typeof secret?.value !== "string") {
164
+ throw new Error(`Failed to fetch secret '${name}': malformed fetch response`);
165
+ }
166
+ removeCacheEntry(cacheScope, name);
167
+ resolved.set(name, secret.value);
168
+ }
169
+ return resolved;
170
+ }
171
+ export async function loadRevealedSecrets(token, companyUid, keys, usage, options = {}) {
172
+ if (token.startsWith(HQ_API_KEY_PREFIX)) {
173
+ return loadRevealedSecretsViaApiKey(token, keys);
174
+ }
175
+ const resolved = new Map();
176
+ const requested = [...new Set(keys)];
177
+ const principalUid = cognitoCachePrincipalUid(token);
178
+ const cachedCandidates = new Set(principalUid
179
+ ? requested.filter((name) => hasCacheEntry(companyUid, name, principalUid))
180
+ : []);
181
+ try {
182
+ // The batch endpoint is the only non-capturing authorization path that also
183
+ // evaluates the supplied usage metadata. It covers warm and cold names in
184
+ // bounded requests, then a warm name may use its locally authenticated
185
+ // plaintext only when the returned version and current policy TTL agree.
186
+ for (let i = 0; i < requested.length; i += MAX_BATCH_NAMES) {
187
+ const chunk = requested.slice(i, i + MAX_BATCH_NAMES);
188
+ const chunkNames = new Set(chunk);
189
+ const res = await vaultApiFetch({
190
+ token,
191
+ path: `/secrets/${encodeURIComponent(companyUid)}/load`,
192
+ method: "POST",
193
+ body: usage ? { names: chunk, usage } : { names: chunk },
194
+ signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
195
+ });
196
+ if (!res.ok) {
197
+ const body = (await res.json().catch(() => ({})));
198
+ const message = extractApiMessage(body, res.statusText);
199
+ // High-security refusal surfaced at the batch level (rather than
200
+ // per-name): point the caller at the sandbox/proxy and never leak
201
+ // plaintext. A local script attestation is not an override.
202
+ if (body.code === "high_security_denied" ||
203
+ body.code === "high_security_sandbox_only" ||
204
+ body.highSecurity === true) {
205
+ throw new Error(highSecuritySandboxOnlyMessage());
206
+ }
207
+ if (isUnavailableSecretLoadHttpStatus(res.status)) {
208
+ throw new SecretLoadUnavailableError(`Failed to batch-load secrets: ${message}`);
209
+ }
210
+ if (res.status >= 400 &&
211
+ res.status < 500 &&
212
+ typeof body.code === "string") {
213
+ throw new Error(message);
214
+ }
215
+ if (res.status === 403) {
216
+ throw new Error(`Failed to fetch secret '${chunk[0]}': No read permission`);
217
+ }
218
+ throw new Error(`Failed to batch-load secrets: ${message}`);
219
+ }
220
+ const data = (await res.json());
221
+ if (!Array.isArray(data.secrets) || !Array.isArray(data.errors)) {
222
+ throw new Error("Invalid secret load response from vault");
223
+ }
224
+ const errorsByName = new Map();
225
+ const seenNames = new Set();
226
+ for (const rawSecret of data.secrets) {
227
+ if (!rawSecret || typeof rawSecret !== "object") {
228
+ throw new Error("Invalid secret load response from vault");
229
+ }
230
+ const s = rawSecret;
231
+ if (typeof s.name !== "string" ||
232
+ !chunkNames.has(s.name) ||
233
+ seenNames.has(s.name) ||
234
+ (s.value != null && typeof s.value !== "string")) {
235
+ throw new Error("Invalid secret load response from vault");
236
+ }
237
+ seenNames.add(s.name);
238
+ if (s.revokedAt != null) {
239
+ errorsByName.set(s.name, {
240
+ code: "revoked",
241
+ message: `Secret '${s.name}' has been revoked`,
242
+ });
243
+ removeCacheEntry(companyUid, s.name);
244
+ continue;
245
+ }
246
+ if (s.value == null) {
247
+ errorsByName.set(s.name, {
248
+ code: "not_returned",
249
+ message: `Secret '${s.name}' has no value (reveal may not be permitted).`,
250
+ });
251
+ removeCacheEntry(companyUid, s.name);
252
+ continue;
253
+ }
254
+ const cacheTtlMs = normalizeCacheTtlMs(s);
255
+ const cached = cachedCandidates.has(s.name) &&
256
+ cacheTtlMs > 0 &&
257
+ typeof s.version === "number" &&
258
+ Number.isSafeInteger(s.version) &&
259
+ s.version > 0
260
+ ? readCache(companyUid, s.name, principalUid, s.version, cacheTtlMs)
261
+ : null;
262
+ if (cached !== null) {
263
+ resolved.set(s.name, cached);
264
+ continue;
265
+ }
266
+ if (cacheTtlMs > 0) {
267
+ if (typeof s.version === "number" &&
268
+ Number.isSafeInteger(s.version) &&
269
+ s.version > 0) {
270
+ if (principalUid) {
271
+ writeCache(companyUid, s.name, principalUid, s.value, cacheTtlMs, s.version);
272
+ }
273
+ }
274
+ else if (principalUid) {
275
+ writeCache(companyUid, s.name, principalUid, s.value, cacheTtlMs);
276
+ }
277
+ }
278
+ else {
279
+ removeCacheEntry(companyUid, s.name);
280
+ }
281
+ resolved.set(s.name, s.value);
282
+ }
283
+ for (const rawError of data.errors) {
284
+ if (!rawError || typeof rawError !== "object") {
285
+ throw new Error("Invalid secret load response from vault");
286
+ }
287
+ const e = rawError;
288
+ if (typeof e.name !== "string" ||
289
+ !chunkNames.has(e.name) ||
290
+ seenNames.has(e.name) ||
291
+ typeof e.code !== "string" ||
292
+ (e.message !== undefined && typeof e.message !== "string")) {
293
+ throw new Error("Invalid secret load response from vault");
294
+ }
295
+ seenNames.add(e.name);
296
+ errorsByName.set(e.name, { code: e.code, message: e.message });
297
+ resolved.delete(e.name);
298
+ removeCacheEntry(companyUid, e.name);
299
+ }
300
+ // Any requested key in this chunk the server did not return is a per-key
301
+ // failure — surface it with the same prefix the single-GET path used so
302
+ // callers (and scripts grepping stderr) see no behavior change.
303
+ let firstFailure = null;
304
+ for (const key of chunk) {
305
+ if (resolved.has(key))
306
+ continue;
307
+ removeCacheEntry(companyUid, key);
308
+ const err = errorsByName.get(key);
309
+ // Every caller of loadRevealedSecrets injects or prints the plaintext
310
+ // locally (`secrets get --reveal`, `secrets exec`, `secrets env`). Both
311
+ // high-security denial shapes therefore receive the same explicit
312
+ // sandbox/proxy guidance, even if the caller supplied `--script`.
313
+ if (err?.code === "high_security_denied" ||
314
+ err?.code === "high_security_sandbox_only") {
315
+ firstFailure ??= new Error(highSecuritySandboxOnlyMessage(key));
316
+ continue;
317
+ }
318
+ const reason = err?.code === "not_found"
319
+ ? "Secret not found"
320
+ : err?.code === "forbidden"
321
+ ? err.message ?? "No read permission"
322
+ : err?.message ?? err?.code ?? "not returned by vault";
323
+ firstFailure ??= new Error(`Failed to fetch secret '${key}': ${reason}`);
324
+ }
325
+ if (firstFailure) {
326
+ throw firstFailure;
327
+ }
328
+ }
329
+ return resolved;
330
+ }
331
+ catch (err) {
332
+ if (isSecretLoadUnavailable(err) && options.allowOfflineFallback === true) {
333
+ const offline = new Map();
334
+ for (const key of requested) {
335
+ const cached = cachedCandidates.has(key)
336
+ ? readCache(companyUid, key, principalUid)
337
+ : null;
338
+ if (cached === null) {
339
+ // Keep the same failure behavior for a cache miss, while leaving any
340
+ // still-valid entries untouched for a later retry.
341
+ throw err;
342
+ }
343
+ offline.set(key, cached);
344
+ }
345
+ console.error(CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE);
346
+ return offline;
347
+ }
348
+ if (isSecretLoadUnavailable(err)) {
349
+ throw err;
350
+ }
351
+ // A transport failure, stale session, malformed response, or a later chunk
352
+ // failure is an explicit denial or unusable response. Evict the whole
353
+ // request before failing closed. Transient no-answer/5xx/429 failures above
354
+ // preserve entries and only use values still valid under their stored TTL.
355
+ for (const key of requested) {
356
+ removeCacheEntry(companyUid, key);
357
+ }
358
+ throw err;
359
+ }
360
+ }
361
+ /**
362
+ * Emit `hq secrets env` output and terminate deterministically.
363
+ *
364
+ * The process MUST exit even when the consuming pipe has gone away. The
365
+ * orphaned-process leak (personal/projects/hq-secrets-process-leak) was caused
366
+ * by relying on `process.stdout.write(payload, () => process.exit(0))`: when the
367
+ * parent shell reading `source <(hq secrets env …)` is killed on timeout, the
368
+ * stdout pipe loses its reader, the write callback never fires, `process.exit(0)`
369
+ * is never reached, and — because `env` forces no other exit — the process parks
370
+ * in `epoll_pwait(-1)` forever, reparented to PID 1. Hundreds accumulated this
371
+ * way, exhausting swap and driving load into triple digits.
372
+ *
373
+ * Writing synchronously to fd 1 and then exiting unconditionally removes every
374
+ * dependency on the event loop draining and on the reader still being attached:
375
+ * - a live reader receives the export lines (synchronous write reaches the
376
+ * kernel pipe buffer before we exit, so nothing is truncated);
377
+ * - a dead reader surfaces as EPIPE, which we swallow — the secret is already
378
+ * resolved and there is nothing left to deliver, so a clean exit is correct;
379
+ * - EAGAIN (stdout in non-blocking mode with a momentarily full buffer) is
380
+ * retried a bounded number of times. `env` payloads are a few short export
381
+ * lines, far under the 64 KiB pipe buffer, so this effectively never spins.
382
+ *
383
+ * `exit` and `write` are injectable for tests; production uses the real ones.
384
+ */
385
+ export function emitEnvExportsAndExit(payload, exit = process.exit, write = writeSync) {
386
+ const buf = Buffer.from(payload, "utf8");
387
+ const MAX_EAGAIN_RETRIES = 10_000;
388
+ let offset = 0;
389
+ let eagainRetries = 0;
390
+ while (offset < buf.length) {
391
+ try {
392
+ offset += write(1, buf, offset, buf.length - offset);
393
+ }
394
+ catch (err) {
395
+ const code = err.code;
396
+ if (code === "EAGAIN" && eagainRetries++ < MAX_EAGAIN_RETRIES)
397
+ continue;
398
+ // EPIPE (reader gone) or retries exhausted: the output cannot be
399
+ // delivered, but the process must never hang. Stop and exit cleanly.
400
+ break;
401
+ }
402
+ }
403
+ return exit(0);
404
+ }
405
+ /**
406
+ * Shared `secrets env` action for Commander and the default-off lightweight
407
+ * entrypoint. The fast entrypoint still runs the same credential, usage-audit,
408
+ * server authorization, cache-fallback, redaction, and output code.
409
+ */
410
+ export async function runSecretsEnvCommand(opts, parentScope, options = {}) {
411
+ let secretLoadStarted = false;
412
+ try {
413
+ const redact = process.stdout.isTTY;
414
+ if (redact) {
415
+ console.error(chalk.yellow("stdout is a terminal — values redacted. Use: source <(hq secrets env --only KEY1,KEY2)"));
416
+ }
417
+ const keys = parseSecretNameList(opts.only);
418
+ const cred = await resolveVaultCredentialForCapability("secrets:read", "secrets env", secretsCredentialOptions());
419
+ const companyUid = cred.kind === "api-key"
420
+ ? "__api_key__"
421
+ : await getEntityUid(cred.token, scopeOpts(parentScope), {
422
+ allowOfflineScopeFallback: true,
423
+ });
424
+ const usage = cred.kind === "cognito"
425
+ ? await buildSecretUsage("env", opts.script, opts.scriptId)
426
+ : undefined;
427
+ secretLoadStarted = true;
428
+ const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, usage, { allowOfflineFallback: true });
429
+ let payload = "";
430
+ for (const key of keys) {
431
+ const value = revealed.get(key);
432
+ // loadRevealedSecrets throws on any unresolved key, so a miss here is
433
+ // unreachable — guard rather than print an `undefined` export line.
434
+ if (value === undefined) {
435
+ throw new Error(`Failed to fetch secret '${key}': not returned by vault`);
436
+ }
437
+ const out = redact ? "[REDACTED]" : value;
438
+ payload += `export ${key}=${shellSingleQuote(out)}\n`;
439
+ }
440
+ // Persist an already-running company snapshot before the synchronous
441
+ // export write exits the process. Personal exports do not wait.
442
+ await finishEnvExportsAndExit(payload, undefined, process.exit, undefined, !parentScope.personal, companyUid);
443
+ return "handled";
444
+ }
445
+ catch (err) {
446
+ if (options.fallbackOnAuthFailure &&
447
+ !secretLoadStarted &&
448
+ (isAuthError(err) ||
449
+ (err instanceof Error &&
450
+ (/^No valid HQ session\b/.test(err.message) ||
451
+ /^HQ_API_KEY must start with /.test(err.message))))) {
452
+ return "fallback";
453
+ }
454
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
455
+ process.exit(1);
456
+ return "handled";
457
+ }
458
+ }
459
+ //# sourceMappingURL=secrets-env-action.js.map
@@ -8,7 +8,7 @@ export async function runSecretsEnvFastPath(request) {
8
8
  ]);
9
9
  adoptAgentIdentityEnv(agentKitPaths(os.homedir(), process.env), process.env);
10
10
  }
11
- const { runSecretsEnvCommand } = await import("./commands/secrets.js");
11
+ const { runSecretsEnvCommand } = await import("./secrets-env-action.js");
12
12
  return runSecretsEnvCommand(request.options, request.scope, {
13
13
  fallbackOnAuthFailure: true,
14
14
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.342.1",
3
+ "version": "5.342.3",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {