@indigoai-us/hq-cli 5.47.9 → 5.47.11

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.
@@ -7,6 +7,10 @@ import {
7
7
  vi,
8
8
  type MockInstance,
9
9
  } from "vitest";
10
+ import { createHash } from "node:crypto";
11
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
10
14
 
11
15
  vi.mock("../utils/cognito-session.js", async (importOriginal) => {
12
16
  const original = (await importOriginal()) as Record<string, unknown>;
@@ -45,21 +49,31 @@ vi.mock("../utils/secrets-cache.js", async (importOriginal) => {
45
49
  };
46
50
  });
47
51
 
52
+ vi.mock("node:child_process", () => ({
53
+ spawn: vi.fn(() => ({
54
+ on: vi.fn(),
55
+ })),
56
+ }));
57
+
48
58
  import { Command } from "commander";
49
59
  import { registerSecretsCommand, loadRevealedSecrets } from "./secrets.js";
60
+ import { spawn } from "node:child_process";
50
61
  import { getEntityUid, vaultApiFetch } from "../utils/vault-api.js";
51
62
  import { readCache, writeCache } from "../utils/secrets-cache.js";
52
63
 
53
64
  let logSpy: MockInstance<typeof console.log>;
54
65
  let errSpy: MockInstance<typeof console.error>;
66
+ let tempDir: string;
55
67
 
56
68
  beforeEach(() => {
57
69
  vi.clearAllMocks();
70
+ tempDir = mkdtempSync(join(tmpdir(), "hq-secrets-test-"));
58
71
  logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
59
72
  errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
60
73
  });
61
74
 
62
75
  afterEach(() => {
76
+ rmSync(tempDir, { recursive: true, force: true });
63
77
  vi.restoreAllMocks();
64
78
  });
65
79
 
@@ -74,6 +88,13 @@ function buildProgram(): Command {
74
88
  return program;
75
89
  }
76
90
 
91
+ function jsonRes(body: unknown, status = 200): Response {
92
+ return new Response(JSON.stringify(body), {
93
+ status,
94
+ headers: { "Content-Type": "application/json" },
95
+ });
96
+ }
97
+
77
98
  // HQ-4H: `hq secrets exists` — HEAD existence probe with shell-chaining exit
78
99
  // codes (0=present, 1=absent, 2=error). process.exit is spied so the command's
79
100
  // terminal exit doesn't kill the runner; we assert the code it requested.
@@ -201,8 +222,8 @@ describe("secrets exec/env batch-load (HQ-4H)", () => {
201
222
  vi.mocked(vaultApiFetch).mockResolvedValueOnce(
202
223
  jsonRes({
203
224
  secrets: [
204
- { name: "MY_KEY", value: "v1" },
205
- { name: "OTHER", value: "v2" },
225
+ { name: "MY_KEY", value: "v1", cacheTtlMs: 300000 },
226
+ { name: "OTHER", value: "v2", cacheTtlMs: 300000 },
206
227
  ],
207
228
  errors: [],
208
229
  }),
@@ -224,8 +245,26 @@ describe("secrets exec/env batch-load (HQ-4H)", () => {
224
245
  });
225
246
  // The crux: the capturing GET route is never touched.
226
247
  expect(vaultApiFetch).not.toHaveBeenCalledWith(CAPTURING_GET);
227
- expect(writeCache).toHaveBeenCalledWith("prs_alice", "MY_KEY", "v1");
228
- expect(writeCache).toHaveBeenCalledWith("prs_alice", "OTHER", "v2");
248
+ expect(writeCache).toHaveBeenCalledWith("prs_alice", "MY_KEY", "v1", 300000);
249
+ expect(writeCache).toHaveBeenCalledWith("prs_alice", "OTHER", "v2", 300000);
250
+ });
251
+
252
+ it("controlled secrets with cacheTtlMs=0 skip the disk cache", async () => {
253
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
254
+ jsonRes({
255
+ secrets: [
256
+ { name: "LOCKED", value: "v1", cacheTtlMs: 0 },
257
+ ],
258
+ errors: [],
259
+ }),
260
+ );
261
+
262
+ const out = await loadRevealedSecrets("test-token", "prs_alice", [
263
+ "LOCKED",
264
+ ]);
265
+
266
+ expect(out.get("LOCKED")).toBe("v1");
267
+ expect(writeCache).not.toHaveBeenCalled();
229
268
  });
230
269
 
231
270
  it("a missing key fails cleanly WITHOUT hitting the GET-404 capture path", async () => {
@@ -286,3 +325,354 @@ describe("secrets exec/env batch-load (HQ-4H)", () => {
286
325
  ).rejects.toThrow("Failed to batch-load secrets: Internal server error");
287
326
  });
288
327
  });
