@indigoai-us/hq-cli 5.18.3 → 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.
- package/dist/commands/cloud.d.ts +17 -0
- package/dist/commands/cloud.js +28 -2
- package/dist/commands/files-browse.d.ts +42 -5
- package/dist/commands/files-browse.js +130 -26
- package/package.json +1 -1
- package/src/commands/cloud.selectors.test.ts +53 -0
- package/src/commands/cloud.ts +31 -0
- package/src/commands/files-browse.test.ts +212 -0
- package/src/commands/files-browse.ts +219 -41
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -153,6 +153,23 @@ export declare function pushAll(options: PushAllOptions, deps: PushAllDeps): Pro
|
|
|
153
153
|
* no person entity (typically means they haven't run `hq onboard`).
|
|
154
154
|
*/
|
|
155
155
|
export declare function resolveCanonicalPersonUid(vaultClient: PullAllVaultClient): Promise<string>;
|
|
156
|
+
/**
|
|
157
|
+
* Refuse `hq sync push --personal <path>` — the combination silently
|
|
158
|
+
* bypasses `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL` (which is only applied by
|
|
159
|
+
* `computePersonalVaultPaths`), risking cross-scope upload of `companies/`,
|
|
160
|
+
* `repos/`, `workspace/`, or `.git/` content to the personal vault. Real
|
|
161
|
+
* incident (2026-05-21): a single command uploaded 196 `companies/{slug}/**`
|
|
162
|
+
* objects to a personal vault before being killed. Cleanup required a
|
|
163
|
+
* hand-rolled S3 sweep. Closes hq-cli#25.
|
|
164
|
+
*
|
|
165
|
+
* Refusal — not silent filtering — is intentional: explicit is better than
|
|
166
|
+
* implicit guesswork, and the legitimate "I want to push a subset of my
|
|
167
|
+
* personal vault" use case has a clean workaround (drop `--personal`, the
|
|
168
|
+
* subset upload targets the active company via standard semantics).
|
|
169
|
+
*/
|
|
170
|
+
export declare function assertNoPersonalPositionalPaths(opts: {
|
|
171
|
+
personal?: boolean;
|
|
172
|
+
}, paths: string[] | undefined): void;
|
|
156
173
|
/**
|
|
157
174
|
* Refuse ambiguous selector combinations. `--all`, `--personal`, and
|
|
158
175
|
* `--company` are mutually exclusive — at most one may be set per
|
package/dist/commands/cloud.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* hq sync status — show local journal summary
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
16
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f6dfc1de-3284-575a-b790-3dc10db2707c")}catch(e){}}();
|
|
17
17
|
import chalk from "chalk";
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as path from "path";
|
|
@@ -234,6 +234,30 @@ export async function resolveCanonicalPersonUid(vaultClient) {
|
|
|
234
234
|
}
|
|
235
235
|
return pick.uid;
|
|
236
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Refuse `hq sync push --personal <path>` — the combination silently
|
|
239
|
+
* bypasses `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL` (which is only applied by
|
|
240
|
+
* `computePersonalVaultPaths`), risking cross-scope upload of `companies/`,
|
|
241
|
+
* `repos/`, `workspace/`, or `.git/` content to the personal vault. Real
|
|
242
|
+
* incident (2026-05-21): a single command uploaded 196 `companies/{slug}/**`
|
|
243
|
+
* objects to a personal vault before being killed. Cleanup required a
|
|
244
|
+
* hand-rolled S3 sweep. Closes hq-cli#25.
|
|
245
|
+
*
|
|
246
|
+
* Refusal — not silent filtering — is intentional: explicit is better than
|
|
247
|
+
* implicit guesswork, and the legitimate "I want to push a subset of my
|
|
248
|
+
* personal vault" use case has a clean workaround (drop `--personal`, the
|
|
249
|
+
* subset upload targets the active company via standard semantics).
|
|
250
|
+
*/
|
|
251
|
+
export function assertNoPersonalPositionalPaths(opts, paths) {
|
|
252
|
+
if (opts.personal && paths && paths.length > 0) {
|
|
253
|
+
throw new Error("`--personal` cannot be combined with explicit [paths]: " +
|
|
254
|
+
"positional paths bypass the PERSONAL_VAULT_EXCLUDED_TOP_LEVEL " +
|
|
255
|
+
"guard (skips .git/, companies/, repos/, workspace/), risking " +
|
|
256
|
+
"cross-scope upload of company data to the personal vault. " +
|
|
257
|
+
"Use bare `--personal` to push the whole personal scope, OR " +
|
|
258
|
+
"drop `--personal` to push specific paths to the active company.");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
237
261
|
/**
|
|
238
262
|
* Refuse ambiguous selector combinations. `--all`, `--personal`, and
|
|
239
263
|
* `--company` are mutually exclusive — at most one may be set per
|
|
@@ -372,6 +396,8 @@ export function registerCloudCommands(program) {
|
|
|
372
396
|
"Cognito session, while --creds-from-stdin expects the caller " +
|
|
373
397
|
"to have already resolved entity + credentials. Pick one.");
|
|
374
398
|
}
|
|
399
|
+
// Closes hq-cli#25 — see `assertNoPersonalPositionalPaths` doc-block.
|
|
400
|
+
assertNoPersonalPositionalPaths(options, paths);
|
|
375
401
|
log(chalk.bold("\nHQ Sync — Push"));
|
|
376
402
|
log(` HQ root: ${options.hqRoot}`);
|
|
377
403
|
// Resolve credentials. Two paths:
|
|
@@ -1063,4 +1089,4 @@ function resolveUploadAuthorFromCache() {
|
|
|
1063
1089
|
}
|
|
1064
1090
|
}
|
|
1065
1091
|
//# sourceMappingURL=cloud.js.map
|
|
1066
|
-
//# debugId=
|
|
1092
|
+
//# debugId=f6dfc1de-3284-575a-b790-3dc10db2707c
|
|
@@ -72,8 +72,17 @@ export type S3ClientFactory = (input: {
|
|
|
72
72
|
sessionToken: string;
|
|
73
73
|
};
|
|
74
74
|
}) => FilesBrowseS3Client;
|
|
75
|
-
/**
|
|
76
|
-
|
|
75
|
+
/**
|
|
76
|
+
* ACL provenance for a single listed key.
|
|
77
|
+
* - `shared-with-you`: an explicit grant the caller holds covers the key.
|
|
78
|
+
* - `role-bypass`: the caller has no covering explicit grant, but
|
|
79
|
+
* owner/admin role widened the browse-vend policy to include it.
|
|
80
|
+
* - `personal-vault`: the key lives in the caller's own person-entity
|
|
81
|
+
* vault, where no grants graph applies — the caller is the only
|
|
82
|
+
* principal with access by construction. Emitted only when
|
|
83
|
+
* `runBrowse({ personalMode: true })`.
|
|
84
|
+
*/
|
|
85
|
+
export type AclSource = "shared-with-you" | "role-bypass" | "personal-vault";
|
|
77
86
|
export interface BrowseRow {
|
|
78
87
|
key: string;
|
|
79
88
|
size: number;
|
|
@@ -112,10 +121,30 @@ export declare function assertOutPathOutsideCompanies(outPath: string, hqRoot: s
|
|
|
112
121
|
*/
|
|
113
122
|
export declare function formatBrowseTable(rows: BrowseRow[]): string;
|
|
114
123
|
export interface RunBrowseInput {
|
|
115
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Vault path prefix.
|
|
126
|
+
* - Company mode (`personalMode: false | undefined`): must start with
|
|
127
|
+
* `companies/<slug>/`, e.g. `companies/indigo/scratch/`.
|
|
128
|
+
* - Personal mode (`personalMode: true`): bucket-relative; empty string
|
|
129
|
+
* lists the whole personal vault root.
|
|
130
|
+
*/
|
|
116
131
|
pathPrefix: string;
|
|
117
|
-
/** Caller-overridden company slug (defaults to slug parsed from path). */
|
|
132
|
+
/** Caller-overridden company slug (defaults to slug parsed from path). Ignored under `personalMode`. */
|
|
118
133
|
companySlug?: string;
|
|
134
|
+
/**
|
|
135
|
+
* Personal-vault mode. Skips the `companies/<slug>/` path requirement,
|
|
136
|
+
* resolves the entity via `entity.get(personalUid)` instead of the
|
|
137
|
+
* company namespace, omits the explicit-grants fetch (no grants graph
|
|
138
|
+
* on a person bucket), and marks every row's `aclSource` as
|
|
139
|
+
* `"personal-vault"`. Closes hq-cli#26 (audit gap for personal vault).
|
|
140
|
+
*/
|
|
141
|
+
personalMode?: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* Canonical person-entity UID (e.g. `prs_…`). Required when
|
|
144
|
+
* `personalMode: true`; ignored otherwise. Caller resolves via
|
|
145
|
+
* `resolveCanonicalPersonUid` to keep this orchestrator pure.
|
|
146
|
+
*/
|
|
147
|
+
personalUid?: string;
|
|
119
148
|
vaultClient: FilesBrowseVaultClient;
|
|
120
149
|
s3Factory: S3ClientFactory;
|
|
121
150
|
region: string;
|
|
@@ -137,7 +166,11 @@ export interface RunBrowseResult {
|
|
|
137
166
|
*/
|
|
138
167
|
export declare function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>;
|
|
139
168
|
export interface RunCatInput {
|
|
140
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* Single vault key.
|
|
171
|
+
* - Company mode: must be a `companies/<slug>/...` path.
|
|
172
|
+
* - Personal mode: bucket-relative, e.g. `.claude/CLAUDE.md`.
|
|
173
|
+
*/
|
|
141
174
|
key: string;
|
|
142
175
|
/**
|
|
143
176
|
* Where to write the body. `undefined` ⇒ stdout. Bright-line-guarded
|
|
@@ -146,6 +179,10 @@ export interface RunCatInput {
|
|
|
146
179
|
out?: string;
|
|
147
180
|
hqRoot: string;
|
|
148
181
|
companySlug?: string;
|
|
182
|
+
/** Personal-vault mode — see `RunBrowseInput.personalMode`. */
|
|
183
|
+
personalMode?: boolean;
|
|
184
|
+
/** Canonical person-entity UID; required when `personalMode: true`. */
|
|
185
|
+
personalUid?: string;
|
|
149
186
|
vaultClient: FilesBrowseVaultClient;
|
|
150
187
|
s3Factory: S3ClientFactory;
|
|
151
188
|
region: string;
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* `pnpm.overrides` until that release ships to npm.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
32
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="55a4ece7-6b33-5809-97c0-475c423bc012")}catch(e){}}();
|
|
33
33
|
import chalk from "chalk";
|
|
34
34
|
import * as fs from "node:fs";
|
|
35
35
|
import * as path from "node:path";
|
|
@@ -38,6 +38,7 @@ import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/clie
|
|
|
38
38
|
import { VaultClient, } from "@indigoai-us/hq-cloud";
|
|
39
39
|
import { DEFAULT_HQ_ROOT, DEFAULT_COGNITO, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
40
40
|
import { getCompanyUid } from "../utils/vault-api.js";
|
|
41
|
+
import { resolveCanonicalPersonUid } from "./cloud.js";
|
|
41
42
|
// ── Pure helpers ────────────────────────────────────────────────────────────
|
|
42
43
|
/**
|
|
43
44
|
* Parse the company slug from a vault prefix. Vault paths are anchored at
|
|
@@ -122,19 +123,43 @@ export function formatBrowseTable(rows) {
|
|
|
122
123
|
* Pure-ish: no console output, no process.exit — caller renders + exits.
|
|
123
124
|
*/
|
|
124
125
|
export async function runBrowse(input) {
|
|
125
|
-
const { pathPrefix, vaultClient, s3Factory, region } = input;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
const { pathPrefix, vaultClient, s3Factory, region, personalMode } = input;
|
|
127
|
+
// Branch by mode. Company mode parses slug from path and looks up by
|
|
128
|
+
// namespace; personal mode resolves the entity directly by the supplied
|
|
129
|
+
// person UID and skips the slug + grants machinery (a person bucket has
|
|
130
|
+
// no grants graph — the owner is the only principal). The vend call is
|
|
131
|
+
// identical for both modes once we have the entity in hand.
|
|
132
|
+
let bucket;
|
|
133
|
+
let entityUid;
|
|
134
|
+
if (personalMode) {
|
|
135
|
+
if (!input.personalUid) {
|
|
136
|
+
throw new Error("runBrowse: personalMode requires personalUid. Resolve via " +
|
|
137
|
+
"resolveCanonicalPersonUid() before calling.");
|
|
138
|
+
}
|
|
139
|
+
const entity = await vaultClient.entity.get(input.personalUid);
|
|
140
|
+
if (!entity.bucketName) {
|
|
141
|
+
throw new Error(`Personal entity '${input.personalUid}' has no provisioned bucket.`);
|
|
142
|
+
}
|
|
143
|
+
entityUid = entity.uid;
|
|
144
|
+
bucket = entity.bucketName;
|
|
130
145
|
}
|
|
131
|
-
|
|
132
|
-
|
|
146
|
+
else {
|
|
147
|
+
const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
|
|
148
|
+
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
149
|
+
if (!entity) {
|
|
150
|
+
throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
|
|
151
|
+
}
|
|
152
|
+
if (!entity.bucketName) {
|
|
153
|
+
throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
|
|
154
|
+
}
|
|
155
|
+
entityUid = entity.uid;
|
|
156
|
+
bucket = entity.bucketName;
|
|
133
157
|
}
|
|
134
|
-
const companyUid = entity.uid;
|
|
135
|
-
const bucket = entity.bucketName;
|
|
136
158
|
// Distinct vend call from sync — `purpose: 'browse'` opts the request
|
|
137
|
-
// into the role-bypass-allowed code path on the server (US-009).
|
|
159
|
+
// into the role-bypass-allowed code path on the server (US-009). The
|
|
160
|
+
// personal mode vends against the person entity which is owner-only by
|
|
161
|
+
// construction; the vend response shape is identical so downstream
|
|
162
|
+
// S3Client construction doesn't branch.
|
|
138
163
|
const vend = await vaultClient.vend({
|
|
139
164
|
paths: [pathPrefix],
|
|
140
165
|
operations: "read-only",
|
|
@@ -149,8 +174,12 @@ export async function runBrowse(input) {
|
|
|
149
174
|
},
|
|
150
175
|
});
|
|
151
176
|
// Pull the caller's explicit-grant graph once so per-key classification
|
|
152
|
-
// is O(grants) without N round-trips.
|
|
153
|
-
|
|
177
|
+
// is O(grants) without N round-trips. Skipped in personal mode — the
|
|
178
|
+
// grants graph is a company concept; a person bucket marks every row
|
|
179
|
+
// as `"personal-vault"` directly.
|
|
180
|
+
const grants = personalMode
|
|
181
|
+
? []
|
|
182
|
+
: await vaultClient.listMyExplicitGrants(entityUid);
|
|
154
183
|
const rows = [];
|
|
155
184
|
let continuationToken;
|
|
156
185
|
do {
|
|
@@ -169,7 +198,7 @@ export async function runBrowse(input) {
|
|
|
169
198
|
key: obj.Key,
|
|
170
199
|
size: obj.Size ?? 0,
|
|
171
200
|
lastModified: obj.LastModified,
|
|
172
|
-
aclSource: classifyAclSource(obj.Key, grants),
|
|
201
|
+
aclSource: personalMode ? "personal-vault" : classifyAclSource(obj.Key, grants),
|
|
173
202
|
});
|
|
174
203
|
}
|
|
175
204
|
continuationToken = resp.NextContinuationToken ?? undefined;
|
|
@@ -182,20 +211,37 @@ export async function runBrowse(input) {
|
|
|
182
211
|
* containment guard). Refuses ahead of any I/O when `--out` is unsafe.
|
|
183
212
|
*/
|
|
184
213
|
export async function runCat(input) {
|
|
185
|
-
const { key, vaultClient, s3Factory, region, hqRoot } = input;
|
|
186
|
-
const slug = input.companySlug ?? parseCompanySlugFromPath(key);
|
|
214
|
+
const { key, vaultClient, s3Factory, region, hqRoot, personalMode } = input;
|
|
187
215
|
// Acceptance 5: refuse BEFORE vending — no point pulling credentials
|
|
188
216
|
// for a request we're already going to abort.
|
|
189
217
|
let absOut;
|
|
190
218
|
if (input.out !== undefined) {
|
|
191
219
|
absOut = assertOutPathOutsideCompanies(input.out, hqRoot);
|
|
192
220
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
221
|
+
// Same branch logic as runBrowse — see that function's doc-block for
|
|
222
|
+
// the personal-vs-company rationale.
|
|
223
|
+
let bucket;
|
|
224
|
+
if (personalMode) {
|
|
225
|
+
if (!input.personalUid) {
|
|
226
|
+
throw new Error("runCat: personalMode requires personalUid. Resolve via " +
|
|
227
|
+
"resolveCanonicalPersonUid() before calling.");
|
|
228
|
+
}
|
|
229
|
+
const entity = await vaultClient.entity.get(input.personalUid);
|
|
230
|
+
if (!entity.bucketName) {
|
|
231
|
+
throw new Error(`Personal entity '${input.personalUid}' has no provisioned bucket.`);
|
|
232
|
+
}
|
|
233
|
+
bucket = entity.bucketName;
|
|
196
234
|
}
|
|
197
|
-
|
|
198
|
-
|
|
235
|
+
else {
|
|
236
|
+
const slug = input.companySlug ?? parseCompanySlugFromPath(key);
|
|
237
|
+
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
238
|
+
if (!entity) {
|
|
239
|
+
throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
|
|
240
|
+
}
|
|
241
|
+
if (!entity.bucketName) {
|
|
242
|
+
throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
|
|
243
|
+
}
|
|
244
|
+
bucket = entity.bucketName;
|
|
199
245
|
}
|
|
200
246
|
const vend = await vaultClient.vend({
|
|
201
247
|
paths: [key],
|
|
@@ -210,7 +256,7 @@ export async function runCat(input) {
|
|
|
210
256
|
sessionToken: vend.credentials.sessionToken,
|
|
211
257
|
},
|
|
212
258
|
});
|
|
213
|
-
const resp = (await s3.send(new GetObjectCommand({ Bucket:
|
|
259
|
+
const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })));
|
|
214
260
|
if (!resp.Body) {
|
|
215
261
|
throw new Error(`GetObject for '${key}' returned no body.`);
|
|
216
262
|
}
|
|
@@ -247,15 +293,47 @@ const defaultS3Factory = ({ region, credentials }) => new S3Client({ region, cre
|
|
|
247
293
|
*/
|
|
248
294
|
export function registerFilesBrowseCommands(filesCmd) {
|
|
249
295
|
filesCmd
|
|
250
|
-
.command("browse
|
|
251
|
-
.description("List vault objects under
|
|
296
|
+
.command("browse [path]")
|
|
297
|
+
.description("List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.")
|
|
252
298
|
.option("--company <slug>", "Company slug (defaults to the slug parsed from <path>)")
|
|
299
|
+
.option("--personal", "Browse the caller's canonical personal vault. [path] is treated as " +
|
|
300
|
+
"bucket-relative (omit it to list the vault root). Mutually exclusive " +
|
|
301
|
+
"with --company.")
|
|
253
302
|
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
254
303
|
.action(async (pathArg, options) => {
|
|
255
304
|
try {
|
|
305
|
+
if (options.personal && options.company) {
|
|
306
|
+
throw new Error("--personal and --company are mutually exclusive. Pick one.");
|
|
307
|
+
}
|
|
256
308
|
const accessToken = await ensureCognitoToken();
|
|
257
309
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
258
310
|
const client = new VaultClient(vaultConfig);
|
|
311
|
+
if (options.personal) {
|
|
312
|
+
// Personal-vault path. Resolve the caller's canonical person
|
|
313
|
+
// entity once; the orchestrator does the bucket lookup + vend.
|
|
314
|
+
// Empty [path] → list bucket root.
|
|
315
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
316
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
317
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
318
|
+
getEntity: async () => null,
|
|
319
|
+
});
|
|
320
|
+
const result = await runBrowse({
|
|
321
|
+
pathPrefix: pathArg ?? "",
|
|
322
|
+
personalMode: true,
|
|
323
|
+
personalUid,
|
|
324
|
+
vaultClient: client,
|
|
325
|
+
s3Factory: defaultS3Factory,
|
|
326
|
+
region: DEFAULT_COGNITO.region,
|
|
327
|
+
});
|
|
328
|
+
console.log(formatBrowseTable(result.rows));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// Company path. [path] is required here — the slug parse needs it.
|
|
332
|
+
if (!pathArg) {
|
|
333
|
+
throw new Error("browse: [path] is required when --personal is not set. " +
|
|
334
|
+
"Pass a companies/<slug>/... path, or add --personal to " +
|
|
335
|
+
"browse your personal vault.");
|
|
336
|
+
}
|
|
259
337
|
// Resolve slug — CLI flag wins, otherwise parse from path arg.
|
|
260
338
|
const slug = options.company ?? parseCompanySlugFromPath(pathArg);
|
|
261
339
|
// If the user passed `--company` AND the path doesn't begin with
|
|
@@ -301,15 +379,41 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
301
379
|
});
|
|
302
380
|
filesCmd
|
|
303
381
|
.command("cat <path>")
|
|
304
|
-
.description("Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path.")
|
|
382
|
+
.description("Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path. Pass --personal to read from the caller's personal vault.")
|
|
305
383
|
.option("--out <file>", "Write the object body to <file> instead of stdout. Refused under <hqRoot>/companies/.")
|
|
306
384
|
.option("--company <slug>", "Company slug (defaults to the slug parsed from <path>)")
|
|
385
|
+
.option("--personal", "Read from the caller's canonical personal vault. <path> is treated as " +
|
|
386
|
+
"bucket-relative. Mutually exclusive with --company.")
|
|
307
387
|
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
308
388
|
.action(async (keyArg, options) => {
|
|
309
389
|
try {
|
|
390
|
+
if (options.personal && options.company) {
|
|
391
|
+
throw new Error("--personal and --company are mutually exclusive. Pick one.");
|
|
392
|
+
}
|
|
310
393
|
const accessToken = await ensureCognitoToken();
|
|
311
394
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
312
395
|
const client = new VaultClient(vaultConfig);
|
|
396
|
+
if (options.personal) {
|
|
397
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
398
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
399
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
400
|
+
getEntity: async () => null,
|
|
401
|
+
});
|
|
402
|
+
const result = await runCat({
|
|
403
|
+
key: keyArg,
|
|
404
|
+
out: options.out,
|
|
405
|
+
hqRoot: options.hqRoot,
|
|
406
|
+
personalMode: true,
|
|
407
|
+
personalUid,
|
|
408
|
+
vaultClient: client,
|
|
409
|
+
s3Factory: defaultS3Factory,
|
|
410
|
+
region: DEFAULT_COGNITO.region,
|
|
411
|
+
});
|
|
412
|
+
if (result.destination.kind === "file") {
|
|
413
|
+
console.error(chalk.green("✓"), `Wrote ${result.bytesWritten} bytes to ${result.destination.absPath}`);
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
313
417
|
const slug = options.company ?? parseCompanySlugFromPath(keyArg);
|
|
314
418
|
if (options.company !== undefined) {
|
|
315
419
|
const fromPath = (() => {
|
|
@@ -345,4 +449,4 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
345
449
|
});
|
|
346
450
|
}
|
|
347
451
|
//# sourceMappingURL=files-browse.js.map
|
|
348
|
-
//# debugId=
|
|
452
|
+
//# debugId=55a4ece7-6b33-5809-97c0-475c423bc012
|
package/package.json
CHANGED
|
@@ -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<{
|
package/src/commands/cloud.ts
CHANGED
|
@@ -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
|
});
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
buildVaultConfig,
|
|
58
58
|
} from "../utils/cognito-session.js";
|
|
59
59
|
import { getCompanyUid } from "../utils/vault-api.js";
|
|
60
|
+
import { resolveCanonicalPersonUid } from "./cloud.js";
|
|
60
61
|
|
|
61
62
|
// ── Types ───────────────────────────────────────────────────────────────────
|
|
62
63
|
|
|
@@ -99,8 +100,17 @@ export type S3ClientFactory = (input: {
|
|
|
99
100
|
};
|
|
100
101
|
}) => FilesBrowseS3Client;
|
|
101
102
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
103
|
+
/**
|
|
104
|
+
* ACL provenance for a single listed key.
|
|
105
|
+
* - `shared-with-you`: an explicit grant the caller holds covers the key.
|
|
106
|
+
* - `role-bypass`: the caller has no covering explicit grant, but
|
|
107
|
+
* owner/admin role widened the browse-vend policy to include it.
|
|
108
|
+
* - `personal-vault`: the key lives in the caller's own person-entity
|
|
109
|
+
* vault, where no grants graph applies — the caller is the only
|
|
110
|
+
* principal with access by construction. Emitted only when
|
|
111
|
+
* `runBrowse({ personalMode: true })`.
|
|
112
|
+
*/
|
|
113
|
+
export type AclSource = "shared-with-you" | "role-bypass" | "personal-vault";
|
|
104
114
|
|
|
105
115
|
export interface BrowseRow {
|
|
106
116
|
key: string;
|
|
@@ -201,10 +211,30 @@ export function formatBrowseTable(rows: BrowseRow[]): string {
|
|
|
201
211
|
// ── Orchestrators ───────────────────────────────────────────────────────────
|
|
202
212
|
|
|
203
213
|
export interface RunBrowseInput {
|
|
204
|
-
/**
|
|
214
|
+
/**
|
|
215
|
+
* Vault path prefix.
|
|
216
|
+
* - Company mode (`personalMode: false | undefined`): must start with
|
|
217
|
+
* `companies/<slug>/`, e.g. `companies/indigo/scratch/`.
|
|
218
|
+
* - Personal mode (`personalMode: true`): bucket-relative; empty string
|
|
219
|
+
* lists the whole personal vault root.
|
|
220
|
+
*/
|
|
205
221
|
pathPrefix: string;
|
|
206
|
-
/** Caller-overridden company slug (defaults to slug parsed from path). */
|
|
222
|
+
/** Caller-overridden company slug (defaults to slug parsed from path). Ignored under `personalMode`. */
|
|
207
223
|
companySlug?: string;
|
|
224
|
+
/**
|
|
225
|
+
* Personal-vault mode. Skips the `companies/<slug>/` path requirement,
|
|
226
|
+
* resolves the entity via `entity.get(personalUid)` instead of the
|
|
227
|
+
* company namespace, omits the explicit-grants fetch (no grants graph
|
|
228
|
+
* on a person bucket), and marks every row's `aclSource` as
|
|
229
|
+
* `"personal-vault"`. Closes hq-cli#26 (audit gap for personal vault).
|
|
230
|
+
*/
|
|
231
|
+
personalMode?: boolean;
|
|
232
|
+
/**
|
|
233
|
+
* Canonical person-entity UID (e.g. `prs_…`). Required when
|
|
234
|
+
* `personalMode: true`; ignored otherwise. Caller resolves via
|
|
235
|
+
* `resolveCanonicalPersonUid` to keep this orchestrator pure.
|
|
236
|
+
*/
|
|
237
|
+
personalUid?: string;
|
|
208
238
|
vaultClient: FilesBrowseVaultClient;
|
|
209
239
|
s3Factory: S3ClientFactory;
|
|
210
240
|
region: string;
|
|
@@ -227,25 +257,52 @@ export interface RunBrowseResult {
|
|
|
227
257
|
* Pure-ish: no console output, no process.exit — caller renders + exits.
|
|
228
258
|
*/
|
|
229
259
|
export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult> {
|
|
230
|
-
const { pathPrefix, vaultClient, s3Factory, region } = input;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
260
|
+
const { pathPrefix, vaultClient, s3Factory, region, personalMode } = input;
|
|
261
|
+
|
|
262
|
+
// Branch by mode. Company mode parses slug from path and looks up by
|
|
263
|
+
// namespace; personal mode resolves the entity directly by the supplied
|
|
264
|
+
// person UID and skips the slug + grants machinery (a person bucket has
|
|
265
|
+
// no grants graph — the owner is the only principal). The vend call is
|
|
266
|
+
// identical for both modes once we have the entity in hand.
|
|
267
|
+
let bucket: string;
|
|
268
|
+
let entityUid: string;
|
|
269
|
+
if (personalMode) {
|
|
270
|
+
if (!input.personalUid) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
"runBrowse: personalMode requires personalUid. Resolve via " +
|
|
273
|
+
"resolveCanonicalPersonUid() before calling.",
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const entity = await vaultClient.entity.get(input.personalUid);
|
|
277
|
+
if (!entity.bucketName) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`Personal entity '${input.personalUid}' has no provisioned bucket.`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
entityUid = entity.uid;
|
|
283
|
+
bucket = entity.bucketName;
|
|
284
|
+
} else {
|
|
285
|
+
const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
|
|
286
|
+
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
287
|
+
if (!entity) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
if (!entity.bucketName) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`Company '${slug}' (${entity.uid}) has no provisioned bucket.`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
entityUid = entity.uid;
|
|
298
|
+
bucket = entity.bucketName;
|
|
243
299
|
}
|
|
244
|
-
const companyUid = entity.uid;
|
|
245
|
-
const bucket = entity.bucketName;
|
|
246
300
|
|
|
247
301
|
// Distinct vend call from sync — `purpose: 'browse'` opts the request
|
|
248
|
-
// into the role-bypass-allowed code path on the server (US-009).
|
|
302
|
+
// into the role-bypass-allowed code path on the server (US-009). The
|
|
303
|
+
// personal mode vends against the person entity which is owner-only by
|
|
304
|
+
// construction; the vend response shape is identical so downstream
|
|
305
|
+
// S3Client construction doesn't branch.
|
|
249
306
|
const vend = await vaultClient.vend({
|
|
250
307
|
paths: [pathPrefix],
|
|
251
308
|
operations: "read-only",
|
|
@@ -262,8 +319,12 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
262
319
|
});
|
|
263
320
|
|
|
264
321
|
// Pull the caller's explicit-grant graph once so per-key classification
|
|
265
|
-
// is O(grants) without N round-trips.
|
|
266
|
-
|
|
322
|
+
// is O(grants) without N round-trips. Skipped in personal mode — the
|
|
323
|
+
// grants graph is a company concept; a person bucket marks every row
|
|
324
|
+
// as `"personal-vault"` directly.
|
|
325
|
+
const grants = personalMode
|
|
326
|
+
? ([] as ExplicitGrant[])
|
|
327
|
+
: await vaultClient.listMyExplicitGrants(entityUid);
|
|
267
328
|
|
|
268
329
|
const rows: BrowseRow[] = [];
|
|
269
330
|
let continuationToken: string | undefined;
|
|
@@ -285,7 +346,7 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
285
346
|
key: obj.Key,
|
|
286
347
|
size: obj.Size ?? 0,
|
|
287
348
|
lastModified: obj.LastModified,
|
|
288
|
-
aclSource: classifyAclSource(obj.Key, grants),
|
|
349
|
+
aclSource: personalMode ? "personal-vault" : classifyAclSource(obj.Key, grants),
|
|
289
350
|
});
|
|
290
351
|
}
|
|
291
352
|
|
|
@@ -296,7 +357,11 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
296
357
|
}
|
|
297
358
|
|
|
298
359
|
export interface RunCatInput {
|
|
299
|
-
/**
|
|
360
|
+
/**
|
|
361
|
+
* Single vault key.
|
|
362
|
+
* - Company mode: must be a `companies/<slug>/...` path.
|
|
363
|
+
* - Personal mode: bucket-relative, e.g. `.claude/CLAUDE.md`.
|
|
364
|
+
*/
|
|
300
365
|
key: string;
|
|
301
366
|
/**
|
|
302
367
|
* Where to write the body. `undefined` ⇒ stdout. Bright-line-guarded
|
|
@@ -305,6 +370,10 @@ export interface RunCatInput {
|
|
|
305
370
|
out?: string;
|
|
306
371
|
hqRoot: string;
|
|
307
372
|
companySlug?: string;
|
|
373
|
+
/** Personal-vault mode — see `RunBrowseInput.personalMode`. */
|
|
374
|
+
personalMode?: boolean;
|
|
375
|
+
/** Canonical person-entity UID; required when `personalMode: true`. */
|
|
376
|
+
personalUid?: string;
|
|
308
377
|
vaultClient: FilesBrowseVaultClient;
|
|
309
378
|
s3Factory: S3ClientFactory;
|
|
310
379
|
region: string;
|
|
@@ -324,8 +393,7 @@ export interface RunCatResult {
|
|
|
324
393
|
* containment guard). Refuses ahead of any I/O when `--out` is unsafe.
|
|
325
394
|
*/
|
|
326
395
|
export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
327
|
-
const { key, vaultClient, s3Factory, region, hqRoot } = input;
|
|
328
|
-
const slug = input.companySlug ?? parseCompanySlugFromPath(key);
|
|
396
|
+
const { key, vaultClient, s3Factory, region, hqRoot, personalMode } = input;
|
|
329
397
|
|
|
330
398
|
// Acceptance 5: refuse BEFORE vending — no point pulling credentials
|
|
331
399
|
// for a request we're already going to abort.
|
|
@@ -334,16 +402,37 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
334
402
|
absOut = assertOutPathOutsideCompanies(input.out, hqRoot);
|
|
335
403
|
}
|
|
336
404
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
405
|
+
// Same branch logic as runBrowse — see that function's doc-block for
|
|
406
|
+
// the personal-vs-company rationale.
|
|
407
|
+
let bucket: string;
|
|
408
|
+
if (personalMode) {
|
|
409
|
+
if (!input.personalUid) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
"runCat: personalMode requires personalUid. Resolve via " +
|
|
412
|
+
"resolveCanonicalPersonUid() before calling.",
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
const entity = await vaultClient.entity.get(input.personalUid);
|
|
416
|
+
if (!entity.bucketName) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
`Personal entity '${input.personalUid}' has no provisioned bucket.`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
bucket = entity.bucketName;
|
|
422
|
+
} else {
|
|
423
|
+
const slug = input.companySlug ?? parseCompanySlugFromPath(key);
|
|
424
|
+
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
425
|
+
if (!entity) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (!entity.bucketName) {
|
|
431
|
+
throw new Error(
|
|
432
|
+
`Company '${slug}' (${entity.uid}) has no provisioned bucket.`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
bucket = entity.bucketName;
|
|
347
436
|
}
|
|
348
437
|
|
|
349
438
|
const vend = await vaultClient.vend({
|
|
@@ -362,7 +451,7 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
362
451
|
});
|
|
363
452
|
|
|
364
453
|
const resp = (await s3.send(
|
|
365
|
-
new GetObjectCommand({ Bucket:
|
|
454
|
+
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
|
366
455
|
)) as GetObjectCommandOutput;
|
|
367
456
|
|
|
368
457
|
if (!resp.Body) {
|
|
@@ -403,6 +492,13 @@ const defaultS3Factory: S3ClientFactory = ({ region, credentials }) =>
|
|
|
403
492
|
interface FilesBrowseCliOptions {
|
|
404
493
|
company?: string;
|
|
405
494
|
hqRoot: string;
|
|
495
|
+
/**
|
|
496
|
+
* Personal-vault mode (hq-cli#26). Skips the `companies/<slug>/`
|
|
497
|
+
* requirement on the path arg, resolves the entity from the caller's
|
|
498
|
+
* canonical person UID, and emits rows tagged `personal-vault`.
|
|
499
|
+
* Mutually exclusive with `--company`.
|
|
500
|
+
*/
|
|
501
|
+
personal?: boolean;
|
|
406
502
|
}
|
|
407
503
|
|
|
408
504
|
interface FilesCatCliOptions extends FilesBrowseCliOptions {
|
|
@@ -417,25 +513,69 @@ interface FilesCatCliOptions extends FilesBrowseCliOptions {
|
|
|
417
513
|
*/
|
|
418
514
|
export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
419
515
|
filesCmd
|
|
420
|
-
.command("browse
|
|
516
|
+
.command("browse [path]")
|
|
421
517
|
.description(
|
|
422
|
-
"List vault objects under
|
|
518
|
+
"List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.",
|
|
423
519
|
)
|
|
424
520
|
.option(
|
|
425
521
|
"--company <slug>",
|
|
426
522
|
"Company slug (defaults to the slug parsed from <path>)",
|
|
427
523
|
)
|
|
524
|
+
.option(
|
|
525
|
+
"--personal",
|
|
526
|
+
"Browse the caller's canonical personal vault. [path] is treated as " +
|
|
527
|
+
"bucket-relative (omit it to list the vault root). Mutually exclusive " +
|
|
528
|
+
"with --company.",
|
|
529
|
+
)
|
|
428
530
|
.option(
|
|
429
531
|
"--hq-root <path>",
|
|
430
532
|
`Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
|
|
431
533
|
DEFAULT_HQ_ROOT,
|
|
432
534
|
)
|
|
433
|
-
.action(async (pathArg: string, options: FilesBrowseCliOptions) => {
|
|
535
|
+
.action(async (pathArg: string | undefined, options: FilesBrowseCliOptions) => {
|
|
434
536
|
try {
|
|
537
|
+
if (options.personal && options.company) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
"--personal and --company are mutually exclusive. Pick one.",
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
435
543
|
const accessToken = await ensureCognitoToken();
|
|
436
544
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
437
545
|
const client = new VaultClient(vaultConfig);
|
|
438
546
|
|
|
547
|
+
if (options.personal) {
|
|
548
|
+
// Personal-vault path. Resolve the caller's canonical person
|
|
549
|
+
// entity once; the orchestrator does the bucket lookup + vend.
|
|
550
|
+
// Empty [path] → list bucket root.
|
|
551
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
552
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
553
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
554
|
+
getEntity: async () => null,
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const result = await runBrowse({
|
|
558
|
+
pathPrefix: pathArg ?? "",
|
|
559
|
+
personalMode: true,
|
|
560
|
+
personalUid,
|
|
561
|
+
vaultClient: client,
|
|
562
|
+
s3Factory: defaultS3Factory,
|
|
563
|
+
region: DEFAULT_COGNITO.region,
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
console.log(formatBrowseTable(result.rows));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Company path. [path] is required here — the slug parse needs it.
|
|
571
|
+
if (!pathArg) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
"browse: [path] is required when --personal is not set. " +
|
|
574
|
+
"Pass a companies/<slug>/... path, or add --personal to " +
|
|
575
|
+
"browse your personal vault.",
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
439
579
|
// Resolve slug — CLI flag wins, otherwise parse from path arg.
|
|
440
580
|
const slug = options.company ?? parseCompanySlugFromPath(pathArg);
|
|
441
581
|
|
|
@@ -496,7 +636,7 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
496
636
|
filesCmd
|
|
497
637
|
.command("cat <path>")
|
|
498
638
|
.description(
|
|
499
|
-
"Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path.",
|
|
639
|
+
"Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path. Pass --personal to read from the caller's personal vault.",
|
|
500
640
|
)
|
|
501
641
|
.option(
|
|
502
642
|
"--out <file>",
|
|
@@ -506,6 +646,11 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
506
646
|
"--company <slug>",
|
|
507
647
|
"Company slug (defaults to the slug parsed from <path>)",
|
|
508
648
|
)
|
|
649
|
+
.option(
|
|
650
|
+
"--personal",
|
|
651
|
+
"Read from the caller's canonical personal vault. <path> is treated as " +
|
|
652
|
+
"bucket-relative. Mutually exclusive with --company.",
|
|
653
|
+
)
|
|
509
654
|
.option(
|
|
510
655
|
"--hq-root <path>",
|
|
511
656
|
`Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
|
|
@@ -513,10 +658,43 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
513
658
|
)
|
|
514
659
|
.action(async (keyArg: string, options: FilesCatCliOptions) => {
|
|
515
660
|
try {
|
|
661
|
+
if (options.personal && options.company) {
|
|
662
|
+
throw new Error(
|
|
663
|
+
"--personal and --company are mutually exclusive. Pick one.",
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
516
667
|
const accessToken = await ensureCognitoToken();
|
|
517
668
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
518
669
|
const client = new VaultClient(vaultConfig);
|
|
519
670
|
|
|
671
|
+
if (options.personal) {
|
|
672
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
673
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
674
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
675
|
+
getEntity: async () => null,
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
const result = await runCat({
|
|
679
|
+
key: keyArg,
|
|
680
|
+
out: options.out,
|
|
681
|
+
hqRoot: options.hqRoot,
|
|
682
|
+
personalMode: true,
|
|
683
|
+
personalUid,
|
|
684
|
+
vaultClient: client,
|
|
685
|
+
s3Factory: defaultS3Factory,
|
|
686
|
+
region: DEFAULT_COGNITO.region,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
if (result.destination.kind === "file") {
|
|
690
|
+
console.error(
|
|
691
|
+
chalk.green("✓"),
|
|
692
|
+
`Wrote ${result.bytesWritten} bytes to ${result.destination.absPath}`,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
520
698
|
const slug = options.company ?? parseCompanySlugFromPath(keyArg);
|
|
521
699
|
if (options.company !== undefined) {
|
|
522
700
|
const fromPath = (() => {
|