@indigoai-us/hq-cli 5.28.0 → 5.30.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.
@@ -0,0 +1,49 @@
1
+ ---
2
+ id: hq-cli-interactive-reads-use-sts-vend
3
+ title: Interactive vault reads vend via /sts/vend, never the legacy POST /vend
4
+ scope: repo
5
+ trigger: any hq-cli command that vends vault credentials to read/list company objects (files browse, files cat, files get/search, or any new interactive read surface)
6
+ enforcement: hard
7
+ public: false
8
+ version: 1
9
+ created: 2026-05-29
10
+ updated: 2026-05-29
11
+ source: back-pressure-failure
12
+ applies_to: [aws]
13
+ ---
14
+
15
+ ## Rule
16
+
17
+ Interactive vault reads in hq-cli MUST vend through the multi-tenant STS
18
+ routes — `VaultClient.sts.vend({ companyUid })` (`POST /sts/vend`, company) or
19
+ `VaultClient.sts.vendSelf({ personUid })` (`POST /sts/vend-self`, personal).
20
+ NEVER vend through the legacy `VaultClient.vend(...)` (`POST /vend`).
21
+
22
+ The legacy `POST /vend` (hq-pro `VaultVendFunction` → `vend.handler`) builds
23
+ its IAM policy against a static `process.env.BUCKET_ARN`, but the hq-pro infra
24
+ (`infra/vault-service.ts`) deliberately does NOT set `BUCKET_ARN` — production
25
+ is multi-tenant with one bucket per company. At runtime `BUCKET_ARN` is
26
+ `undefined`, so the session policy contains `"Resource":[null,"undefined/…"]`
27
+ and STS rejects it with `MalformedPolicyDocument: Syntax error at position
28
+ (-1,-1)` → the client sees a 500 "Failed to generate credentials". Only
29
+ `/sts/vend{,-self,-child}` resolve the per-entity bucket (`entity.bucketArn`)
30
+ and apply role/ACL scoping server-side (owner/admin → full access; member/
31
+ guest → per-prefix). `policy-builder.ts` even documents that `POST /vend` "has
32
+ no production callers" — treat it as dead.
33
+
34
+ Related namespace rule: company vault bucket keys are **company-relative**
35
+ (`knowledge/foo.md`, NOT `companies/<slug>/knowledge/foo.md`). Translate the
36
+ CLI's anchored `companies/<slug>/…` form to the bucket-relative key at the S3
37
+ boundary (`toBucketRelative` / `toCompanyAnchored` in `files-browse.ts`), and
38
+ normalize ACL grant paths with `grantPathToPrefix` before `startsWith`
39
+ classification. Listing/getting with the anchored prefix returns nothing.
40
+
41
+ ## Rationale
42
+
43
+ `hq files browse`/`cat` (US-009) were wired to `POST /vend`, which 500'd on
44
+ every multi-tenant call. The root cause was an undefined `BUCKET_ARN` env on
45
+ the vend Lambda, not a stale deploy — redeploying clean source did not fix it.
46
+ The working fix routed both commands through `/sts/vend` + `/sts/vend-self`
47
+ (client-only, hq-cloud 5.43.0 / hq-cli 5.29.0) and fixed the company-relative
48
+ namespace translation. Captured so future interactive-read surfaces reach for
49
+ the STS routes from the start and don't resurrect the dead `POST /vend`.
@@ -17,31 +17,57 @@
17
17
  * `hq sync` owns, and writing a peeked object there would
18
18
  * silently re-import it into the sync envelope.
19
19
  *
20
- * Both subcommands vend via the new `purpose: 'browse'` path
21
- * (`VaultClient.vend`) shipped in hq-cloud US-009. The server treats that
22
- * purpose as the role-bypass-allowed surface sync vends NEVER widen, so
23
- * keeping browse on its own vend call is the acceptance-criteria-1
24
- * separation we need.
20
+ * Both subcommands vend through the multi-tenant STS routes
21
+ * `VaultClient.sts.vend` (`/sts/vend`, company) and `.sts.vendSelf`
22
+ * (`/sts/vend-self`, personal). These resolve the caller's per-entity bucket
23
+ * and apply role/ACL scoping server-side (owner/admin full access; member/
24
+ * guest per-prefix). The legacy `POST /vend` is deliberately NOT used: it
25
+ * assumes a single static `BUCKET_ARN` that is unset in multi-tenant prod, so
26
+ * it builds an invalid policy and STS rejects it (`MalformedPolicyDocument`).
25
27
  *