328
+
329
+ describe("secrets script usage", () => {
330
+ it("exec hashes the script and sends usage metadata on /load", async () => {
331
+ const scriptPath = join(tempDir, "exec-script.sh");
332
+ const scriptBody = "#!/usr/bin/env bash\necho exec\n";
333
+ writeFileSync(scriptPath, scriptBody);
334
+ const expectedSha = createHash("sha256").update(scriptBody).digest("hex");
335
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
336
+ jsonRes({
337
+ secrets: [{ name: "MY_KEY", value: "v1", cacheTtlMs: 300000 }],
338
+ errors: [],
339
+ }),
340
+ );
341
+
342
+ const program = buildProgram();
343
+ await program.parseAsync([
344
+ "node",
345
+ "hq",
346
+ "secrets",
347
+ "exec",
348
+ "--only",
349
+ "MY_KEY",
350
+ "--script",
351
+ scriptPath,
352
+ "--",
353
+ "env",
354
+ ]);
355
+
356
+ expect(vaultApiFetch).toHaveBeenCalledWith({
357
+ token: "test-token",
358
+ path: "/secrets/prs_alice/load",
359
+ method: "POST",
360
+ body: {
361
+ names: ["MY_KEY"],
362
+ usage: {
363
+ channel: "exec",
364
+ script: {
365
+ scriptId: scriptPath,
366
+ path: scriptPath,
367
+ sha256: expectedSha,
368
+ attestationLevel: "self-asserted-hash",
369
+ },
370
+ },
371
+ },
372
+ });
373
+ expect(spawn).toHaveBeenCalledWith(
374
+ "env",
375
+ [],
376
+ expect.objectContaining({
377
+ stdio: "inherit",
378
+ env: expect.objectContaining({ MY_KEY: "v1" }),
379
+ }),
380
+ );
381
+ });
382
+
383
+ it("env hashes the script and sends usage metadata on /load", async () => {
384
+ const scriptPath = join(tempDir, "env-script.sh");
385
+ const scriptBody = "#!/usr/bin/env bash\necho env\n";
386
+ writeFileSync(scriptPath, scriptBody);
387
+ const expectedSha = createHash("sha256").update(scriptBody).digest("hex");
388
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
389
+ jsonRes({
390
+ secrets: [{ name: "MY_KEY", value: "v1", cacheTtlMs: 300000 }],
391
+ errors: [],
392
+ }),
393
+ );
394
+
395
+ const stdoutWriteSpy = vi
396
+ .spyOn(process.stdout, "write")
397
+ .mockImplementation(() => true);
398
+
399
+ const program = buildProgram();
400
+ await program.parseAsync([
401
+ "node",
402
+ "hq",
403
+ "secrets",
404
+ "env",
405
+ "--only",
406
+ "MY_KEY",
407
+ "--script",
408
+ scriptPath,
409
+ ]);
410
+
411
+ expect(vaultApiFetch).toHaveBeenCalledWith({
412
+ token: "test-token",
413
+ path: "/secrets/prs_alice/load",
414
+ method: "POST",
415
+ body: {
416
+ names: ["MY_KEY"],
417
+ usage: {
418
+ channel: "env",
419
+ script: {
420
+ scriptId: scriptPath,
421
+ path: scriptPath,
422
+ sha256: expectedSha,
423
+ attestationLevel: "self-asserted-hash",
424
+ },
425
+ },
426
+ },
427
+ });
428
+ expect(stdoutWriteSpy).toHaveBeenCalledWith("export MY_KEY='v1'\n");
429
+ });
430
+ });
431
+
432
+ describe("secrets reveal and policy controls", () => {
433
+ it("get --reveal prints the backend denial message from a controlled secret", async () => {
434
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
435
+ throw new Error("__exit__");
436
+ }) as never);
437
+ vi.mocked(vaultApiFetch)
438
+ .mockResolvedValueOnce(
439
+ jsonRes({
440
+ secret: {
441
+ name: "LOCKED",
442
+ companyUid: "prs_alice",
443
+ tier: "nuclear",
444
+ scriptLock: { mode: "enforced" },
445
+ },
446
+ }),
447
+ )
448
+ .mockResolvedValueOnce(
449
+ jsonRes(
450
+ {
451
+ code: "reveal_disabled",
452
+ message: "Reveal is disabled for this secret.",
453
+ },
454
+ 403,
455
+ ),
456
+ );
457
+
458
+ const program = buildProgram();
459
+ await expect(
460
+ program.parseAsync([
461
+ "node",
462
+ "hq",
463
+ "secrets",
464
+ "get",
465
+ "LOCKED",
466
+ "--reveal",
467
+ ]),
468
+ ).rejects.toThrow("__exit__");
469
+
470
+ expect(exitSpy.mock.calls[0]?.[0]).toBe(1);
471
+ expect(logSpy).toHaveBeenCalledWith(" Tier: nuclear");
472
+ expect(logSpy).toHaveBeenCalledWith(" Script Lock: enforced");
473
+ expect(errSpy.mock.calls.some((call) =>
474
+ call.some((arg) => String(arg).includes("Reveal is disabled for this secret.")),
475
+ )).toBe(true);
476
+ });
477
+
478
+ it("policy get hits the policy endpoint and renders tier/lock", async () => {
479
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
480
+ jsonRes({
481
+ policy: {
482
+ path: "LOCKED",
483
+ tier: "nuclear",
484
+ scriptLock: { mode: "enforced" },
485
+ },
486
+ }),
487
+ );
488
+
489
+ const program = buildProgram();
490
+ await program.parseAsync([
491
+ "node",
492
+ "hq",
493
+ "secrets",
494
+ "policy",
495
+ "get",
496
+ "LOCKED",
497
+ ]);
498
+
499
+ expect(vaultApiFetch).toHaveBeenCalledWith({
500
+ token: "test-token",
501
+ path: "/secrets/prs_alice/policy",
502
+ query: { path: "LOCKED" },
503
+ });
504
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Policy: LOCKED"));
505
+ expect(logSpy).toHaveBeenCalledWith(" Tier: nuclear");
506
+ expect(logSpy).toHaveBeenCalledWith(" Script Lock: enforced");
507
+ });
508
+
509
+ it("list renders tier and script lock columns", async () => {
510
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
511
+ jsonRes({
512
+ secrets: [
513
+ {
514
+ name: "LOCKED",
515
+ permission: "read",
516
+ tier: "nuclear",
517
+ scriptLock: { mode: "enforced" },
518
+ lastModifiedDate: "2026-06-18T00:00:00.000Z",
519
+ },
520
+ ],
521
+ }),
522
+ );
523
+
524
+ const program = buildProgram();
525
+ await program.parseAsync(["node", "hq", "secrets", "list"]);
526
+
527
+ expect(logSpy).toHaveBeenCalledWith(
528
+ expect.stringContaining("SCRIPT LOCK"),
529
+ );
530
+ expect(logSpy).toHaveBeenCalledWith(
531
+ expect.stringContaining("nuclear"),
532
+ );
533
+ expect(logSpy).toHaveBeenCalledWith(
534
+ expect.stringContaining("enforced"),
535
+ );
536
+ });
537
+
538
+ it("policy set hits the policy endpoint with tier and script lock", async () => {
539
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
540
+ jsonRes({
541
+ policy: {
542
+ path: "LOCKED",
543
+ tier: "nuclear",
544
+ scriptLock: { mode: "enforced" },
545
+ },
546
+ }),
547
+ );
548
+
549
+ const program = buildProgram();
550
+ await program.parseAsync([
551
+ "node",
552
+ "hq",
553
+ "secrets",
554
+ "policy",
555
+ "set",
556
+ "LOCKED",
557
+ "--tier",
558
+ "nuclear",
559
+ "--lock-script",
560
+ "enforce",
561
+ ]);
562
+
563
+ expect(vaultApiFetch).toHaveBeenCalledWith({
564
+ token: "test-token",
565
+ path: "/secrets/prs_alice/policy",
566
+ method: "PUT",
567
+ body: {
568
+ path: "LOCKED",
569
+ tier: "nuclear",
570
+ scriptLock: { mode: "enforced" },
571
+ },
572
+ });
573
+ expect(logSpy).toHaveBeenCalledWith(" Tier: nuclear");
574
+ expect(logSpy).toHaveBeenCalledWith(" Script Lock: enforced");
575
+ });
576
+
577
+ it("script approve hashes the local file and hits the approval endpoint", async () => {
578
+ const scriptPath = join(tempDir, "approved.sh");
579
+ const scriptBody = "#!/usr/bin/env bash\necho approved\n";
580
+ writeFileSync(scriptPath, scriptBody);
581
+ const expectedSha = createHash("sha256").update(scriptBody).digest("hex");
582
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
583
+
584
+ const program = buildProgram();
585
+ await program.parseAsync([
586
+ "node",
587
+ "hq",
588
+ "secrets",
589
+ "script",
590
+ "approve",
591
+ "LOCKED",
592
+ "--id",
593
+ "deploy-script",
594
+ "--script",
595
+ scriptPath,
596
+ ]);
597
+
598
+ expect(vaultApiFetch).toHaveBeenCalledWith({
599
+ token: "test-token",
600
+ path: "/secrets/prs_alice/policy/scripts",
601
+ method: "POST",
602
+ body: {
603
+ path: "LOCKED",
604
+ scriptId: "deploy-script",
605
+ scriptPath,
606
+ sha256: expectedSha,
607
+ attestationLevel: "self-asserted-hash",
608
+ },
609
+ });
610
+ });
611
+
612
+ it("script revoke hits the revoke endpoint", async () => {
613
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
614
+
615
+ const program = buildProgram();
616
+ await program.parseAsync([
617
+ "node",
618
+ "hq",
619
+ "secrets",
620
+ "script",
621
+ "revoke",
622
+ "LOCKED",
623
+ "--id",
624
+ "deploy-script",
625
+ ]);
626
+
627
+ expect(vaultApiFetch).toHaveBeenCalledWith({
628
+ token: "test-token",
629
+ path: "/secrets/prs_alice/policy/scripts",
630
+ method: "DELETE",
631
+ body: {
632
+ path: "LOCKED",
633
+ scriptId: "deploy-script",
634
+ },
635
+ });
636
+ });
637
+
638
+ it("script list renders the current policy and approved scripts", async () => {
639
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
640
+ jsonRes({
641
+ policy: {
642
+ path: "LOCKED",
643
+ tier: "nuclear",
644
+ scriptLock: { mode: "enforced" },
645
+ scripts: [
646
+ {
647
+ scriptId: "deploy-script",
648
+ scriptPath: "/tmp/deploy.sh",
649
+ sha256: "abc123",
650
+ attestationLevel: "self-asserted-hash",
651
+ },
652
+ ],
653
+ },
654
+ }),
655
+ );
656
+
657
+ const program = buildProgram();
658
+ await program.parseAsync([
659
+ "node",
660
+ "hq",
661
+ "secrets",
662
+ "script",
663
+ "list",
664
+ "LOCKED",
665
+ ]);
666
+
667
+ expect(vaultApiFetch).toHaveBeenCalledWith({
668
+ token: "test-token",
669
+ path: "/secrets/prs_alice/policy",
670
+ query: { path: "LOCKED" },
671
+ });
672
+ expect(logSpy).toHaveBeenCalledWith(" Tier: nuclear");
673
+ expect(logSpy).toHaveBeenCalledWith(" Script Lock: enforced");
674
+ expect(logSpy).toHaveBeenCalledWith(
675
+ expect.stringContaining("deploy-script"),
676
+ );
677
+ });
678
+ });