@indigoai-us/hq-cli 5.108.24 → 5.108.25

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/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.25] — 2026-09-08
6
+
5
7
  ## [5.108.24] — 2026-09-08
6
8
 
7
9
  ### Added
@@ -89,6 +89,17 @@ export declare function stripRedundantCompanyScope(prefix: string): {
89
89
  prefix: string;
90
90
  strippedSlug: string;
91
91
  } | null;
92
+ /**
93
+ * The company an anchored path names, or `undefined` for a relative/personal
94
+ * path.
95
+ *
96
+ * Discarding this slug is what makes retargeting possible: strip the anchor,
97
+ * then resolve the company from somewhere else, and the operation lands on a
98
+ * DIFFERENT company than the operator pasted. Callers pair this with
99
+ * `normalizeCompanyFilePrefix` so the anchor either MATCHES the explicit
100
+ * `--company` (or is refused), or BECOMES the company when none was given.
101
+ */
102
+ export declare function companySlugFromAnchor(prefix: string, personal?: boolean): string | undefined;
92
103
  export declare function runFilesDelete(params: RunFilesDeleteParams, deps?: {
93
104
  confirm?: ConfirmFn;
94
105
  }): Promise<void>;
@@ -5,6 +5,8 @@ import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
5
5
  import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
6
6
  import { vaultApiFetch, getCompanyUid } from "./secrets.js";
7
7
  import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
8
+ import { looksLikeCompanyUid } from "../utils/vault-api.js";
9
+ import { AuthError } from "../utils/auth-error.js";
8
10
  // ---------------------------------------------------------------------------
9
11
  // Pure helpers (exported for unit tests)
10
12
  // ---------------------------------------------------------------------------
@@ -157,8 +159,8 @@ export function registerFilesCommand(program) {
157
159
  process.exit(1);
158
160
  }
159
161
  // Fork: --with present → existing direct-grant path. No --with →
160
- // browser-launch share-session path. Per PRD, the direct-grant
161
- // path is unchanged from US-001 and operates on a single prefix.
162
+ // browser-launch share-session path. The direct-grant path
163
+ // operates on a single prefix.
162
164
  if (opts.with !== undefined) {
163
165
  if (paths.length !== 1) {
164
166
  console.error(chalk.red("Direct grant (--with) takes exactly one prefix. Pass multiple paths only when minting a share-session URL."));
@@ -190,7 +192,15 @@ export function registerFilesCommand(program) {
190
192
  .requiredOption("--with <principal>", "Email address, group id, or '@all' to remove the company-wide grant")
191
193
  .action(async (prefix, opts) => {
192
194
  try {
193
- const canonicalPrefix = normalizeFilePrefix(prefix);
195
+ // Unshare MUTATES access with no preview, and it resolves the target
196
+ // company from --company (not from the pasted anchor), so a mismatched
197
+ // anchor would revoke a real grant in the wrong company.
198
+ const canonicalPrefix = normalizeCompanyFilePrefix(prefix, {
199
+ mutating: true,
200
+ ...(files.opts().company !== undefined
201
+ ? { companySlug: files.opts().company }
202
+ : {}),
203
+ });
194
204
  const principal = opts.with;
195
205
  const isAll = principal === "@all";
196
206
  const isEmail = !isAll && EMAIL_PATTERN.test(principal);
@@ -264,10 +274,21 @@ export function registerFilesCommand(program) {
264
274
  .description("Show the ACL (access control list) for a file prefix")
265
275
  .action(async (prefix) => {
266
276
  try {
267
- const canonicalPrefix = normalizeFilePrefix(prefix);
277
+ // Read-only, so stripping is safe — but a mismatched anchor would show
278
+ // a DIFFERENT company's ACL than the one the operator pasted.
279
+ const explicitCompany = files.opts().company;
280
+ const canonicalPrefix = normalizeCompanyFilePrefix(prefix, {
281
+ ...(explicitCompany !== undefined
282
+ ? { companySlug: explicitCompany }
283
+ : {}),
284
+ });
268
285
  const token = (await resolveVaultCredential()).token;
269
- const companySlug = files.opts().company;
270
- const companyUid = await getCompanyUid(token, companySlug);
286
+ // Resolve through the anchor so the answer describes the company the
287
+ // operator actually pasted, and never a different one.
288
+ const companyUid = await resolveCompanyUidForAnchoredPath(token, {
289
+ explicit: explicitCompany,
290
+ anchorSlug: companySlugFromAnchor(prefix),
291
+ });
271
292
  // `/acl/tree` carries the prefix's own row metadata (directRow) and the
272
293
  // caller's effectivePermission alongside direct/inherited/children, so
273
294
  // a single request returns everything the "files acl" view needs.
@@ -474,7 +495,13 @@ export function registerFilesCommand(program) {
474
495
  return files;
475
496
  }
476
497
  async function runDirectGrant(params) {
477
- const canonicalPrefix = normalizeFilePrefix(params.prefix);
498
+ const canonicalPrefix = normalizeCompanyFilePrefix(params.prefix, {
499
+ mutating: true,
500
+ rootAdvice: "Use --full --with <principal> to share the entire vault, or pass a bounded path.",
501
+ ...(params.companySlug !== undefined
502
+ ? { companySlug: params.companySlug }
503
+ : {}),
504
+ });
478
505
  if (!params.permission) {
479
506
  console.error(chalk.red("--permission is required when --with is set (read | write)"));
480
507
  process.exit(1);
@@ -565,9 +592,15 @@ async function runDirectGrant(params) {
565
592
  }
566
593
  }
567
594
  async function runShareSession(params) {
568
- // Normalize every path through the shared prefix helper so a trailing
569
- // `/` becomes `/*` consistently with the direct-grant path.
570
- const normalizedPaths = params.paths.map(normalizeFilePrefix);
595
+ // Validate every path before minting a session; an anchored path must never
596
+ // silently become a share of the corresponding company-relative objects.
597
+ const normalizedPaths = params.paths.map((prefix) => normalizeCompanyFilePrefix(prefix, {
598
+ mutating: true,
599
+ rootAdvice: "Use --full --with <principal> to share the entire vault, or pass a bounded path.",
600
+ ...(params.companySlug !== undefined
601
+ ? { companySlug: params.companySlug }
602
+ : {}),
603
+ }));
571
604
  let expiresInMs;
572
605
  if (params.expires !== undefined) {
573
606
  const parsed = parseDuration(params.expires);
@@ -723,36 +756,179 @@ export function stripRedundantCompanyScope(prefix) {
723
756
  return null;
724
757
  return { prefix: m[2] ?? "", strippedSlug: m[1] };
725
758
  }
759
+ /**
760
+ * Resolve the company for a possibly-anchored path, refusing any retarget.
761
+ *
762
+ * The pure normalizer can only compare slug against slug. That left a hole: a
763
+ * UID-form `--company` was skipped by the string check, so
764
+ * `--company cmp_beta delete <alpha-anchored-path>` stripped the anchor,
765
+ * resolved beta, and previewed/deleted from beta. Comparing IDENTITIES rather
766
+ * than strings is the only way to close it, and that needs resolution.
767
+ *
768
+ * - no anchor -> unchanged behaviour
769
+ * - anchor, no --company -> the anchor IS the company
770
+ * - UID anchor -> compare with the selected company UID
771
+ * - slug + explicit UID -> validate the selected entity's slug directly
772
+ * - slug + slug -> resolve BOTH and require the same uid
773
+ *
774
+ * API keys can verify UID anchors through their company selector or keyed
775
+ * membership. Slug anchors still fail closed because keys cannot read entities.
776
+ */
777
+ async function resolveCompanyUidForAnchoredPath(token, opts) {
778
+ if (opts.anchorSlug === undefined) {
779
+ return getCompanyUid(token, opts.explicit);
780
+ }
781
+ if (looksLikeCompanyUid(opts.anchorSlug)) {
782
+ const selectedUid = await getCompanyUid(token, opts.explicit ?? (token.startsWith("hqk_") ? undefined : opts.anchorSlug));
783
+ if (selectedUid !== opts.anchorSlug) {
784
+ throw new Error(`Refusing anchored path: it names company '${opts.anchorSlug}' ` +
785
+ `but this command targets '${selectedUid}'. Pass a matching company or path.`);
786
+ }
787
+ return selectedUid;
788
+ }
789
+ if (token.startsWith("hqk_")) {
790
+ throw new Error(`Refusing anchored path under an API key: the anchor names ` +
791
+ `'${opts.anchorSlug}', and an API key cannot resolve a slug to verify ` +
792
+ `that is the company it is bound to. Pass the bucket-relative path.`);
793
+ }
794
+ if (opts.explicit !== undefined && looksLikeCompanyUid(opts.explicit)) {
795
+ // An explicit UID disambiguates duplicate slugs. Resolving the slug first
796
+ // would fail before the caller's unambiguous selector could be considered.
797
+ const response = await vaultApiFetch({
798
+ token,
799
+ path: `/entity/${encodeURIComponent(opts.explicit)}`,
800
+ signal: AbortSignal.timeout(15_000),
801
+ });
802
+ if (response.status === 401)
803
+ throw new AuthError();
804
+ if (!response.ok) {
805
+ throw new Error(`Failed to resolve company '${opts.explicit}' (HTTP ${response.status})`);
806
+ }
807
+ const data = (await response.json());
808
+ if (data.entity?.uid !== opts.explicit ||
809
+ data.entity.type !== "company" ||
810
+ data.entity.slug !== opts.anchorSlug) {
811
+ throw new Error(`Refusing anchored path: cannot verify that company '${opts.explicit}' ` +
812
+ `has the anchored slug '${opts.anchorSlug}'. Pass a matching company or path.`);
813
+ }
814
+ return opts.explicit;
815
+ }
816
+ const anchorUid = await getCompanyUid(token, opts.anchorSlug);
817
+ if (opts.explicit === undefined)
818
+ return anchorUid;
819
+ const explicitUid = await getCompanyUid(token, opts.explicit);
820
+ if (explicitUid !== anchorUid) {
821
+ throw new Error(`Refusing anchored path: it names company '${opts.anchorSlug}' ` +
822
+ `(${anchorUid}) but this command targets '${opts.explicit}' ` +
823
+ `(${explicitUid}). Re-run with --company ${opts.anchorSlug}, or pass ` +
824
+ `the bucket-relative path.`);
825
+ }
826
+ return anchorUid;
827
+ }
828
+ /**
829
+ * The company an anchored path names, or `undefined` for a relative/personal
830
+ * path.
831
+ *
832
+ * Discarding this slug is what makes retargeting possible: strip the anchor,
833
+ * then resolve the company from somewhere else, and the operation lands on a
834
+ * DIFFERENT company than the operator pasted. Callers pair this with
835
+ * `normalizeCompanyFilePrefix` so the anchor either MATCHES the explicit
836
+ * `--company` (or is refused), or BECOMES the company when none was given.
837
+ */
838
+ export function companySlugFromAnchor(prefix, personal) {
839
+ if (personal)
840
+ return undefined;
841
+ return stripRedundantCompanyScope(prefix)?.strippedSlug;
842
+ }
843
+ /**
844
+ * Normalize company paths at the CLI boundary, preserving personal keys.
845
+ *
846
+ * `mutating` marks a route that CHANGES access and has no preview or
847
+ * confirmation step (grant, share, unshare). Those refuse an anchored path
848
+ * outright rather than guessing, because stripping the anchor changes which
849
+ * objects the operation reaches and there is nothing downstream to catch it.
850
+ *
851
+ * `companySlug` is the company the command is actually targeting. When the
852
+ * anchor names a DIFFERENT company we refuse in every mode: stripping would
853
+ * silently retarget the operation at the active company, so
854
+ * `--company beta unshare <alpha-anchored-path>` would revoke a real grant in
855
+ * beta while leaving alpha untouched.
856
+ */
857
+ function normalizeCompanyFilePrefix(prefix, options = {}) {
858
+ const scope = options.personal ? null : stripRedundantCompanyScope(prefix);
859
+ if (!scope)
860
+ return normalizeFilePrefix(prefix);
861
+ // A `cmp_…` reference is a UID, not a slug, so it can only be compared to the
862
+ // anchor by RESOLVING it — which this pure helper cannot do. Comparing the
863
+ // strings would refuse `--company cmp_acme` against an `acme` anchor even
864
+ // though both name the same company, and `getCompanyUid`'s ambiguity error
865
+ // explicitly tells callers to retry with `--company <uid>`. Skip the check
866
+ // rather than break that recovery path; the UID is authoritative either way.
867
+ if (options.companySlug !== undefined &&
868
+ !looksLikeCompanyUid(options.companySlug) &&
869
+ !looksLikeCompanyUid(scope.strippedSlug) &&
870
+ options.companySlug !== scope.strippedSlug) {
871
+ throw new Error(`Refusing anchored path '${prefix}': it names company ` +
872
+ `'${scope.strippedSlug}' but this command targets ` +
873
+ `'${options.companySlug}'. Stripping the anchor would apply the ` +
874
+ `operation to '${options.companySlug}' instead. Re-run with ` +
875
+ `--company ${scope.strippedSlug}, or pass the bucket-relative path.`);
876
+ }
877
+ // A bare anchor strips to nothing. The ACL routes would ship that empty
878
+ // prefix to a server that answers 400, so fail here with a message that says
879
+ // what to do instead. `deferRootReject` is for callers that already have a
880
+ // root guard with better copy — `runFilesDelete` says "Refusing to delete the
881
+ // vault root", which beats anything this generic helper could write.
882
+ if (!scope.prefix && !options.deferRootReject) {
883
+ // Route-specific: `--full` exists ONLY on `files share`. Suggesting it for
884
+ // `unshare` fails with an unknown option and names the opposite operation.
885
+ const advice = options.rootAdvice ??
886
+ "Pass a bounded bucket-relative path (e.g. 'projects/foo').";
887
+ throw new Error(`Refusing anchored path '${prefix}': it resolves to the vault root. ` +
888
+ advice);
889
+ }
890
+ if (options.mutating) {
891
+ throw new Error(`Refusing anchored path '${prefix}': the vault is already ` +
892
+ `company-scoped, so removing 'companies/${scope.strippedSlug}/' would ` +
893
+ `change which objects this affects. Retry with the bucket-relative ` +
894
+ `path '${normalizeFilePrefix(scope.prefix)}' if that is what you intend.`);
895
+ }
896
+ console.error(chalk.yellow(`Note: stripped redundant 'companies/${scope.strippedSlug}/' — the vault ` +
897
+ `is already company-scoped; using bucket-relative '${scope.prefix}'.`));
898
+ return normalizeFilePrefix(scope.prefix);
899
+ }
726
900
  export async function runFilesDelete(params, deps = {}) {
727
901
  const confirm = deps.confirm ?? realConfirm;
728
- // The vault is already company-scoped a `companies/<slug>/` prefix is the HQ
729
- // LOCAL tree layout, not a vault key, and the server 400s it (HQ-8F). Strip it
730
- // here so a pasted local path is gracefully normalized to bucket-relative
731
- // BEFORE the dry-run/preview (so the operator still sees the exact keys and
732
- // confirms the right target). If stripping empties the prefix, the root-reject
733
- // below catches it with a clear message.
734
- const scope = stripRedundantCompanyScope(params.prefix);
735
- if (scope) {
736
- console.error(chalk.yellow(`Note: stripped redundant 'companies/${scope.strippedSlug}/' — the vault ` +
737
- `is already company-scoped; using bucket-relative ` +
738
- `'${scope.prefix || "(root)"}'.`));
739
- }
740
- const rawPrefix = scope ? scope.prefix : params.prefix;
741
- // Normalize exactly as the share/unshare/acl paths do (trailing `/` → `/*`),
742
- // then reject the root/empty prefix CLIENT-side so a typo never reaches the
743
- // server as a vault-wide delete. The server enforces this too (defense in
744
- // depth), but failing fast here is clearer and avoids a wasted round-trip.
745
- const normalized = normalizeFilePrefix(rawPrefix);
902
+ // Strip the local company anchor before preview/confirmation (HQ-8F/HQ-CA).
903
+ // Personal keys are already bucket-relative and must keep any such anchor.
904
+ // Normalize trailing `/` to `/*`, then reject the root/empty prefix before
905
+ // any network call, including when stripping a bare company anchor empties it.
906
+ // The anchor names a company. Compare it with --company (refusing a
907
+ // mismatch), and when --company was omitted ADOPT it rather than letting the
908
+ // membership fallback pick a different company to delete from.
909
+ const anchorSlug = companySlugFromAnchor(params.prefix, params.personal);
910
+ const normalized = normalizeCompanyFilePrefix(params.prefix, {
911
+ personal: params.personal,
912
+ deferRootReject: true,
913
+ ...(params.companySlug !== undefined
914
+ ? { companySlug: params.companySlug }
915
+ : {}),
916
+ });
746
917
  if (normalized === "" || normalized === "*" || normalized === "/*") {
747
918
  console.error(chalk.red("Refusing to delete the vault root. Pass a bounded prefix (e.g. 'projects/foo/' or 'projects/foo/*') or an exact key."));
748
919
  process.exit(1);
749
920
  }
750
921
  const token = (await resolveVaultCredential()).token;
751
922
  // Personal scope resolves the vault server-side from the caller's identity —
752
- // no company to look up. Company scope resolves the companyUid as before.
923
+ // no company to look up. Company scope resolves the companyUid through the
924
+ // anchor-aware resolver so an anchored path can never delete from a company
925
+ // other than the one it names.
753
926
  const companyUid = params.personal
754
927
  ? undefined
755
- : await getCompanyUid(token, params.companySlug);
928
+ : await resolveCompanyUidForAnchoredPath(token, {
929
+ explicit: params.companySlug,
930
+ anchorSlug,
931
+ });
756
932
  const scopeArgs = params.personal
757
933
  ? { personal: true }
758
934
  : { companyUid };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.108.24",
3
+ "version": "5.108.25",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {