@indigoai-us/hq-cli 5.18.2 → 5.19.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.
@@ -11,6 +11,7 @@
11
11
  import { describe, expect, it, vi } from "vitest";
12
12
 
13
13
  import {
14
+ assertNoPersonalPositionalPaths,
14
15
  assertSingleSelector,
15
16
  resolveCanonicalPersonUid,
16
17
  type PullAllVaultClient,
@@ -76,6 +77,58 @@ describe("assertSingleSelector", () => {
76
77
  });
77
78
  });
78
79
 
80
+ // ── assertNoPersonalPositionalPaths (hq-cli#25) ─────────────────────────────
81
+ //
82
+ // The combination `--personal <path>` silently bypasses
83
+ // PERSONAL_VAULT_EXCLUDED_TOP_LEVEL (which is only applied by
84
+ // computePersonalVaultPaths). Real incident: 196 companies/{slug}/** objects
85
+ // uploaded to a personal vault. Refusal forces the operator to drop one of
86
+ // the two — either `--personal` (full vault scope) or the positional paths
87
+ // (specific subset against the active company).
88
+
89
+ describe("assertNoPersonalPositionalPaths", () => {
90
+ it("accepts --personal alone (no positional paths)", () => {
91
+ expect(() => assertNoPersonalPositionalPaths({ personal: true }, undefined)).not.toThrow();
92
+ expect(() => assertNoPersonalPositionalPaths({ personal: true }, [])).not.toThrow();
93
+ });
94
+
95
+ it("accepts positional paths without --personal", () => {
96
+ expect(() => assertNoPersonalPositionalPaths({ personal: false }, ["./scratch"])).not.toThrow();
97
+ expect(() => assertNoPersonalPositionalPaths({}, ["./scratch"])).not.toThrow();
98
+ });
99
+
100
+ it("accepts neither --personal nor positional paths", () => {
101
+ expect(() => assertNoPersonalPositionalPaths({}, undefined)).not.toThrow();
102
+ expect(() => assertNoPersonalPositionalPaths({}, [])).not.toThrow();
103
+ });
104
+
105
+ it("throws when --personal combines with one positional path", () => {
106
+ expect(() => assertNoPersonalPositionalPaths({ personal: true }, ["./scratch"])).toThrow(
107
+ /--personal.*cannot be combined with explicit \[paths\]/,
108
+ );
109
+ });
110
+
111
+ it("throws when --personal combines with multiple positional paths", () => {
112
+ expect(() =>
113
+ assertNoPersonalPositionalPaths({ personal: true }, ["./a", "./b", "./c"]),
114
+ ).toThrow(/PERSONAL_VAULT_EXCLUDED_TOP_LEVEL/);
115
+ });
116
+
117
+ it("error message names the dangerous prefixes so the operator understands the risk", () => {
118
+ let caught: Error | null = null;
119
+ try {
120
+ assertNoPersonalPositionalPaths({ personal: true }, ["/Users/corey/Documents/HQ"]);
121
+ } catch (e) {
122
+ caught = e as Error;
123
+ }
124
+ expect(caught).not.toBeNull();
125
+ expect(caught!.message).toContain(".git/");
126
+ expect(caught!.message).toContain("companies/");
127
+ expect(caught!.message).toContain("repos/");
128
+ expect(caught!.message).toContain("workspace/");
129
+ });
130
+ });
131
+
79
132
  // ── resolveCanonicalPersonUid ──────────────────────────────────────────────
80
133
 
81
134
  function makeClient(persons: Array<{
@@ -451,6 +451,35 @@ export async function resolveCanonicalPersonUid(
451
451
  return pick.uid;
452
452
  }
453
453
 
454
+ /**
455
+ * Refuse `hq sync push --personal <path>` — the combination silently
456
+ * bypasses `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL` (which is only applied by
457
+ * `computePersonalVaultPaths`), risking cross-scope upload of `companies/`,
458
+ * `repos/`, `workspace/`, or `.git/` content to the personal vault. Real
459
+ * incident (2026-05-21): a single command uploaded 196 `companies/{slug}/**`
460
+ * objects to a personal vault before being killed. Cleanup required a
461
+ * hand-rolled S3 sweep. Closes hq-cli#25.
462
+ *
463
+ * Refusal — not silent filtering — is intentional: explicit is better than
464
+ * implicit guesswork, and the legitimate "I want to push a subset of my
465
+ * personal vault" use case has a clean workaround (drop `--personal`, the
466
+ * subset upload targets the active company via standard semantics).
467
+ */
468
+ export function assertNoPersonalPositionalPaths(opts: {
469
+ personal?: boolean;
470
+ }, paths: string[] | undefined): void {
471
+ if (opts.personal && paths && paths.length > 0) {
472
+ throw new Error(
473
+ "`--personal` cannot be combined with explicit [paths]: " +
474
+ "positional paths bypass the PERSONAL_VAULT_EXCLUDED_TOP_LEVEL " +
475
+ "guard (skips .git/, companies/, repos/, workspace/), risking " +
476
+ "cross-scope upload of company data to the personal vault. " +
477
+ "Use bare `--personal` to push the whole personal scope, OR " +
478
+ "drop `--personal` to push specific paths to the active company.",
479
+ );
480
+ }
481
+ }
482
+
454
483
  /**
455
484
  * Refuse ambiguous selector combinations. `--all`, `--personal`, and
456
485
  * `--company` are mutually exclusive — at most one may be set per
@@ -675,6 +704,8 @@ export function registerCloudCommands(program: Command): void {
675
704
  "to have already resolved entity + credentials. Pick one.",
676
705
  );
677
706
  }
707
+ // Closes hq-cli#25 — see `assertNoPersonalPositionalPaths` doc-block.
708
+ assertNoPersonalPositionalPaths(options, paths);
678
709
 
679
710
  log(chalk.bold("\nHQ Sync — Push"));
680
711
  log(` HQ root: ${options.hqRoot}`);
@@ -387,6 +387,151 @@ describe("runBrowse", () => {
387
387
  }),
388
388
  ).rejects.toThrow(/No company found for slug 'notmine'/);
389
389
  });
390
+
391
+ // ── personalMode (hq-cli#26) ──────────────────────────────────────────────
392
+ //
393
+ // Personal mode resolves the entity via `entity.get(personalUid)` (skipping
394
+ // the company-namespace lookup), omits the explicit-grants fetch, and tags
395
+ // every row with `aclSource: "personal-vault"`. The path arg is bucket-
396
+ // relative — companies/<slug>/ prefix is NOT required (and would be
397
+ // incorrect, since the person bucket is owner-only with no companies/
398
+ // subtree).
399
+
400
+ it("personalMode: resolves entity via entity.get(personalUid), skips namespace lookup", async () => {
401
+ const { client, spies } = makeStubVaultClient({
402
+ entity: {
403
+ uid: "prs_test",
404
+ slug: "personal",
405
+ bucketName: "hq-vault-prs-test",
406
+ },
407
+ });
408
+ const { factory } = makeStubS3Factory({
409
+ listResponses: [
410
+ {
411
+ Contents: [{ Key: ".claude/CLAUDE.md", Size: 100, LastModified: new Date() }],
412
+ },
413
+ ],
414
+ });
415
+
416
+ const result = await runBrowse({
417
+ pathPrefix: ".claude/",
418
+ personalMode: true,
419
+ personalUid: "prs_test",
420
+ vaultClient: client,
421
+ s3Factory: factory,
422
+ region: "us-east-1",
423
+ });
424
+
425
+ expect(result.rows).toHaveLength(1);
426
+ expect(result.rows[0].key).toBe(".claude/CLAUDE.md");
427
+ // findInMyNamespace is the company-mode lookup — must not be touched.
428
+ expect(spies.findInMyNamespace).not.toHaveBeenCalled();
429
+ // Grants graph is a company concept — must not be fetched.
430
+ expect(spies.listMyExplicitGrants).not.toHaveBeenCalled();
431
+ // Vend still issued for browse purpose, no policy difference.
432
+ expect(spies.vend).toHaveBeenCalledWith(
433
+ expect.objectContaining({ purpose: "browse", operations: "read-only" }),
434
+ );
435
+ });
436
+
437
+ it("personalMode: empty pathPrefix lists the bucket root", async () => {
438
+ const { client } = makeStubVaultClient({
439
+ entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
440
+ });
441
+ const { factory, sendSpy } = makeStubS3Factory({
442
+ listResponses: [
443
+ {
444
+ Contents: [
445
+ { Key: ".claude/CLAUDE.md", Size: 100, LastModified: new Date() },
446
+ { Key: "core/policies/foo.md", Size: 50, LastModified: new Date() },
447
+ ],
448
+ },
449
+ ],
450
+ });
451
+
452
+ const result = await runBrowse({
453
+ pathPrefix: "",
454
+ personalMode: true,
455
+ personalUid: "prs_test",
456
+ vaultClient: client,
457
+ s3Factory: factory,
458
+ region: "us-east-1",
459
+ });
460
+
461
+ expect(result.rows).toHaveLength(2);
462
+ // ListObjectsV2 issued with an empty Prefix means "list everything".
463
+ const listCmd = sendSpy.mock.calls[0][0] as ListObjectsV2Command;
464
+ expect(listCmd.input.Prefix).toBe("");
465
+ });
466
+
467
+ it("personalMode: every row is tagged aclSource='personal-vault'", async () => {
468
+ const { client } = makeStubVaultClient({
469
+ entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
470
+ // Note: even if grants WERE present, personalMode would skip the
471
+ // lookup entirely — no chance of accidentally tagging a personal
472
+ // key as `shared-with-you`.
473
+ grants: [fakeGrant("companies/indigo/")],
474
+ });
475
+ const { factory } = makeStubS3Factory({
476
+ listResponses: [
477
+ {
478
+ Contents: [
479
+ { Key: ".claude/CLAUDE.md", Size: 1, LastModified: new Date() },
480
+ { Key: "core/policies/_digest.md", Size: 2, LastModified: new Date() },
481
+ { Key: "personal/notes.md", Size: 3, LastModified: new Date() },
482
+ ],
483
+ },
484
+ ],
485
+ });
486
+
487
+ const result = await runBrowse({
488
+ pathPrefix: "",
489
+ personalMode: true,
490
+ personalUid: "prs_test",
491
+ vaultClient: client,
492
+ s3Factory: factory,
493
+ region: "us-east-1",
494
+ });
495
+
496
+ for (const row of result.rows) {
497
+ expect(row.aclSource).toBe("personal-vault");
498
+ }
499
+ });
500
+
501
+ it("personalMode: throws when personalUid is missing", async () => {
502
+ const { client } = makeStubVaultClient({});
503
+ const { factory } = makeStubS3Factory({});
504
+
505
+ await expect(
506
+ runBrowse({
507
+ pathPrefix: "",
508
+ personalMode: true,
509
+ // personalUid intentionally omitted
510
+ vaultClient: client,
511
+ s3Factory: factory,
512
+ region: "us-east-1",
513
+ }),
514
+ ).rejects.toThrow(/personalMode requires personalUid/);
515
+ });
516
+
517
+ it("personalMode: throws when entity has no provisioned bucket", async () => {
518
+ // Force entity.get to return a bucket-less entity.
519
+ const { client } = makeStubVaultClient({
520
+ entity: { uid: "prs_test", slug: "personal" },
521
+ });
522
+ const { factory } = makeStubS3Factory({});
523
+
524
+ await expect(
525
+ runBrowse({
526
+ pathPrefix: "",
527
+ personalMode: true,
528
+ personalUid: "prs_test",
529
+ vaultClient: client,
530
+ s3Factory: factory,
531
+ region: "us-east-1",
532
+ }),
533
+ ).rejects.toThrow(/no provisioned bucket/);
534
+ });
390
535
  });
391
536
 
392
537
  // ── runCat ──────────────────────────────────────────────────────────────────
@@ -472,4 +617,71 @@ describe("runCat", () => {
472
617
  }),
473
618
  ).rejects.toThrow(/Expected a path starting with 'companies\//);
474
619
  });
620
+
621
+ // ── personalMode (hq-cli#26) ──────────────────────────────────────────────
622
+
623
+ it("personalMode: streams from the person bucket, no slug parse on the key", async () => {
624
+ const { client, spies } = makeStubVaultClient({
625
+ entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
626
+ });
627
+ const body = new Readable({
628
+ read() {
629
+ this.push("hello personal");
630
+ this.push(null);
631
+ },
632
+ });
633
+ const { factory, sendSpy } = makeStubS3Factory({
634
+ getResponse: { Body: body as unknown as GetObjectCommandOutput["Body"] },
635
+ });
636
+
637
+ // The key has no `companies/<slug>/` prefix — would normally fail
638
+ // parseCompanySlugFromPath. Under personalMode, that parse is skipped.
639
+ const sink = new Writable({
640
+ write(_chunk, _enc, cb) {
641
+ cb();
642
+ },
643
+ });
644
+ const result = await runCat({
645
+ key: ".claude/CLAUDE.md",
646
+ personalMode: true,
647
+ personalUid: "prs_test",
648
+ vaultClient: client,
649
+ s3Factory: factory,
650
+ region: "us-east-1",
651
+ hqRoot: tmpRoot,
652
+ stdout: sink,
653
+ });
654
+
655
+ expect(result.destination.kind).toBe("stdout");
656
+ expect(spies.findInMyNamespace).not.toHaveBeenCalled();
657
+ // Vend issued against the bucket-relative key, browse purpose.
658
+ expect(spies.vend).toHaveBeenCalledWith(
659
+ expect.objectContaining({
660
+ paths: [".claude/CLAUDE.md"],
661
+ purpose: "browse",
662
+ }),
663
+ );
664
+ // GetObject targeted the person bucket.
665
+ const getCmd = sendSpy.mock.calls.find(
666
+ (c) => c[0] instanceof GetObjectCommand,
667
+ )?.[0] as GetObjectCommand;
668
+ expect(getCmd.input.Bucket).toBe("hq-vault-prs-test");
669
+ expect(getCmd.input.Key).toBe(".claude/CLAUDE.md");
670
+ });
671
+
672
+ it("personalMode: throws when personalUid is missing", async () => {
673
+ const { client } = makeStubVaultClient({});
674
+ const { factory } = makeStubS3Factory({});
675
+ await expect(
676
+ runCat({
677
+ key: ".claude/CLAUDE.md",
678
+ personalMode: true,
679
+ // personalUid omitted
680
+ vaultClient: client,
681
+ s3Factory: factory,
682
+ region: "us-east-1",
683
+ hqRoot: tmpRoot,
684
+ }),
685
+ ).rejects.toThrow(/personalMode requires personalUid/);
686
+ });
475
687
  });