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