@indigoai-us/hq-cli 5.345.83 → 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 +8 -0
- package/dist/commands/secrets.js +11 -2
- 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,14 @@
|
|
|
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
|
+
|
|
5
13
|
## [5.345.83] — 2026-10-11
|
|
6
14
|
- HQ-root scaffolds now package the shared runtime used by generated shell forwarders.
|
|
7
15
|
- Lanes hook installation now replaces stale hq-cli drain registrations across
|
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)
|
|
@@ -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
|
});
|