@indigoai-us/hq-cli 5.108.26 → 5.109.1
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 +27 -0
- package/dist/commands/__fixtures__/access-vault.d.ts +93 -0
- package/dist/commands/__fixtures__/access-vault.js +166 -0
- package/dist/commands/access.d.ts +158 -0
- package/dist/commands/access.js +803 -0
- package/dist/commands/cloud.js +11 -1
- package/dist/commands/files-browse.d.ts +25 -1
- package/dist/commands/files-browse.js +81 -17
- package/dist/commands/files.js +15 -5
- package/dist/commands/sync-mode.js +12 -1
- package/dist/commands/sync-narrow.js +12 -1
- package/dist/register-all.js +3 -0
- package/dist/utils/access-denied-hint.d.ts +32 -0
- package/dist/utils/access-denied-hint.js +139 -0
- package/dist/utils/access-outcomes.d.ts +23 -0
- package/dist/utils/access-outcomes.js +53 -0
- package/dist/utils/access-requests.d.ts +28 -0
- package/dist/utils/access-requests.js +98 -0
- package/package.json +1 -1
package/dist/commands/cloud.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* hq sync pull — pull all permitted files from the vault
|
|
13
13
|
* hq sync status — show local journal summary
|
|
14
14
|
*/
|
|
15
|
+
import { isAccessDeniedError, accessDeniedKeyOf, formatAccessDenied, } from "../utils/access-denied-hint.js";
|
|
15
16
|
import chalk from "chalk";
|
|
16
17
|
import * as fs from "fs";
|
|
17
18
|
import * as path from "path";
|
|
@@ -44,6 +45,14 @@ export class SyncExitError extends Error {
|
|
|
44
45
|
* CLI are released independently.
|
|
45
46
|
*/
|
|
46
47
|
export function formatSyncFailure(err) {
|
|
48
|
+
if (isAccessDeniedError(err)) {
|
|
49
|
+
const key = accessDeniedKeyOf(err);
|
|
50
|
+
const base = err instanceof Error ? err.message : String(err);
|
|
51
|
+
if (key) {
|
|
52
|
+
return formatAccessDenied(`Access denied for '${key}': ${base}`, key);
|
|
53
|
+
}
|
|
54
|
+
return formatAccessDenied(base);
|
|
55
|
+
}
|
|
47
56
|
if (typeof err === "object" && err !== null) {
|
|
48
57
|
const diagnostic = err;
|
|
49
58
|
if (diagnostic.code === "POLICY_WRITE_SCOPE_TRUNCATED") {
|
|
@@ -978,7 +987,8 @@ export function registerCloudCommands(program) {
|
|
|
978
987
|
catch (err) {
|
|
979
988
|
if (syncHealth)
|
|
980
989
|
await syncHealth.failed();
|
|
981
|
-
|
|
990
|
+
const message = formatSyncFailure(err);
|
|
991
|
+
console.error(chalk.red("\n✗ Pull failed:"), message);
|
|
982
992
|
process.exit(1);
|
|
983
993
|
}
|
|
984
994
|
});
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import { Command } from "commander";
|
|
37
37
|
import { ListObjectsV2Command, GetObjectCommand, type ListObjectsV2CommandOutput, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
|
|
38
|
-
import { type ExplicitGrant } from "@indigoai-us/hq-cloud";
|
|
38
|
+
import { VaultClient, type ExplicitGrant } from "@indigoai-us/hq-cloud";
|
|
39
39
|
/** STS-vended credential set the browse/cat path consumes. */
|
|
40
40
|
export interface BrowseCredentials {
|
|
41
41
|
accessKeyId: string;
|
|
@@ -433,7 +433,18 @@ export declare const PRESIGN_BUFFER_MAX_BYTES: number;
|
|
|
433
433
|
export declare function createCompanyPresignClient(input: {
|
|
434
434
|
token: string;
|
|
435
435
|
companyUid: string;
|
|
436
|
+
/**
|
|
437
|
+
* Company slug the client is scoped to. Attached as `company` on every 403
|
|
438
|
+
* so the access ladder targets THIS tenant rather than the active company
|
|
439
|
+
* (keys on these errors are bucket-relative and carry no slug).
|
|
440
|
+
*/
|
|
441
|
+
companySlug?: string;
|
|
436
442
|
}): FilesBrowseS3Client;
|
|
443
|
+
/**
|
|
444
|
+
* Production company-mode client factory — closes over the caller's token and
|
|
445
|
+
* (when known) the company slug, so 403s are tenant-anchored.
|
|
446
|
+
*/
|
|
447
|
+
export declare function makeCompanyPresignFactory(token: string, companySlug?: string): CompanyBrowseClientFactory;
|
|
437
448
|
/**
|
|
438
449
|
* `FilesBrowseVaultClient` for a Bearer `hqk_` key.
|
|
439
450
|
*
|
|
@@ -455,6 +466,19 @@ export declare function createCompanyPresignClient(input: {
|
|
|
455
466
|
* roll-up) throw a fail-closed error naming the Cognito requirement.
|
|
456
467
|
*/
|
|
457
468
|
export declare function createApiKeyBrowseVaultClient(token: string): FilesBrowseVaultClient & FilesSharedWithMeVaultClient;
|
|
469
|
+
/**
|
|
470
|
+
* The caller's credential plus the vault client that can carry it.
|
|
471
|
+
*
|
|
472
|
+
* `cognito` is undefined under HQ_API_KEY: `VaultClient` speaks only the JWT
|
|
473
|
+
* routes, so the keyed adapter stands in for the company path — and the
|
|
474
|
+
* personal path, which needs `VaultClient.entity.listByType` plus an STS vend,
|
|
475
|
+
* has nothing to run on and must fail closed at its branch.
|
|
476
|
+
*/
|
|
477
|
+
export declare function resolveBrowseSession(): Promise<{
|
|
478
|
+
token: string;
|
|
479
|
+
client: FilesBrowseVaultClient & FilesSharedWithMeVaultClient;
|
|
480
|
+
cognito?: VaultClient;
|
|
481
|
+
}>;
|
|
458
482
|
/**
|
|
459
483
|
* Wire `hq files browse` + `hq files cat` onto an existing `files`
|
|
460
484
|
* Commander group. `registerFilesCommand` in files.ts builds the group
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
* Cross-package note: depends on the `VaultClient.sts.vend`/`.vendSelf`
|
|
34
34
|
* methods and `grantPathToPrefix` from hq-cloud.
|
|
35
35
|
*/
|
|
36
|
+
import { isAccessDeniedError, accessDeniedKeyOf, reportAccessDenied, accessDeniedCompanyOf, } from "../utils/access-denied-hint.js";
|
|
36
37
|
import chalk from "chalk";
|
|
37
38
|
import * as fs from "node:fs";
|
|
38
39
|
import * as path from "node:path";
|
|
@@ -595,7 +596,13 @@ export const PRESIGN_BUFFER_MAX_BYTES = 32 * 1024 * 1024;
|
|
|
595
596
|
* `send(...)` as if it held an S3 client.
|
|
596
597
|
*/
|
|
597
598
|
export function createCompanyPresignClient(input) {
|
|
598
|
-
const { token, companyUid } = input;
|
|
599
|
+
const { token, companyUid, companySlug } = input;
|
|
600
|
+
const denied = (message, key, extra) => Object.assign(new Error(message), {
|
|
601
|
+
status: 403,
|
|
602
|
+
key,
|
|
603
|
+
...(companySlug ? { company: companySlug } : {}),
|
|
604
|
+
...(extra ?? {}),
|
|
605
|
+
});
|
|
599
606
|
async function listObjects(cmd) {
|
|
600
607
|
const prefix = cmd.input.Prefix ?? "";
|
|
601
608
|
const query = { company: companyUid };
|
|
@@ -606,7 +613,11 @@ export function createCompanyPresignClient(input) {
|
|
|
606
613
|
const res = await vaultApiFetch({ token, path: "/v1/files/list", query });
|
|
607
614
|
if (!res.ok) {
|
|
608
615
|
const body = (await res.json().catch(() => ({})));
|
|
609
|
-
|
|
616
|
+
const message = body.message ?? body.error ?? `files list failed (${res.status})`;
|
|
617
|
+
if (res.status === 403) {
|
|
618
|
+
throw denied(message, prefix);
|
|
619
|
+
}
|
|
620
|
+
throw new Error(message);
|
|
610
621
|
}
|
|
611
622
|
const body = (await res.json());
|
|
612
623
|
return {
|
|
@@ -636,13 +647,21 @@ export function createCompanyPresignClient(input) {
|
|
|
636
647
|
});
|
|
637
648
|
if (!res.ok) {
|
|
638
649
|
const body = (await res.json().catch(() => ({})));
|
|
639
|
-
|
|
650
|
+
const message = body.message ?? body.error ?? `presign failed (${res.status})`;
|
|
651
|
+
if (res.status === 403) {
|
|
652
|
+
throw denied(message, key);
|
|
653
|
+
}
|
|
654
|
+
throw new Error(message);
|
|
640
655
|
}
|
|
641
656
|
const body = (await res.json());
|
|
642
657
|
const first = body.results?.[0];
|
|
643
658
|
if (!first || !first.url) {
|
|
644
659
|
// Per-key denial/validation surfaces here (e.g. FILES_PRESIGN_FORBIDDEN).
|
|
645
|
-
|
|
660
|
+
const message = first?.error ?? `No presigned URL returned for '${key}'`;
|
|
661
|
+
if (first?.code === "FILES_PRESIGN_FORBIDDEN") {
|
|
662
|
+
throw denied(message, key, { code: "FILES_PRESIGN_FORBIDDEN" });
|
|
663
|
+
}
|
|
664
|
+
throw new Error(message);
|
|
646
665
|
}
|
|
647
666
|
const dl = await fetch(first.url);
|
|
648
667
|
if (!dl.ok) {
|
|
@@ -687,9 +706,21 @@ export function createCompanyPresignClient(input) {
|
|
|
687
706
|
}
|
|
688
707
|
return { send };
|
|
689
708
|
}
|
|
690
|
-
/**
|
|
691
|
-
|
|
692
|
-
|
|
709
|
+
/**
|
|
710
|
+
* Production company-mode client factory — closes over the caller's token and
|
|
711
|
+
* (when known) the company slug, so 403s are tenant-anchored.
|
|
712
|
+
*/
|
|
713
|
+
export function makeCompanyPresignFactory(token, companySlug) {
|
|
714
|
+
return ({ companyUid }) => createCompanyPresignClient({ token, companyUid, companySlug });
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Tenant anchor for the access ladder in a CLI catch block: prefer the slug
|
|
718
|
+
* the failing client stamped on the 403, else the `--company` flag. `hqRoot`
|
|
719
|
+
* rides along so `hq access` reads the same tree the command was run against.
|
|
720
|
+
*/
|
|
721
|
+
function accessLadderContextFor(err, command, hqRoot) {
|
|
722
|
+
const flag = command.optsWithGlobals().company;
|
|
723
|
+
return { company: accessDeniedCompanyOf(err) ?? flag, hqRoot };
|
|
693
724
|
}
|
|
694
725
|
// ── HQ_API_KEY vault client (keyed routes only) ──────────────────────────────
|
|
695
726
|
/** Fail-closed error the top-level handler prints without a Sentry report. */
|
|
@@ -793,7 +824,7 @@ function personalVaultNeedsCognito() {
|
|
|
793
824
|
* personal path, which needs `VaultClient.entity.listByType` plus an STS vend,
|
|
794
825
|
* has nothing to run on and must fail closed at its branch.
|
|
795
826
|
*/
|
|
796
|
-
async function resolveBrowseSession() {
|
|
827
|
+
export async function resolveBrowseSession() {
|
|
797
828
|
const cred = await resolveVaultCredential();
|
|
798
829
|
if (cred.kind === "api-key") {
|
|
799
830
|
return {
|
|
@@ -882,7 +913,7 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
882
913
|
pathPrefix: pathArg,
|
|
883
914
|
companySlug: slug,
|
|
884
915
|
vaultClient: client,
|
|
885
|
-
companyClient: makeCompanyPresignFactory(accessToken),
|
|
916
|
+
companyClient: makeCompanyPresignFactory(accessToken, slug),
|
|
886
917
|
region: DEFAULT_COGNITO.region,
|
|
887
918
|
});
|
|
888
919
|
console.log(formatBrowseTable(result.rows));
|
|
@@ -895,7 +926,13 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
895
926
|
}
|
|
896
927
|
}
|
|
897
928
|
catch (err) {
|
|
898
|
-
|
|
929
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
930
|
+
if (isAccessDeniedError(err)) {
|
|
931
|
+
await reportAccessDenied(chalk.red("Error:") + " " + message, accessDeniedKeyOf(err) ?? pathArg, accessLadderContextFor(err, command, options.hqRoot));
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
console.error(chalk.red("Error:"), message);
|
|
935
|
+
}
|
|
899
936
|
process.exit(1);
|
|
900
937
|
}
|
|
901
938
|
});
|
|
@@ -959,7 +996,7 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
959
996
|
hqRoot: options.hqRoot,
|
|
960
997
|
companySlug: slug,
|
|
961
998
|
vaultClient: client,
|
|
962
|
-
companyClient: makeCompanyPresignFactory(accessToken),
|
|
999
|
+
companyClient: makeCompanyPresignFactory(accessToken, slug),
|
|
963
1000
|
region: DEFAULT_COGNITO.region,
|
|
964
1001
|
});
|
|
965
1002
|
if (result.destination.kind === "file") {
|
|
@@ -967,7 +1004,13 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
967
1004
|
}
|
|
968
1005
|
}
|
|
969
1006
|
catch (err) {
|
|
970
|
-
|
|
1007
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1008
|
+
if (isAccessDeniedError(err)) {
|
|
1009
|
+
await reportAccessDenied(chalk.red("Error:") + " " + message, accessDeniedKeyOf(err) ?? keyArg, accessLadderContextFor(err, command, options.hqRoot));
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
console.error(chalk.red("Error:"), message);
|
|
1013
|
+
}
|
|
971
1014
|
process.exit(1);
|
|
972
1015
|
}
|
|
973
1016
|
});
|
|
@@ -1003,7 +1046,13 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
1003
1046
|
console.log(formatSharedWithMeTable(rows));
|
|
1004
1047
|
}
|
|
1005
1048
|
catch (err) {
|
|
1006
|
-
|
|
1049
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1050
|
+
if (isAccessDeniedError(err)) {
|
|
1051
|
+
await reportAccessDenied(chalk.red("Error:") + " " + message, accessDeniedKeyOf(err), accessLadderContextFor(err, command));
|
|
1052
|
+
}
|
|
1053
|
+
else {
|
|
1054
|
+
console.error(chalk.red("Error:"), message);
|
|
1055
|
+
}
|
|
1007
1056
|
process.exit(1);
|
|
1008
1057
|
}
|
|
1009
1058
|
});
|
|
@@ -1049,13 +1098,22 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
1049
1098
|
query,
|
|
1050
1099
|
companySlug: company,
|
|
1051
1100
|
vaultClient: client,
|
|
1052
|
-
companyClient: makeCompanyPresignFactory(accessToken),
|
|
1101
|
+
companyClient: makeCompanyPresignFactory(accessToken, company),
|
|
1053
1102
|
region: DEFAULT_COGNITO.region,
|
|
1054
1103
|
});
|
|
1055
1104
|
console.log(formatBrowseTable(rows));
|
|
1056
1105
|
}
|
|
1057
1106
|
catch (err) {
|
|
1058
|
-
|
|
1107
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1108
|
+
if (isAccessDeniedError(err)) {
|
|
1109
|
+
await reportAccessDenied(chalk.red("Error:") + " " + message, accessDeniedKeyOf(err) ??
|
|
1110
|
+
(command.optsWithGlobals().company
|
|
1111
|
+
? `companies/${command.optsWithGlobals().company}`
|
|
1112
|
+
: undefined), accessLadderContextFor(err, command));
|
|
1113
|
+
}
|
|
1114
|
+
else {
|
|
1115
|
+
console.error(chalk.red("Error:"), message);
|
|
1116
|
+
}
|
|
1059
1117
|
process.exit(1);
|
|
1060
1118
|
}
|
|
1061
1119
|
});
|
|
@@ -1091,7 +1149,7 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
1091
1149
|
hqRoot: options.hqRoot,
|
|
1092
1150
|
companySlug: slug,
|
|
1093
1151
|
vaultClient: client,
|
|
1094
|
-
companyClient: makeCompanyPresignFactory(accessToken),
|
|
1152
|
+
companyClient: makeCompanyPresignFactory(accessToken, slug),
|
|
1095
1153
|
region: DEFAULT_COGNITO.region,
|
|
1096
1154
|
});
|
|
1097
1155
|
console.error(chalk.green("✓"), `Materialized ${result.filesWritten} file(s), ${result.bytesWritten} bytes.`);
|
|
@@ -1100,7 +1158,13 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
1100
1158
|
}
|
|
1101
1159
|
}
|
|
1102
1160
|
catch (err) {
|
|
1103
|
-
|
|
1161
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1162
|
+
if (isAccessDeniedError(err)) {
|
|
1163
|
+
await reportAccessDenied(chalk.red("Error:") + " " + message, accessDeniedKeyOf(err) ?? pathArg, accessLadderContextFor(err, command, options.hqRoot));
|
|
1164
|
+
}
|
|
1165
|
+
else {
|
|
1166
|
+
console.error(chalk.red("Error:"), message);
|
|
1167
|
+
}
|
|
1104
1168
|
process.exit(1);
|
|
1105
1169
|
}
|
|
1106
1170
|
});
|
package/dist/commands/files.js
CHANGED
|
@@ -7,6 +7,7 @@ import { vaultApiFetch, getCompanyUid } from "./secrets.js";
|
|
|
7
7
|
import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
|
|
8
8
|
import { looksLikeCompanyUid } from "../utils/vault-api.js";
|
|
9
9
|
import { AuthError } from "../utils/auth-error.js";
|
|
10
|
+
import { formatAccessDenied, offerAccessLadder, } from "../utils/access-denied-hint.js";
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// Pure helpers (exported for unit tests)
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
@@ -75,9 +76,9 @@ export function formatShareSessionError(err) {
|
|
|
75
76
|
}
|
|
76
77
|
if (err.status === 403) {
|
|
77
78
|
if (err.path) {
|
|
78
|
-
return `Not authorized to share '${err.path}' — you need read access on every path
|
|
79
|
+
return formatAccessDenied(`Not authorized to share '${err.path}' — you need read access on every path`, err.path);
|
|
79
80
|
}
|
|
80
|
-
return "Not authorized — you need to be a company member with read access on every path";
|
|
81
|
+
return formatAccessDenied("Not authorized — you need to be a company member with read access on every path");
|
|
81
82
|
}
|
|
82
83
|
if (err.status === 400) {
|
|
83
84
|
return `Invalid request: ${err.message}`;
|
|
@@ -236,6 +237,9 @@ export function registerFilesCommand(program) {
|
|
|
236
237
|
}
|
|
237
238
|
else if (res.status === 403) {
|
|
238
239
|
console.error(chalk.red("Not authorized to modify this file prefix's ACL"));
|
|
240
|
+
await offerAccessLadder(canonicalPrefix, {
|
|
241
|
+
company: files.opts().company,
|
|
242
|
+
});
|
|
239
243
|
}
|
|
240
244
|
else if (res.status === 404) {
|
|
241
245
|
// A 404 means ZERO rows were removed — never report a green success
|
|
@@ -304,6 +308,9 @@ export function registerFilesCommand(program) {
|
|
|
304
308
|
}
|
|
305
309
|
else if (treeRes.status === 403) {
|
|
306
310
|
console.error(chalk.red("Not authorized to view this file prefix's ACL"));
|
|
311
|
+
await offerAccessLadder(canonicalPrefix, {
|
|
312
|
+
company: files.opts().company,
|
|
313
|
+
});
|
|
307
314
|
}
|
|
308
315
|
else if (treeRes.status >= 500) {
|
|
309
316
|
console.error(chalk.red(`Server error: ${body.error ?? treeRes.statusText}`));
|
|
@@ -566,6 +573,9 @@ async function runDirectGrant(params) {
|
|
|
566
573
|
}
|
|
567
574
|
else if (res.status === 403) {
|
|
568
575
|
console.error(chalk.red("Not authorized to share this file prefix"));
|
|
576
|
+
await offerAccessLadder(canonicalPrefix, {
|
|
577
|
+
company: params.companySlug,
|
|
578
|
+
});
|
|
569
579
|
}
|
|
570
580
|
else if (res.status === 404) {
|
|
571
581
|
console.error(chalk.red("ACL record not found — the prefix may not have an ACL yet"));
|
|
@@ -666,7 +676,7 @@ export function formatFilesDeleteError(err, prefix) {
|
|
|
666
676
|
return "Not authenticated — please run `hq login`";
|
|
667
677
|
}
|
|
668
678
|
if (err.status === 403) {
|
|
669
|
-
return `Not authorized to delete '${prefix}' — you need write access on it
|
|
679
|
+
return formatAccessDenied(`Not authorized to delete '${prefix}' — you need write access on it`, prefix);
|
|
670
680
|
}
|
|
671
681
|
if (err.status === 400) {
|
|
672
682
|
return `Invalid request: ${err.message}`;
|
|
@@ -1084,9 +1094,9 @@ function formatFilesRecoveryError(err, key) {
|
|
|
1084
1094
|
return "Not authenticated — please run hq login";
|
|
1085
1095
|
}
|
|
1086
1096
|
if (err.status === 403) {
|
|
1087
|
-
return key
|
|
1097
|
+
return formatAccessDenied(key
|
|
1088
1098
|
? "Not authorized to modify '" + key + "' — you need write access on it"
|
|
1089
|
-
: "Not authorized to view this vault path";
|
|
1099
|
+
: "Not authorized to view this vault path", key);
|
|
1090
1100
|
}
|
|
1091
1101
|
if (err.code === "FILES_RESTORE_VERSION_NOT_FOUND") {
|
|
1092
1102
|
return "That version is unavailable or is a delete marker.";
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* in package.json — revert that line to `^5.20.0` (or whatever the
|
|
31
31
|
* published cut is) once US-004 ships to npm.
|
|
32
32
|
*/
|
|
33
|
+
import { isAccessDeniedError, accessDeniedKeyOf, reportAccessDenied, accessDeniedCompanyOf, } from "../utils/access-denied-hint.js";
|
|
33
34
|
import chalk from "chalk";
|
|
34
35
|
import * as fs from "node:fs";
|
|
35
36
|
import * as path from "node:path";
|
|
@@ -242,7 +243,17 @@ export function registerSyncModeCommand(syncCmd) {
|
|
|
242
243
|
}
|
|
243
244
|
}
|
|
244
245
|
catch (err) {
|
|
245
|
-
|
|
246
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
247
|
+
if (isAccessDeniedError(err)) {
|
|
248
|
+
await reportAccessDenied(chalk.red("✗ sync mode failed:") + " " + message, accessDeniedKeyOf(err) ??
|
|
249
|
+
(options.company ? `companies/${options.company}` : undefined), {
|
|
250
|
+
company: accessDeniedCompanyOf(err) ?? options.company,
|
|
251
|
+
hqRoot: options.hqRoot,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
console.error(chalk.red("✗ sync mode failed:"), message);
|
|
256
|
+
}
|
|
246
257
|
process.exit(1);
|
|
247
258
|
}
|
|
248
259
|
});
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* release is unpublished, hq-cli pins `@indigoai-us/hq-cloud` to
|
|
37
37
|
* `file:../hq-cloud` via `pnpm.overrides`.
|
|
38
38
|
*/
|
|
39
|
+
import { isAccessDeniedError, accessDeniedKeyOf, reportAccessDenied, accessDeniedCompanyOf, } from "../utils/access-denied-hint.js";
|
|
39
40
|
import chalk from "chalk";
|
|
40
41
|
import * as readline from "node:readline";
|
|
41
42
|
import * as fs from "node:fs";
|
|
@@ -327,7 +328,17 @@ export function registerSyncNarrowCommand(syncCmd) {
|
|
|
327
328
|
console.log(chalk.dim(` audit: server-side MEMBERSHIP_SYNC_CONFIG_CHANGED row written by PUT /v1/memberships/${target.membership.membershipKey}/sync-config`));
|
|
328
329
|
}
|
|
329
330
|
catch (err) {
|
|
330
|
-
|
|
331
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
332
|
+
if (isAccessDeniedError(err)) {
|
|
333
|
+
await reportAccessDenied(chalk.red("✗ sync narrow failed:") + " " + message, accessDeniedKeyOf(err) ??
|
|
334
|
+
(options.company ? `companies/${options.company}` : undefined), {
|
|
335
|
+
company: accessDeniedCompanyOf(err) ?? options.company,
|
|
336
|
+
hqRoot: options.hqRoot,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
console.error(chalk.red("✗ sync narrow failed:"), message);
|
|
341
|
+
}
|
|
331
342
|
process.exit(1);
|
|
332
343
|
}
|
|
333
344
|
});
|
package/dist/register-all.js
CHANGED
|
@@ -57,6 +57,7 @@ import { registerWorkersCommand } from "./commands/workers.js";
|
|
|
57
57
|
import { registerGroupGrantsCommand } from "./commands/group-grants.js";
|
|
58
58
|
import { registerFilesCommand } from "./commands/files.js";
|
|
59
59
|
import { registerFilesBrowseCommands } from "./commands/files-browse.js";
|
|
60
|
+
import { registerAccessCommand } from "./commands/access.js";
|
|
60
61
|
import { registerSkillCommand } from "./commands/skill.js";
|
|
61
62
|
import { registerMembersCommand } from "./commands/members.js";
|
|
62
63
|
import { registerPeopleCommand } from "./commands/people.js";
|
|
@@ -157,6 +158,8 @@ export function registerAllCommands(program) {
|
|
|
157
158
|
// browse-vs-sync subcommands (`hq files browse`/`cat`) onto the same group.
|
|
158
159
|
const filesCmd = registerFilesCommand(program);
|
|
159
160
|
registerFilesBrowseCommands(filesCmd);
|
|
161
|
+
// Top-level `hq access` — vault existence + ACL probe (self-healing ladder).
|
|
162
|
+
registerAccessCommand(program);
|
|
160
163
|
// Comment-only skill improvement loop. Structured suggestion/review commands are
|
|
161
164
|
// intentionally absent; live content changes remain governed by FILE_ACL sync.
|
|
162
165
|
registerSkillCommand(program);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared 403 / access-denied formatter for `hq files` and `hq sync`.
|
|
3
|
+
*
|
|
4
|
+
* `src/commands/sync.ts` is the legacy modules-sync command and has no 403
|
|
5
|
+
* path — leave it untouched; this util is the only 403 hint surface.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ACCESS_HINT_PREFIX = "Run: hq access";
|
|
8
|
+
export declare function accessDeniedHint(path?: string): string;
|
|
9
|
+
export declare function formatAccessDenied(message: string, path?: string): string;
|
|
10
|
+
export declare function isAccessDeniedError(err: unknown): boolean;
|
|
11
|
+
export declare function accessDeniedKeyOf(err: unknown): string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Company slug the denied operation was running against, when the thrower
|
|
14
|
+
* attached one (`createCompanyPresignClient` does). Keys on 403 errors are
|
|
15
|
+
* bucket-relative, so without this the ladder would fall back to the ACTIVE
|
|
16
|
+
* company and could probe/DM the wrong tenant.
|
|
17
|
+
*/
|
|
18
|
+
export declare function accessDeniedCompanyOf(err: unknown): string | undefined;
|
|
19
|
+
/** Tenant anchor forwarded to `hq access` so it never guesses the company. */
|
|
20
|
+
export interface AccessLadderContext {
|
|
21
|
+
company?: string;
|
|
22
|
+
hqRoot?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface OfferAccessLadderOptions extends AccessLadderContext {
|
|
25
|
+
isTTY?: boolean;
|
|
26
|
+
ask?: (question: string) => Promise<boolean>;
|
|
27
|
+
run?: (path: string, ctx?: AccessLadderContext) => Promise<unknown>;
|
|
28
|
+
stderr?: (line: string) => void;
|
|
29
|
+
}
|
|
30
|
+
export declare function offerAccessLadder(path: string | undefined, opts?: OfferAccessLadderOptions): Promise<boolean>;
|
|
31
|
+
export declare function reportAccessDenied(message: string, path: string | undefined, opts?: OfferAccessLadderOptions): Promise<void>;
|
|
32
|
+
//# sourceMappingURL=access-denied-hint.d.ts.map
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared 403 / access-denied formatter for `hq files` and `hq sync`.
|
|
3
|
+
*
|
|
4
|
+
* `src/commands/sync.ts` is the legacy modules-sync command and has no 403
|
|
5
|
+
* path — leave it untouched; this util is the only 403 hint surface.
|
|
6
|
+
*/
|
|
7
|
+
import * as readline from "node:readline";
|
|
8
|
+
export const ACCESS_HINT_PREFIX = "Run: hq access";
|
|
9
|
+
export function accessDeniedHint(path) {
|
|
10
|
+
const target = path && path.length > 0 ? path : "<path>";
|
|
11
|
+
return `${ACCESS_HINT_PREFIX} ${target}`;
|
|
12
|
+
}
|
|
13
|
+
export function formatAccessDenied(message, path) {
|
|
14
|
+
return `${message}\n${accessDeniedHint(path)}`;
|
|
15
|
+
}
|
|
16
|
+
function errRecord(err) {
|
|
17
|
+
if (typeof err === "object" && err !== null)
|
|
18
|
+
return err;
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
function errMessage(err) {
|
|
22
|
+
if (err instanceof Error)
|
|
23
|
+
return err.message;
|
|
24
|
+
if (typeof err === "string")
|
|
25
|
+
return err;
|
|
26
|
+
const rec = errRecord(err);
|
|
27
|
+
if (rec && typeof rec.message === "string")
|
|
28
|
+
return rec.message;
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
export function isAccessDeniedError(err) {
|
|
32
|
+
const rec = errRecord(err);
|
|
33
|
+
if (rec) {
|
|
34
|
+
if (rec.status === 403)
|
|
35
|
+
return true;
|
|
36
|
+
const meta = rec.$metadata;
|
|
37
|
+
if (typeof meta === "object" &&
|
|
38
|
+
meta !== null &&
|
|
39
|
+
meta.httpStatusCode === 403) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (rec.code === "FILES_PRESIGN_FORBIDDEN")
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return /\b403\b|forbidden|not authorized|access denied/i.test(errMessage(err));
|
|
46
|
+
}
|
|
47
|
+
export function accessDeniedKeyOf(err) {
|
|
48
|
+
const rec = errRecord(err);
|
|
49
|
+
if (!rec)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (typeof rec.key === "string" && rec.key.length > 0)
|
|
52
|
+
return rec.key;
|
|
53
|
+
if (typeof rec.path === "string" && rec.path.length > 0)
|
|
54
|
+
return rec.path;
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Company slug the denied operation was running against, when the thrower
|
|
59
|
+
* attached one (`createCompanyPresignClient` does). Keys on 403 errors are
|
|
60
|
+
* bucket-relative, so without this the ladder would fall back to the ACTIVE
|
|
61
|
+
* company and could probe/DM the wrong tenant.
|
|
62
|
+
*/
|
|
63
|
+
export function accessDeniedCompanyOf(err) {
|
|
64
|
+
const rec = errRecord(err);
|
|
65
|
+
if (!rec)
|
|
66
|
+
return undefined;
|
|
67
|
+
if (typeof rec.company === "string" && rec.company.length > 0)
|
|
68
|
+
return rec.company;
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
function defaultIsTTY() {
|
|
72
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
73
|
+
}
|
|
74
|
+
async function defaultAsk(question) {
|
|
75
|
+
const rl = readline.createInterface({
|
|
76
|
+
input: process.stdin,
|
|
77
|
+
output: process.stderr,
|
|
78
|
+
});
|
|
79
|
+
const answer = await new Promise((resolve) => {
|
|
80
|
+
rl.question(`${question} `, (line) => {
|
|
81
|
+
rl.close();
|
|
82
|
+
resolve(line);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
const t = answer.trim().toLowerCase();
|
|
86
|
+
return t === "y" || t === "yes";
|
|
87
|
+
}
|
|
88
|
+
async function defaultRun(path, ctx) {
|
|
89
|
+
const m = await import("../commands/access.js");
|
|
90
|
+
return m.runAccessForPath(path, ctx);
|
|
91
|
+
}
|
|
92
|
+
function ladderContext(opts) {
|
|
93
|
+
if (!opts)
|
|
94
|
+
return undefined;
|
|
95
|
+
const ctx = {};
|
|
96
|
+
if (opts.company)
|
|
97
|
+
ctx.company = opts.company;
|
|
98
|
+
if (opts.hqRoot)
|
|
99
|
+
ctx.hqRoot = opts.hqRoot;
|
|
100
|
+
return Object.keys(ctx).length > 0 ? ctx : undefined;
|
|
101
|
+
}
|
|
102
|
+
export async function offerAccessLadder(path, opts) {
|
|
103
|
+
const stderr = opts?.stderr ?? ((line) => console.error(line));
|
|
104
|
+
stderr(accessDeniedHint(path));
|
|
105
|
+
const isTTY = opts?.isTTY ?? defaultIsTTY();
|
|
106
|
+
if (!isTTY || !path)
|
|
107
|
+
return false;
|
|
108
|
+
const ask = opts?.ask ?? defaultAsk;
|
|
109
|
+
const question = "Run it now? [y/N]";
|
|
110
|
+
let yes;
|
|
111
|
+
try {
|
|
112
|
+
yes = await ask(question);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
if (!yes)
|
|
118
|
+
return false;
|
|
119
|
+
const run = opts?.run ?? defaultRun;
|
|
120
|
+
const ctx = ladderContext(opts);
|
|
121
|
+
try {
|
|
122
|
+
if (ctx)
|
|
123
|
+
await run(path, ctx);
|
|
124
|
+
else
|
|
125
|
+
await run(path);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
130
|
+
stderr(`Error: ${message}`);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export async function reportAccessDenied(message, path, opts) {
|
|
135
|
+
const stderr = opts?.stderr ?? ((line) => console.error(line));
|
|
136
|
+
stderr(message);
|
|
137
|
+
await offerAccessLadder(path, opts);
|
|
138
|
+
}
|
|
139
|
+
//# sourceMappingURL=access-denied-hint.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AccessOutcome } from "../commands/access.js";
|
|
2
|
+
/**
|
|
3
|
+
* One line of the `hq access` cause-mix ledger. Deliberately narrow: no path,
|
|
4
|
+
* requester, or grantor — only enough to review which outcomes users hit.
|
|
5
|
+
*/
|
|
6
|
+
export interface AccessOutcomeRecord {
|
|
7
|
+
outcome: AccessOutcome;
|
|
8
|
+
company: string;
|
|
9
|
+
ts: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function accessOutcomesPath(hqRoot: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Append one JSONL line. Best-effort by contract: callers are expected to
|
|
14
|
+
* catch and downgrade any failure, since telemetry must never change an
|
|
15
|
+
* `hq access` outcome or exit code.
|
|
16
|
+
*/
|
|
17
|
+
export declare function recordAccessOutcome(hqRoot: string, rec: AccessOutcomeRecord): void;
|
|
18
|
+
/**
|
|
19
|
+
* Read the ledger. A missing file is the empty ledger; malformed lines are
|
|
20
|
+
* skipped so one bad append never hides the rest of the history.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readAccessOutcomes(hqRoot: string): AccessOutcomeRecord[];
|
|
23
|
+
//# sourceMappingURL=access-outcomes.d.ts.map
|