@indigoai-us/hq-cli 5.77.3 → 5.77.5

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/commands/cloud-demote.js +3 -2
  3. package/dist/commands/cloud-provision.js +3 -2
  4. package/dist/commands/meetings.js +37 -0
  5. package/dist/commands/pack-install.js +5 -2
  6. package/dist/commands/people.js +2 -8
  7. package/dist/commands/pkg-install.js +3 -3
  8. package/dist/commands/pkg-update.js +2 -2
  9. package/dist/commands/publish.js +2 -1
  10. package/dist/commands/secrets.js +25 -19
  11. package/dist/commands/workers.js +3 -2
  12. package/dist/utils/manifest.js +3 -2
  13. package/dist/utils/people.js +2 -8
  14. package/dist/utils/registry-client.js +2 -2
  15. package/dist/utils/registry.js +2 -1
  16. package/dist/utils/user-yaml-error.d.ts +9 -0
  17. package/dist/utils/user-yaml-error.js +25 -0
  18. package/package.json +1 -1
  19. package/src/commands/cloud-demote.ts +3 -2
  20. package/src/commands/cloud-provision.ts +3 -2
  21. package/src/commands/meetings.test.ts +56 -0
  22. package/src/commands/meetings.ts +44 -0
  23. package/src/commands/pack-install.ts +5 -2
  24. package/src/commands/people.test.ts +12 -3
  25. package/src/commands/people.ts +5 -9
  26. package/src/commands/pkg-install.ts +4 -4
  27. package/src/commands/pkg-update.ts +3 -3
  28. package/src/commands/publish.ts +5 -1
  29. package/src/commands/secrets.test.ts +55 -6
  30. package/src/commands/secrets.ts +30 -31
  31. package/src/commands/workers.ts +9 -6
  32. package/src/utils/manifest.test.ts +19 -0
  33. package/src/utils/manifest.ts +3 -2
  34. package/src/utils/people.ts +2 -9
  35. package/src/utils/registry-client.ts +2 -2
  36. package/src/utils/registry.ts +2 -1
  37. package/src/utils/user-yaml-error.test.ts +24 -0
  38. package/src/utils/user-yaml-error.ts +30 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.77.5]
6
+
7
+ ### Added
8
+
9
+ - **Cross-platform meeting-bot invites.** Run
10
+ `hq meetings invite <meeting-url>` to schedule the meeting bot from any
11
+ terminal. Add `--company <slug-or-uid>` to route the transcript to a company
12
+ vault; omitting it keeps the invite in your personal vault. (#252)
13
+
14
+ ### Fixed
15
+
16
+ - High-security secret refusals now clearly direct callers to `hq secrets
17
+ sandbox` or the HQ secret proxy, including sandbox-only denials and
18
+ `--script` attempts. (#253)
19
+
20
+ ## [5.77.4]
21
+
22
+ ### Fixed
23
+
24
+ - Malformed user-managed YAML now reports the affected file and line/column as
25
+ an expected, actionable CLI error instead of creating a Sentry exception.
26
+
5
27
  ## [5.77.2]
6
28
 
7
29
  ### Fixed
@@ -30,6 +30,7 @@
30
30
  import * as fs from "node:fs";
31
31
  import * as path from "node:path";
32
32
  import * as yaml from "js-yaml";
33
+ import { parseUserYaml } from "../utils/user-yaml-error.js";
33
34
  import chalk from "chalk";
34
35
  import { ProvisionError, companyConfigPath, companyDirPath, createDefaultVaultClient, manifestPath, validateManifestAndDir, validateSlug, } from "./cloud-provision.js";
35
36
  import { DEFAULT_HQ_ROOT, DEFAULT_VAULT_API_URL, ensureCognitoToken, } from "../utils/cognito-session.js";
@@ -49,7 +50,7 @@ export function flipCompanyYamlCloudOff(hqRoot, slug) {
49
50
  if (!fs.existsSync(yPath))
50
51
  return false;
51
52
  const raw = fs.readFileSync(yPath, "utf-8");
52
- const parsed = yaml.load(raw) ?? {};
53
+ const parsed = parseUserYaml(raw, yPath) ?? {};
53
54
  if (parsed.cloud === false)
54
55
  return false;
55
56
  parsed.cloud = false;
@@ -72,7 +73,7 @@ export function stripManifestCloudForSlug(hqRoot, slug) {
72
73
  if (!fs.existsSync(mPath))
73
74
  return false;
74
75
  const raw = fs.readFileSync(mPath, "utf-8");
75
- const parsed = yaml.load(raw) ?? {};
76
+ const parsed = parseUserYaml(raw, mPath) ?? {};
76
77
  const companies = parsed.companies;
77
78
  if (!companies || !(slug in companies))
78
79
  return false;
@@ -31,6 +31,7 @@ import chalk from "chalk";
31
31
  import * as fs from "node:fs";
32
32
  import * as path from "node:path";
33
33
  import * as yaml from "js-yaml";
34
+ import { parseUserYaml } from "../utils/user-yaml-error.js";
34
35
  import { share } from "@indigoai-us/hq-cloud";
35
36
  import { DEFAULT_HQ_ROOT, DEFAULT_VAULT_API_URL, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
36
37
  /** Custom error class so the CLI runner can map to exit codes. */
@@ -105,7 +106,7 @@ export function validateManifestAndDir(hqRoot, slug) {
105
106
  throw new ProvisionError(2, `companies/manifest.yaml not found at ${mPath}`);
106
107
  }
107
108
  const raw = fs.readFileSync(mPath, "utf-8");
108
- const parsed = yaml.load(raw);
109
+ const parsed = parseUserYaml(raw, mPath);
109
110
  if (!parsed ||
110
111
  typeof parsed !== "object" ||
111
112
  !("companies" in parsed) ||
@@ -225,7 +226,7 @@ export function ensureManifestEntryForProvision(hqRoot, slug) {
225
226
  export function patchManifest(hqRoot, slug, cloudUid, bucketName) {
226
227
  const mPath = manifestPath(hqRoot);
227
228
  const raw = fs.readFileSync(mPath, "utf-8");
228
- const parsed = yaml.load(raw) ?? { companies: {} };
229
+ const parsed = parseUserYaml(raw, mPath) ?? { companies: {} };
229
230
  if (!parsed.companies)
230
231
  parsed.companies = {};
231
232
  const existing = parsed.companies[slug];
@@ -208,6 +208,43 @@ export function registerMeetingsCommand(program) {
208
208
  process.exit(1);
209
209
  }
210
210
  });
211
+ // ── hq meetings invite <meeting-url> ──────────────────────────────
212
+ meetings
213
+ .command("invite <meetingUrl>")
214
+ .description("Invite the meeting bot to a Google Meet, Zoom, or Teams URL")
215
+ .action(async (meetingUrl) => {
216
+ try {
217
+ const token = await ensureCognitoToken();
218
+ const companySlug = meetings.opts().company;
219
+ const query = {};
220
+ if (companySlug)
221
+ query.companyId = await getCompanyUid(token, companySlug);
222
+ const res = await vaultApiFetch({
223
+ token,
224
+ method: "POST",
225
+ path: "/v1/bot/invite",
226
+ query,
227
+ body: { meetingUrl },
228
+ });
229
+ if (!res.ok)
230
+ await handleApiError(res);
231
+ const data = (await res.json());
232
+ if (meetings.opts().json) {
233
+ console.log(JSON.stringify(data, null, 2));
234
+ return;
235
+ }
236
+ console.log(chalk.green(`\n✓ Meeting bot invited to ${chalk.cyan(data.meetingUrl ?? meetingUrl)}.`));
237
+ if (data.botId)
238
+ console.log(chalk.dim(` Bot: ${data.botId}`));
239
+ if (data.status)
240
+ console.log(chalk.dim(` Status: ${data.status}`));
241
+ console.log();
242
+ }
243
+ catch (err) {
244
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
245
+ process.exit(1);
246
+ }
247
+ });
211
248
  // ── hq meetings get <id> ──────────────────────────────────────────
212
249
  meetings
213
250
  .command("get <meetingId>")
@@ -37,7 +37,7 @@ import * as fs from 'fs';
37
37
  import * as os from 'os';
38
38
  import * as path from 'path';
39
39
  import * as readline from 'readline';
40
- import * as yaml from 'js-yaml';
40
+ import { parseUserYaml } from '../utils/user-yaml-error.js';
41
41
  import { createHash, createPublicKey, verify as cryptoVerify, } from 'node:crypto';
42
42
  import { execFileSync, spawnSync } from 'child_process';
43
43
  import chalk from 'chalk';
@@ -940,9 +940,12 @@ export function validateManifest(payloadDir, hqVersion) {
940
940
  }
941
941
  let parsed;
942
942
  try {
943
- parsed = yaml.load(fs.readFileSync(manifestPath, 'utf-8'));
943
+ parsed = parseUserYaml(fs.readFileSync(manifestPath, 'utf-8'), manifestPath);
944
944
  }
945
945
  catch (e) {
946
+ if (e instanceof Error && e.expected === true) {
947
+ throw e;
948
+ }
946
949
  throw new Error(`package.yaml invalid YAML: ${e.message}`);
947
950
  }
948
951
  const m = parsed;
@@ -14,7 +14,7 @@
14
14
  import * as fs from "fs";
15
15
  import { Option } from "commander";
16
16
  import chalk from "chalk";
17
- import * as yaml from "js-yaml";
17
+ import { parseUserYaml } from "../utils/user-yaml-error.js";
18
18
  import { findHqRoot } from "../utils/manifest.js";
19
19
  import { manifestPath } from "./cloud-provision.js";
20
20
  import { ensureCognitoToken } from "../utils/cognito-session.js";
@@ -96,13 +96,7 @@ export function resolveCompanySlug(hqRoot, explicit) {
96
96
  throw new Error("Could not determine the active company — companies/manifest.yaml not found. " +
97
97
  "Re-run with --company <slug>.");
98
98
  }
99
- let manifest;
100
- try {
101
- manifest = yaml.load(fs.readFileSync(mPath, "utf-8"));
102
- }
103
- catch (err) {
104
- throw new Error(`companies/manifest.yaml is malformed: ${err instanceof Error ? err.message : String(err)}`);
105
- }
99
+ const manifest = parseUserYaml(fs.readFileSync(mPath, "utf-8"), mPath);
106
100
  const slugs = activeCompanySlugs(manifest ?? {});
107
101
  if (slugs.length === 1)
108
102
  return slugs[0];
@@ -15,7 +15,7 @@
15
15
  import * as fs from 'fs';
16
16
  import * as os from 'os';
17
17
  import * as path from 'path';
18
- import * as yaml from 'js-yaml';
18
+ import { parseUserYaml } from '../utils/user-yaml-error.js';
19
19
  import { execSync } from 'child_process';
20
20
  import chalk from 'chalk';
21
21
  import { ensureCognitoToken } from '../utils/cognito-session.js';
@@ -117,7 +117,7 @@ async function installPackage(slug, company) {
117
117
  const packageYamlPath = path.resolve(installDir, 'package.yaml');
118
118
  if (fs.existsSync(packageYamlPath)) {
119
119
  const pkgContent = fs.readFileSync(packageYamlPath, 'utf-8');
120
- const pkgMeta = yaml.load(pkgContent);
120
+ const pkgMeta = parseUserYaml(pkgContent, packageYamlPath);
121
121
  if (pkgMeta?.slug && pkgMeta.slug !== slug) {
122
122
  // Mismatch — clean up and abort
123
123
  fs.rmSync(installDir, { recursive: true, force: true });
@@ -137,7 +137,7 @@ async function installPackage(slug, company) {
137
137
  const packageYaml = path.resolve(installDir, 'package.yaml');
138
138
  if (fs.existsSync(packageYaml)) {
139
139
  const content = fs.readFileSync(packageYaml, 'utf-8');
140
- const meta = yaml.load(content);
140
+ const meta = parseUserYaml(content, packageYaml);
141
141
  if (meta?.version)
142
142
  version = meta.version;
143
143
  if (meta?.name)
@@ -7,7 +7,7 @@
7
7
  import * as fs from 'fs';
8
8
  import * as os from 'os';
9
9
  import * as path from 'path';
10
- import * as yaml from 'js-yaml';
10
+ import { parseUserYaml } from '../utils/user-yaml-error.js';
11
11
  import { execSync } from 'child_process';
12
12
  import chalk from 'chalk';
13
13
  import { ensureCognitoToken } from '../utils/cognito-session.js';
@@ -92,7 +92,7 @@ async function updatePackages(slug) {
92
92
  const packageYamlPath = path.resolve(installDir, 'package.yaml');
93
93
  if (fs.existsSync(packageYamlPath)) {
94
94
  const pkgContent = fs.readFileSync(packageYamlPath, 'utf-8');
95
- const pkgMeta = yaml.load(pkgContent);
95
+ const pkgMeta = parseUserYaml(pkgContent, packageYamlPath);
96
96
  if (pkgMeta?.slug && pkgMeta.slug !== entry.slug) {
97
97
  fs.rmSync(installDir, { recursive: true, force: true });
98
98
  throw new Error(`Package slug mismatch: expected "${entry.slug}", got "${pkgMeta.slug}"`);
@@ -31,6 +31,7 @@ import * as fs from 'fs';
31
31
  import * as os from 'os';
32
32
  import * as path from 'path';
33
33
  import * as yaml from 'js-yaml';
34
+ import { parseUserYaml } from '../utils/user-yaml-error.js';
34
35
  import { execFileSync } from 'child_process';
35
36
  import chalk from 'chalk';
36
37
  import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
@@ -91,7 +92,7 @@ export function stampAuthorYaml(manifest, author) {
91
92
  /** Read package.yaml, stamp author, write it back. Returns the stamped author. */
92
93
  export function stampAuthorIntoPackage(payloadDir, author) {
93
94
  const manifestPath = path.join(payloadDir, 'package.yaml');
94
- const parsed = yaml.load(fs.readFileSync(manifestPath, 'utf-8'));
95
+ const parsed = parseUserYaml(fs.readFileSync(manifestPath, 'utf-8'), manifestPath);
95
96
  fs.writeFileSync(manifestPath, stampAuthorYaml(parsed, author));
96
97
  }
97
98
  // ---------------------------------------------------------------------------
@@ -131,6 +131,12 @@ function promptSecretInteractively() {
131
131
  // server (the legacy per-key GET path had no such cap).
132
132
  const MAX_BATCH_NAMES = 100;
133
133
  const SECRET_LOAD_TIMEOUT_MS = 30_000;
134
+ function highSecuritySandboxOnlyMessage(secretName) {
135
+ const sandboxExample = secretName
136
+ ? `hq secrets sandbox --only ${secretName} -- <command>`
137
+ : "hq secrets sandbox --only <secret> -- <command>";
138
+ return `High-security secrets can run only in the sandbox (use \`${sandboxExample}\`) or through the HQ secret proxy. Local injection and script locks (\`--script\`) are not supported.`;
139
+ }
134
140
  function parseSecretAclPrincipal(principal) {
135
141
  const p = principal.trim();
136
142
  if (p === "@all") {
@@ -435,10 +441,13 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
435
441
  if (!res.ok) {
436
442
  const body = (await res.json().catch(() => ({})));
437
443
  const message = extractApiMessage(body, res.statusText);
438
- // High-security ("nuclear") refusal surfaced at the batch level (rather
439
- // than per-name): point the caller at the proxy and never leak plaintext.
440
- if (body.code === "high_security_denied" || body.highSecurity === true) {
441
- throw new Error("A requested secret is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.");
444
+ // High-security refusal surfaced at the batch level (rather than
445
+ // per-name): point the caller at the sandbox/proxy and never leak
446
+ // plaintext. A local script attestation is not an override.
447
+ if (body.code === "high_security_denied" ||
448
+ body.code === "high_security_sandbox_only" ||
449
+ body.highSecurity === true) {
450
+ throw new Error(highSecuritySandboxOnlyMessage());
442
451
  }
443
452
  if (res.status >= 400 &&
444
453
  res.status < 500 &&
@@ -508,14 +517,13 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
508
517
  continue;
509
518
  removeCacheEntry(companyUid, key);
510
519
  const err = errorsByName.get(key);
511
- // High-security ("nuclear") secret: the server refuses to vend it on the
512
- // local-injection (batch-load) path per-name code `high_security_denied`,
513
- // no plaintext returned. Every caller of loadRevealedSecrets injects or
514
- // prints the plaintext locally (`secrets get --reveal`, `secrets exec`,
515
- // `secrets env`), so a high-security secret can NEVER be used here. Surface
516
- // a clear, actionable error pointing at the proxy instead of a raw failure.
517
- if (err?.code === "high_security_denied") {
518
- firstFailure ??= new Error(`Secret '${key}' is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.`);
520
+ // Every caller of loadRevealedSecrets injects or prints the plaintext
521
+ // locally (`secrets get --reveal`, `secrets exec`, `secrets env`). Both
522
+ // high-security denial shapes therefore receive the same explicit
523
+ // sandbox/proxy guidance, even if the caller supplied `--script`.
524
+ if (err?.code === "high_security_denied" ||
525
+ err?.code === "high_security_sandbox_only") {
526
+ firstFailure ??= new Error(highSecuritySandboxOnlyMessage(key));
519
527
  continue;
520
528
  }
521
529
  const reason = err?.code === "not_found"
@@ -655,7 +663,7 @@ export function registerSecretsCommand(program) {
655
663
  removeCacheEntry(companyUid, name);
656
664
  console.log(chalk.green(formatSecretSaved(name, scopeLabel)));
657
665
  if (opts.highSecurity) {
658
- console.log(chalk.dim(` High-security: destination pinned to ${destinations?.[0]}. This value can never be revealed or injected locally only used through the HQ secret proxy.`));
666
+ console.log(chalk.dim(` High-security: destination pinned to ${destinations?.[0]}. This value can run only in the sandbox (hq secrets sandbox) or through the HQ secret proxy; local injection and script locks are unsupported.`));
659
667
  }
660
668
  }
661
669
  catch (err) {
@@ -677,13 +685,11 @@ export function registerSecretsCommand(program) {
677
685
  });
678
686
  if (!res.ok) {
679
687
  const body = (await res.json().catch(() => ({})));
680
- // High-security ("nuclear") secret: the server refuses to reveal it on
681
- // the local-injection path (403, no plaintext). Surface a clear,
682
- // actionable error pointing the user at the proxy rather than a raw
683
- // 4xx — the value can ONLY be used through the server-side proxy.
688
+ // High-security secrets cannot be revealed locally. Surface the
689
+ // hosted execution path rather than a raw 4xx; `--script` never
690
+ // overrides this boundary.
684
691
  if (res.status === 403 && body.highSecurity === true) {
685
- console.error(chalk.red(`Secret '${name}' is high-security and cannot be revealed locally.`));
686
- console.error(chalk.dim(" It can only be used through the HQ secret proxy, which keeps the plaintext server-side."));
692
+ console.error(chalk.red(highSecuritySandboxOnlyMessage(name)));
687
693
  process.exit(1);
688
694
  }
689
695
  console.error(chalk.red(`Failed to get secret: ${extractApiMessage(body, res.statusText)}`));
@@ -2,6 +2,7 @@ import chalk from "chalk";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import * as yaml from "js-yaml";
5
+ import { parseUserYaml } from "../utils/user-yaml-error.js";
5
6
  import { ensureCognitoToken } from "../utils/cognito-session.js";
6
7
  import { vaultApiFetch, getCompanyUid } from "./secrets.js";
7
8
  import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
@@ -11,7 +12,7 @@ export function readWorkerRegistry(hqRoot) {
11
12
  const p = path.join(hqRoot, "core/workers/registry.yaml");
12
13
  if (!fs.existsSync(p))
13
14
  return [];
14
- const doc = yaml.load(fs.readFileSync(p, "utf8"));
15
+ const doc = parseUserYaml(fs.readFileSync(p, "utf8"), p);
15
16
  return doc?.workers ?? [];
16
17
  }
17
18
  /**
@@ -87,7 +88,7 @@ export function writeGrantSidecar(hqRoot, workerPath, principalLabel) {
87
88
  const sidecar = path.join(dir, ".grants.yaml");
88
89
  let grants = [];
89
90
  if (fs.existsSync(sidecar)) {
90
- const doc = yaml.load(fs.readFileSync(sidecar, "utf8"));
91
+ const doc = parseUserYaml(fs.readFileSync(sidecar, "utf8"), sidecar);
91
92
  if (Array.isArray(doc?.grants))
92
93
  grants = doc.grants.filter((g) => typeof g === "string");
93
94
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import * as yaml from 'js-yaml';
4
+ import { parseUserYaml } from './user-yaml-error.js';
4
5
  const MANIFEST_FILE = 'modules.yaml';
5
6
  const LOCK_FILE = 'modules.lock';
6
7
  const STATE_FILE = '.hq-sync-state.json';
@@ -51,7 +52,7 @@ export function readManifest(hqRoot) {
51
52
  return null;
52
53
  }
53
54
  const content = fs.readFileSync(manifestPath, 'utf-8');
54
- return yaml.load(content);
55
+ return parseUserYaml(content, manifestPath);
55
56
  }
56
57
  export function writeManifest(hqRoot, manifest) {
57
58
  const manifestPath = getManifestPath(hqRoot);
@@ -68,7 +69,7 @@ export function readLock(hqRoot) {
68
69
  return null;
69
70
  }
70
71
  const content = fs.readFileSync(lockPath, 'utf-8');
71
- return yaml.load(content);
72
+ return parseUserYaml(content, lockPath);
72
73
  }
73
74
  export function writeLock(hqRoot, lock) {
74
75
  const lockPath = getLockPath(hqRoot);
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import * as fs from "fs";
18
18
  import * as path from "path";
19
- import * as yaml from "js-yaml";
19
+ import { parseUserYaml } from "./user-yaml-error.js";
20
20
  /**
21
21
  * Company slugs map directly onto a filesystem path segment, so we validate
22
22
  * them before joining to keep a malicious or fat-fingered `--company` value
@@ -45,13 +45,7 @@ export function companyPeopleDir(hqRoot, companySlug) {
45
45
  * surfaced as a half-row. Exported for unit testing.
46
46
  */
47
47
  export function parsePersonMeta(raw, slug, source) {
48
- let doc;
49
- try {
50
- doc = yaml.load(raw);
51
- }
52
- catch (err) {
53
- throw new Error(`Failed to parse ${source}: ${err instanceof Error ? err.message : String(err)}`);
54
- }
48
+ const doc = parseUserYaml(raw, source);
55
49
  if (!doc || typeof doc !== "object")
56
50
  return null;
57
51
  const d = doc;
@@ -6,8 +6,8 @@
6
6
  */
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
- import * as yaml from 'js-yaml';
10
9
  import { resolveDefaultHqRoot } from './cognito-session.js';
10
+ import { parseUserYaml } from './user-yaml-error.js';
11
11
  // ---------------------------------------------------------------------------
12
12
  // URL helper (unchanged from US-004)
13
13
  // ---------------------------------------------------------------------------
@@ -23,7 +23,7 @@ export function getRegistryUrl() {
23
23
  throw new Error(`No packages/sources.yaml found at ${sourcesPath}. Is your HQ packages directory set up?`);
24
24
  }
25
25
  const content = fs.readFileSync(sourcesPath, 'utf-8');
26
- const parsed = yaml.load(content);
26
+ const parsed = parseUserYaml(content, sourcesPath);
27
27
  if (!parsed?.sources?.length) {
28
28
  throw new Error('No sources defined in packages/sources.yaml');
29
29
  }
@@ -4,6 +4,7 @@
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
6
  import * as yaml from 'js-yaml';
7
+ import { parseUserYaml } from './user-yaml-error.js';
7
8
  function registryPath(hqRoot) {
8
9
  return path.resolve(hqRoot, 'packages', 'registry.yaml');
9
10
  }
@@ -17,7 +18,7 @@ export function readRegistry(hqRoot) {
17
18
  return [];
18
19
  }
19
20
  const content = fs.readFileSync(filePath, 'utf-8');
20
- const parsed = yaml.load(content);
21
+ const parsed = parseUserYaml(content, filePath);
21
22
  return parsed?.packages ?? [];
22
23
  }
23
24
  /**
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Parse YAML supplied from an on-disk HQ file.
3
+ *
4
+ * YAML syntax is user-correctable content, not an hq-cli defect. js-yaml's
5
+ * default error includes a source excerpt, which can inadvertently echo
6
+ * secrets, so replace it with a concise, actionable location instead.
7
+ */
8
+ export declare function parseUserYaml<T>(content: string, filePath: string): T;
9
+ //# sourceMappingURL=user-yaml-error.d.ts.map
@@ -0,0 +1,25 @@
1
+ import * as yaml from "js-yaml";
2
+ /**
3
+ * Parse YAML supplied from an on-disk HQ file.
4
+ *
5
+ * YAML syntax is user-correctable content, not an hq-cli defect. js-yaml's
6
+ * default error includes a source excerpt, which can inadvertently echo
7
+ * secrets, so replace it with a concise, actionable location instead.
8
+ */
9
+ export function parseUserYaml(content, filePath) {
10
+ try {
11
+ return yaml.load(content, { filename: filePath });
12
+ }
13
+ catch (err) {
14
+ if (!(err instanceof yaml.YAMLException))
15
+ throw err;
16
+ const line = err.mark ? err.mark.line + 1 : undefined;
17
+ const column = err.mark ? err.mark.column + 1 : undefined;
18
+ const location = line !== undefined && column !== undefined
19
+ ? `:${line}:${column}`
20
+ : "";
21
+ throw Object.assign(new Error(`Invalid YAML in ${filePath}${location}: ${err.reason}. ` +
22
+ "Fix the indentation or syntax and try again."), { expected: true });
23
+ }
24
+ }
25
+ //# sourceMappingURL=user-yaml-error.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.77.3",
3
+ "version": "5.77.5",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -31,6 +31,7 @@
31
31
  import * as fs from "node:fs";
32
32
  import * as path from "node:path";
33
33
  import * as yaml from "js-yaml";
34
+ import { parseUserYaml } from "../utils/user-yaml-error.js";
34
35
  import { Command } from "commander";
35
36
  import chalk from "chalk";
36
37
 
@@ -98,7 +99,7 @@ export function flipCompanyYamlCloudOff(hqRoot: string, slug: string): boolean {
98
99
  const yPath = path.join(companyDirPath(hqRoot, slug), "company.yaml");
99
100
  if (!fs.existsSync(yPath)) return false;
100
101
  const raw = fs.readFileSync(yPath, "utf-8");
101
- const parsed = (yaml.load(raw) as Record<string, unknown> | null) ?? {};
102
+ const parsed = parseUserYaml<Record<string, unknown> | null>(raw, yPath) ?? {};
102
103
  if (parsed.cloud === false) return false;
103
104
  parsed.cloud = false;
104
105
  const dump = yaml.dump(parsed, { lineWidth: -1, noRefs: true });
@@ -120,7 +121,7 @@ export function stripManifestCloudForSlug(hqRoot: string, slug: string): boolean
120
121
  const mPath = manifestPath(hqRoot);
121
122
  if (!fs.existsSync(mPath)) return false;
122
123
  const raw = fs.readFileSync(mPath, "utf-8");
123
- const parsed = (yaml.load(raw) as { companies?: Record<string, unknown> } | null) ?? {};
124
+ const parsed = parseUserYaml<{ companies?: Record<string, unknown> } | null>(raw, mPath) ?? {};
124
125
  const companies = parsed.companies;
125
126
  if (!companies || !(slug in companies)) return false;
126
127
  const entry = companies[slug];
@@ -33,6 +33,7 @@ import chalk from "chalk";
33
33
  import * as fs from "node:fs";
34
34
  import * as path from "node:path";
35
35
  import * as yaml from "js-yaml";
36
+ import { parseUserYaml } from "../utils/user-yaml-error.js";
36
37
 
37
38
  import { share } from "@indigoai-us/hq-cloud";
38
39
 
@@ -255,7 +256,7 @@ export function validateManifestAndDir(
255
256
  );
256
257
  }
257
258
  const raw = fs.readFileSync(mPath, "utf-8");
258
- const parsed = yaml.load(raw) as unknown;
259
+ const parsed = parseUserYaml<unknown>(raw, mPath);
259
260
  if (
260
261
  !parsed ||
261
262
  typeof parsed !== "object" ||
@@ -411,7 +412,7 @@ export function patchManifest(
411
412
  ): boolean {
412
413
  const mPath = manifestPath(hqRoot);
413
414
  const raw = fs.readFileSync(mPath, "utf-8");
414
- const parsed = (yaml.load(raw) as ManifestDoc) ?? { companies: {} };
415
+ const parsed = parseUserYaml<ManifestDoc | null>(raw, mPath) ?? { companies: {} };
415
416
  if (!parsed.companies) parsed.companies = {};
416
417
  const existing = parsed.companies[slug];
417
418
  // Preserve null / object / unknown — promote null → {} so we can write keys.
@@ -32,6 +32,7 @@ vi.mock("../utils/vault-api.js", async (importOriginal) => {
32
32
  });
33
33
 
34
34
  import { registerMeetingsCommand } from "./meetings.js";
35
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
35
36
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
36
37
 
37
38
  let logSpy: MockInstance<typeof console.log>;
@@ -145,6 +146,61 @@ describe("meetings get — short id resolution", () => {
145
146
  });
146
147
  });
147
148
 
149
+ describe("meetings invite", () => {
150
+ it("authenticates and posts the meeting URL with the optional company routing", async () => {
151
+ const meetingUrl = "https://meet.google.com/abc-defg-hij";
152
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
153
+ jsonRes({
154
+ botId: "bot_123",
155
+ meetingUrl,
156
+ platform: "google_meet",
157
+ status: "scheduled",
158
+ }),
159
+ );
160
+
161
+ const program = buildProgram();
162
+ await program.parseAsync([
163
+ "node",
164
+ "hq",
165
+ "meetings",
166
+ "--company",
167
+ "indigo",
168
+ "invite",
169
+ meetingUrl,
170
+ ]);
171
+
172
+ expect(ensureCognitoToken).toHaveBeenCalledOnce();
173
+ expect(getCompanyUid).toHaveBeenCalledWith("test-token", "indigo");
174
+ expect(vaultApiFetch).toHaveBeenCalledWith({
175
+ token: "test-token",
176
+ method: "POST",
177
+ path: "/v1/bot/invite",
178
+ query: { companyId: "cmp_indigo" },
179
+ body: { meetingUrl },
180
+ });
181
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Meeting bot invited"));
182
+ });
183
+
184
+ it("omits company routing so the backend uses the caller's personal vault", async () => {
185
+ const meetingUrl = "https://zoom.us/j/123456789";
186
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
187
+ jsonRes({ botId: "bot_123", meetingUrl, status: "scheduled" }),
188
+ );
189
+
190
+ const program = buildProgram();
191
+ await program.parseAsync(["node", "hq", "meetings", "invite", meetingUrl]);
192
+
193
+ expect(getCompanyUid).not.toHaveBeenCalled();
194
+ expect(vaultApiFetch).toHaveBeenCalledWith({
195
+ token: "test-token",
196
+ method: "POST",
197
+ path: "/v1/bot/invite",
198
+ query: {},
199
+ body: { meetingUrl },
200
+ });
201
+ });
202
+ });
203
+
148
204
  describe("meetings set-company", () => {
149
205
  it("POSTs the resolved company id and applies to the recurring series by default", async () => {
150
206
  vi.mocked(vaultApiFetch).mockResolvedValueOnce(
@@ -101,6 +101,13 @@ interface MeetingDocument {
101
101
  notes: MeetingNotes | null;
102
102
  }
103
103
 
104
+ interface MeetingBotInviteResponse {
105
+ botId?: string;
106
+ meetingUrl?: string;
107
+ platform?: string;
108
+ status?: string;
109
+ }
110
+
104
111
  function formatDuration(seconds: number): string {
105
112
  const h = Math.floor(seconds / 3600);
106
113
  const m = Math.floor((seconds % 3600) / 60);
@@ -344,6 +351,43 @@ export function registerMeetingsCommand(program: Command): void {
344
351
  }
345
352
  });
346
353
 
354
+ // ── hq meetings invite <meeting-url> ──────────────────────────────
355
+
356
+ meetings
357
+ .command("invite <meetingUrl>")
358
+ .description("Invite the meeting bot to a Google Meet, Zoom, or Teams URL")
359
+ .action(async (meetingUrl: string) => {
360
+ try {
361
+ const token = await ensureCognitoToken();
362
+ const companySlug = meetings.opts().company as string | undefined;
363
+ const query: Record<string, string> = {};
364
+ if (companySlug) query.companyId = await getCompanyUid(token, companySlug);
365
+
366
+ const res = await vaultApiFetch({
367
+ token,
368
+ method: "POST",
369
+ path: "/v1/bot/invite",
370
+ query,
371
+ body: { meetingUrl },
372
+ });
373
+ if (!res.ok) await handleApiError(res);
374
+ const data = (await res.json()) as MeetingBotInviteResponse;
375
+
376
+ if (meetings.opts().json) {
377
+ console.log(JSON.stringify(data, null, 2));
378
+ return;
379
+ }
380
+
381
+ console.log(chalk.green(`\n✓ Meeting bot invited to ${chalk.cyan(data.meetingUrl ?? meetingUrl)}.`));
382
+ if (data.botId) console.log(chalk.dim(` Bot: ${data.botId}`));
383
+ if (data.status) console.log(chalk.dim(` Status: ${data.status}`));
384
+ console.log();
385
+ } catch (err) {
386
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
387
+ process.exit(1);
388
+ }
389
+ });
390
+
347
391
  // ── hq meetings get <id> ──────────────────────────────────────────
348
392
 
349
393
  meetings
@@ -38,7 +38,7 @@ import * as fs from 'fs';
38
38
  import * as os from 'os';
39
39
  import * as path from 'path';
40
40
  import * as readline from 'readline';
41
- import * as yaml from 'js-yaml';
41
+ import { parseUserYaml } from '../utils/user-yaml-error.js';
42
42
  import {
43
43
  createHash,
44
44
  createPublicKey,
@@ -1261,8 +1261,11 @@ export function validateManifest(
1261
1261
  }
1262
1262
  let parsed: unknown;
1263
1263
  try {
1264
- parsed = yaml.load(fs.readFileSync(manifestPath, 'utf-8'));
1264
+ parsed = parseUserYaml(fs.readFileSync(manifestPath, 'utf-8'), manifestPath);
1265
1265
  } catch (e) {
1266
+ if (e instanceof Error && (e as { expected?: unknown }).expected === true) {
1267
+ throw e;
1268
+ }
1266
1269
  throw new Error(`package.yaml invalid YAML: ${(e as Error).message}`);
1267
1270
  }
1268
1271
  const m = parsed as Partial<PackManifest> & Record<string, unknown>;
@@ -19,6 +19,7 @@ import {
19
19
  resolveNameToEmail,
20
20
  type PersonRecord,
21
21
  } from "../utils/people.js";
22
+ import { isExpectedUserError } from "../utils/expected-cli-error.js";
22
23
  import {
23
24
  activeMemberToPersonRecord,
24
25
  mergePeople,
@@ -138,9 +139,17 @@ describe("parsePersonMeta", () => {
138
139
  });
139
140
 
140
141
  it("throws on unparseable yaml", () => {
141
- expect(() => parsePersonMeta("name: [unterminated\n", "x", "/x")).toThrow(
142
- /Failed to parse/,
143
- );
142
+ const error = (() => {
143
+ try {
144
+ parsePersonMeta("name: [unterminated\n", "x", "/x");
145
+ } catch (err) {
146
+ return err;
147
+ }
148
+ throw new Error("expected malformed YAML to throw");
149
+ })();
150
+
151
+ expect(isExpectedUserError(error)).toBe(true);
152
+ expect((error as Error).message).toContain("Invalid YAML in /x:2:1");
144
153
  });
145
154
  });
146
155
 
@@ -15,7 +15,7 @@
15
15
  import * as fs from "fs";
16
16
  import { Command, Option } from "commander";
17
17
  import chalk from "chalk";
18
- import * as yaml from "js-yaml";
18
+ import { parseUserYaml } from "../utils/user-yaml-error.js";
19
19
  import { findHqRoot } from "../utils/manifest.js";
20
20
  import { manifestPath, type ManifestDoc } from "./cloud-provision.js";
21
21
  import { ensureCognitoToken } from "../utils/cognito-session.js";
@@ -146,14 +146,10 @@ export function resolveCompanySlug(
146
146
  "Re-run with --company <slug>.",
147
147
  );
148
148
  }
149
- let manifest: ManifestDoc;
150
- try {
151
- manifest = yaml.load(fs.readFileSync(mPath, "utf-8")) as ManifestDoc;
152
- } catch (err) {
153
- throw new Error(
154
- `companies/manifest.yaml is malformed: ${err instanceof Error ? err.message : String(err)}`,
155
- );
156
- }
149
+ const manifest = parseUserYaml<ManifestDoc>(
150
+ fs.readFileSync(mPath, "utf-8"),
151
+ mPath,
152
+ );
157
153
  const slugs = activeCompanySlugs(manifest ?? {});
158
154
  if (slugs.length === 1) return slugs[0];
159
155
  if (slugs.length === 0) {
@@ -16,7 +16,7 @@
16
16
  import * as fs from 'fs';
17
17
  import * as os from 'os';
18
18
  import * as path from 'path';
19
- import * as yaml from 'js-yaml';
19
+ import { parseUserYaml } from '../utils/user-yaml-error.js';
20
20
  import { execSync } from 'child_process';
21
21
  import { Command } from 'commander';
22
22
  import chalk from 'chalk';
@@ -157,7 +157,7 @@ async function installPackage(
157
157
  const packageYamlPath = path.resolve(installDir, 'package.yaml');
158
158
  if (fs.existsSync(packageYamlPath)) {
159
159
  const pkgContent = fs.readFileSync(packageYamlPath, 'utf-8');
160
- const pkgMeta = yaml.load(pkgContent) as { slug?: string } | null;
160
+ const pkgMeta = parseUserYaml<{ slug?: string } | null>(pkgContent, packageYamlPath);
161
161
  if (pkgMeta?.slug && pkgMeta.slug !== slug) {
162
162
  // Mismatch — clean up and abort
163
163
  fs.rmSync(installDir, { recursive: true, force: true });
@@ -179,10 +179,10 @@ async function installPackage(
179
179
  const packageYaml = path.resolve(installDir, 'package.yaml');
180
180
  if (fs.existsSync(packageYaml)) {
181
181
  const content = fs.readFileSync(packageYaml, 'utf-8');
182
- const meta = yaml.load(content) as {
182
+ const meta = parseUserYaml<{
183
183
  version?: string;
184
184
  name?: string;
185
- } | null;
185
+ } | null>(content, packageYaml);
186
186
  if (meta?.version) version = meta.version;
187
187
  if (meta?.name) name = meta.name;
188
188
  }
@@ -8,7 +8,7 @@
8
8
  import * as fs from 'fs';
9
9
  import * as os from 'os';
10
10
  import * as path from 'path';
11
- import * as yaml from 'js-yaml';
11
+ import { parseUserYaml } from '../utils/user-yaml-error.js';
12
12
  import { execSync } from 'child_process';
13
13
  import { Command } from 'commander';
14
14
  import chalk from 'chalk';
@@ -146,9 +146,9 @@ async function updatePackages(slug?: string): Promise<void> {
146
146
  const packageYamlPath = path.resolve(installDir, 'package.yaml');
147
147
  if (fs.existsSync(packageYamlPath)) {
148
148
  const pkgContent = fs.readFileSync(packageYamlPath, 'utf-8');
149
- const pkgMeta = yaml.load(pkgContent) as {
149
+ const pkgMeta = parseUserYaml<{
150
150
  slug?: string;
151
- } | null;
151
+ } | null>(pkgContent, packageYamlPath);
152
152
  if (pkgMeta?.slug && pkgMeta.slug !== entry.slug) {
153
153
  fs.rmSync(installDir, { recursive: true, force: true });
154
154
  throw new Error(
@@ -32,6 +32,7 @@ import * as fs from 'fs';
32
32
  import * as os from 'os';
33
33
  import * as path from 'path';
34
34
  import * as yaml from 'js-yaml';
35
+ import { parseUserYaml } from '../utils/user-yaml-error.js';
35
36
  import { execFileSync } from 'child_process';
36
37
  import { Command } from 'commander';
37
38
  import chalk from 'chalk';
@@ -124,7 +125,10 @@ export function stampAuthorIntoPackage(
124
125
  author: ResolvedAuthor,
125
126
  ): void {
126
127
  const manifestPath = path.join(payloadDir, 'package.yaml');
127
- const parsed = yaml.load(fs.readFileSync(manifestPath, 'utf-8')) as Record<string, unknown>;
128
+ const parsed = parseUserYaml<Record<string, unknown>>(
129
+ fs.readFileSync(manifestPath, 'utf-8'),
130
+ manifestPath,
131
+ );
128
132
  fs.writeFileSync(manifestPath, stampAuthorYaml(parsed, author));
129
133
  }
130
134
 
@@ -138,6 +138,29 @@ describe("secrets sandbox", () => {
138
138
  expect(channel).toBe("sandbox");
139
139
  });
140
140
 
141
+ it("runs a high-security secret in the sandbox without a local script attestation", async () => {
142
+ const program = buildProgram();
143
+ await program.parseAsync([
144
+ "node",
145
+ "hq",
146
+ "secrets",
147
+ "sandbox",
148
+ "--only",
149
+ "HIGH_SECURITY_KEY",
150
+ "--",
151
+ "env",
152
+ ]);
153
+
154
+ expect(startJobSpy).toHaveBeenCalledWith("test-token", {
155
+ companyUid: "prs_alice",
156
+ secretNames: ["HIGH_SECURITY_KEY"],
157
+ command: "env",
158
+ });
159
+ expect(vaultApiFetch).not.toHaveBeenCalledWith(
160
+ expect.objectContaining({ path: expect.stringContaining("/load") }),
161
+ );
162
+ });
163
+
141
164
  it("parses --company, --only, and joins args after -- into a command", async () => {
142
165
  const program = buildProgram();
143
166
  await program.parseAsync([
@@ -534,9 +557,9 @@ describe("secrets exists (HQ-4H HEAD probe)", () => {
534
557
 
535
558
  // US-003 (secrets-server-proxy): the CLI surfaces the SERVER's refusal of a
536
559
  // high-security ("nuclear") secret on the local-injection path as a clear,
537
- // actionable error pointing at the proxy — and never prints the value. The
538
- // server-side deny is the real control (it returns 403 + highSecurity:true and
539
- // NO plaintext); these tests assert the CLI's surfacing behavior.
560
+ // actionable sandbox/proxy error — and never prints the value. The server-side
561
+ // deny is the real control (it returns 403 + highSecurity:true and NO
562
+ // plaintext); these tests assert the CLI's surfacing behavior.
540
563
  describe("US-003 — CLI refuses high-security secrets on local injection", () => {
541
564
  // The server's 403 refusal shape for a high-security secret.
542
565
  function highSecurityDenied(): Response {
@@ -550,6 +573,20 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
550
573
  );
551
574
  }
552
575
 
576
+ function highSecuritySandboxOnly(): Response {
577
+ return jsonRes({
578
+ secrets: [],
579
+ errors: [
580
+ {
581
+ name: "ANTHROPIC_API_KEY",
582
+ code: "high_security_sandbox_only",
583
+ message:
584
+ "High-security-tier secrets can run only in the sandbox (`hq secrets sandbox`) or through the secret proxy. Local injection and script locks (`--script`) are not supported.",
585
+ },
586
+ ],
587
+ });
588
+ }
589
+
553
590
  let exitSpy: MockInstance<typeof process.exit>;
554
591
  beforeEach(() => {
555
592
  exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
@@ -576,17 +613,22 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
576
613
  const exitCode = exitSpy.mock.calls[0]?.[0] as number | undefined;
577
614
 
578
615
  expect(exitCode).toBe(1);
579
- // A clear, actionable error mentioning high-security + the proxy.
616
+ // A clear, actionable error names the sandbox/proxy boundary and rules
617
+ // out script-lock bypasses.
580
618
  const errText = errSpy.mock.calls.flat().join(" ");
581
619
  expect(errText).toMatch(/high-security/i);
620
+ expect(errText).toMatch(/hq secrets sandbox/i);
582
621
  expect(errText).toMatch(/proxy/i);
622
+ expect(errText).toMatch(/--script/);
583
623
  // The value is NEVER printed — no "Value:" line carrying plaintext.
584
624
  const logText = logSpy.mock.calls.flat().join(" ");
585
625
  expect(logText).not.toMatch(/sk-ant/i);
586
626
  });
587
627
 
588
- it("E2E: `secrets exec --only <name> -- env` is denied — proxy-pointing error, command not run", async () => {
589
- vi.mocked(vaultApiFetch).mockResolvedValueOnce(highSecurityDenied());
628
+ it("E2E: `secrets exec --script <path>` is denied — sandbox guidance, command not run", async () => {
629
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(highSecuritySandboxOnly());
630
+ const scriptPath = join(tempDir, "deploy.sh");
631
+ writeFileSync(scriptPath, "#!/usr/bin/env bash\necho deploy\n");
590
632
 
591
633
  const program = buildProgram();
592
634
  try {
@@ -597,6 +639,10 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
597
639
  "exec",
598
640
  "--only",
599
641
  "ANTHROPIC_API_KEY",
642
+ "--script",
643
+ scriptPath,
644
+ "--script-id",
645
+ "script.deploy",
600
646
  "--",
601
647
  "env",
602
648
  ]);
@@ -608,7 +654,10 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
608
654
  expect(exitCode).toBe(1);
609
655
  const errText = errSpy.mock.calls.flat().join(" ");
610
656
  expect(errText).toMatch(/high-security/i);
657
+ expect(errText).toMatch(/hq secrets sandbox/i);
611
658
  expect(errText).toMatch(/proxy/i);
659
+ expect(errText).toMatch(/--script/);
660
+ expect(spawn).not.toHaveBeenCalled();
612
661
  });
613
662
  });
614
663
 
@@ -212,6 +212,13 @@ export interface SecretLoadResponse {
212
212
  errors: Array<{ name: string; code: string; message?: string }>;
213
213
  }
214
214
 
215
+ function highSecuritySandboxOnlyMessage(secretName?: string): string {
216
+ const sandboxExample = secretName
217
+ ? `hq secrets sandbox --only ${secretName} -- <command>`
218
+ : "hq secrets sandbox --only <secret> -- <command>";
219
+ return `High-security secrets can run only in the sandbox (use \`${sandboxExample}\`) or through the HQ secret proxy. Local injection and script locks (\`--script\`) are not supported.`;
220
+ }
221
+
215
222
  interface SecretGetResponse {
216
223
  secret: {
217
224
  name: string;
@@ -669,12 +676,15 @@ export async function loadRevealedSecrets(
669
676
  if (!res.ok) {
670
677
  const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
671
678
  const message = extractApiMessage(body, res.statusText);
672
- // High-security ("nuclear") refusal surfaced at the batch level (rather
673
- // than per-name): point the caller at the proxy and never leak plaintext.
674
- if (body.code === "high_security_denied" || body.highSecurity === true) {
675
- throw new Error(
676
- "A requested secret is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.",
677
- );
679
+ // High-security refusal surfaced at the batch level (rather than
680
+ // per-name): point the caller at the sandbox/proxy and never leak
681
+ // plaintext. A local script attestation is not an override.
682
+ if (
683
+ body.code === "high_security_denied" ||
684
+ body.code === "high_security_sandbox_only" ||
685
+ body.highSecurity === true
686
+ ) {
687
+ throw new Error(highSecuritySandboxOnlyMessage());
678
688
  }
679
689
  if (
680
690
  res.status >= 400 &&
@@ -752,16 +762,15 @@ export async function loadRevealedSecrets(
752
762
  if (resolved.has(key)) continue;
753
763
  removeCacheEntry(companyUid, key);
754
764
  const err = errorsByName.get(key);
755
- // High-security ("nuclear") secret: the server refuses to vend it on the
756
- // local-injection (batch-load) path per-name code `high_security_denied`,
757
- // no plaintext returned. Every caller of loadRevealedSecrets injects or
758
- // prints the plaintext locally (`secrets get --reveal`, `secrets exec`,
759
- // `secrets env`), so a high-security secret can NEVER be used here. Surface
760
- // a clear, actionable error pointing at the proxy instead of a raw failure.
761
- if (err?.code === "high_security_denied") {
762
- firstFailure ??= new Error(
763
- `Secret '${key}' is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.`,
764
- );
765
+ // Every caller of loadRevealedSecrets injects or prints the plaintext
766
+ // locally (`secrets get --reveal`, `secrets exec`, `secrets env`). Both
767
+ // high-security denial shapes therefore receive the same explicit
768
+ // sandbox/proxy guidance, even if the caller supplied `--script`.
769
+ if (
770
+ err?.code === "high_security_denied" ||
771
+ err?.code === "high_security_sandbox_only"
772
+ ) {
773
+ firstFailure ??= new Error(highSecuritySandboxOnlyMessage(key));
765
774
  continue;
766
775
  }
767
776
  const reason =
@@ -947,7 +956,7 @@ export function registerSecretsCommand(program: Command): void {
947
956
  if (opts.highSecurity) {
948
957
  console.log(
949
958
  chalk.dim(
950
- ` High-security: destination pinned to ${destinations?.[0]}. This value can never be revealed or injected locally only used through the HQ secret proxy.`,
959
+ ` High-security: destination pinned to ${destinations?.[0]}. This value can run only in the sandbox (hq secrets sandbox) or through the HQ secret proxy; local injection and script locks are unsupported.`,
951
960
  ),
952
961
  );
953
962
  }
@@ -979,21 +988,11 @@ export function registerSecretsCommand(program: Command): void {
979
988
 
980
989
  if (!res.ok) {
981
990
  const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
982
- // High-security ("nuclear") secret: the server refuses to reveal it on
983
- // the local-injection path (403, no plaintext). Surface a clear,
984
- // actionable error pointing the user at the proxy rather than a raw
985
- // 4xx — the value can ONLY be used through the server-side proxy.
991
+ // High-security secrets cannot be revealed locally. Surface the
992
+ // hosted execution path rather than a raw 4xx; `--script` never
993
+ // overrides this boundary.
986
994
  if (res.status === 403 && body.highSecurity === true) {
987
- console.error(
988
- chalk.red(
989
- `Secret '${name}' is high-security and cannot be revealed locally.`,
990
- ),
991
- );
992
- console.error(
993
- chalk.dim(
994
- " It can only be used through the HQ secret proxy, which keeps the plaintext server-side.",
995
- ),
996
- );
995
+ console.error(chalk.red(highSecuritySandboxOnlyMessage(name)));
997
996
  process.exit(1);
998
997
  }
999
998
  console.error(
@@ -3,6 +3,7 @@ import chalk from "chalk";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import * as yaml from "js-yaml";
6
+ import { parseUserYaml } from "../utils/user-yaml-error.js";
6
7
  import { ensureCognitoToken } from "../utils/cognito-session.js";
7
8
  import { vaultApiFetch, getCompanyUid } from "./secrets.js";
8
9
  import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
@@ -26,9 +27,10 @@ export interface RegistryWorker {
26
27
  export function readWorkerRegistry(hqRoot: string): RegistryWorker[] {
27
28
  const p = path.join(hqRoot, "core/workers/registry.yaml");
28
29
  if (!fs.existsSync(p)) return [];
29
- const doc = yaml.load(fs.readFileSync(p, "utf8")) as
30
- | { workers?: RegistryWorker[] }
31
- | null;
30
+ const doc = parseUserYaml<{ workers?: RegistryWorker[] } | null>(
31
+ fs.readFileSync(p, "utf8"),
32
+ p,
33
+ );
32
34
  return doc?.workers ?? [];
33
35
  }
34
36
 
@@ -120,9 +122,10 @@ export function writeGrantSidecar(
120
122
  const sidecar = path.join(dir, ".grants.yaml");
121
123
  let grants: string[] = [];
122
124
  if (fs.existsSync(sidecar)) {
123
- const doc = yaml.load(fs.readFileSync(sidecar, "utf8")) as
124
- | { grants?: string[] }
125
- | null;
125
+ const doc = parseUserYaml<{ grants?: string[] } | null>(
126
+ fs.readFileSync(sidecar, "utf8"),
127
+ sidecar,
128
+ );
126
129
  if (Array.isArray(doc?.grants)) grants = doc!.grants.filter((g) => typeof g === "string");
127
130
  }
128
131
  if (!grants.includes(principalLabel)) grants.push(principalLabel);
@@ -28,6 +28,7 @@ import {
28
28
  writeManifest,
29
29
  } from './manifest.js';
30
30
  import type { ModulesManifest } from '../types.js';
31
+ import { isExpectedUserError } from './expected-cli-error.js';
31
32
 
32
33
  let tmpRoot: string;
33
34
 
@@ -83,6 +84,24 @@ describe('getManifestPath', () => {
83
84
  });
84
85
 
85
86
  describe('readManifest / writeManifest round-trip', () => {
87
+ it('surfaces malformed user YAML with the manifest file and location', () => {
88
+ const nested = path.join(tmpRoot, 'modules', 'modules.yaml');
89
+ fs.mkdirSync(path.dirname(nested), { recursive: true });
90
+ fs.writeFileSync(nested, 'modules:\n - name: hq\n bad: indentation\n');
91
+
92
+ const error = (() => {
93
+ try {
94
+ readManifest(tmpRoot);
95
+ } catch (err) {
96
+ return err;
97
+ }
98
+ throw new Error('expected malformed YAML to throw');
99
+ })();
100
+
101
+ expect(isExpectedUserError(error)).toBe(true);
102
+ expect((error as Error).message).toContain(`${nested}:3:2`);
103
+ });
104
+
86
105
  it('round-trips against the nested layout', () => {
87
106
  writeManifest(tmpRoot, sampleManifest);
88
107
 
@@ -2,6 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import * as yaml from 'js-yaml';
4
4
  import type { ModulesManifest, ModuleDefinition, ModuleLock, SyncState } from '../types.js';
5
+ import { parseUserYaml } from './user-yaml-error.js';
5
6
 
6
7
  const MANIFEST_FILE = 'modules.yaml';
7
8
  const LOCK_FILE = 'modules.lock';
@@ -57,7 +58,7 @@ export function readManifest(hqRoot: string): ModulesManifest | null {
57
58
  return null;
58
59
  }
59
60
  const content = fs.readFileSync(manifestPath, 'utf-8');
60
- return yaml.load(content) as ModulesManifest;
61
+ return parseUserYaml<ModulesManifest>(content, manifestPath);
61
62
  }
62
63
 
63
64
  export function writeManifest(hqRoot: string, manifest: ModulesManifest): void {
@@ -76,7 +77,7 @@ export function readLock(hqRoot: string): ModuleLock | null {
76
77
  return null;
77
78
  }
78
79
  const content = fs.readFileSync(lockPath, 'utf-8');
79
- return yaml.load(content) as ModuleLock;
80
+ return parseUserYaml<ModuleLock>(content, lockPath);
80
81
  }
81
82
 
82
83
  export function writeLock(hqRoot: string, lock: ModuleLock): void {
@@ -17,7 +17,7 @@
17
17
 
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
20
- import * as yaml from "js-yaml";
20
+ import { parseUserYaml } from "./user-yaml-error.js";
21
21
 
22
22
  /** A single person/member record parsed from a `people/<slug>/meta.yaml`. */
23
23
  export interface PersonRecord {
@@ -76,14 +76,7 @@ export function parsePersonMeta(
76
76
  slug: string,
77
77
  source: string,
78
78
  ): PersonRecord | null {
79
- let doc: unknown;
80
- try {
81
- doc = yaml.load(raw);
82
- } catch (err) {
83
- throw new Error(
84
- `Failed to parse ${source}: ${err instanceof Error ? err.message : String(err)}`,
85
- );
86
- }
79
+ const doc = parseUserYaml<unknown>(raw, source);
87
80
  if (!doc || typeof doc !== "object") return null;
88
81
  const d = doc as Record<string, unknown>;
89
82
 
@@ -7,8 +7,8 @@
7
7
 
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
- import * as yaml from 'js-yaml';
11
10
  import { resolveDefaultHqRoot } from './cognito-session.js';
11
+ import { parseUserYaml } from './user-yaml-error.js';
12
12
 
13
13
  // ---------------------------------------------------------------------------
14
14
  // Types
@@ -86,7 +86,7 @@ export function getRegistryUrl(): string {
86
86
  }
87
87
 
88
88
  const content = fs.readFileSync(sourcesPath, 'utf-8');
89
- const parsed = yaml.load(content) as SourcesFile;
89
+ const parsed = parseUserYaml<SourcesFile>(content, sourcesPath);
90
90
 
91
91
  if (!parsed?.sources?.length) {
92
92
  throw new Error('No sources defined in packages/sources.yaml');
@@ -5,6 +5,7 @@
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import * as yaml from 'js-yaml';
8
+ import { parseUserYaml } from './user-yaml-error.js';
8
9
 
9
10
  export interface RegistryEntry {
10
11
  name: string;
@@ -35,7 +36,7 @@ export function readRegistry(hqRoot: string): RegistryEntry[] {
35
36
  return [];
36
37
  }
37
38
  const content = fs.readFileSync(filePath, 'utf-8');
38
- const parsed = yaml.load(content) as RegistryFile | null;
39
+ const parsed = parseUserYaml<RegistryFile | null>(content, filePath);
39
40
  return parsed?.packages ?? [];
40
41
  }
41
42
 
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isExpectedUserError } from "./expected-cli-error.js";
3
+ import { parseUserYaml } from "./user-yaml-error.js";
4
+
5
+ describe("parseUserYaml", () => {
6
+ it("marks malformed user YAML as expected and names its file and location", () => {
7
+ const filePath = "/tmp/HQ/modules/modules.yaml";
8
+ const error = (() => {
9
+ try {
10
+ parseUserYaml('apiKey: "secret-do-not-echo"\nmodule:\n name: hq\n bad: indentation\n', filePath);
11
+ } catch (err) {
12
+ return err;
13
+ }
14
+ throw new Error("expected malformed YAML to throw");
15
+ })();
16
+
17
+ expect(isExpectedUserError(error)).toBe(true);
18
+ expect((error as Error).message).toContain(
19
+ `Invalid YAML in ${filePath}:4:2: bad indentation of a mapping entry.`,
20
+ );
21
+ expect((error as Error).message).toContain("Fix the indentation or syntax and try again.");
22
+ expect((error as Error).message).not.toContain("secret-do-not-echo");
23
+ });
24
+ });
@@ -0,0 +1,30 @@
1
+ import * as yaml from "js-yaml";
2
+
3
+ /**
4
+ * Parse YAML supplied from an on-disk HQ file.
5
+ *
6
+ * YAML syntax is user-correctable content, not an hq-cli defect. js-yaml's
7
+ * default error includes a source excerpt, which can inadvertently echo
8
+ * secrets, so replace it with a concise, actionable location instead.
9
+ */
10
+ export function parseUserYaml<T>(content: string, filePath: string): T {
11
+ try {
12
+ return yaml.load(content, { filename: filePath }) as T;
13
+ } catch (err) {
14
+ if (!(err instanceof yaml.YAMLException)) throw err;
15
+
16
+ const line = err.mark ? err.mark.line + 1 : undefined;
17
+ const column = err.mark ? err.mark.column + 1 : undefined;
18
+ const location = line !== undefined && column !== undefined
19
+ ? `:${line}:${column}`
20
+ : "";
21
+
22
+ throw Object.assign(
23
+ new Error(
24
+ `Invalid YAML in ${filePath}${location}: ${err.reason}. ` +
25
+ "Fix the indentation or syntax and try again.",
26
+ ),
27
+ { expected: true as const },
28
+ );
29
+ }
30
+ }