@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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
29
|
-
* `
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
|
104
|
-
* `
|
|
105
|
-
*
|
|
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(
|
|
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:
|
|
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
|
|
162
|
-
*
|
|
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:
|
|
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
|