@indigoai-us/hq-cli 5.345.154 → 5.345.156
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 +138 -0
- package/dist/command-catalog.generated.d.ts +59 -7
- package/dist/command-catalog.generated.js +75 -7
- package/dist/commands/agent-inbox.js +2 -2
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +92 -3
- package/dist/commands/core.js +1 -1
- package/dist/commands/files-browse.d.ts +16 -0
- package/dist/commands/files-browse.js +52 -1
- package/dist/commands/integrations-core.d.ts +13 -1
- package/dist/commands/integrations-core.js +49 -4
- package/dist/commands/integrations.js +4 -2
- package/dist/commands/library-add.d.ts +6 -2
- package/dist/commands/library-add.js +45 -6
- package/dist/commands/library-caption.d.ts +38 -1
- package/dist/commands/library-caption.js +304 -3
- package/dist/commands/library-link.d.ts +7 -3
- package/dist/commands/library-link.js +10 -4
- package/dist/commands/library-remove.d.ts +122 -0
- package/dist/commands/library-remove.js +681 -0
- package/dist/commands/library.d.ts +26 -1
- package/dist/commands/library.js +101 -14
- package/dist/commands/secrets.d.ts +16 -0
- package/dist/commands/secrets.js +144 -8
- package/dist/commands/storage-offload.d.ts +22 -4
- package/dist/commands/storage-offload.js +196 -2
- package/dist/commands/storage.js +5 -0
- package/dist/lib/agent-kit/inbox-state.d.ts +7 -0
- package/dist/lib/agent-kit/inbox-state.js +5 -1
- package/dist/lib/agent-kit/mcp/tools.js +2 -1
- package/dist/lib/daemon/machine-login.d.ts +20 -0
- package/dist/lib/daemon/machine-login.js +53 -8
- package/dist/lib/daemon/machine-token-identity.d.ts +34 -0
- package/dist/lib/daemon/machine-token-identity.js +123 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +9 -0
- package/dist/lib/doctor/checks/sync-health.js +66 -1
- package/dist/lib/doctor/company-resolution-state.d.ts +30 -0
- package/dist/lib/doctor/company-resolution-state.js +89 -0
- package/dist/lib/doctor/fix/apply.d.ts +36 -6
- package/dist/lib/doctor/fix/apply.js +128 -12
- package/dist/lib/doctor/types.d.ts +1 -1
- package/dist/lib/doctor/types.js +1 -0
- package/dist/lib/hook-engine/resolve.js +6 -2
- package/dist/lib/index-render/company-knowledge.d.ts +1 -1
- package/dist/lib/index-render/company-knowledge.js +3 -2
- package/dist/lib/index-render/index.js +44 -3
- package/dist/lib/index-render/projects.d.ts +1 -1
- package/dist/lib/index-render/projects.js +4 -2
- package/dist/lib/lanes/registry-flag.d.ts +23 -2
- package/dist/lib/lanes/registry-flag.js +256 -36
- package/dist/lib/personal-secrets-audit.d.ts +31 -0
- package/dist/lib/personal-secrets-audit.js +45 -0
- package/dist/sentry-before-send.js +1 -0
- package/dist/utils/access-denied-hint.js +6 -0
- package/dist/utils/client-health.d.ts +34 -0
- package/dist/utils/client-health.js +96 -3
- package/package.json +7 -7
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { type CloudPlaceholder } from "@indigoai-us/hq-cloud";
|
|
3
3
|
import { type SearchCollection } from "../lib/search-index/index.js";
|
|
4
|
-
import { type
|
|
4
|
+
import { type CompaniesManifestMap } from "../lib/work-context/repo-remote.js";
|
|
5
|
+
import { type LibraryCompany, type LibraryAddResult } from "./library-add.js";
|
|
5
6
|
/** Mirrors hq-pro's MAX_ASSET_SEARCH_TAGS / tag length bound. */
|
|
6
7
|
export declare const MAX_LIBRARY_SEARCH_TAGS = 20;
|
|
7
8
|
export interface LibrarySearchOptions {
|
|
@@ -68,5 +69,29 @@ export declare function searchLocalAssets(query: string, options: LibrarySearchO
|
|
|
68
69
|
export declare function listLibraryAssets(hqRoot: string, directory: string, tags: readonly string[], env?: Pick<LocalSearchEnv, "readFile" | "listPlaceholders">): LibraryAssetHit[];
|
|
69
70
|
export declare function formatLibraryResult(result: LibrarySearchResult): string;
|
|
70
71
|
export declare function formatLibraryAddResult(result: LibraryAddResult): string;
|
|
72
|
+
/**
|
|
73
|
+
* The company for a library command run on an HQ_API_KEY, from a slug or a
|
|
74
|
+
* cmp_ uid. A key cannot read memberships (hq-pro serves no keyed
|
|
75
|
+
* /membership/me) and is bound to one company, which hq-pro checks on every
|
|
76
|
+
* keyed presign. So the slug ↔ uid mapping comes from the HQ tree's
|
|
77
|
+
* companies/manifest.yaml (`<slug>: { cloud_uid }`), as `hq secrets` does,
|
|
78
|
+
* and a key minted for another company is refused by the server.
|
|
79
|
+
* `folderSlug` is the companies/<slug>/ of an explicit `--to`.
|
|
80
|
+
*/
|
|
81
|
+
export declare function libraryCompanyForApiKey(manifest: CompaniesManifestMap | null, ref: string, folderSlug?: string): LibraryCompany;
|
|
82
|
+
/**
|
|
83
|
+
* The company uid for `hq library link` on an HQ_API_KEY. A cmp_ uid passes
|
|
84
|
+
* through (hq-pro checks it against the key). A slug, from --company or from
|
|
85
|
+
* the placeholder's folder, maps through companies/manifest.yaml. With
|
|
86
|
+
* neither --company nor a placeholder, or with HQ_COMPANY_UID set and no
|
|
87
|
+
* --company, the existing key default applies (`fallback`). An explicit
|
|
88
|
+
* --company always wins: `--company <slug>` maps through the manifest even
|
|
89
|
+
* when HQ_COMPANY_UID is set, and the env uid is ignored.
|
|
90
|
+
*/
|
|
91
|
+
export declare function libraryLinkCompanyForApiKey(ref: string | undefined, placeholderSlug: string | undefined, deps: {
|
|
92
|
+
manifest: () => CompaniesManifestMap | null;
|
|
93
|
+
envCompanyUid?: string;
|
|
94
|
+
fallback: () => Promise<string>;
|
|
95
|
+
}): Promise<string>;
|
|
71
96
|
export declare function registerLibraryCommand(program: Command): void;
|
|
72
97
|
//# sourceMappingURL=library.d.ts.map
|
package/dist/commands/library.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `hq library search|add|tag|ls|fetch|describe|link` — the company media library.
|
|
2
|
+
* `hq library search|add|tag|ls|fetch|describe|link|rm` — the company media library.
|
|
3
3
|
* `add` and `tag` live in ./library-add.ts; AI captions (`add` and `describe`)
|
|
4
|
-
* live in ./library-caption.ts; `link` lives in ./library-link.ts; `
|
|
4
|
+
* live in ./library-caption.ts; `link` lives in ./library-link.ts; `rm` lives in
|
|
5
|
+
* ./library-remove.ts; `fetch`
|
|
5
6
|
* wraps `hq storage fetch`.
|
|
6
7
|
*
|
|
7
8
|
* `hq library search <query> [--tag t] [--json]` — find library assets.
|
|
@@ -34,10 +35,12 @@ import { ASSET_COLLECTION_MASK, deriveCollections, runQmd, } from "../lib/search
|
|
|
34
35
|
import { findHqRoot } from "../utils/manifest.js";
|
|
35
36
|
import { networkTransportErrorCode } from "../utils/network-transport-error.js";
|
|
36
37
|
import { resolveVaultCredentialForCapability } from "../utils/resolve-vault-credential.js";
|
|
37
|
-
import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
38
|
+
import { getCompanyUid, looksLikeCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
39
|
+
import { readCompaniesManifestMap } from "../lib/work-context/repo-remote.js";
|
|
38
40
|
import { defaultLibraryIo, resolveLibrarySyncScope, runLibraryAdd, runLibraryTag, } from "./library-add.js";
|
|
39
41
|
import { captionAddedAsset, extractCaptionFrames, runLibraryDescribe, runLocalClaude } from "./library-caption.js";
|
|
40
42
|
import { LIBRARY_LINK_DEFAULT_MINUTES, LIBRARY_LINK_MAX_MINUTES, runLibraryLink } from "./library-link.js";
|
|
43
|
+
import { registerLibraryRemoveCommand } from "./library-remove.js";
|
|
41
44
|
/** Mirrors hq-pro's MAX_ASSET_SEARCH_TAGS / tag length bound. */
|
|
42
45
|
export const MAX_LIBRARY_SEARCH_TAGS = 20;
|
|
43
46
|
const MAX_TAG_LENGTH = 100;
|
|
@@ -335,17 +338,92 @@ async function downloadPresigned(url) {
|
|
|
335
338
|
throw new Error(`Could not download the asset (HTTP ${res.status}).`);
|
|
336
339
|
return Readable.fromWeb(res.body);
|
|
337
340
|
}
|
|
338
|
-
/** The active company membership for `slug
|
|
339
|
-
async function libraryCompany(token,
|
|
341
|
+
/** The active company membership for `ref` (slug or cmp_ uid), via /membership/me. */
|
|
342
|
+
async function libraryCompany(token, ref) {
|
|
340
343
|
const { companiesFromMemberships } = await import("./storage.js");
|
|
341
344
|
const res = await vaultApiFetch({ token, path: "/membership/me" });
|
|
342
345
|
if (!res.ok)
|
|
343
346
|
throw new Error(`Could not load your companies (HTTP ${res.status}). Run \`hq login\`.`);
|
|
344
|
-
const company = companiesFromMemberships(await res.json()).find((c) => c.slug ===
|
|
347
|
+
const company = companiesFromMemberships(await res.json()).find((c) => c.slug === ref || c.uid === ref);
|
|
345
348
|
if (!company)
|
|
346
|
-
throw new Error(`You are not an active member of '${
|
|
349
|
+
throw new Error(`You are not an active member of '${ref}'.`);
|
|
347
350
|
return { uid: company.uid, slug: company.slug };
|
|
348
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* The company for a library command run on an HQ_API_KEY, from a slug or a
|
|
354
|
+
* cmp_ uid. A key cannot read memberships (hq-pro serves no keyed
|
|
355
|
+
* /membership/me) and is bound to one company, which hq-pro checks on every
|
|
356
|
+
* keyed presign. So the slug ↔ uid mapping comes from the HQ tree's
|
|
357
|
+
* companies/manifest.yaml (`<slug>: { cloud_uid }`), as `hq secrets` does,
|
|
358
|
+
* and a key minted for another company is refused by the server.
|
|
359
|
+
* `folderSlug` is the companies/<slug>/ of an explicit `--to`.
|
|
360
|
+
*/
|
|
361
|
+
export function libraryCompanyForApiKey(manifest, ref, folderSlug) {
|
|
362
|
+
const uidFor = (slug) => {
|
|
363
|
+
const uid = manifest?.[slug]?.cloud_uid;
|
|
364
|
+
return typeof uid === "string" && looksLikeCompanyUid(uid) ? uid : undefined;
|
|
365
|
+
};
|
|
366
|
+
// A missing or unparsable manifest is its own problem: say so rather than
|
|
367
|
+
// report a missing cloud_uid. A uid with an explicit --to folder needs no
|
|
368
|
+
// lookup, so it still goes through.
|
|
369
|
+
if (manifest === null && (!looksLikeCompanyUid(ref) || folderSlug === undefined)) {
|
|
370
|
+
throw new Error(`HQ_API_KEY is set and companies/manifest.yaml could not be read (missing or not valid YAML), ` +
|
|
371
|
+
`so '${ref}' cannot be matched to a company. Fix the manifest, or pass --company <cmp_uid> ` +
|
|
372
|
+
"(for library add, with --to companies/<slug>/<dir>).");
|
|
373
|
+
}
|
|
374
|
+
if (!looksLikeCompanyUid(ref)) {
|
|
375
|
+
const uid = uidFor(ref);
|
|
376
|
+
if (!uid) {
|
|
377
|
+
throw new Error(`HQ_API_KEY is set and companies/manifest.yaml has no cloud_uid for '${ref}'. ` +
|
|
378
|
+
`Pass --company <cmp_uid> (for library add, with --to companies/${ref}/<dir>).`);
|
|
379
|
+
}
|
|
380
|
+
return { uid, slug: ref };
|
|
381
|
+
}
|
|
382
|
+
if (folderSlug !== undefined) {
|
|
383
|
+
const known = uidFor(folderSlug);
|
|
384
|
+
if (known !== undefined && known !== ref) {
|
|
385
|
+
throw new Error(`--to is inside companies/${folderSlug}/, which is ${known}, but --company is ${ref}.`);
|
|
386
|
+
}
|
|
387
|
+
return { uid: ref, slug: folderSlug };
|
|
388
|
+
}
|
|
389
|
+
const slugs = Object.keys(manifest ?? {}).filter((slug) => !looksLikeCompanyUid(slug) && uidFor(slug) === ref);
|
|
390
|
+
if (slugs.length !== 1) {
|
|
391
|
+
throw new Error(`HQ_API_KEY is set and companies/manifest.yaml ${slugs.length === 0 ? "names no company folder" : `names ${slugs.length} company folders`} for ${ref}. Pass --company <slug> or --to companies/<slug>/<dir>.`);
|
|
392
|
+
}
|
|
393
|
+
return { uid: ref, slug: slugs[0] };
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* The company uid for `hq library link` on an HQ_API_KEY. A cmp_ uid passes
|
|
397
|
+
* through (hq-pro checks it against the key). A slug, from --company or from
|
|
398
|
+
* the placeholder's folder, maps through companies/manifest.yaml. With
|
|
399
|
+
* neither --company nor a placeholder, or with HQ_COMPANY_UID set and no
|
|
400
|
+
* --company, the existing key default applies (`fallback`). An explicit
|
|
401
|
+
* --company always wins: `--company <slug>` maps through the manifest even
|
|
402
|
+
* when HQ_COMPANY_UID is set, and the env uid is ignored.
|
|
403
|
+
*/
|
|
404
|
+
export async function libraryLinkCompanyForApiKey(ref, placeholderSlug, deps) {
|
|
405
|
+
if (ref !== undefined && looksLikeCompanyUid(ref))
|
|
406
|
+
return ref;
|
|
407
|
+
const slug = ref ?? (deps.envCompanyUid ? undefined : placeholderSlug);
|
|
408
|
+
if (slug === undefined)
|
|
409
|
+
return deps.fallback();
|
|
410
|
+
return libraryCompanyForApiKey(deps.manifest(), slug).uid;
|
|
411
|
+
}
|
|
412
|
+
function manifestOrNull() {
|
|
413
|
+
try {
|
|
414
|
+
return readCompaniesManifestMap(findHqRoot());
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/** Company resolver for `add` and `describe`: memberships on a session, the manifest on a key. */
|
|
421
|
+
function libraryCompanyResolver(credential, hqRoot) {
|
|
422
|
+
if (credential.kind === "api-key") {
|
|
423
|
+
return async (ref, folderSlug) => libraryCompanyForApiKey(readCompaniesManifestMap(hqRoot), ref, folderSlug);
|
|
424
|
+
}
|
|
425
|
+
return (ref) => libraryCompany(credential.token, ref);
|
|
426
|
+
}
|
|
349
427
|
export function registerLibraryCommand(program) {
|
|
350
428
|
const library = program
|
|
351
429
|
.command("library")
|
|
@@ -392,7 +470,7 @@ export function registerLibraryCommand(program) {
|
|
|
392
470
|
.option("--tags <tags>", "Tags (comma-separated, repeatable)", collectTag, [])
|
|
393
471
|
.option("--title <title>", "Asset title (default: the file name)")
|
|
394
472
|
.option("--note <text>", "Asset description")
|
|
395
|
-
.option("--company <
|
|
473
|
+
.option("--company <company>", "Company slug or uid (cmp_…) whose vault receives the files")
|
|
396
474
|
.option("--no-caption", "Skip the AI description and suggested tags for images and videos")
|
|
397
475
|
.option("--json", "Print machine-readable JSON")
|
|
398
476
|
.action(async (sources, options) => {
|
|
@@ -411,7 +489,7 @@ export function registerLibraryCommand(program) {
|
|
|
411
489
|
}, {
|
|
412
490
|
hqRoot,
|
|
413
491
|
now: () => new Date(),
|
|
414
|
-
resolveCompany: (
|
|
492
|
+
resolveCompany: libraryCompanyResolver(credential, hqRoot),
|
|
415
493
|
presign: (body) => vaultApiFetch({ token, path: "/v1/files/presign", method: "POST", body }),
|
|
416
494
|
...defaultLibraryIo,
|
|
417
495
|
...(await librarySyncScopeDeps(credential, hqRoot)),
|
|
@@ -482,13 +560,15 @@ export function registerLibraryCommand(program) {
|
|
|
482
560
|
.option("--json", "Print machine-readable JSON")
|
|
483
561
|
.action(async (target, options) => {
|
|
484
562
|
try {
|
|
485
|
-
const
|
|
563
|
+
const credential = await resolveVaultCredentialForCapability("files:read", "library describe", {
|
|
486
564
|
interactive: true,
|
|
487
565
|
});
|
|
566
|
+
const { token } = credential;
|
|
567
|
+
const hqRoot = findHqRoot();
|
|
488
568
|
const result = await runLibraryDescribe(target, {
|
|
489
|
-
hqRoot
|
|
569
|
+
hqRoot,
|
|
490
570
|
now: () => new Date(),
|
|
491
|
-
resolveCompany: (
|
|
571
|
+
resolveCompany: libraryCompanyResolver(credential, hqRoot),
|
|
492
572
|
presign: (body) => vaultApiFetch({ token, path: "/v1/files/presign", method: "POST", body }),
|
|
493
573
|
download: downloadPresigned,
|
|
494
574
|
runLocal: runLocalClaude,
|
|
@@ -532,7 +612,7 @@ export function registerLibraryCommand(program) {
|
|
|
532
612
|
.command("link")
|
|
533
613
|
.description("Mint a short-lived download URL for a library asset (needs files:read)")
|
|
534
614
|
.argument("<target>", "A .hqcloud placeholder, its original path, or a vault key (.hq-offload/…)")
|
|
535
|
-
.option("--company <company>", "Company uid (cmp_…)
|
|
615
|
+
.option("--company <company>", "Company slug or uid (cmp_…); default: the placeholder's company or the key's")
|
|
536
616
|
.option("--expires <minutes>", `Link lifetime in minutes, 1-${LIBRARY_LINK_MAX_MINUTES} (default ${LIBRARY_LINK_DEFAULT_MINUTES})`, (value) => Number(value))
|
|
537
617
|
.option("--json", "Print machine-readable JSON")
|
|
538
618
|
.action(async (target, options) => {
|
|
@@ -544,7 +624,13 @@ export function registerLibraryCommand(program) {
|
|
|
544
624
|
const result = await runLibraryLink(target, { company: options.company, expiresMinutes: options.expires }, {
|
|
545
625
|
hqRoot: () => findHqRoot(),
|
|
546
626
|
apiKey: credential.kind === "api-key",
|
|
547
|
-
resolveCompany:
|
|
627
|
+
resolveCompany: credential.kind === "api-key"
|
|
628
|
+
? (ref, placeholderSlug) => libraryLinkCompanyForApiKey(ref, placeholderSlug, {
|
|
629
|
+
manifest: manifestOrNull,
|
|
630
|
+
envCompanyUid: process.env.HQ_COMPANY_UID?.trim() || undefined,
|
|
631
|
+
fallback: () => getCompanyUid(token, undefined),
|
|
632
|
+
})
|
|
633
|
+
: (ref) => getCompanyUid(token, ref),
|
|
548
634
|
presign: (body) => vaultApiFetch({ token, path: "/v1/files/presign", method: "POST", body }),
|
|
549
635
|
});
|
|
550
636
|
// The URL is the only stdout line (or the JSON), so scripts can capture it.
|
|
@@ -557,5 +643,6 @@ export function registerLibraryCommand(program) {
|
|
|
557
643
|
process.exitCode = 1;
|
|
558
644
|
}
|
|
559
645
|
});
|
|
646
|
+
registerLibraryRemoveCommand(library);
|
|
560
647
|
}
|
|
561
648
|
//# sourceMappingURL=library.js.map
|
|
@@ -45,5 +45,21 @@ export declare function parseDestinationUrl(raw: string): {
|
|
|
45
45
|
};
|
|
46
46
|
export declare function collectSecretNames(value: string, previous?: string[]): string[];
|
|
47
47
|
export declare function scrubSandboxOutput(text: string, secretNames?: string[]): string;
|
|
48
|
+
/**
|
|
49
|
+
* Check a `--remote-path` value and return the path to store, or the reason it
|
|
50
|
+
* can never match.
|
|
51
|
+
*
|
|
52
|
+
* An absolute path is sent exactly as typed: the server compares absolute
|
|
53
|
+
* approvals byte for byte with the path `hq secrets exec` reports. A relative
|
|
54
|
+
* path names the script from the HQ root. `\` becomes `/` and a leading `./`
|
|
55
|
+
* is dropped, then the same rules the server applies are checked: at least one
|
|
56
|
+
* folder before the file name, no `.`, `..` or empty segments, no `~`, no
|
|
57
|
+
* drive letter, and no surrounding whitespace.
|
|
58
|
+
*/
|
|
59
|
+
export declare function normalizeApprovedScriptPath(input: string): {
|
|
60
|
+
path: string;
|
|
61
|
+
} | {
|
|
62
|
+
error: string;
|
|
63
|
+
};
|
|
48
64
|
export declare function registerSecretsCommand(program: Command): void;
|
|
49
65
|
//# sourceMappingURL=secrets.d.ts.map
|
package/dist/commands/secrets.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as yaml from "js-yaml";
|
|
|
4
4
|
import { assertApiKeyCompany, buildSecretUsage, extractApiMessage, highSecuritySandboxOnlyMessage, loadRevealedSecrets, normalizeScriptLockMode, normalizeSecretTier, parseSecretNameList, requestedApiKeyCompany, runSecretsEnvCommand, scopeOpts, secretsCredentialOptions, } from "../secrets-env-action.js";
|
|
5
5
|
import { readSessionCompanyLock } from "../lib/session-company-lock.js";
|
|
6
6
|
import { sessionIdFromEnv } from "../lib/core-utils/session-id.js";
|
|
7
|
+
import { appendPersonalSecretsAudit, isEntityScopeRef, PERSONAL_SCOPE_READ_ONLY_MESSAGE, } from "../lib/personal-secrets-audit.js";
|
|
7
8
|
export { readSessionCompanyLock } from "../lib/session-company-lock.js";
|
|
8
9
|
export { buildSecretUsage, emitEnvExportsAndExit, loadRevealedSecrets, parseSecretNameList, runSecretsEnvCommand, } from "../secrets-env-action.js";
|
|
9
10
|
function companyUidForSlug(root, slug) {
|
|
@@ -53,7 +54,7 @@ async function resolveApiKeyCompanyWithinLock(input) {
|
|
|
53
54
|
throw new Error("HQ_API_KEY is not bound to a company in this session's locked company set.");
|
|
54
55
|
}
|
|
55
56
|
import chalk from "chalk";
|
|
56
|
-
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
57
|
+
import { cognitoSubjectFromToken, ensureCognitoToken } from "../utils/cognito-session.js";
|
|
57
58
|
/**
|
|
58
59
|
* Exit code for `hq secrets exec` run with no session id and no explicit
|
|
59
60
|
* company. 78 is EX_CONFIG from sysexits.h: the caller's invocation is missing
|
|
@@ -461,6 +462,61 @@ function normalizePolicyRecord(secretPath, data) {
|
|
|
461
462
|
scripts,
|
|
462
463
|
};
|
|
463
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* Check a `--remote-path` value and return the path to store, or the reason it
|
|
467
|
+
* can never match.
|
|
468
|
+
*
|
|
469
|
+
* An absolute path is sent exactly as typed: the server compares absolute
|
|
470
|
+
* approvals byte for byte with the path `hq secrets exec` reports. A relative
|
|
471
|
+
* path names the script from the HQ root. `\` becomes `/` and a leading `./`
|
|
472
|
+
* is dropped, then the same rules the server applies are checked: at least one
|
|
473
|
+
* folder before the file name, no `.`, `..` or empty segments, no `~`, no
|
|
474
|
+
* drive letter, and no surrounding whitespace.
|
|
475
|
+
*/
|
|
476
|
+
export function normalizeApprovedScriptPath(input) {
|
|
477
|
+
if (input.length === 0 || input.trim().length === 0) {
|
|
478
|
+
return { error: "the path is empty" };
|
|
479
|
+
}
|
|
480
|
+
if (input !== input.trim()) {
|
|
481
|
+
return { error: "the path starts or ends with whitespace" };
|
|
482
|
+
}
|
|
483
|
+
if (input.startsWith("/") || input.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(input)) {
|
|
484
|
+
return { path: input };
|
|
485
|
+
}
|
|
486
|
+
if (input.startsWith("~")) {
|
|
487
|
+
return {
|
|
488
|
+
error: "the path starts with ~, which is never expanded; give the absolute path or a path relative to the HQ root",
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (/^[A-Za-z]:/.test(input)) {
|
|
492
|
+
return { error: "the path starts with a drive letter but is not absolute" };
|
|
493
|
+
}
|
|
494
|
+
const relative = input.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
|
|
495
|
+
const segments = relative.split("/");
|
|
496
|
+
if (segments.some((segment) => segment === "")) {
|
|
497
|
+
return {
|
|
498
|
+
error: "the path has an empty segment (a trailing or doubled /)",
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if (segments.some((segment) => segment === "." || segment === "..")) {
|
|
502
|
+
return { error: "the path has a . or .. segment" };
|
|
503
|
+
}
|
|
504
|
+
if (segments.length < 2) {
|
|
505
|
+
return {
|
|
506
|
+
error: "a bare file name would match that file in any folder; include at least one folder, for example skills/<skill>/<file>",
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
return { path: relative };
|
|
510
|
+
}
|
|
511
|
+
function approvalIsActive(policy, scriptId) {
|
|
512
|
+
const approved = policy.scriptLock?.approvedScripts;
|
|
513
|
+
if (!Array.isArray(approved))
|
|
514
|
+
return true;
|
|
515
|
+
return approved.some((script) => script !== null &&
|
|
516
|
+
typeof script === "object" &&
|
|
517
|
+
script.scriptId === scriptId &&
|
|
518
|
+
!script.revokedAt);
|
|
519
|
+
}
|
|
464
520
|
function isSecretPolicyScript(value) {
|
|
465
521
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
466
522
|
return false;
|
|
@@ -508,6 +564,35 @@ export function registerSecretsCommand(program) {
|
|
|
508
564
|
.description("Manage secrets in HQ vault (SSM Parameter Store)")
|
|
509
565
|
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
510
566
|
.option("--personal", "Operate on the caller's personal vault (no sharing)");
|
|
567
|
+
// In a session locked to a company, the caller's personal vault is read-only
|
|
568
|
+
// and reachable only through `secrets exec` (which audit-logs each use).
|
|
569
|
+
// Writes, sharing, listing and printing values stay refused.
|
|
570
|
+
secrets.hook("preAction", async (_parent, actionCommand) => {
|
|
571
|
+
if (actionCommand.parent === secrets && actionCommand.name() === "exec")
|
|
572
|
+
return;
|
|
573
|
+
const parentOpts = secrets.opts();
|
|
574
|
+
const actionOpts = actionCommand.opts();
|
|
575
|
+
const companyRef = actionOpts.company ?? parentOpts.company ?? process.env.HQ_COMPANY_UID?.trim();
|
|
576
|
+
// --company prs_…/agt_… names a personal vault: same read-only rule.
|
|
577
|
+
const personal = Boolean(parentOpts.personal) || Boolean(actionOpts.personal)
|
|
578
|
+
|| (companyRef !== undefined && isEntityScopeRef(companyRef));
|
|
579
|
+
if (!personal || !sessionIdFromEnv(process.env))
|
|
580
|
+
return;
|
|
581
|
+
if (!(await fastFlagEnabledDefaultOn(MULTI_COMPANY_SESSION_LOCK_FLAG)))
|
|
582
|
+
return;
|
|
583
|
+
let lock;
|
|
584
|
+
try {
|
|
585
|
+
lock = readSessionCompanyLock(process.env);
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
589
|
+
process.exit(1);
|
|
590
|
+
}
|
|
591
|
+
if (!lock || lock.companies.length === 0 || lock.companies.includes("personal"))
|
|
592
|
+
return;
|
|
593
|
+
console.error(chalk.red("Error:"), PERSONAL_SCOPE_READ_ONLY_MESSAGE);
|
|
594
|
+
process.exit(1);
|
|
595
|
+
});
|
|
511
596
|
secrets
|
|
512
597
|
.command("set <name>")
|
|
513
598
|
.description("Create or update a secret")
|
|
@@ -958,7 +1043,7 @@ export function registerSecretsCommand(program) {
|
|
|
958
1043
|
.description("Approve a script for a secret path")
|
|
959
1044
|
.requiredOption("--id <scriptId>", "Stable script identifier")
|
|
960
1045
|
.requiredOption("--script <path>", "Path to the local script file")
|
|
961
|
-
.option("--remote-path <path>", "Script path
|
|
1046
|
+
.option("--remote-path <path>", "Script path to approve instead of the local path. A path relative to the HQ root (for example companies/<co>/skills/<skill>/<file>) matches on every HQ root; an absolute path must match the runtime's path exactly")
|
|
962
1047
|
.option("--attestation <level>", "Attestation level", "self-asserted-hash")
|
|
963
1048
|
.action(async (secretPath, opts) => {
|
|
964
1049
|
try {
|
|
@@ -967,7 +1052,16 @@ export function registerSecretsCommand(program) {
|
|
|
967
1052
|
console.error(chalk.red(`Invalid secret path '${secretPath}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
968
1053
|
process.exit(1);
|
|
969
1054
|
}
|
|
1055
|
+
const checkedRemotePath = opts.remotePath === undefined
|
|
1056
|
+
? undefined
|
|
1057
|
+
: normalizeApprovedScriptPath(opts.remotePath);
|
|
1058
|
+
if (checkedRemotePath && "error" in checkedRemotePath) {
|
|
1059
|
+
console.error(chalk.red(`Invalid --remote-path '${opts.remotePath}': ${checkedRemotePath.error}.`));
|
|
1060
|
+
process.exit(1);
|
|
1061
|
+
}
|
|
1062
|
+
const remotePath = checkedRemotePath?.path;
|
|
970
1063
|
const usage = await buildSecretUsage("exec", opts.script, opts.id, opts.attestation);
|
|
1064
|
+
const approvedPath = remotePath ?? usage.script?.path;
|
|
971
1065
|
const token = await requireCognitoTokenForSecrets("secrets script approve", "secrets:write");
|
|
972
1066
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
973
1067
|
const res = await vaultApiFetch({
|
|
@@ -977,7 +1071,7 @@ export function registerSecretsCommand(program) {
|
|
|
977
1071
|
body: {
|
|
978
1072
|
path: secretPath,
|
|
979
1073
|
scriptId: usage.script?.scriptId,
|
|
980
|
-
scriptPath:
|
|
1074
|
+
scriptPath: approvedPath,
|
|
981
1075
|
sha256: usage.script?.sha256,
|
|
982
1076
|
attestationLevel: usage.script?.attestationLevel,
|
|
983
1077
|
},
|
|
@@ -988,7 +1082,12 @@ export function registerSecretsCommand(program) {
|
|
|
988
1082
|
process.exit(1);
|
|
989
1083
|
}
|
|
990
1084
|
removeCacheEntry(companyUid, secretPath);
|
|
991
|
-
|
|
1085
|
+
const data = (await res.json().catch(() => ({})));
|
|
1086
|
+
if (data.policy && !approvalIsActive(data.policy, opts.id)) {
|
|
1087
|
+
console.error(chalk.red(`Failed to approve script: the server did not store an active approval for '${opts.id}' on '${secretPath}'. If this id was revoked before, approve the script under a new --id and pass that id to --script-id.`));
|
|
1088
|
+
process.exit(1);
|
|
1089
|
+
}
|
|
1090
|
+
console.log(chalk.green(`Approved script '${opts.id}' for '${secretPath}' (path: ${approvedPath}).`));
|
|
992
1091
|
}
|
|
993
1092
|
catch (err) {
|
|
994
1093
|
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
@@ -1181,9 +1280,9 @@ export function registerSecretsCommand(program) {
|
|
|
1181
1280
|
process.exit(1);
|
|
1182
1281
|
}
|
|
1183
1282
|
const keys = parseSecretNameList(_opts.only);
|
|
1184
|
-
const parentScope = secrets.opts();
|
|
1283
|
+
const parentScope = { ...secrets.opts() };
|
|
1185
1284
|
const inheritedCompany = parentScope.company;
|
|
1186
|
-
|
|
1285
|
+
let requestedCompany = _opts.company ?? inheritedCompany;
|
|
1187
1286
|
// A launchd or cron job has no session id. Session resolution would then
|
|
1188
1287
|
// fall back to workspace/sessions/.current, which names whichever
|
|
1189
1288
|
// interactive session last ran a hook, and the job would read that
|
|
@@ -1200,6 +1299,17 @@ export function registerSecretsCommand(program) {
|
|
|
1200
1299
|
const sessionLock = multiCompanyLockEnabled && hasCallerSession ? readSessionCompanyLock(process.env) : undefined;
|
|
1201
1300
|
const hasLockedCompanies = (sessionLock?.companies.length ?? 0) > 0;
|
|
1202
1301
|
const lockRoot = hasLockedCompanies ? resolveDefaultHqRoot({ env: process.env }) : undefined;
|
|
1302
|
+
// A prs_/agt_ value passed as --company names a personal vault. In a
|
|
1303
|
+
// locked session it gets exactly the --personal rules: one locked
|
|
1304
|
+
// company, exec only, the caller's own uid only, and the audit line.
|
|
1305
|
+
const entityScopeRef = hasLockedCompanies && requestedCompany && isEntityScopeRef(requestedCompany)
|
|
1306
|
+
? requestedCompany
|
|
1307
|
+
: undefined;
|
|
1308
|
+
if (entityScopeRef) {
|
|
1309
|
+
requestedCompany = undefined;
|
|
1310
|
+
delete parentScope.company;
|
|
1311
|
+
parentScope.personal = true;
|
|
1312
|
+
}
|
|
1203
1313
|
const selectedCompany = (hasLockedCompanies && requestedCompany && looksLikeCompanyUid(requestedCompany)
|
|
1204
1314
|
? companySlugForUid(lockRoot, sessionLock.companies, requestedCompany) ?? requestedCompany
|
|
1205
1315
|
: requestedCompany)
|
|
@@ -1207,8 +1317,14 @@ export function registerSecretsCommand(program) {
|
|
|
1207
1317
|
if (hasLockedCompanies && selectedCompany && !sessionLock.companies.includes(selectedCompany)) {
|
|
1208
1318
|
throw new Error(`Company '${selectedCompany}' is outside this session's locked company set.`);
|
|
1209
1319
|
}
|
|
1210
|
-
|
|
1211
|
-
|
|
1320
|
+
// Owner decision dq_20261020234444_f1b1ea4185: the caller's own personal
|
|
1321
|
+
// vault may be read next to exactly one locked company, through exec
|
|
1322
|
+
// only, and every use is audit-logged below.
|
|
1323
|
+
const personalNextToCompany = hasLockedCompanies
|
|
1324
|
+
&& Boolean(parentScope.personal)
|
|
1325
|
+
&& !sessionLock.companies.includes("personal");
|
|
1326
|
+
if (personalNextToCompany && sessionLock.companies.length !== 1) {
|
|
1327
|
+
throw new Error(`Personal scope can be read next to exactly one locked company; this session locks ${sessionLock.companies.length}.`);
|
|
1212
1328
|
}
|
|
1213
1329
|
if (hasLockedCompanies && !selectedCompany && !parentScope.personal) {
|
|
1214
1330
|
throw new Error("This session has no locked company scope for secrets exec.");
|
|
@@ -1220,6 +1336,9 @@ export function registerSecretsCommand(program) {
|
|
|
1220
1336
|
? scopeOpts({ personal: true })
|
|
1221
1337
|
: scopeOpts({ ...parentScope, ...(selectedCompany ? { company: selectedCompany } : {}) });
|
|
1222
1338
|
const cred = await resolveVaultCredentialForCapability("secrets:read", "secrets exec", secretsCredentialOptions());
|
|
1339
|
+
if ((personalNextToCompany || entityScopeRef) && cred.kind !== "cognito") {
|
|
1340
|
+
throw new Error("Personal scope in a company-locked session needs your own HQ sign-in, not an API key.");
|
|
1341
|
+
}
|
|
1223
1342
|
const apiKeyCompany = cred.kind === "api-key" && hasLockedCompanies
|
|
1224
1343
|
? await resolveApiKeyCompanyWithinLock({
|
|
1225
1344
|
token: cred.token,
|
|
@@ -1240,6 +1359,23 @@ export function registerSecretsCommand(program) {
|
|
|
1240
1359
|
? await assertApiKeyCompany(cred.token, unlockedApiKeyCompanyRef, "secrets exec")
|
|
1241
1360
|
: "__api_key__"))
|
|
1242
1361
|
: await getEntityUid(cred.token, secretScope, { allowOfflineScopeFallback: true });
|
|
1362
|
+
if (entityScopeRef && companyUid !== entityScopeRef) {
|
|
1363
|
+
throw new Error("Another person's personal vault is outside this session's scope; refusing.");
|
|
1364
|
+
}
|
|
1365
|
+
if (personalNextToCompany) {
|
|
1366
|
+
// Only the caller's own scope: getEntityUid({ personal }) resolves the
|
|
1367
|
+
// token's own entity. Refuse anything that is not a person/agent-self
|
|
1368
|
+
// uid, or that disagrees with the token's entity claim.
|
|
1369
|
+
const claimedUid = cognitoSubjectFromToken(cred.token).entityUid;
|
|
1370
|
+
if (!/^(prs|agt)_/.test(companyUid) || (claimedUid && claimedUid !== companyUid)) {
|
|
1371
|
+
throw new Error("Personal scope did not resolve to your own vault; refusing.");
|
|
1372
|
+
}
|
|
1373
|
+
appendPersonalSecretsAudit(lockRoot, {
|
|
1374
|
+
session_id: sessionLock.sessionId,
|
|
1375
|
+
locked_company: sessionLock.companies[0],
|
|
1376
|
+
secret_names: keys,
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1243
1379
|
const readSecrets = async () => loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
|
|
1244
1380
|
? await buildSecretUsage("exec", _opts.script, _opts.scriptId)
|
|
1245
1381
|
: undefined, { allowOfflineFallback: true });
|
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
* recorded in the sync journal as synced and cloud-only, then the
|
|
24
24
|
* placeholder is written, then the local file is removed. A path the
|
|
25
25
|
* sync ignore rules exclude is refused and the excluding rule is named
|
|
26
|
-
* (for `.hqinclude`, with the exact line to add).
|
|
26
|
+
* (for `.hqinclude`, with the exact line to add). A library original
|
|
27
|
+
* restored by `hq library fetch`, which the journal does not know, is
|
|
28
|
+
* pointed back at its verified blob `.hq-offload/blobs/<sha256>` in the
|
|
29
|
+
* company vault (`point-at-library`), uploading nothing. Other unsynced
|
|
27
30
|
* company files are skipped with "run hq sync first".
|
|
28
31
|
* - Any other file points at its personal-vault sync object the same
|
|
29
32
|
* way, or is uploaded to the personal vault's `.hq-offload/blobs/`.
|
|
@@ -65,6 +68,12 @@ export declare const RUN_SYNC_FIRST = "run hq sync first";
|
|
|
65
68
|
export declare const SYNC_MAX_FILE_BYTES: number;
|
|
66
69
|
/** Object metadata key holding the sha256 of a company-vault offload upload. */
|
|
67
70
|
export declare const VAULT_SHA256_METADATA_KEY = "hq-sha256";
|
|
71
|
+
/**
|
|
72
|
+
* Object metadata key on library blobs (`hq library add`). hq-pro's put
|
|
73
|
+
* presign binds it to the `x-amz-checksum-sha256` it signs, and S3 checks that
|
|
74
|
+
* checksum against the bytes, so it is the stored bytes' sha256.
|
|
75
|
+
*/
|
|
76
|
+
export declare const CONTENT_SHA256_METADATA_KEY = "hq-content-sha256";
|
|
68
77
|
/** Top-level HQ-root folder that holds offloaded blobs in the personal vault. */
|
|
69
78
|
export declare const OFFLOAD_TOP_LEVEL = ".hq-offload";
|
|
70
79
|
/**
|
|
@@ -197,11 +206,13 @@ export interface VaultObjectStore {
|
|
|
197
206
|
/**
|
|
198
207
|
* Size and unquoted ETag of `key`, or null when it is not there. `sha256` is
|
|
199
208
|
* the object's `hq-sha256` metadata, set on company-vault offload uploads.
|
|
209
|
+
* `contentSha256` is its `hq-content-sha256` metadata, set on library blobs.
|
|
200
210
|
*/
|
|
201
211
|
head(key: string): Promise<{
|
|
202
212
|
size: number;
|
|
203
213
|
etag: string;
|
|
204
214
|
sha256?: string;
|
|
215
|
+
contentSha256?: string;
|
|
205
216
|
} | null>;
|
|
206
217
|
/** Download `key` to `dest` only if its ETag is `etag`; returns the served ETag. */
|
|
207
218
|
download(key: string, dest: string, etag: string): Promise<string>;
|
|
@@ -403,10 +414,17 @@ export interface CurrentFilePlan {
|
|
|
403
414
|
/**
|
|
404
415
|
* upload+offload: a company file over the sync limit, uploaded to the
|
|
405
416
|
* company vault at `vault_key`, then replaced by a placeholder.
|
|
417
|
+
* point-at-library: a restored library original whose verified blob is
|
|
418
|
+
* already in the company vault at `vault_key`; nothing is uploaded.
|
|
406
419
|
*/
|
|
407
|
-
action: "point-at-vault" | "upload" | "upload+offload" | "skip";
|
|
408
|
-
/** Company vault key for upload+offload. */
|
|
420
|
+
action: "point-at-vault" | "upload" | "upload+offload" | "point-at-library" | "skip";
|
|
421
|
+
/** Company vault key for upload+offload and point-at-library. */
|
|
409
422
|
vault_key?: string;
|
|
423
|
+
/**
|
|
424
|
+
* point-at-library only: "kept" when the library placeholder already sits
|
|
425
|
+
* beside the file (it is left as is), "written" when offload writes it.
|
|
426
|
+
*/
|
|
427
|
+
placeholder?: "kept" | "written";
|
|
410
428
|
/** The HQ sync ignore layer that excludes this path. */
|
|
411
429
|
excluded_by?: string;
|
|
412
430
|
reason?: string;
|
|
@@ -416,7 +434,7 @@ export interface CurrentOffloaded {
|
|
|
416
434
|
bytes: number;
|
|
417
435
|
vault: "personal" | "company";
|
|
418
436
|
company?: string;
|
|
419
|
-
action: "point-at-vault" | "upload" | "upload+offload";
|
|
437
|
+
action: "point-at-vault" | "upload" | "upload+offload" | "point-at-library";
|
|
420
438
|
}
|
|
421
439
|
export interface OffloadOptions {
|
|
422
440
|
minBytes: number;
|