26
- * Cross-package note: depends on `VendInput`/`VendResult` + the
27
- * `VaultClient.vend` method from hq-cloud US-009 (commit 2f790c5).
28
- * hq-cli pins `@indigoai-us/hq-cloud` to `file:../hq-cloud` via
29
- * `pnpm.overrides` until that release ships to npm.
28
+ * Namespace note: company vault keys are company-relative (no
29
+ * `companies/<slug>/` prefix). The CLI speaks the anchored form for user
30
+ * familiarity and translates at the S3 boundary via `toBucketRelative` /
31
+ * `toCompanyAnchored`.
32
+ *
33
+ * Cross-package note: depends on the `VaultClient.sts.vend`/`.vendSelf`
34
+ * methods and `grantPathToPrefix` from hq-cloud.
30
35
  */
31
36
  import { Command } from "commander";
32
37
  import { ListObjectsV2Command, GetObjectCommand, type ListObjectsV2CommandOutput, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
33
- import { type VendResult, type ExplicitGrant } from "@indigoai-us/hq-cloud";
38
+ import { type ExplicitGrant } from "@indigoai-us/hq-cloud";
39
+ /** STS-vended credential set the browse/cat path consumes. */
40
+ export interface BrowseCredentials {
41
+ accessKeyId: string;
42
+ secretAccessKey: string;
43
+ sessionToken: string;
44
+ }
45
+ /** Minimal STS-vend response shape (both `/sts/vend` and `/sts/vend-self`). */
46
+ export interface BrowseVendResult {
47
+ credentials: BrowseCredentials;
48
+ }
34
49
  /**
35
50
  * Subset of `VaultClient` this command actually uses — exposed so tests
36
51
  * can stub vend + grants without standing up a real `VaultClient`.
52
+ *
53
+ * Browse/cat vend through the multi-tenant `/sts/vend` (company) and
54
+ * `/sts/vend-self` (personal) routes — NOT the legacy `POST /vend`, which
55
+ * assumes a single static bucket and is non-functional in multi-tenant
56
+ * production (it builds a policy against an undefined `BUCKET_ARN`, so STS
57
+ * rejects it with `MalformedPolicyDocument`). The STS routes resolve the
58
+ * caller's per-entity bucket and apply role/ACL scoping server-side.
37
59
  */
38
60
  export interface FilesBrowseVaultClient {
39
- vend(input: {
40
- paths: string[];
41
- operations: "read-only" | "read-write" | "staged-write";
42
- purpose: "sync" | "browse";
43
- duration?: number;
44
- }): Promise<VendResult>;
61
+ sts: {
62
+ vend(input: {
63
+ companyUid: string;
64
+ durationSeconds?: number;
65
+ }): Promise<BrowseVendResult>;
66
+ vendSelf(input: {
67
+ personUid: string;
68
+ durationSeconds?: number;
69
+ }): Promise<BrowseVendResult>;
70
+ };
45
71
  listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
46
72
  entity: {
47
73
  get(uid: string): Promise<{
@@ -96,15 +122,33 @@ export interface BrowseRow {
96
122
  */
97
123
  export declare function parseCompanySlugFromPath(prefix: string): string;
98
124
  /**
99
- * Classify a single S3 key against the caller's explicit-grant list. Any
100
- * grant whose `path` is a prefix of the key contributes `shared-with-you`;
101
- * otherwise the key is only visible via role-bypass on the vend call.
125
+ * Translate a CLI `companies/<slug>/…` path into the company-relative S3 key
126
+ * the vault bucket actually stores. Company vault buckets are already
127
+ * company-scoped, so their keys carry NO `companies/<slug>/` prefix (e.g.
128
+ * `knowledge/foo.md`, not `companies/indigo/knowledge/foo.md`). The CLI speaks
129
+ * the anchored form for user familiarity; we strip the anchor at the S3
130
+ * boundary. A path without the anchor (or personal-mode, bucket-relative
131
+ * paths) passes through unchanged.
132
+ */
133
+ export declare function toBucketRelative(pathOrPrefix: string, slug: string): string;
134
+ /**
135
+ * Re-attach the `companies/<slug>/` anchor to a company-relative bucket key
136
+ * for display + `hq files cat` round-trip, so the CLI surface keeps speaking
137
+ * the anchored form the user passed in.
138
+ */
139
+ export declare function toCompanyAnchored(bucketRelKey: string, slug: string): string;
140
+ /**
141
+ * Classify a single company-relative S3 key against the caller's
142
+ * (already-normalized) explicit-grant prefixes. Any prefix that covers the
143
+ * key contributes `shared-with-you`; otherwise the key is visible only via
144
+ * the owner/admin role-bypass the `/sts/vend` policy applied. An empty-string
145
+ * prefix is a company-wide grant and matches everything.
102
146
  *
103
- * Grant paths and S3 keys live in the same canonical form ("companies/<slug>/…");
104
- * `coalescePrefixes` would shrink the list further but isn't required for
105
- * correctness`startsWith` already short-circuits on the first match.
147
+ * Grant `path`s arrive in inconsistent/glob form; the caller normalizes them
148
+ * to company-relative `startsWith` prefixes via `grantPathToPrefix` before
149
+ * calling this keeping this helper pure and trivially testable.
106
150
  */
107
- export declare function classifyAclSource(key: string, grants: ExplicitGrant[]): AclSource;
151
+ export declare function classifyAclSource(bucketRelKey: string, grantPrefixes: string[]): AclSource;
108
152
  /**
109
153
  * Bright-line guard for `--out`: refuse to write any byte beneath
110
154
  * `<hqRoot>/companies/`. We do NOT enumerate `companies/manifest.yaml`
@@ -151,15 +195,17 @@ export interface RunBrowseInput {
151
195
  }
152
196
  export interface RunBrowseResult {
153
197
  rows: BrowseRow[];
154
- vend: VendResult;
198
+ vend: BrowseVendResult;
155
199
  }
156
200
  /**
157
201
  * `hq files browse <path>` orchestrator.
158
202
  *
159
203
  * 1. Parse slug from prefix (or use override).
160
204
  * 2. Resolve companyUid + bucketName via VaultClient.entity.
161
- * 3. Vend with `purpose: 'browse'`, `operations: 'read-only'`, paths: [prefix].
162
- * 4. Construct S3Client from vended creds, paginate ListObjectsV2.
205
+ * 3. Vend read creds via the multi-tenant STS route (`/sts/vend` company,
206
+ * `/sts/vend-self` personal) resolves the per-entity bucket + role/ACL.
207
+ * 4. Construct S3Client from vended creds, paginate ListObjectsV2 over the
208
+ * company-relative key space.
163
209
  * 5. Fetch explicit grants once, classify each key.
164
210
  *
165
211
  * Pure-ish: no console output, no process.exit — caller renders + exits.
@@ -197,7 +243,7 @@ export interface RunCatResult {
197
243
  kind: "file";
198
244
  absPath: string;
199
245
  };
200
- vend: VendResult;
246
+ vend: BrowseVendResult;
201
247
  }
202
248
  /**
203
249
  * `hq files cat <path>` orchestrator. Vends with `purpose: 'browse'`, then
@@ -254,6 +300,78 @@ export declare function runSharedWithMe(input: RunSharedWithMeInput): Promise<Sh
254
300
  * Render `shared-with-me` rows as a padded table. Mirrors `formatBrowseTable`.
255
301
  */
256
302
  export declare function formatSharedWithMeTable(rows: SharedWithMeRow[]): string;
303
+ export interface RunSearchInput {
304
+ /** Case-insensitive substring matched against each object's full key. */
305
+ query: string;
306
+ /** Company slug to search (ignored under personalMode). */
307
+ companySlug: string;
308
+ personalMode?: boolean;
309
+ personalUid?: string;
310
+ vaultClient: FilesBrowseVaultClient;
311
+ s3Factory: S3ClientFactory;
312
+ region: string;
313
+ }
314
+ /**
315
+ * `hq files search <query>` orchestrator. Lists the company (or personal)
316
+ * vault under its root via `runBrowse`, then filters to keys containing the
317
+ * query (case-insensitive). v1 is a name/path search over the listing — no
318
+ * content search. Rows carry the same ACL-source classification as browse.
319
+ *
320
+ * Pure-ish: no console output. The caller renders with `formatBrowseTable`.
321
+ */
322
+ export declare function runSearch(input: RunSearchInput): Promise<BrowseRow[]>;
323
+ interface PinFile {
324
+ version: number;
325
+ /** companySlug → sorted list of company-relative pinned prefixes. */
326
+ pins: Record<string, string[]>;
327
+ }
328
+ /** Per-machine pin set path: `<hqRoot>/.hq/pins.json`. */
329
+ export declare function pinFilePath(hqRoot: string): string;
330
+ /** Read the pin set, tolerating a missing or corrupt file (→ fresh). */
331
+ export declare function readPins(hqRoot: string): PinFile;
332
+ /**
333
+ * Register a company-relative prefix in the per-machine pin set. Pins are what
334
+ * keep an on-demand `hq files get` from being pruned by the next *scoped*
335
+ * sync (`syncMode: shared|custom`): the sync runner unions the company's pins
336
+ * into its pull scope. Idempotent + sorted for stable diffs.
337
+ */
338
+ export declare function addPin(hqRoot: string, companySlug: string, prefix: string): void;
339
+ export interface RunGetInput {
340
+ /** Vault path to materialize: `companies/<slug>/...` (file or prefix). */
341
+ path: string;
342
+ /**
343
+ * Override destination directory. Default (omitted) writes in place under
344
+ * `<hqRoot>/companies/<slug>/...` — the tree `hq sync` manages — and
345
+ * registers a pin. `--into` writes outside that envelope and pins nothing.
346
+ */
347
+ into?: string;
348
+ hqRoot: string;
349
+ companySlug?: string;
350
+ vaultClient: FilesBrowseVaultClient;
351
+ s3Factory: S3ClientFactory;
352
+ region: string;
353
+ }
354
+ export interface RunGetResult {
355
+ filesWritten: number;
356
+ bytesWritten: number;
357
+ destinations: string[];
358
+ /** Set only for in-place (no `--into`) materialization. */
359
+ pinned?: {
360
+ companySlug: string;
361
+ prefix: string;
362
+ };
363
+ }
364
+ /**
365
+ * `hq files get <path>` orchestrator. Materializes a vault file or prefix into
366
+ * the local HQ tree on demand. Unlike `cat` (which refuses to write under
367
+ * `companies/`), `get` deliberately writes INTO `companies/<slug>/...` by
368
+ * default — that's the point: pull a path you have access to but don't sync.
369
+ * It then registers a pin so the next scoped sync keeps it.
370
+ *
371
+ * Company mode only in v1 — materializing a personal vault would target the
372
+ * HQ root itself, which is too broad to do implicitly.
373
+ */
374
+ export declare function runGet(input: RunGetInput): Promise<RunGetResult>;
257
375
  /**
258
376
  * Wire `hq files browse` + `hq files cat` onto an existing `files`
259
377
  * Commander group. `registerFilesCommand` in files.ts builds the group
@@ -261,4 +379,5 @@ export declare function formatSharedWithMeTable(rows: SharedWithMeRow[]): string
261
379
  * new browse-vs-sync subcommands so they share the `--company` switch.
262
380
  */
263
381
  export declare function registerFilesBrowseCommands(filesCmd: Command): void;
382
+ export {};
264
383
  //# sourceMappingURL=files-browse.d.ts.map