@indigoai-us/hq-cli 5.60.0 → 5.61.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.
@@ -528,6 +528,324 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
528
528
  });
529
529
  });
530
530
 
531
+ // secrets-proxy-per-secret-destination US-006: hq-cli creation-side parity.
532
+ // `hq secrets set NAME --high-security --destination <url> [--auth-style ...]`
533
+ // POSTs `highSecurity`/`destinations`/`injection` to the vault API — the CLI
534
+ // counterpart to the console create-form (US-005) and the server contract
535
+ // (US-001/US-004). Value input is exercised via --from-stdin (unaffected by
536
+ // this story) so these tests isolate the new flag-handling/validation.
537
+ describe("US-006 — hq secrets set --high-security --destination parity", () => {
538
+ let exitSpy: MockInstance<typeof process.exit>;
539
+ let stdinIsTTYSpy: MockInstance<() => boolean> | undefined;
540
+
541
+ beforeEach(() => {
542
+ exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
543
+ throw new Error("__exit__");
544
+ }) as never);
545
+ });
546
+
547
+ afterEach(() => {
548
+ stdinIsTTYSpy?.mockRestore();
549
+ stdinIsTTYSpy = undefined;
550
+ });
551
+
552
+ // Drives the value through the existing --from-stdin path (unchanged by
553
+ // this story) by feeding a piped-stdin double: mocks isTTY=false and
554
+ // replaces the data/end listeners `readFromPipedStdin` attaches.
555
+ function stubPipedStdin(value: string): void {
556
+ Object.defineProperty(process.stdin, "isTTY", {
557
+ configurable: true,
558
+ value: false,
559
+ });
560
+ vi.spyOn(process.stdin, "on").mockImplementation(((
561
+ event: string,
562
+ cb: (...args: unknown[]) => void,
563
+ ) => {
564
+ if (event === "data") cb(value);
565
+ if (event === "end") cb();
566
+ return process.stdin;
567
+ }) as never);
568
+ }
569
+
570
+ it("with no flags, the POST body is byte-for-byte unchanged (just name+value)", async () => {
571
+ stubPipedStdin("plain-value");
572
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
573
+
574
+ const program = buildProgram();
575
+ await program.parseAsync([
576
+ "node",
577
+ "hq",
578
+ "secrets",
579
+ "set",
580
+ "MY_KEY",
581
+ "--from-stdin",
582
+ ]);
583
+
584
+ expect(vaultApiFetch).toHaveBeenCalledWith({
585
+ token: "test-token",
586
+ path: "/secrets/prs_alice",
587
+ method: "POST",
588
+ body: { name: "MY_KEY", value: "plain-value" },
589
+ });
590
+ expect(errSpy).not.toHaveBeenCalled();
591
+ });
592
+
593
+ it("--high-security with a KNOWN destination auto-resolves the recipe (no --auth-style needed)", async () => {
594
+ stubPipedStdin("sk-secret-value");
595
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
596
+
597
+ const program = buildProgram();
598
+ await program.parseAsync([
599
+ "node",
600
+ "hq",
601
+ "secrets",
602
+ "set",
603
+ "OPENAI_KEY",
604
+ "--from-stdin",
605
+ "--high-security",
606
+ "--destination",
607
+ "https://api.openai.com",
608
+ ]);
609
+
610
+ expect(vaultApiFetch).toHaveBeenCalledWith({
611
+ token: "test-token",
612
+ path: "/secrets/prs_alice",
613
+ method: "POST",
614
+ body: {
615
+ name: "OPENAI_KEY",
616
+ value: "sk-secret-value",
617
+ highSecurity: true,
618
+ destinations: ["https://api.openai.com"],
619
+ },
620
+ });
621
+ expect(errSpy).not.toHaveBeenCalled();
622
+ expect(logSpy.mock.calls.flat().join(" ")).toMatch(/high-security/i);
623
+ });
624
+
625
+ it("--auth-style bearer maps to {header: authorization, scheme: bearer}", async () => {
626
+ stubPipedStdin("bearer-token-value");
627
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
628
+
629
+ const program = buildProgram();
630
+ await program.parseAsync([
631
+ "node",
632
+ "hq",
633
+ "secrets",
634
+ "set",
635
+ "SOME_BEARER_KEY",
636
+ "--from-stdin",
637
+ "--high-security",
638
+ "--destination",
639
+ "https://api.example.com",
640
+ "--auth-style",
641
+ "bearer",
642
+ ]);
643
+
644
+ expect(vaultApiFetch).toHaveBeenCalledWith({
645
+ token: "test-token",
646
+ path: "/secrets/prs_alice",
647
+ method: "POST",
648
+ body: {
649
+ name: "SOME_BEARER_KEY",
650
+ value: "bearer-token-value",
651
+ highSecurity: true,
652
+ destinations: ["https://api.example.com"],
653
+ injection: { header: "authorization", scheme: "bearer" },
654
+ },
655
+ });
656
+ });
657
+
658
+ it("--auth-style x-api-key maps to {header: x-api-key, scheme: raw}", async () => {
659
+ stubPipedStdin("raw-key-value");
660
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
661
+
662
+ const program = buildProgram();
663
+ await program.parseAsync([
664
+ "node",
665
+ "hq",
666
+ "secrets",
667
+ "set",
668
+ "SOME_RAW_KEY",
669
+ "--from-stdin",
670
+ "--high-security",
671
+ "--destination",
672
+ "https://api.example.com",
673
+ "--auth-style",
674
+ "x-api-key",
675
+ ]);
676
+
677
+ expect(vaultApiFetch).toHaveBeenCalledWith({
678
+ token: "test-token",
679
+ path: "/secrets/prs_alice",
680
+ method: "POST",
681
+ body: {
682
+ name: "SOME_RAW_KEY",
683
+ value: "raw-key-value",
684
+ highSecurity: true,
685
+ destinations: ["https://api.example.com"],
686
+ injection: { header: "x-api-key", scheme: "raw" },
687
+ },
688
+ });
689
+ });
690
+
691
+ it("--auth-style header:NAME maps to a custom raw header", async () => {
692
+ stubPipedStdin("custom-header-value");
693
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
694
+
695
+ const program = buildProgram();
696
+ await program.parseAsync([
697
+ "node",
698
+ "hq",
699
+ "secrets",
700
+ "set",
701
+ "CUSTOM_HEADER_KEY",
702
+ "--from-stdin",
703
+ "--high-security",
704
+ "--destination",
705
+ "https://api.example.com",
706
+ "--auth-style",
707
+ "header:X-Custom-Key",
708
+ ]);
709
+
710
+ expect(vaultApiFetch).toHaveBeenCalledWith({
711
+ token: "test-token",
712
+ path: "/secrets/prs_alice",
713
+ method: "POST",
714
+ body: {
715
+ name: "CUSTOM_HEADER_KEY",
716
+ value: "custom-header-value",
717
+ highSecurity: true,
718
+ destinations: ["https://api.example.com"],
719
+ injection: { header: "X-Custom-Key", scheme: "raw" },
720
+ },
721
+ });
722
+ });
723
+
724
+ it("--high-security with an UNKNOWN host and no --auth-style errors clearly, client-side (no request sent)", async () => {
725
+ const program = buildProgram();
726
+ await expect(
727
+ program.parseAsync([
728
+ "node",
729
+ "hq",
730
+ "secrets",
731
+ "set",
732
+ "UNKNOWN_HOST_KEY",
733
+ "--from-stdin",
734
+ "--high-security",
735
+ "--destination",
736
+ "https://api.unknown-vendor.example",
737
+ ]),
738
+ ).rejects.toThrow("__exit__");
739
+
740
+ expect(exitSpy.mock.calls[0]?.[0]).toBe(1);
741
+ const errText = errSpy.mock.calls.flat().join(" ");
742
+ expect(errText).toMatch(/unknown destination host/i);
743
+ expect(errText).toMatch(/--auth-style/);
744
+ // Client-side rejection — never reaches the network.
745
+ expect(vaultApiFetch).not.toHaveBeenCalled();
746
+ });
747
+
748
+ it("--high-security with no --destination errors clearly (non-zero exit, no request sent)", async () => {
749
+ const program = buildProgram();
750
+ await expect(
751
+ program.parseAsync([
752
+ "node",
753
+ "hq",
754
+ "secrets",
755
+ "set",
756
+ "MISSING_DEST_KEY",
757
+ "--from-stdin",
758
+ "--high-security",
759
+ ]),
760
+ ).rejects.toThrow("__exit__");
761
+
762
+ expect(exitSpy.mock.calls[0]?.[0]).toBe(1);
763
+ const errText = errSpy.mock.calls.flat().join(" ");
764
+ expect(errText).toMatch(/--high-security/);
765
+ expect(errText).toMatch(/--destination/);
766
+ expect(vaultApiFetch).not.toHaveBeenCalled();
767
+ });
768
+
769
+ it("--destination without --high-security errors clearly (non-zero exit, no request sent)", async () => {
770
+ const program = buildProgram();
771
+ await expect(
772
+ program.parseAsync([
773
+ "node",
774
+ "hq",
775
+ "secrets",
776
+ "set",
777
+ "STRAY_DEST_KEY",
778
+ "--from-stdin",
779
+ "--destination",
780
+ "https://api.openai.com",
781
+ ]),
782
+ ).rejects.toThrow("__exit__");
783
+
784
+ expect(exitSpy.mock.calls[0]?.[0]).toBe(1);
785
+ expect(vaultApiFetch).not.toHaveBeenCalled();
786
+ });
787
+
788
+ it("an invalid --destination URL (path segment) errors clearly, client-side", async () => {
789
+ const program = buildProgram();
790
+ await expect(
791
+ program.parseAsync([
792
+ "node",
793
+ "hq",
794
+ "secrets",
795
+ "set",
796
+ "BAD_URL_KEY",
797
+ "--from-stdin",
798
+ "--high-security",
799
+ "--destination",
800
+ "https://api.openai.com/v1",
801
+ ]),
802
+ ).rejects.toThrow("__exit__");
803
+
804
+ expect(exitSpy.mock.calls[0]?.[0]).toBe(1);
805
+ const errText = errSpy.mock.calls.flat().join(" ");
806
+ expect(errText).toMatch(/no path, query, or fragment/i);
807
+ expect(vaultApiFetch).not.toHaveBeenCalled();
808
+ });
809
+
810
+ it("surfaces the server's rejection message when the backend 400s (e.g. unknown host drift)", async () => {
811
+ stubPipedStdin("value");
812
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
813
+ jsonRes(
814
+ {
815
+ error:
816
+ "unknown destination host api.newer-vendor.com: provide an injection recipe (header + scheme)",
817
+ },
818
+ 400,
819
+ ),
820
+ );
821
+
822
+ const program = buildProgram();
823
+ // A host absent from the CLI's own known-set copy but WOULD be flagged
824
+ // client-side too (this asserts the server-error surfacing path itself
825
+ // stays intact, using --auth-style to bypass the client-side check and
826
+ // reach the network).
827
+ await expect(
828
+ program.parseAsync([
829
+ "node",
830
+ "hq",
831
+ "secrets",
832
+ "set",
833
+ "DRIFT_KEY",
834
+ "--from-stdin",
835
+ "--high-security",
836
+ "--destination",
837
+ "https://api.newer-vendor.com",
838
+ "--auth-style",
839
+ "bearer",
840
+ ]),
841
+ ).rejects.toThrow("__exit__");
842
+
843
+ expect(exitSpy.mock.calls[0]?.[0]).toBe(1);
844
+ const errText = errSpy.mock.calls.flat().join(" ");
845
+ expect(errText).toMatch(/unknown destination host/i);
846
+ });
847
+ });
848
+
531
849
  describe("secrets generate-link", () => {
532
850
  it("mints one-time submission links for personal secrets", async () => {
533
851
  const program = buildProgram();
@@ -279,6 +279,110 @@ function describeSecretAclPrincipal(principal: SecretAclPrincipal): string {
279
279
  : principal.granteeId;
280
280
  }
281
281
 
282
+ // secrets-proxy-per-secret-destination US-006: an InjectionRecipe as sent on
283
+ // the create/update POST body. Mirrors hq-pro's `InjectionRecipe`
284
+ // (src/vault-service/handlers/secrets.ts) — kept as a local shape (not
285
+ // imported) since the CLI does not depend on the hq-pro package.
286
+ export interface SecretInjectionRecipe {
287
+ header: string;
288
+ scheme: "raw" | "bearer";
289
+ extraHeaders?: Record<string, string>;
290
+ }
291
+
292
+ // Mirrors hq-pro's server-authoritative KNOWN_DESTINATION_REGISTRY
293
+ // (src/vault-service/handlers/destination-registry.ts) — a KNOWN host's
294
+ // --auth-style is optional because the server resolves the recipe itself.
295
+ // This client-side copy exists purely so an UNKNOWN host with no
296
+ // --auth-style can be rejected immediately with a clear, actionable message
297
+ // instead of a round trip; the server remains the authoritative validator
298
+ // (this list drifting stale merely means one extra CLI round trip, not a
299
+ // security gap — the server still 400s an unrecognized host with no recipe).
300
+ const KNOWN_DESTINATION_HOSTS = new Set([
301
+ "api.anthropic.com",
302
+ "api.openai.com",
303
+ "api.stripe.com",
304
+ ]);
305
+
306
+ // Parses `--auth-style` into the InjectionRecipe shape the server expects.
307
+ // Returns `null` (with a printed error) for an unrecognized value.
308
+ function parseAuthStyle(authStyle: string): SecretInjectionRecipe | null {
309
+ if (authStyle === "bearer") {
310
+ return { header: "authorization", scheme: "bearer" };
311
+ }
312
+ if (authStyle === "x-api-key") {
313
+ return { header: "x-api-key", scheme: "raw" };
314
+ }
315
+ const headerMatch = authStyle.match(/^header:(.+)$/);
316
+ if (headerMatch) {
317
+ const headerName = headerMatch[1].trim();
318
+ if (!headerName) {
319
+ console.error(
320
+ chalk.red(
321
+ `Invalid --auth-style 'header:': must name a header, e.g. header:X-Custom-Key`,
322
+ ),
323
+ );
324
+ return null;
325
+ }
326
+ return { header: headerName, scheme: "raw" };
327
+ }
328
+ console.error(
329
+ chalk.red(
330
+ `Invalid --auth-style '${authStyle}': must be one of bearer, x-api-key, or header:NAME`,
331
+ ),
332
+ );
333
+ return null;
334
+ }
335
+
336
+ // Validates `--destination` is a bare HTTPS scheme+host URL (no path, query,
337
+ // port). Mirrors hq-pro's `validateDestinations` server-side check
338
+ // (src/vault-service/handlers/secrets.ts) so a malformed URL is caught
339
+ // locally with an actionable message rather than a round trip — the server
340
+ // re-validates and remains authoritative.
341
+ function parseDestinationUrl(
342
+ raw: string,
343
+ ): { ok: true; url: string; hostname: string } | { ok: false } {
344
+ let parsed: URL;
345
+ try {
346
+ parsed = new URL(raw);
347
+ } catch {
348
+ console.error(
349
+ chalk.red(`Invalid --destination '${raw}': must be a valid URL`),
350
+ );
351
+ return { ok: false };
352
+ }
353
+ if (parsed.protocol !== "https:") {
354
+ console.error(
355
+ chalk.red(`Invalid --destination '${raw}': must use https://`),
356
+ );
357
+ return { ok: false };
358
+ }
359
+ if (!parsed.hostname) {
360
+ console.error(
361
+ chalk.red(`Invalid --destination '${raw}': missing hostname`),
362
+ );
363
+ return { ok: false };
364
+ }
365
+ if (
366
+ (parsed.pathname !== "" && parsed.pathname !== "/") ||
367
+ parsed.search !== "" ||
368
+ parsed.hash !== ""
369
+ ) {
370
+ console.error(
371
+ chalk.red(
372
+ `Invalid --destination '${raw}': must be a bare scheme+host URL with no path, query, or fragment (e.g. https://api.openai.com)`,
373
+ ),
374
+ );
375
+ return { ok: false };
376
+ }
377
+ if (parsed.port !== "") {
378
+ console.error(
379
+ chalk.red(`Invalid --destination '${raw}': must not specify a port`),
380
+ );
381
+ return { ok: false };
382
+ }
383
+ return { ok: true, url: `https://${parsed.hostname}`, hostname: parsed.hostname };
384
+ }
385
+
282
386
  function normalizeSecretTier(tier?: string): SecretTier {
283
387
  return tier === "sensitive" || tier === "nuclear" ? tier : "standard";
284
388
  }
@@ -572,13 +676,87 @@ export function registerSecretsCommand(program: Command): void {
572
676
  .command("set <name>")
573
677
  .description("Create or update a secret")
574
678
  .option("--from-stdin", "Read secret value from piped stdin")
575
- .action(async (name: string, opts: { fromStdin?: boolean }) => {
679
+ .option(
680
+ "--high-security",
681
+ "Mark the secret high-security: it can never be revealed or injected locally, only used through the HQ secret proxy (requires --destination)",
682
+ )
683
+ .option(
684
+ "--destination <https-url>",
685
+ "Approved scheme+host HTTPS URL the proxy may forward this secret to (e.g. https://api.openai.com); required with --high-security",
686
+ )
687
+ .option(
688
+ "--auth-style <style>",
689
+ "How the proxy attaches the key upstream: bearer | x-api-key | header:NAME. Optional for known destinations (auto-resolved server-side); required for unknown ones",
690
+ )
691
+ .action(async (
692
+ name: string,
693
+ opts: {
694
+ fromStdin?: boolean;
695
+ highSecurity?: boolean;
696
+ destination?: string;
697
+ authStyle?: string;
698
+ },
699
+ ) => {
576
700
  try {
577
701
  if (!SECRET_NAME_PATTERN.test(name)) {
578
702
  console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_API_KEY or DEV/MY_KEY)`));
579
703
  process.exit(1);
580
704
  }
581
705
 
706
+ // secrets-proxy-per-secret-destination US-006: --high-security marks
707
+ // the secret so it can only ever be used through the server-side
708
+ // proxy (never revealed/injected locally — that refusal is the
709
+ // pre-existing consumption-side behavior in `get`/`exec`/`env` above,
710
+ // unchanged by this story). It REQUIRES a --destination: the proxy
711
+ // (hq-pro US-002) fails closed with no destination configured, so
712
+ // catching the missing pin here is a clear, immediate CLI error
713
+ // rather than a deferred proxy-time failure.
714
+ let destinations: string[] | undefined;
715
+ let injection: SecretInjectionRecipe | undefined;
716
+ if (opts.highSecurity) {
717
+ if (!opts.destination) {
718
+ console.error(
719
+ chalk.red(
720
+ "Error: --high-security requires --destination <https-url> (e.g. --destination https://api.openai.com).",
721
+ ),
722
+ );
723
+ process.exit(1);
724
+ }
725
+
726
+ const destResult = parseDestinationUrl(opts.destination);
727
+ if (!destResult.ok) {
728
+ process.exit(1);
729
+ }
730
+ destinations = [destResult.url];
731
+
732
+ if (opts.authStyle) {
733
+ const recipe = parseAuthStyle(opts.authStyle);
734
+ if (!recipe) {
735
+ process.exit(1);
736
+ }
737
+ injection = recipe;
738
+ } else if (!KNOWN_DESTINATION_HOSTS.has(destResult.hostname)) {
739
+ // Unknown host + no explicit recipe: the server would reject this
740
+ // 400 anyway (US-004 registry lookup only, never guesses) — fail
741
+ // fast locally with an actionable message instead of a round trip.
742
+ console.error(
743
+ chalk.red(
744
+ `Error: unknown destination host '${destResult.hostname}' — provide --auth-style <bearer|x-api-key|header:NAME> (known hosts auto-resolve: ${[...KNOWN_DESTINATION_HOSTS].join(", ")}).`,
745
+ ),
746
+ );
747
+ process.exit(1);
748
+ }
749
+ // Known host + no --auth-style: leave `injection` undefined so the
750
+ // server (US-004) auto-resolves the recipe from its registry.
751
+ } else if (opts.destination || opts.authStyle) {
752
+ console.error(
753
+ chalk.red(
754
+ "Error: --destination/--auth-style require --high-security.",
755
+ ),
756
+ );
757
+ process.exit(1);
758
+ }
759
+
582
760
  let value: string;
583
761
  if (opts.fromStdin) {
584
762
  if (process.stdin.isTTY) {
@@ -617,19 +795,35 @@ export function registerSecretsCommand(program: Command): void {
617
795
  token,
618
796
  path: `/secrets/${encodeURIComponent(companyUid)}`,
619
797
  method: "POST",
620
- body: { name, value },
798
+ body: {
799
+ name,
800
+ value,
801
+ // Only present when --high-security was passed — an ordinary
802
+ // `set` with no flags sends exactly `{ name, value }`, byte-for-
803
+ // byte unchanged from before this story.
804
+ ...(opts.highSecurity ? { highSecurity: true } : {}),
805
+ ...(destinations ? { destinations } : {}),
806
+ ...(injection ? { injection } : {}),
807
+ },
621
808
  });
622
809
 
623
810
  if (!res.ok) {
624
- const body = await res.json().catch(() => ({}));
811
+ const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
625
812
  console.error(
626
- chalk.red(`Failed to set secret: ${(body as Record<string, string>).error ?? res.statusText}`),
813
+ chalk.red(`Failed to set secret: ${extractApiMessage(body, res.statusText)}`),
627
814
  );
628
815
  process.exit(1);
629
816
  }
630
817
 
631
818
  removeCacheEntry(companyUid, name);
632
819
  console.log(chalk.green(formatSecretSaved(name, scopeLabel)));
820
+ if (opts.highSecurity) {
821
+ console.log(
822
+ chalk.dim(
823
+ ` High-security: destination pinned to ${destinations?.[0]}. This value can never be revealed or injected locally — only used through the HQ secret proxy.`,
824
+ ),
825
+ );
826
+ }
633
827
  } catch (err) {
634
828
  console.error(
635
829
  chalk.red("Error:"),
@@ -0,0 +1,32 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("./cli-version.js", () => ({
4
+ CLI_VERSION: "5.60.0",
5
+ CLI_NAME: "@indigoai-us/hq-cli",
6
+ }));
7
+
8
+ vi.mock("./node-preflight.js", () => ({}));
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ vi.resetModules();
13
+ });
14
+
15
+ describe("bin bootstrap", () => {
16
+ it("answers --version without importing the command graph", async () => {
17
+ const originalArgv = process.argv;
18
+ const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
19
+ const runCli = vi.fn();
20
+ vi.doMock("./main.js", () => ({ runCli }));
21
+ process.argv = ["node", "hq", "--version"];
22
+
23
+ try {
24
+ await import("./index.js");
25
+ } finally {
26
+ process.argv = originalArgv;
27
+ }
28
+
29
+ expect(write).toHaveBeenCalledWith("5.60.0\n");
30
+ expect(runCli).not.toHaveBeenCalled();
31
+ });
32
+ });