@indigoai-us/hq-cli 5.345.82 → 5.345.84

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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.84] — 2026-10-11
6
+ - `hq secrets exec` and `hq secrets env` with `HQ_API_KEY` now refuse a
7
+ `--company` (or `HQ_COMPANY_UID`) that the key was not minted for. They
8
+ exit 1 with "HQ_API_KEY belongs to a different company than <uid>". They
9
+ used to ignore the flag and inject the key company's same-named secret.
10
+ A company slug under an API key is also refused, with a request for the
11
+ `cmp_` uid. Without `--company`, the key's own company is used as before.
12
+
13
+ ## [5.345.83] — 2026-10-11
14
+ - HQ-root scaffolds now package the shared runtime used by generated shell forwarders.
15
+ - Lanes hook installation now replaces stale hq-cli drain registrations across
16
+ Claude settings and Codex config, including entries whose old install path no
17
+ longer exists. Other hooks are preserved, duplicate drains are removed, and a
18
+ repeat install leaves the registration file byte-identical.
19
+
5
20
  ## [5.345.82] — 2026-10-11
6
21
  - Session-start health no longer treats an ordinary HQ Core upgrade warning
7
22
  as an unrepaired doctor finding. `hq doctor` still shows the upgrade;
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env bash
2
+ # Shared runtime for generated hq CLI forwarders.
3
+
4
+ forwarder_exec() {
5
+ local script_name="$1" min_cli="$2" root_mode="$3" command="$4" stdin_closed="$5"
6
+ shift 5
7
+ local forwarder_library_dir="${BASH_SOURCE[0]%/*}"
8
+ local path_rule index value path_option path_value
9
+ local -a forwarded_args path_rules path_options path_values converted_values command_parts
10
+ forwarded_args=("$@")
11
+ local forwarded_arg_count=$#
12
+
13
+ if [ "$root_mode" = live-project ]; then
14
+ HQ_ROOT="${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$(cd "$SCRIPT_DIR/../.." && pwd)}}"
15
+ elif [ "$root_mode" = live ]; then
16
+ HQ_ROOT="${HQ_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
17
+ fi
18
+
19
+ if ! command -v hq >/dev/null 2>&1; then
20
+ printf "%s: requires the hq CLI — this script's implementation now ships with it.\n" "$script_name" >&2
21
+ printf 'Install it with: npm install -g @indigoai-us/hq-cli\n' >&2
22
+ exit 127
23
+ fi
24
+
25
+ [ "$forwarder_library_dir" != "${BASH_SOURCE[0]}" ] || forwarder_library_dir=.
26
+ if [ -f "$forwarder_library_dir/hq-cli-floor.sh" ]; then
27
+ # shellcheck source=lib/hq-cli-floor.sh
28
+ . "$forwarder_library_dir/hq-cli-floor.sh"
29
+ hq_cli_floor_check "$script_name" "$min_cli"
30
+ fi
31
+
32
+ if [ "$stdin_closed" = true ]; then
33
+ if ! { true 3<&0; } 2>/dev/null; then
34
+ export HQ_CORE_STDIN_CLOSED=1
35
+ fi
36
+ fi
37
+
38
+ if [ "$root_mode" = live ] || [ "$root_mode" = live-project ]; then
39
+ if command -v cygpath >/dev/null 2>&1; then
40
+ HQ_ROOT="$(cygpath -m "$HQ_ROOT")"
41
+ fi
42
+ fi
43
+
44
+ if command -v cygpath >/dev/null 2>&1; then
45
+ IFS=';' read -r -a path_rules <<< "$PATH_OPERANDS"
46
+ for path_rule in "${path_rules[@]}"; do
47
+ case "$path_rule" in
48
+ none) ;;
49
+ position:*)
50
+ index="${path_rule#position:}"
51
+ if (( index < forwarded_arg_count )); then convert_forwarded_path "$index"; fi
52
+ ;;
53
+ first-nonoption)
54
+ for ((index = 0; index < forwarded_arg_count; index++)); do
55
+ case "${forwarded_args[index]}" in --*) ;; *) convert_forwarded_path "$index"; break ;; esac
56
+ done
57
+ ;;
58
+ all-nonoptions)
59
+ for ((index = 0; index < forwarded_arg_count; index++)); do
60
+ case "${forwarded_args[index]}" in --*) ;; *) convert_forwarded_path "$index" ;; esac
61
+ done
62
+ ;;
63
+ options:*)
64
+ IFS=, read -r -a path_options <<< "${path_rule#options:}"
65
+ for ((index = 0; index < forwarded_arg_count; index++)); do
66
+ for path_option in "${path_options[@]}"; do
67
+ case "${forwarded_args[index]}" in
68
+ "$path_option")
69
+ index=$((index + 1))
70
+ if (( index < forwarded_arg_count )); then convert_forwarded_path "$index"; fi
71
+ break
72
+ ;;
73
+ "$path_option"=*)
74
+ value="${forwarded_args[index]#*=}"
75
+ case "$value" in /*) forwarded_args[index]="${path_option}=$(cygpath -m "$value")" ;; esac
76
+ break
77
+ ;;
78
+ esac
79
+ done
80
+ done
81
+ ;;
82
+ comma:*)
83
+ path_option="${path_rule#comma:}"
84
+ for ((index = 0; index < forwarded_arg_count; index++)); do
85
+ case "${forwarded_args[index]}" in
86
+ "$path_option")
87
+ index=$((index + 1))
88
+ if (( index < forwarded_arg_count )); then
89
+ IFS=, read -r -a path_values <<< "${forwarded_args[index]}"
90
+ converted_values=()
91
+ for path_value in "${path_values[@]}"; do
92
+ case "$path_value" in /*) converted_values+=("$(cygpath -m "$path_value")") ;; *) converted_values+=("$path_value") ;; esac
93
+ done
94
+ value="$(IFS=,; printf '%s' "${converted_values[*]}")"
95
+ forwarded_args[index]="$value"
96
+ fi
97
+ break
98
+ ;;
99
+ "$path_option"=*)
100
+ value="${forwarded_args[index]#*=}"
101
+ case "$value" in
102
+ /*,*|/*)
103
+ IFS=, read -r -a path_values <<< "$value"
104
+ converted_values=()
105
+ for path_value in "${path_values[@]}"; do
106
+ case "$path_value" in /*) converted_values+=("$(cygpath -m "$path_value")") ;; *) converted_values+=("$path_value") ;; esac
107
+ done
108
+ value="$(IFS=,; printf '%s' "${converted_values[*]}")"
109
+ forwarded_args[index]="${path_option}=$value"
110
+ ;;
111
+ esac
112
+ break
113
+ ;;
114
+ esac
115
+ done
116
+ ;;
117
+ *) printf 'unknown path_operands rule: %s\n' "$path_rule" >&2; exit 70 ;;
118
+ esac
119
+ done
120
+ fi
121
+
122
+ IFS=' ' read -r -a command_parts <<< "$command"
123
+ if [ "$root_mode" = live ] || [ "$root_mode" = live-project ]; then
124
+ if [ "$forwarded_arg_count" -gt 0 ]; then
125
+ MSYS2_ARG_CONV_EXCL='*' exec hq core --hq-root "$HQ_ROOT" "${command_parts[@]}" "${forwarded_args[@]}"
126
+ else
127
+ MSYS2_ARG_CONV_EXCL='*' exec hq core --hq-root "$HQ_ROOT" "${command_parts[@]}"
128
+ fi
129
+ elif [ "$forwarded_arg_count" -gt 0 ]; then
130
+ MSYS2_ARG_CONV_EXCL='*' exec hq core "${command_parts[@]}" "${forwarded_args[@]}"
131
+ else
132
+ MSYS2_ARG_CONV_EXCL='*' exec hq core "${command_parts[@]}"
133
+ fi
134
+ }
135
+
136
+ convert_forwarded_path() {
137
+ local arg_index="$1"
138
+ local value="${forwarded_args[$arg_index]}"
139
+ case "$value" in
140
+ /*) forwarded_args[$arg_index]="$(cygpath -m "$value")" ;;
141
+ esac
142
+ }
@@ -268,6 +268,7 @@ export const SCAFFOLD_ONLY_ASSETS = [
268
268
  { asset: "core/scripts/lib/session-scope-capability.sh", root: "live", summary: "Session scope-capability lib sourced by hq-session" },
269
269
  { asset: "core/scripts/lib/session-id.sh", root: "live", summary: "Session-id resolver lib sourced by hq-session" },
270
270
  { asset: "core/scripts/lib/hq-cli-floor.sh", root: "live", summary: "Shared CLI version-floor helper sourced by generated forwarders" },
271
+ { asset: "core/scripts/lib/forwarder.sh", root: "live", summary: "Shared runtime sourced by generated scaffold forwarders" },
271
272
  { asset: "core/scripts/lib/work-mesh-enqueue.sh", root: "live", summary: "Work Mesh Live spool enqueue lib sourced by hooks" },
272
273
  { asset: "core/scripts/lib/work-mesh-live-hook.sh", root: "live", summary: "Work Mesh Live hook helpers sourced by rebind" },
273
274
  { asset: "core/scripts/lib/work-mesh-live-rebind.sh", root: "live", summary: "Work Mesh Live session rebind library sourced by hooks and the native rollback command" },
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as yaml from "js-yaml";
4
- import { buildSecretUsage, extractApiMessage, highSecuritySandboxOnlyMessage, loadRevealedSecrets, normalizeScriptLockMode, normalizeSecretTier, parseSecretNameList, runSecretsEnvCommand, scopeOpts, secretsCredentialOptions, } from "../secrets-env-action.js";
4
+ import { assertApiKeyCompany, buildSecretUsage, extractApiMessage, highSecuritySandboxOnlyMessage, loadRevealedSecrets, normalizeScriptLockMode, normalizeSecretTier, parseSecretNameList, requestedApiKeyCompany, runSecretsEnvCommand, scopeOpts, secretsCredentialOptions, } from "../secrets-env-action.js";
5
5
  export { buildSecretUsage, emitEnvExportsAndExit, loadRevealedSecrets, parseSecretNameList, runSecretsEnvCommand, } from "../secrets-env-action.js";
6
6
  export function readSessionCompanyLock(env = process.env) {
7
7
  const root = resolveDefaultHqRoot({ env });
@@ -1284,8 +1284,17 @@ export function registerSecretsCommand(program) {
1284
1284
  ...(requestedCompany ? { requestedCompany } : {}),
1285
1285
  })
1286
1286
  : undefined;
1287
+ // Without a session lock, an explicit --company / HQ_COMPANY_UID must
1288
+ // still match the key's company: it used to be ignored, and the key's
1289
+ // own same-named secret was injected instead.
1290
+ const unlockedApiKeyCompanyRef = cred.kind === "api-key" && !hasLockedCompanies
1291
+ ? requestedApiKeyCompany(requestedCompany)
1292
+ : undefined;
1287
1293
  const companyUid = cred.kind === "api-key"
1288
- ? (apiKeyCompany?.companyUid ?? "__api_key__")
1294
+ ? (apiKeyCompany?.companyUid
1295
+ ?? (unlockedApiKeyCompanyRef
1296
+ ? await assertApiKeyCompany(cred.token, unlockedApiKeyCompanyRef, "secrets exec")
1297
+ : "__api_key__"))
1289
1298
  : await getEntityUid(cred.token, secretScope, { allowOfflineScopeFallback: true });
1290
1299
  const readSecrets = async () => loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
1291
1300
  ? await buildSecretUsage("exec", _opts.script, _opts.scriptId)
@@ -149,9 +149,18 @@ function isInstalledDrainEntry(entryPath) {
149
149
  * Both paths target the same hq-cli drain entry, so recognize an installed
150
150
  * package entry alongside the exact entry belonging to this CLI invocation.
151
151
  */
