@indigoai-us/hq-cli 5.74.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.
- package/dist/commands/mcp-registration.d.ts +4 -5
- package/dist/commands/mcp-registration.js +5 -4
- package/dist/commands/outposts.d.ts +23 -5
- package/dist/commands/outposts.js +207 -14
- package/dist/commands/pack-install.d.ts +14 -17
- package/dist/commands/pack-install.js +53 -29
- package/dist/commands/pkg-install.js +3 -1
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +9 -3
- package/dist/commands/secrets.js +189 -87
- package/dist/run/hq-plugin.js +94 -31
- package/dist/utils/sandbox-runner-client.d.ts +1 -0
- package/dist/utils/sandbox-runner-client.js +1 -0
- package/dist/utils/secrets-cache.d.ts +4 -5
- package/dist/utils/secrets-cache.js +5 -8
- package/package.json +3 -2
- package/pnpm-workspace.yaml +2 -0
- package/src/commands/mcp-registration.ts +9 -9
- package/src/commands/outposts.test.ts +252 -24
- package/src/commands/outposts.ts +405 -50
- package/src/commands/pack-install-secret-authorization.test.ts +115 -0
- package/src/commands/pack-install.test.ts +5 -1
- package/src/commands/pack-install.ts +67 -29
- package/src/commands/pkg-install.ts +3 -1
- package/src/commands/run.test.ts +45 -0
- package/src/commands/run.ts +20 -4
- package/src/commands/secrets.test.ts +366 -25
- package/src/commands/secrets.ts +222 -96
- package/src/run/hq-plugin.test.ts +186 -10
- package/src/run/hq-plugin.ts +102 -32
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
- package/src/utils/pack-contributions.test.ts +90 -31
- package/src/utils/sandbox-runner-client.test.ts +28 -0
- package/src/utils/sandbox-runner-client.ts +2 -0
- package/src/utils/secrets-cache.ts +5 -8
- package/test/commands/signals.test.ts +2 -2
- package/test/commands/sources.test.ts +2 -2
- package/test/helpers/vault-service-mock.ts +76 -17
- package/test/sources-signals/smoke.test.ts +2 -2
package/dist/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
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(() => ({}));
|
package/dist/commands/secrets.js
CHANGED
|
@@ -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,
|
|
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(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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.
|
|
312
|
-
? policy.
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
417
|
-
if (
|
|
418
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
package/dist/run/hq-plugin.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { ResolutionError } from 'varlock/plugin-lib';
|
|
2
|
-
import { DEFAULT_SECRETS_CACHE_TTL_MS,
|
|
3
|
-
function normalizeCacheTtlMs(
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
//
|
|
45
|
-
// `prewarmHqSecrets(graph, opts, state)`
|
|
46
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
errorsByName
|
|
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
|
|
@@ -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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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;
|