@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 +15 -0
- package/assets/scaffold/core/scripts/lib/forwarder.sh +142 -0
- package/dist/commands/core.js +1 -0
- package/dist/commands/secrets.js +11 -2
- package/dist/lib/lanes/hooks-install.js +204 -28
- package/dist/secrets-env-action.d.ts +17 -0
- package/dist/secrets-env-action.js +59 -5
- package/package.json +1 -1
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
|
+
}
|
package/dist/commands/core.js
CHANGED
|
@@ -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" },
|
package/dist/commands/secrets.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
190
|
-
hook.type
|
|
191
|
-
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 =
|
|
254
|
-
nextHooks[event] =
|
|
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
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
});
|