152
- function drainCommandsMatch(candidate, expected) {
153
- if (candidate === expected)
154
- return true;
152
+ function drainCommandsMatch(candidate, expected, requireInstalledPaths = false) {
153
+ if (candidate === expected) {
154
+ if (!requireInstalledPaths)
155
+ return true;
156
+ const withoutProviderEnv = candidate.startsWith("HQ_LANE_PROVIDER=grok ")
157
+ ? candidate.slice("HQ_LANE_PROVIDER=grok ".length)
158
+ : candidate;
159
+ const currentArgv = parseShellQuotedArguments(withoutProviderEnv);
160
+ return currentArgv !== null && currentArgv.length === 2 &&
161
+ path.parse(currentArgv[0]).name.toLowerCase() === "node" &&
162
+ fs.existsSync(currentArgv[0]) && fs.existsSync(currentArgv[1]);
163
+ }
155
164
  const withoutProviderEnv = candidate.startsWith("HQ_LANE_PROVIDER=grok ")
156
165
  ? candidate.slice("HQ_LANE_PROVIDER=grok ".length)
157
166
  : candidate;
@@ -162,9 +171,9 @@ function drainCommandsMatch(candidate, expected) {
162
171
  return false;
163
172
  if (!isInstalledDrainEntry(argv[1]))
164
173
  return false;
165
- return fs.existsSync(argv[0]) && fs.existsSync(argv[1]);
174
+ return !requireInstalledPaths || (fs.existsSync(argv[0]) && fs.existsSync(argv[1]));
166
175
  }
167
- function groupsContainDrainCommand(groups, expected) {
176
+ function groupsContainDrainCommand(groups, expected, requireInstalledPaths = false) {
168
177
  if (!Array.isArray(groups))
169
178
  return false;
170
179
  return groups.some((group) => {
@@ -173,29 +182,51 @@ function groupsContainDrainCommand(groups, expected) {
173
182
  return group.hooks.some((hook) => isPlainObject(hook) &&
174
183
  hook.type === "command" &&
175
184
  typeof hook.command === "string" &&
176
- drainCommandsMatch(hook.command, expected));
185
+ drainCommandsMatch(hook.command, expected, requireInstalledPaths));
177
186
  });
178
187
  }
179
- function tomlHasDrainCommand(parsed, expected) {
188
+ function tomlHasDrainCommand(parsed, expected, requireInstalledPaths = false) {
180
189
  const hooks = isPlainObject(parsed.hooks) ? parsed.hooks : null;
181
190
  return hooks !== null &&
182
- ["PreToolUse", "PostToolUse", "Stop"].every((event) => groupsContainDrainCommand(hooks[event], expected));
191
+ ["PreToolUse", "PostToolUse", "Stop"].every((event) => groupsContainDrainCommand(hooks[event], expected, requireInstalledPaths));
183
192
  }
184
- function removeCommandFromGroups(groups, command) {
193
+ function removeDrainCommandsFromGroups(groups, expected, legacyCommand) {
185
194
  let changed = false;
195
+ let keptCurrent = false;
186
196
  const next = groups.flatMap((group) => {
187
197
  if (!isPlainObject(group) || !Array.isArray(group.hooks))
188
198
  return [group];
189
- const hooks = group.hooks.filter((hook) => !(isPlainObject(hook) &&
190
- hook.type === "command" &&
191
- hook.command === command));
199
+ const hooks = group.hooks.filter((hook) => {
200
+ if (!isPlainObject(hook) || hook.type !== "command" || typeof hook.command !== "string")
201
+ return true;
202
+ const isCurrent = hook.command === expected;
203
+ const isDrain = isCurrent || drainCommandsMatch(hook.command, expected) || hook.command === legacyCommand;
204
+ if (isCurrent && !keptCurrent) {
205
+ keptCurrent = true;
206
+ return true;
207
+ }
208
+ if (isDrain)
209
+ changed = true;
210
+ return !isDrain;
211
+ });
192
212
  if (hooks.length === group.hooks.length)
193
213
  return [group];
194
- changed = true;
195
214
  return hooks.length === 0 ? [] : [{ ...group, hooks }];
196
215
  });
197
216
  return { groups: next, changed };
198
217
  }
218
+ function groupHasExactlyOneCommand(groups, command) {
219
+ let count = 0;
220
+ for (const group of groups) {
221
+ if (!isPlainObject(group) || !Array.isArray(group.hooks))
222
+ continue;
223
+ for (const hook of group.hooks) {
224
+ if (isPlainObject(hook) && hook.type === "command" && hook.command === command)
225
+ count += 1;
226
+ }
227
+ }
228
+ return count === 1;
229
+ }
199
230
  export function renderDrainHookJson(shPath, provider = "grok") {
200
231
  const command = drainHookCommand(shPath, provider);
201
232
  const registration = {
@@ -237,21 +268,18 @@ export function mergeClaudeSettingsHook(existingText, command, settingsPath, leg
237
268
  }
238
269
  eventGroups.set(event, groups);
239
270
  }
240
- const pre = eventGroups.get("PreToolUse");
241
- if (!Array.isArray(pre)) {
242
- throw new LanesError("settings_unparseable", `${settingsPath} has a non-array "hooks.PreToolUse"; refusing to rewrite it`, { path: settingsPath });
243
- }
244
- const migration = legacyCommand && legacyCommand !== command
245
- ? removeCommandFromGroups(pre, legacyCommand)
246
- : { groups: pre, changed: false };
247
- const allRegistered = events.every((event) => groupsContainCommand(eventGroups.get(event), command));
248
- if (allRegistered && !migration.changed) {
271
+ const cleanups = new Map(events.map((event) => [
272
+ event,
273
+ removeDrainCommandsFromGroups(eventGroups.get(event), command, legacyCommand),
274
+ ]));
275
+ if (events.every((event) => groupHasExactlyOneCommand(cleanups.get(event).groups, command) &&
276
+ !cleanups.get(event).changed)) {
249
277
  return { text: existingText ?? "", changed: false };
250
278
  }
251
279
  const nextHooks = { ...hooks };
252
280
  for (const event of events) {
253
- const groups = event === "PreToolUse" ? migration.groups : eventGroups.get(event);
254
- nextHooks[event] = groupsContainCommand(groups, command) ? groups : [
281
+ const groups = cleanups.get(event).groups;
282
+ nextHooks[event] = groupHasExactlyOneCommand(groups, command) ? groups : [
255
283
  ...groups,
256
284
  { hooks: [{ type: "command", command, timeout: DRAIN_HOOK_TIMEOUT_SECONDS }] },
257
285
  ];
@@ -405,6 +433,145 @@ function parseTomlOrThrow(text, configPath, stage) {
405
433
  }
406
434
  return parsed;
407
435
  }
436
+ function renderTomlInline(value) {
437
+ if (typeof value === "string")
438
+ return tomlBasicString(value);
439
+ if (typeof value === "boolean" || typeof value === "number")
440
+ return String(value);
441
+ if (Array.isArray(value))
442
+ return `[${value.map(renderTomlInline).join(", ")}]`;
443
+ if (isPlainObject(value)) {
444
+ const fields = Object.entries(value).map(([key, entry]) => `${JSON.stringify(key)} = ${renderTomlInline(entry)}`);
445
+ return `{ ${fields.join(", ")} }`;
446
+ }
447
+ throw new LanesError("config_toml_migration_failed", "Could not safely render an inline Codex hook while removing stale lane drains");
448
+ }
449
+ function isDrainHookValue(hook, expected, legacyCommand) {
450
+ return isPlainObject(hook) && hook.type === "command" && typeof hook.command === "string" &&
451
+ (drainCommandsMatch(hook.command, expected) || hook.command === legacyCommand);
452
+ }
453
+ function removeStaleCodexDrainHooks(text, parsed, expected, legacyCommand, configPath) {
454
+ const hooks = isPlainObject(parsed.hooks) ? parsed.hooks : {};
455
+ const lines = text.split("\n");
456
+ const spans = [];
457
+ let changed = false;
458
+ for (const event of ["PreToolUse", "PostToolUse", "Stop"]) {
459
+ const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
460
+ const parentIndexes = lines.flatMap((line, index) => {
461
+ const header = tomlHeader(line);
462
+ return header?.array && header.path === `hooks.${event}` ? [index] : [];
463
+ });
464
+ if (parentIndexes.length !== groups.length) {
465
+ throw new LanesError("config_toml_migration_failed", `Could not safely map ${event} drain hooks in ${configPath}; refusing to add a second drain`, { path: configPath, event, groups: groups.length, headers: parentIndexes.length });
466
+ }
467
+ let keptCurrent = false;
468
+ for (let groupIndex = 0; groupIndex < parentIndexes.length; groupIndex += 1) {
469
+ const start = parentIndexes[groupIndex];
470
+ let end = start + 1;
471
+ for (; end < lines.length; end += 1) {
472
+ const header = tomlHeader(lines[end]);
473
+ if (!header)
474
+ continue;
475
+ if (header.array && header.path === `hooks.${event}`)
476
+ break;
477
+ if (header.path.startsWith(`hooks.${event}.`))
478
+ continue;
479
+ break;
480
+ }
481
+ const hookHeaders = lines.flatMap((line, index) => {
482
+ if (index <= start || index >= end)
483
+ return [];
484
+ const header = tomlHeader(line);
485
+ return header?.array && header.path === `hooks.${event}.hooks` ? [index] : [];
486
+ });
487
+ const group = groups[groupIndex];
488
+ const groupHooks = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
489
+ if (hookHeaders.length !== groupHooks.length) {
490
+ const keptInlineHooks = [];
491
+ let inlineNeedsEdit = false;
492
+ for (const hook of groupHooks) {
493
+ const command = isPlainObject(hook) && hook.type === "command" && typeof hook.command === "string"
494
+ ? hook.command
495
+ : undefined;
496
+ if (!command) {
497
+ keptInlineHooks.push(hook);
498
+ continue;
499
+ }
500
+ const current = command === expected;
501
+ const drain = isDrainHookValue(hook, expected, legacyCommand);
502
+ if (!drain || (current && !keptCurrent)) {
503
+ if (current)
504
+ keptCurrent = true;
505
+ keptInlineHooks.push(hook);
506
+ }
507
+ else {
508
+ inlineNeedsEdit = true;
509
+ }
510
+ }
511
+ if (!inlineNeedsEdit)
512
+ continue;
513
+ if (hookHeaders.length === 0) {
514
+ const hooksLine = lines.findIndex((line, index) => index > start && index < end && /^\s*hooks\s*=/.test(line));
515
+ if (hooksLine === -1) {
516
+ throw new LanesError("config_toml_migration_failed", `Could not safely find inline ${event} hooks in ${configPath}; refusing to add a second drain`, { path: configPath, event, groupIndex });
517
+ }
518
+ changed = true;
519
+ if (keptInlineHooks.length === 0) {
520
+ spans.push({ start, end });
521
+ }
522
+ else {
523
+ const indent = /^\s*/.exec(lines[hooksLine])?.[0] ?? "";
524
+ lines[hooksLine] = `${indent}hooks = ${renderTomlInline(keptInlineHooks)}`;
525
+ }
526
+ continue;
527
+ }
528
+ throw new LanesError("config_toml_migration_failed", `Could not safely map ${event} hook entries in ${configPath}; refusing to add a second drain`, { path: configPath, event, groupIndex, hooks: groupHooks.length, headers: hookHeaders.length });
529
+ }
530
+ const removeHooks = [];
531
+ for (let hookIndex = 0; hookIndex < hookHeaders.length; hookIndex += 1) {
532
+ const hookStart = hookHeaders[hookIndex];
533
+ let hookEnd = hookStart + 1;
534
+ for (; hookEnd < end; hookEnd += 1) {
535
+ const header = tomlHeader(lines[hookEnd]);
536
+ if (header?.array && header.path === `hooks.${event}.hooks`)
537
+ break;
538
+ if (header && !header.path.startsWith(`hooks.${event}.hooks.`))
539
+ break;
540
+ }
541
+ const hook = groupHooks[hookIndex];
542
+ const command = isPlainObject(hook) && hook.type === "command" && typeof hook.command === "string"
543
+ ? hook.command
544
+ : undefined;
545
+ if (!command)
546
+ continue;
547
+ const current = command === expected;
548
+ const drain = drainCommandsMatch(command, expected) || command === legacyCommand;
549
+ if (!drain || (current && !keptCurrent)) {
550
+ if (current)
551
+ keptCurrent = true;
552
+ continue;
553
+ }
554
+ removeHooks.push({ start: hookStart, end: hookEnd });
555
+ }
556
+ if (removeHooks.length === 0)
557
+ continue;
558
+ changed = true;
559
+ if (removeHooks.length === hookHeaders.length) {
560
+ spans.push({ start, end });
561
+ }
562
+ else {
563
+ spans.push(...removeHooks);
564
+ }
565
+ }
566
+ }
567
+ if (!changed)
568
+ return { text, changed: false };
569
+ for (const span of spans.sort((a, b) => b.start - a.start)) {
570
+ lines.splice(span.start, span.end - span.start);
571
+ }
572
+ const cleaned = lines.join("\n");
573
+ return { text: cleaned, changed: true };
574
+ }
408
575
  /**
409
576
  * Set `hooks = true` inside `[features]` by editing lines, so the operator's
410
577
  * comments and layout survive. A `[features]` header that exists gets the
@@ -442,12 +609,21 @@ export function mergeCodexConfigHook(existingText, command, configPath, legacyCo
442
609
  const original = existingText ?? "";
443
610
  const parsed = parseTomlOrThrow(original, configPath, "before merge");
444
611
  const migration = removeLegacyCodexHook(original, parsed, legacyCommand, configPath);
445
- const textAfterMigration = migration.text;
612
+ const parsedAfterMigration = parseTomlOrThrow(migration.text, configPath, "after legacy hook removal");
613
+ const drainCleanup = removeStaleCodexDrainHooks(migration.text, parsedAfterMigration, command, legacyCommand, configPath);
614
+ const changedByCleanup = migration.changed || drainCleanup.changed;
615
+ const textAfterMigration = drainCleanup.text;
446
616
  const current = parseTomlOrThrow(textAfterMigration, configPath, "after legacy hook removal");
447
617
  const hasPreToolCommand = tomlHasCommand(current, command);
448
618
  const hasCommand = tomlHasDrainCommand(current, command);
449
619
  const featureOn = tomlHooksFeatureEnabled(current);
450
- if (hasCommand && featureOn && !migration.changed) {
620
+ if (hasCommand && featureOn && !changedByCleanup &&
621
+ ["PreToolUse", "PostToolUse", "Stop"].every((event) => {
622
+ const groups = isPlainObject(current.hooks) && Array.isArray(current.hooks[event])
623
+ ? current.hooks[event]
624
+ : [];
625
+ return groupHasExactlyOneCommand(groups, command);
626
+ })) {
451
627
  return { text: original, changed: false };
452
628
  }
453
629
  let text = textAfterMigration;
@@ -509,7 +685,7 @@ export function probeDrainHookRegistration(provider, providerHome) {
509
685
  const toml = parseToml(text);
510
686
  return {
511
687
  ...base,
512
- registered: isPlainObject(toml) && tomlHasDrainCommand(toml, command) && tomlHooksFeatureEnabled(toml),
688
+ registered: isPlainObject(toml) && tomlHasDrainCommand(toml, command, true) && tomlHooksFeatureEnabled(toml),
513
689
  };
514
690
  }
515
691
  const json = JSON.parse(text);
@@ -517,7 +693,7 @@ export function probeDrainHookRegistration(provider, providerHome) {
517
693
  return {
518
694
  ...base,
519
695
  registered: hooks !== null &&
520
- (parsed === "grok" ? ["PreToolUse"] : ["PreToolUse", "PostToolUse", "Stop"]).every((event) => groupsContainDrainCommand(hooks[event], command)),
696
+ (parsed === "grok" ? ["PreToolUse"] : ["PreToolUse", "PostToolUse", "Stop"]).every((event) => groupsContainDrainCommand(hooks[event], command, true)),
521
697
  };
522
698
  }
523
699
  catch (err) {
@@ -57,6 +57,23 @@ export declare function normalizeScriptLockMode(mode?: string): SecretScriptLock
57
57
  export declare function extractApiMessage(body: Record<string, unknown>, fallback: string): string;
58
58
  export declare function buildSecretUsage(channel: SecretUsageChannel, scriptPath?: string, scriptId?: string, attestationLevel?: string): Promise<SecretUsage>;
59
59
  export declare function parseSecretNameList(input: string[] | undefined): string[];
60
+ /**
61
+ * The company a caller explicitly asked for while authenticating with
62
+ * HQ_API_KEY: `--company`, else HQ_COMPANY_UID. Undefined means "the key's own
63
+ * company", which needs no check.
64
+ */
65
+ export declare function requestedApiKeyCompany(flagCompany: string | undefined): string | undefined;
66
+ /**
67
+ * Refuse an explicitly requested company that the API key is not bound to.
68
+ *
69
+ * A vault API key can only read its own company's secrets. Before this check,
70
+ * `hq secrets exec --company <other>` under HQ_API_KEY ignored the flag and
71
+ * injected the key company's same-named secret with exit 0, which read as a
72
+ * cross-company leak (P0 triage, 2026-10-11). The probe is the keyed list
73
+ * route, which the server 403s with "not scoped to this company" on mismatch.
74
+ * Returns the verified uid; throws before any secret is fetched otherwise.
75
+ */
76
+ export declare function assertApiKeyCompany(token: string, requested: string, commandLabel: string): Promise<string>;
60
77
  export declare function loadRevealedSecrets(token: string, companyUid: string, keys: string[], usage?: SecretUsage, options?: {
61
78
  allowOfflineFallback?: boolean;
62
79
  }): Promise<Map<string, string>>;
@@ -7,7 +7,7 @@ import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, removeCacheEntr
7
7
  import { computeSha256 } from "./utils/integrity.js";
8
8
  import { SECRET_NAME_PATTERN } from "./commands/_patterns.js";
9
9
  import { finishEnvExportsAndExit } from "./commands/secrets-env-exit.js";
10
- import { getEntityUid, vaultApiFetch } from "./utils/vault-api.js";
10
+ import { getEntityUid, looksLikeCompanyUid, vaultApiFetch } from "./utils/vault-api.js";
11
11
  import { HQ_API_KEY_PREFIX, resolveVaultCredentialForCapability, } from "./utils/resolve-vault-credential.js";
12
12
  import { CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE, isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError, } from "./utils/secret-load-availability.js";
13
13
  const MAX_BATCH_NAMES = 100;
@@ -124,16 +124,22 @@ export function parseSecretNameList(input) {
124
124
  // Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
125
125
  // with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
126
126
  // path used — never swallows a failure.
127
- async function loadRevealedSecretsViaApiKey(token, keys) {
127
+ //
128
+ // When the caller names a company uid, it rides in the body so the server can
129
+ // refuse a key bound to a different company. Without it the fetch route reads
130
+ // from the key's own company, which made `--company <other>` silently return
131
+ // the key company's same-named secret (P0 triage, 2026-10-11).
132
+ async function loadRevealedSecretsViaApiKey(token, keys, companyUid) {
128
133
  const resolved = new Map();
129
134
  const requested = [...new Set(keys)];
130
135
  const cacheScope = "__api_key__";
136
+ const scopedCompanyUid = companyUid && looksLikeCompanyUid(companyUid) ? companyUid : undefined;
131
137
  for (const name of requested) {
132
138
  const res = await vaultApiFetch({
133
139
  token,
134
140
  path: "/v1/keys/secrets/fetch",
135
141
  method: "POST",
136
- body: { name },
142
+ body: scopedCompanyUid ? { name, companyUid: scopedCompanyUid } : { name },
137
143
  signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
138
144
  });
139
145
  const body = (await res.json().catch(() => ({})));
@@ -168,9 +174,52 @@ async function loadRevealedSecretsViaApiKey(token, keys) {
168
174
  }
169
175
  return resolved;
170
176
  }
177
+ /**
178
+ * The company a caller explicitly asked for while authenticating with
179
+ * HQ_API_KEY: `--company`, else HQ_COMPANY_UID. Undefined means "the key's own
180
+ * company", which needs no check.
181
+ */
182
+ export function requestedApiKeyCompany(flagCompany) {
183
+ const ref = flagCompany?.trim() || process.env.HQ_COMPANY_UID?.trim();
184
+ return ref ? ref : undefined;
185
+ }
186
+ /**
187
+ * Refuse an explicitly requested company that the API key is not bound to.
188
+ *
189
+ * A vault API key can only read its own company's secrets. Before this check,
190
+ * `hq secrets exec --company <other>` under HQ_API_KEY ignored the flag and
191
+ * injected the key company's same-named secret with exit 0, which read as a
192
+ * cross-company leak (P0 triage, 2026-10-11). The probe is the keyed list
193
+ * route, which the server 403s with "not scoped to this company" on mismatch.
194
+ * Returns the verified uid; throws before any secret is fetched otherwise.
195
+ */
196
+ export async function assertApiKeyCompany(token, requested, commandLabel) {
197
+ if (!looksLikeCompanyUid(requested)) {
198
+ throw new Error(`HQ_API_KEY is set; '${commandLabel}' --company must be the company uid (cmp_…) the key ` +
199
+ `was minted for (resolving a slug needs a Cognito session). Got '${requested}'. ` +
200
+ `Omit --company to use the key's own company.`);
201
+ }
202
+ const response = await vaultApiFetch({
203
+ token,
204
+ path: `/v1/keys/secrets/${encodeURIComponent(requested)}`,
205
+ });
206
+ if (response.ok)
207
+ return requested;
208
+ if (response.status === 401)
209
+ throw new Error("HQ_API_KEY is invalid or no longer active.");
210
+ const body = (await response.json().catch(() => ({})));
211
+ const message = typeof body.error === "string" ? body.error : "";
212
+ if (response.status === 403 && /not scoped to this company/i.test(message)) {
213
+ throw new Error(`HQ_API_KEY belongs to a different company than ${requested}. An API key can only read ` +
214
+ `its own company's secrets. Omit --company (and HQ_COMPANY_UID) to use the key's company, ` +
215
+ `or use a key minted for ${requested}.`);
216
+ }
217
+ throw new Error(`Could not verify that HQ_API_KEY belongs to ${requested} (HTTP ${response.status}` +
218
+ `${message ? `: ${message}` : ""}).`);
219
+ }
171
220
  export async function loadRevealedSecrets(token, companyUid, keys, usage, options = {}) {
172
221
  if (token.startsWith(HQ_API_KEY_PREFIX)) {
173
- return loadRevealedSecretsViaApiKey(token, keys);
222
+ return loadRevealedSecretsViaApiKey(token, keys, companyUid);
174
223
  }
175
224
  const resolved = new Map();
176
225
  const requested = [...new Set(keys)];
@@ -416,8 +465,13 @@ export async function runSecretsEnvCommand(opts, parentScope, options = {}) {
416
465
  }
417
466
  const keys = parseSecretNameList(opts.only);
418
467
  const cred = await resolveVaultCredentialForCapability("secrets:read", "secrets env", secretsCredentialOptions());
468
+ const apiKeyCompanyRef = cred.kind === "api-key"
469
+ ? requestedApiKeyCompany(parentScope.company)
470
+ : undefined;
419
471
  const companyUid = cred.kind === "api-key"
420
- ? "__api_key__"
472
+ ? (apiKeyCompanyRef
473
+ ? await assertApiKeyCompany(cred.token, apiKeyCompanyRef, "secrets env")
474
+ : "__api_key__")
421
475
  : await getEntityUid(cred.token, scopeOpts(parentScope), {
422
476
  allowOfflineScopeFallback: true,
423
477
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.82",
3
+ "version": "5.345.84",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {