@indigoai-us/hq-cli 5.75.0 → 5.77.0
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/dist/commands/agents.js +8 -3
- package/dist/commands/files.d.ts +61 -0
- package/dist/commands/files.js +274 -0
- package/dist/commands/mcp-registration.d.ts +4 -5
- package/dist/commands/mcp-registration.js +5 -4
- package/dist/commands/outposts.d.ts +20 -4
- package/dist/commands/outposts.js +79 -10
- package/dist/commands/pack-install.d.ts +14 -17
- package/dist/commands/pack-install.js +53 -29
- package/dist/commands/pkg-install.js +3 -1
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +9 -3
- package/dist/commands/secrets.js +189 -87
- package/dist/run/hq-plugin.js +94 -31
- package/dist/utils/billing-gate.d.ts +15 -0
- package/dist/utils/billing-gate.js +35 -0
- package/dist/utils/sandbox-runner-client.d.ts +1 -0
- package/dist/utils/sandbox-runner-client.js +1 -0
- package/dist/utils/secrets-cache.d.ts +4 -5
- package/dist/utils/secrets-cache.js +5 -8
- package/package.json +3 -2
- package/pnpm-workspace.yaml +2 -0
- package/src/commands/agents.test.ts +41 -0
- package/src/commands/agents.ts +7 -3
- package/src/commands/files-recovery.test.ts +361 -0
- package/src/commands/files.ts +410 -0
- package/src/commands/mcp-registration.ts +9 -9
- package/src/commands/outposts.test.ts +155 -24
- package/src/commands/outposts.ts +199 -45
- package/src/commands/pack-install-secret-authorization.test.ts +115 -0
- package/src/commands/pack-install.test.ts +5 -1
- package/src/commands/pack-install.ts +67 -29
- package/src/commands/pkg-install.ts +3 -1
- package/src/commands/run.test.ts +45 -0
- package/src/commands/run.ts +20 -4
- package/src/commands/secrets.test.ts +366 -25
- package/src/commands/secrets.ts +222 -96
- package/src/run/hq-plugin.test.ts +186 -10
- package/src/run/hq-plugin.ts +102 -32
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
- package/src/utils/billing-gate.ts +46 -0
- package/src/utils/pack-contributions.test.ts +90 -31
- package/src/utils/sandbox-runner-client.test.ts +28 -0
- package/src/utils/sandbox-runner-client.ts +2 -0
- package/src/utils/secrets-cache.ts +5 -8
- package/test/commands/signals.test.ts +2 -2
- package/test/commands/sources.test.ts +2 -2
- package/test/helpers/vault-service-mock.ts +76 -17
- package/test/sources-signals/smoke.test.ts +2 -2
|
@@ -305,26 +305,21 @@ export declare function renderMcpServerLine(payloadDir: string, item: string): s
|
|
|
305
305
|
*/
|
|
306
306
|
export declare function confirmMcp(pkg: PackManifest, payloadDir: string, allowMcp: boolean): Promise<boolean>;
|
|
307
307
|
/**
|
|
308
|
-
* Build
|
|
309
|
-
*
|
|
308
|
+
* Build an install-time {@link SecretResolver} from a fresh server authorization
|
|
309
|
+
* decision. The encrypted cache may identify one unambiguous scope, but its
|
|
310
|
+
* plaintext is never trusted or read to register an MCP server.
|
|
310
311
|
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* context just provisioned the secret (e.g. the one `/connect-shopify` minted
|
|
318
|
-
* `VYG_API_KEY` under), without guessing, and works whether the vault scoped the
|
|
319
|
-
* key under a company or person entity.
|
|
312
|
+
* With `--company`, the caller-selected company is resolved normally. Without
|
|
313
|
+
* it, exactly one cache scope may identify the intended company/person context;
|
|
314
|
+
* multiple scopes are ambiguous and fail closed. The requested names are then
|
|
315
|
+
* loaded through the same server-authorized endpoint as exec/env/run. Offline,
|
|
316
|
+
* expired-session, forbidden, not-found, script-lock, and malformed-response
|
|
317
|
+
* outcomes all produce an empty resolver, so MCP registration is deferred.
|
|
320
318
|
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
* unresolvable-secret path keys off to defer that server gracefully. With no
|
|
324
|
-
* cached scopes at all (`listSecretCacheScopes()` → `[]`) it simply returns
|
|
325
|
-
* `null` for every name, the desired graceful-deferral behavior.
|
|
319
|
+
* The returned synchronous resolver contains only values from that fresh
|
|
320
|
+
* authorization response. No cached value is a fallback.
|
|
326
321
|
*/
|
|
327
|
-
export declare function makeInstallSecretResolver(): SecretResolver
|
|
322
|
+
export declare function makeInstallSecretResolver(secretNames: string[], company?: string): Promise<SecretResolver>;
|
|
328
323
|
/**
|
|
329
324
|
* Install the fetched payload to `<hqRoot>/core/packages/<pkg.name>/` (HQ
|
|
330
325
|
* v12+ layout). The HQ template (`hq-core` / `hq-core-staging`) ships
|
|
@@ -381,6 +376,8 @@ export declare function runScanPackages(hqRoot: string, opts?: {
|
|
|
381
376
|
}): void;
|
|
382
377
|
export interface InstallPackOptions {
|
|
383
378
|
allowHooks?: boolean;
|
|
379
|
+
/** Company context for online authorization of install-time MCP secret refs. */
|
|
380
|
+
company?: string;
|
|
384
381
|
/**
|
|
385
382
|
* US-010 install-time MCP trust prompt bypass (CI/ambient-trust). Mirrors
|
|
386
383
|
* `allowHooks`: when set, `confirmMcp` skips the prompt and prints a yellow
|
|
@@ -49,9 +49,11 @@ import { findHqRoot } from '../utils/manifest.js';
|
|
|
49
49
|
import { readHqVersion, routeContribution, listInstalledPacks } from '../utils/pack-contributions.js';
|
|
50
50
|
import { CONTRIBUTION_TABLE, payloadFor } from '../utils/contribution-table.js';
|
|
51
51
|
import { safeExtractTarball } from './safe-extract.js';
|
|
52
|
-
import { vaultApiFetchPublic } from '../utils/vault-api.js';
|
|
52
|
+
import { getCompanyUid, vaultApiFetchPublic } from '../utils/vault-api.js';
|
|
53
|
+
import { ensureCognitoToken } from '../utils/cognito-session.js';
|
|
53
54
|
import { redactSecrets, SECRET_REDACTION, registerMcpServers, McpManifestError, } from './mcp-registration.js';
|
|
54
|
-
import {
|
|
55
|
+
import { listSecretCacheScopes } from '../utils/secrets-cache.js';
|
|
56
|
+
import { loadRevealedSecrets } from './secrets.js';
|
|
55
57
|
const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
56
58
|
const PACK_UPDATE_FETCH_TIMEOUT_MS = 3_000;
|
|
57
59
|
const gitLsRemoteMemo = new Map();
|
|
@@ -1349,34 +1351,56 @@ function evalConditional(expr) {
|
|
|
1349
1351
|
// key) still SUCCEEDS, skipping only the server whose secret is not yet present.
|
|
1350
1352
|
// ---------------------------------------------------------------------------
|
|
1351
1353
|
/**
|
|
1352
|
-
* Build
|
|
1353
|
-
*
|
|
1354
|
+
* Build an install-time {@link SecretResolver} from a fresh server authorization
|
|
1355
|
+
* decision. The encrypted cache may identify one unambiguous scope, but its
|
|
1356
|
+
* plaintext is never trusted or read to register an MCP server.
|
|
1354
1357
|
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1360
|
-
*
|
|
1361
|
-
* context just provisioned the secret (e.g. the one `/connect-shopify` minted
|
|
1362
|
-
* `VYG_API_KEY` under), without guessing, and works whether the vault scoped the
|
|
1363
|
-
* key under a company or person entity.
|
|
1358
|
+
* With `--company`, the caller-selected company is resolved normally. Without
|
|
1359
|
+
* it, exactly one cache scope may identify the intended company/person context;
|
|
1360
|
+
* multiple scopes are ambiguous and fail closed. The requested names are then
|
|
1361
|
+
* loaded through the same server-authorized endpoint as exec/env/run. Offline,
|
|
1362
|
+
* expired-session, forbidden, not-found, script-lock, and malformed-response
|
|
1363
|
+
* outcomes all produce an empty resolver, so MCP registration is deferred.
|
|
1364
1364
|
*
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1367
|
-
* unresolvable-secret path keys off to defer that server gracefully. With no
|
|
1368
|
-
* cached scopes at all (`listSecretCacheScopes()` → `[]`) it simply returns
|
|
1369
|
-
* `null` for every name, the desired graceful-deferral behavior.
|
|
1365
|
+
* The returned synchronous resolver contains only values from that fresh
|
|
1366
|
+
* authorization response. No cached value is a fallback.
|
|
1370
1367
|
*/
|
|
1371
|
-
export function makeInstallSecretResolver() {
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1368
|
+
export async function makeInstallSecretResolver(secretNames, company) {
|
|
1369
|
+
const names = [...new Set(secretNames)];
|
|
1370
|
+
if (names.length === 0)
|
|
1371
|
+
return () => null;
|
|
1372
|
+
try {
|
|
1373
|
+
const token = await ensureCognitoToken({ interactive: false });
|
|
1374
|
+
let scopeUid;
|
|
1375
|
+
if (company) {
|
|
1376
|
+
scopeUid = await getCompanyUid(token, company);
|
|
1377
1377
|
}
|
|
1378
|
-
|
|
1379
|
-
|
|
1378
|
+
else {
|
|
1379
|
+
const scopes = listSecretCacheScopes();
|
|
1380
|
+
if (scopes.length !== 1)
|
|
1381
|
+
return () => null;
|
|
1382
|
+
[scopeUid] = scopes;
|
|
1383
|
+
}
|
|
1384
|
+
const authorized = await loadRevealedSecrets(token, scopeUid, names);
|
|
1385
|
+
return (name) => authorized.get(name) ?? null;
|
|
1386
|
+
}
|
|
1387
|
+
catch {
|
|
1388
|
+
return () => null;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
const INSTALL_SECRET_REF_RE = /\$\{secret:([A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]+)*)\}/g;
|
|
1392
|
+
function collectManifestSecretNames(manifest) {
|
|
1393
|
+
const names = new Set();
|
|
1394
|
+
for (const value of [
|
|
1395
|
+
...Object.values(manifest.headers ?? {}),
|
|
1396
|
+
...Object.values(manifest.env ?? {}),
|
|
1397
|
+
]) {
|
|
1398
|
+
INSTALL_SECRET_REF_RE.lastIndex = 0;
|
|
1399
|
+
for (let match = INSTALL_SECRET_REF_RE.exec(value); match; match = INSTALL_SECRET_REF_RE.exec(value)) {
|
|
1400
|
+
names.add(match[1]);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
return [...names];
|
|
1380
1404
|
}
|
|
1381
1405
|
/**
|
|
1382
1406
|
* Load + shape-validate + parse a pack's per-server MCP manifest from disk.
|
|
@@ -1424,13 +1448,13 @@ function extractDeferredSecretName(message) {
|
|
|
1424
1448
|
* - ANY OTHER error → RE-THROW (abort install). Only the
|
|
1425
1449
|
* unresolvable-secret case is swallowed.
|
|
1426
1450
|
*/
|
|
1427
|
-
function wireMcpServers(pkg, destDir) {
|
|
1428
|
-
const resolveSecret = makeInstallSecretResolver();
|
|
1451
|
+
async function wireMcpServers(pkg, destDir, company) {
|
|
1429
1452
|
const loadManifest = (name) => loadMcpManifestFrom(destDir, name);
|
|
1430
1453
|
const registered = [];
|
|
1431
1454
|
const skipped = [];
|
|
1432
1455
|
for (const name of pkg.contributes.mcp ?? []) {
|
|
1433
1456
|
try {
|
|
1457
|
+
const resolveSecret = await makeInstallSecretResolver(collectManifestSecretNames(loadManifest(name)), company);
|
|
1434
1458
|
// Per-server call: registerMcpServers throws on the FIRST unresolvable
|
|
1435
1459
|
// secret, so calling it one name at a time lets us catch + continue.
|
|
1436
1460
|
registerMcpServers(pkg.name, [name], { loadManifest, resolveSecret });
|
|
@@ -1676,7 +1700,7 @@ export async function installPack(source, opts = {}) {
|
|
|
1676
1700
|
// Per-server secret-deferral keeps a fresh install (key not yet minted) at
|
|
1677
1701
|
// exit 0, deferring only the unresolvable server (see wireMcpServers).
|
|
1678
1702
|
if (Array.isArray(pkg.contributes.mcp) && pkg.contributes.mcp.length > 0) {
|
|
1679
|
-
const { registered, skipped } = wireMcpServers(pkg, destDir);
|
|
1703
|
+
const { registered, skipped } = await wireMcpServers(pkg, destDir, opts.company);
|
|
1680
1704
|
// One-line summary (server NAMES only — never resolved secret VALUES).
|
|
1681
1705
|
say(chalk.dim(` MCP servers: registered [${registered.join(', ')}]; ` +
|
|
1682
1706
|
`skipped [${skipped.join(', ')}].`));
|
|
@@ -29,7 +29,7 @@ export function registerPackageInstallCommand(parent) {
|
|
|
29
29
|
.command('install <source>')
|
|
30
30
|
.description('Install a package. Sources: bare slug (registry, Cognito-gated), ' +
|
|
31
31
|
'@scope/name[@ver] (npm pack), git URL[#ref], or local path.')
|
|
32
|
-
.option('--company <co>', 'Scope
|
|
32
|
+
.option('--company <co>', 'Scope package secret authorization to a specific company')
|
|
33
33
|
.option('--allow-hooks', 'Skip the hooks confirmation prompt (content-pack flow)')
|
|
34
34
|
.option('--allow-mcp', 'Skip the MCP server confirmation prompt (content-pack flow)')
|
|
35
35
|
.option('--branch', 'Follow a ref instead of SHA-pinning (git content-pack flow)')
|
|
@@ -37,6 +37,7 @@ export function registerPackageInstallCommand(parent) {
|
|
|
37
37
|
try {
|
|
38
38
|
if (sourceMatchesPackPattern(source)) {
|
|
39
39
|
await installPack(source, {
|
|
40
|
+
company: opts.company,
|
|
40
41
|
allowHooks: opts.allowHooks,
|
|
41
42
|
allowMcp: opts.allowMcp,
|
|
42
43
|
followBranch: opts.branch,
|
|
@@ -48,6 +49,7 @@ export function registerPackageInstallCommand(parent) {
|
|
|
48
49
|
// would fail at registry setup. Resolve it through the marketplace
|
|
49
50
|
// listings transport — the live install path — instead.
|
|
50
51
|
await installPack(`${MARKETPLACE_PREFIX}${source}`, {
|
|
52
|
+
company: opts.company,
|
|
51
53
|
allowHooks: opts.allowHooks,
|
|
52
54
|
allowMcp: opts.allowMcp,
|
|
53
55
|
followBranch: opts.branch,
|
package/dist/commands/run.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
+
import type { SecretUsage } from './secrets.js';
|
|
3
|
+
export declare function buildRunUsage(scriptPath?: string, scriptId?: string): Promise<SecretUsage | undefined>;
|
|
2
4
|
export declare function registerRunCommand(program: Command): void;
|
|
3
5
|
//# sourceMappingURL=run.d.ts.map
|
package/dist/commands/run.js
CHANGED
|
@@ -7,7 +7,11 @@ import { computeSha256 } from '../utils/integrity.js';
|
|
|
7
7
|
import { vaultApiFetch, getCompanyUid } from '../utils/vault-api.js';
|
|
8
8
|
import { discoverSchemas } from '../run/discover-schemas.js';
|
|
9
9
|
import { installHqPlugin, prewarmHqSecrets } from '../run/hq-plugin.js';
|
|
10
|
-
|
|
10
|
+
const SECRET_LOAD_TIMEOUT_MS = 30_000;
|
|
11
|
+
export async function buildRunUsage(scriptPath, scriptId) {
|
|
12
|
+
if (scriptId && !scriptPath) {
|
|
13
|
+
throw new Error('--script-id requires --script');
|
|
14
|
+
}
|
|
11
15
|
if (!scriptPath) {
|
|
12
16
|
return undefined;
|
|
13
17
|
}
|
|
@@ -15,7 +19,7 @@ async function buildRunUsage(scriptPath) {
|
|
|
15
19
|
return {
|
|
16
20
|
channel: 'run',
|
|
17
21
|
script: {
|
|
18
|
-
scriptId: resolvedPath,
|
|
22
|
+
scriptId: scriptId ?? resolvedPath,
|
|
19
23
|
path: resolvedPath,
|
|
20
24
|
sha256: await computeSha256(resolvedPath),
|
|
21
25
|
attestationLevel: 'self-asserted-hash',
|
|
@@ -29,6 +33,7 @@ export function registerRunCommand(program) {
|
|
|
29
33
|
.option('--company <slug>', 'Company slug (overrides @hqCompany in schema)')
|
|
30
34
|
.option('--schema <path>', 'Explicit schema path (skips walk-up discovery)')
|
|
31
35
|
.option('--script <path>', 'Attach local script identity for script-locked secrets')
|
|
36
|
+
.option('--script-id <id>', 'Stable script identifier approved by policy')
|
|
32
37
|
.option('--check', 'Resolve schema and validate vars without executing the command')
|
|
33
38
|
.allowUnknownOption(true)
|
|
34
39
|
.action(async (opts) => {
|
|
@@ -68,13 +73,14 @@ export function registerRunCommand(program) {
|
|
|
68
73
|
}
|
|
69
74
|
const token = await ensureCognitoToken();
|
|
70
75
|
const uid = await getCompanyUid(token, slug);
|
|
71
|
-
const usage = await buildRunUsage(opts.script);
|
|
76
|
+
const usage = await buildRunUsage(opts.script, opts.scriptId);
|
|
72
77
|
const fetchBatch = async (companyUid, names, requestUsage) => {
|
|
73
78
|
const res = await vaultApiFetch({
|
|
74
79
|
token,
|
|
75
80
|
path: `/secrets/${encodeURIComponent(companyUid)}/load`,
|
|
76
81
|
method: 'POST',
|
|
77
82
|
body: requestUsage ? { names, usage: requestUsage } : { names },
|
|
83
|
+
signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
|
|
78
84
|
});
|
|
79
85
|
if (!res.ok) {
|
|
80
86
|
const body = await res.json().catch(() => ({}));
|
package/dist/commands/secrets.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as readline from "node:readline";
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import * as nodePath from "node:path";
|
|
5
5
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
6
|
-
import { DEFAULT_SECRETS_CACHE_TTL_MS,
|
|
6
|
+
import { DEFAULT_SECRETS_CACHE_TTL_MS, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
|
|
7
7
|
import { computeSha256 } from "../utils/integrity.js";
|
|
8
8
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
|
|
9
9
|
import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
|
|
@@ -130,6 +130,7 @@ function promptSecretInteractively() {
|
|
|
130
130
|
// large --only list is chunked client-side rather than 400'd whole by the
|
|
131
131
|
// server (the legacy per-key GET path had no such cap).
|
|
132
132
|
const MAX_BATCH_NAMES = 100;
|
|
133
|
+
const SECRET_LOAD_TIMEOUT_MS = 30_000;
|
|
133
134
|
function parseSecretAclPrincipal(principal) {
|
|
134
135
|
const p = principal.trim();
|
|
135
136
|
if (p === "@all") {
|
|
@@ -232,10 +233,23 @@ function normalizeSecretTier(tier) {
|
|
|
232
233
|
function normalizeScriptLockMode(mode) {
|
|
233
234
|
return mode === "enforced" ? "enforced" : "off";
|
|
234
235
|
}
|
|
235
|
-
function normalizeCacheTtlMs(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
236
|
+
function normalizeCacheTtlMs(metadata) {
|
|
237
|
+
// Controlled rows must never reach the offline cache even if a mixed-version
|
|
238
|
+
// or malformed response carries a positive TTL. The server is authoritative
|
|
239
|
+
// for access, but the client still has enough policy metadata to fail safe.
|
|
240
|
+
if (metadata.tier === "sensitive" ||
|
|
241
|
+
metadata.tier === "nuclear" ||
|
|
242
|
+
metadata.scriptLock?.mode === "enforced") {
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
if (metadata.cacheTtlMs === undefined) {
|
|
246
|
+
return DEFAULT_SECRETS_CACHE_TTL_MS;
|
|
247
|
+
}
|
|
248
|
+
return typeof metadata.cacheTtlMs === "number" &&
|
|
249
|
+
Number.isFinite(metadata.cacheTtlMs) &&
|
|
250
|
+
metadata.cacheTtlMs > 0
|
|
251
|
+
? metadata.cacheTtlMs
|
|
252
|
+
: 0;
|
|
239
253
|
}
|
|
240
254
|
function extractApiMessage(body, fallback) {
|
|
241
255
|
const message = typeof body.message === "string" ? body.message : undefined;
|
|
@@ -243,6 +257,9 @@ function extractApiMessage(body, fallback) {
|
|
|
243
257
|
return message ?? error ?? fallback;
|
|
244
258
|
}
|
|
245
259
|
async function buildSecretUsage(channel, scriptPath, scriptId, attestationLevel = "self-asserted-hash") {
|
|
260
|
+
if (scriptId && !scriptPath) {
|
|
261
|
+
throw new Error("--script-id requires --script");
|
|
262
|
+
}
|
|
246
263
|
if (!scriptPath) {
|
|
247
264
|
return { channel };
|
|
248
265
|
}
|
|
@@ -308,11 +325,25 @@ function renderSandboxJobResult(job, secretNames) {
|
|
|
308
325
|
}
|
|
309
326
|
function normalizePolicyRecord(secretPath, data) {
|
|
310
327
|
const policy = data.policy ?? { path: secretPath };
|
|
311
|
-
const scripts = Array.isArray(policy.
|
|
312
|
-
? policy.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
328
|
+
const scripts = Array.isArray(policy.scriptLock?.approvedScripts)
|
|
329
|
+
? policy.scriptLock.approvedScripts
|
|
330
|
+
.filter((script) => script !== null &&
|
|
331
|
+
typeof script === "object" &&
|
|
332
|
+
typeof script.scriptId === "string" &&
|
|
333
|
+
typeof script.path === "string" &&
|
|
334
|
+
typeof script.attestationLevel === "string" &&
|
|
335
|
+
!script.revokedAt)
|
|
336
|
+
.map((script) => ({
|
|
337
|
+
scriptId: script.scriptId,
|
|
338
|
+
scriptPath: script.path,
|
|
339
|
+
sha256: typeof script.sha256 === "string" ? script.sha256 : "",
|
|
340
|
+
attestationLevel: script.attestationLevel,
|
|
341
|
+
}))
|
|
342
|
+
: Array.isArray(policy.scripts)
|
|
343
|
+
? policy.scripts.filter(isSecretPolicyScript)
|
|
344
|
+
: Array.isArray(data.scripts)
|
|
345
|
+
? data.scripts.filter(isSecretPolicyScript)
|
|
346
|
+
: [];
|
|
316
347
|
return {
|
|
317
348
|
path: policy.path ?? secretPath,
|
|
318
349
|
tier: normalizeSecretTier(policy.tier),
|
|
@@ -323,6 +354,15 @@ function normalizePolicyRecord(secretPath, data) {
|
|
|
323
354
|
scripts,
|
|
324
355
|
};
|
|
325
356
|
}
|
|
357
|
+
function isSecretPolicyScript(value) {
|
|
358
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
359
|
+
return false;
|
|
360
|
+
const script = value;
|
|
361
|
+
return (typeof script.scriptId === "string" &&
|
|
362
|
+
typeof script.scriptPath === "string" &&
|
|
363
|
+
typeof script.sha256 === "string" &&
|
|
364
|
+
typeof script.attestationLevel === "string");
|
|
365
|
+
}
|
|
326
366
|
function renderPolicySummary(policy) {
|
|
327
367
|
console.log(chalk.bold(`Policy: ${policy.path}`));
|
|
328
368
|
console.log(` Tier: ${normalizeSecretTier(policy.tier)}`);
|
|
@@ -351,7 +391,7 @@ function renderPolicyScripts(scripts) {
|
|
|
351
391
|
script.scriptId.padEnd(idWidth),
|
|
352
392
|
script.scriptPath.padEnd(pathWidth),
|
|
353
393
|
script.attestationLevel.padEnd(attestationWidth),
|
|
354
|
-
script.sha256,
|
|
394
|
+
script.sha256 || "-",
|
|
355
395
|
].join(" "));
|
|
356
396
|
}
|
|
357
397
|
}
|
|
@@ -370,84 +410,136 @@ function renderPolicyScripts(scripts) {
|
|
|
370
410
|
// `env` through it stops those callers from emitting the warning while keeping
|
|
371
411
|
// identical UX and error text.
|
|
372
412
|
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
413
|
+
// Every value use through exec/env/reveal is server-authorized, including when
|
|
414
|
+
// an encrypted disk-cache entry exists. The cache remains write-through for
|
|
415
|
+
// explicitly offline install-time consumers; these commands never trust it as
|
|
416
|
+
// an authorization decision. This means a policy, ACL, tier, or script-approval
|
|
417
|
+
// change takes effect on their next use even though there is no push revocation.
|
|
418
|
+
// Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
|
|
419
|
+
// with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
|
|
420
|
+
// path used — never swallows a failure.
|
|
376
421
|
export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
377
422
|
const resolved = new Map();
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (res.status >= 400 &&
|
|
405
|
-
res.status < 500 &&
|
|
406
|
-
typeof body.code === "string") {
|
|
407
|
-
throw new Error(message);
|
|
408
|
-
}
|
|
409
|
-
throw new Error(`Failed to batch-load secrets: ${message}`);
|
|
410
|
-
}
|
|
411
|
-
const data = (await res.json());
|
|
412
|
-
for (const s of data.secrets ?? []) {
|
|
413
|
-
if (s.value == null) {
|
|
414
|
-
throw new Error(`Secret '${s.name}' has no value (reveal may not be permitted).`);
|
|
423
|
+
const requested = [...new Set(keys)];
|
|
424
|
+
try {
|
|
425
|
+
for (let i = 0; i < requested.length; i += MAX_BATCH_NAMES) {
|
|
426
|
+
const chunk = requested.slice(i, i + MAX_BATCH_NAMES);
|
|
427
|
+
const chunkNames = new Set(chunk);
|
|
428
|
+
const res = await vaultApiFetch({
|
|
429
|
+
token,
|
|
430
|
+
path: `/secrets/${encodeURIComponent(companyUid)}/load`,
|
|
431
|
+
method: "POST",
|
|
432
|
+
body: usage ? { names: chunk, usage } : { names: chunk },
|
|
433
|
+
signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
|
|
434
|
+
});
|
|
435
|
+
if (!res.ok) {
|
|
436
|
+
const body = (await res.json().catch(() => ({})));
|
|
437
|
+
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.");
|
|
442
|
+
}
|
|
443
|
+
if (res.status >= 400 &&
|
|
444
|
+
res.status < 500 &&
|
|
445
|
+
typeof body.code === "string") {
|
|
446
|
+
throw new Error(message);
|
|
447
|
+
}
|
|
448
|
+
throw new Error(`Failed to batch-load secrets: ${message}`);
|
|
415
449
|
}
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
418
|
-
|
|
450
|
+
const data = (await res.json());
|
|
451
|
+
if (!Array.isArray(data.secrets) || !Array.isArray(data.errors)) {
|
|
452
|
+
throw new Error("Invalid secret load response from vault");
|
|
453
|
+
}
|
|
454
|
+
const errorsByName = new Map();
|
|
455
|
+
const seenNames = new Set();
|
|
456
|
+
for (const rawSecret of data.secrets) {
|
|
457
|
+
if (!rawSecret || typeof rawSecret !== "object") {
|
|
458
|
+
throw new Error("Invalid secret load response from vault");
|
|
459
|
+
}
|
|
460
|
+
const s = rawSecret;
|
|
461
|
+
if (typeof s.name !== "string" ||
|
|
462
|
+
!chunkNames.has(s.name) ||
|
|
463
|
+
seenNames.has(s.name) ||
|
|
464
|
+
(s.value != null && typeof s.value !== "string")) {
|
|
465
|
+
throw new Error("Invalid secret load response from vault");
|
|
466
|
+
}
|
|
467
|
+
seenNames.add(s.name);
|
|
468
|
+
if (s.value == null) {
|
|
469
|
+
errorsByName.set(s.name, {
|
|
470
|
+
code: "not_returned",
|
|
471
|
+
message: `Secret '${s.name}' has no value (reveal may not be permitted).`,
|
|
472
|
+
});
|
|
473
|
+
removeCacheEntry(companyUid, s.name);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const cacheTtlMs = normalizeCacheTtlMs(s);
|
|
477
|
+
if (cacheTtlMs > 0) {
|
|
478
|
+
writeCache(companyUid, s.name, s.value, cacheTtlMs);
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
removeCacheEntry(companyUid, s.name);
|
|
482
|
+
}
|
|
483
|
+
resolved.set(s.name, s.value);
|
|
484
|
+
}
|
|
485
|
+
for (const rawError of data.errors) {
|
|
486
|
+
if (!rawError || typeof rawError !== "object") {
|
|
487
|
+
throw new Error("Invalid secret load response from vault");
|
|
488
|
+
}
|
|
489
|
+
const e = rawError;
|
|
490
|
+
if (typeof e.name !== "string" ||
|
|
491
|
+
!chunkNames.has(e.name) ||
|
|
492
|
+
seenNames.has(e.name) ||
|
|
493
|
+
typeof e.code !== "string" ||
|
|
494
|
+
(e.message !== undefined && typeof e.message !== "string")) {
|
|
495
|
+
throw new Error("Invalid secret load response from vault");
|
|
496
|
+
}
|
|
497
|
+
seenNames.add(e.name);
|
|
498
|
+
errorsByName.set(e.name, { code: e.code, message: e.message });
|
|
499
|
+
resolved.delete(e.name);
|
|
500
|
+
removeCacheEntry(companyUid, e.name);
|
|
501
|
+
}
|
|
502
|
+
// Any requested key in this chunk the server did not return is a per-key
|
|
503
|
+
// failure — surface it with the same prefix the single-GET path used so
|
|
504
|
+
// callers (and scripts grepping stderr) see no behavior change.
|
|
505
|
+
let firstFailure = null;
|
|
506
|
+
for (const key of chunk) {
|
|
507
|
+
if (resolved.has(key))
|
|
508
|
+
continue;
|
|
509
|
+
removeCacheEntry(companyUid, key);
|
|
510
|
+
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.`);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const reason = err?.code === "not_found"
|
|
522
|
+
? "Secret not found"
|
|
523
|
+
: err?.code === "forbidden"
|
|
524
|
+
? err.message ?? "No read permission"
|
|
525
|
+
: err?.message ?? err?.code ?? "not returned by vault";
|
|
526
|
+
firstFailure ??= new Error(`Failed to fetch secret '${key}': ${reason}`);
|
|
527
|
+
}
|
|
528
|
+
if (firstFailure) {
|
|
529
|
+
throw firstFailure;
|
|
419
530
|
}
|
|
420
|
-
resolved.set(s.name, s.value);
|
|
421
|
-
}
|
|
422
|
-
const errorsByName = new Map();
|
|
423
|
-
for (const e of data.errors ?? []) {
|
|
424
|
-
errorsByName.set(e.name, { code: e.code, message: e.message });
|
|
425
531
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
// local-injection (batch-load) path — per-name code `high_security_denied`,
|
|
435
|
-
// no plaintext returned. Every caller of loadRevealedSecrets injects or
|
|
436
|
-
// prints the plaintext locally (`secrets get --reveal`, `secrets exec`,
|
|
437
|
-
// `secrets env`), so a high-security secret can NEVER be used here. Surface
|
|
438
|
-
// a clear, actionable error pointing at the proxy instead of a raw failure.
|
|
439
|
-
if (err?.code === "high_security_denied") {
|
|
440
|
-
throw 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.`);
|
|
441
|
-
}
|
|
442
|
-
const reason = err?.code === "not_found"
|
|
443
|
-
? "Secret not found"
|
|
444
|
-
: err?.code === "forbidden"
|
|
445
|
-
? err.message ?? "No read permission"
|
|
446
|
-
: err?.message ?? err?.code ?? "not returned by vault";
|
|
447
|
-
throw new Error(`Failed to fetch secret '${key}': ${reason}`);
|
|
532
|
+
return resolved;
|
|
533
|
+
}
|
|
534
|
+
catch (err) {
|
|
535
|
+
// A transport failure, stale session, malformed response, or a later chunk
|
|
536
|
+
// failure must not leave values written by this attempted operation available
|
|
537
|
+
// to offline cache readers. Evict the whole request before failing closed.
|
|
538
|
+
for (const key of requested) {
|
|
539
|
+
removeCacheEntry(companyUid, key);
|
|
448
540
|
}
|
|
541
|
+
throw err;
|
|
449
542
|
}
|
|
450
|
-
return resolved;
|
|
451
543
|
}
|
|
452
544
|
export function registerSecretsCommand(program) {
|
|
453
545
|
const secrets = program
|
|
@@ -846,6 +938,7 @@ export function registerSecretsCommand(program) {
|
|
|
846
938
|
process.exit(1);
|
|
847
939
|
}
|
|
848
940
|
const data = (await res.json().catch(() => ({})));
|
|
941
|
+
removeCacheEntry(companyUid, secretPath);
|
|
849
942
|
console.log(chalk.green(`Policy updated for '${secretPath}'.`));
|
|
850
943
|
renderPolicySummary(normalizePolicyRecord(secretPath, data.policy ? data : { policy: { path: secretPath, tier, scriptLock } }));
|
|
851
944
|
}
|
|
@@ -890,6 +983,7 @@ export function registerSecretsCommand(program) {
|
|
|
890
983
|
console.error(chalk.red(`Failed to approve script: ${extractApiMessage(body, res.statusText)}`));
|
|
891
984
|
process.exit(1);
|
|
892
985
|
}
|
|
986
|
+
removeCacheEntry(companyUid, secretPath);
|
|
893
987
|
console.log(chalk.green(`Approved script '${opts.id}' for '${secretPath}'.`));
|
|
894
988
|
}
|
|
895
989
|
catch (err) {
|
|
@@ -921,6 +1015,7 @@ export function registerSecretsCommand(program) {
|
|
|
921
1015
|
console.error(chalk.red(`Failed to revoke script: ${extractApiMessage(body, res.statusText)}`));
|
|
922
1016
|
process.exit(1);
|
|
923
1017
|
}
|
|
1018
|
+
removeCacheEntry(companyUid, secretPath);
|
|
924
1019
|
console.log(chalk.green(`Revoked script '${opts.id}' for '${secretPath}'.`));
|
|
925
1020
|
}
|
|
926
1021
|
catch (err) {
|
|
@@ -1044,9 +1139,14 @@ export function registerSecretsCommand(program) {
|
|
|
1044
1139
|
renderSandboxJobResult(job, keys);
|
|
1045
1140
|
const exitCode = typeof job.exitCode === "number" ? job.exitCode : undefined;
|
|
1046
1141
|
if (job.success === false || (exitCode !== undefined && exitCode !== 0) || job.status === "failed") {
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1142
|
+
if (exitCode !== undefined && exitCode !== 0) {
|
|
1143
|
+
console.error(chalk.red(`Sandbox command failed with exit code ${exitCode}.`));
|
|
1144
|
+
process.exit(exitCode);
|
|
1145
|
+
}
|
|
1146
|
+
console.error(chalk.red(job.error !== undefined
|
|
1147
|
+
? scrubSandboxOutput(job.error, keys)
|
|
1148
|
+
: "Sandbox execution failed before the command produced an exit code."));
|
|
1149
|
+
process.exit(1);
|
|
1050
1150
|
}
|
|
1051
1151
|
}
|
|
1052
1152
|
catch (err) {
|
|
@@ -1059,6 +1159,7 @@ export function registerSecretsCommand(program) {
|
|
|
1059
1159
|
.description("Run a command with secrets injected as env vars")
|
|
1060
1160
|
.requiredOption("--only <keys>", "Secret names to inject (comma-separated; may be repeated) (required)", collectSecretNames)
|
|
1061
1161
|
.option("--script <path>", "Attach local script identity for script-locked secrets")
|
|
1162
|
+
.option("--script-id <id>", "Stable script identifier approved by policy")
|
|
1062
1163
|
.allowUnknownOption(true)
|
|
1063
1164
|
.action(async (_opts, cmd) => {
|
|
1064
1165
|
try {
|
|
@@ -1078,7 +1179,7 @@ export function registerSecretsCommand(program) {
|
|
|
1078
1179
|
const keys = parseSecretNameList(_opts.only);
|
|
1079
1180
|
const token = await ensureCognitoToken();
|
|
1080
1181
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
1081
|
-
const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("exec", _opts.script));
|
|
1182
|
+
const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("exec", _opts.script, _opts.scriptId));
|
|
1082
1183
|
const secretEnv = {};
|
|
1083
1184
|
for (const key of keys) {
|
|
1084
1185
|
const value = revealed.get(key);
|
|
@@ -1115,6 +1216,7 @@ export function registerSecretsCommand(program) {
|
|
|
1115
1216
|
.description("Print 'export KEY=VALUE' lines suitable for: source <(hq secrets env --only K1,K2)")
|
|
1116
1217
|
.requiredOption("--only <keys>", "Secret names to print (comma-separated; may be repeated) (required)", collectSecretNames)
|
|
1117
1218
|
.option("--script <path>", "Attach local script identity for script-locked secrets")
|
|
1219
|
+
.option("--script-id <id>", "Stable script identifier approved by policy")
|
|
1118
1220
|
.action(async (opts) => {
|
|
1119
1221
|
try {
|
|
1120
1222
|
const redact = process.stdout.isTTY;
|
|
@@ -1124,7 +1226,7 @@ export function registerSecretsCommand(program) {
|
|
|
1124
1226
|
const keys = parseSecretNameList(opts.only);
|
|
1125
1227
|
const token = await ensureCognitoToken();
|
|
1126
1228
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
1127
|
-
const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("env", opts.script));
|
|
1229
|
+
const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("env", opts.script, opts.scriptId));
|
|
1128
1230
|
for (const key of keys) {
|
|
1129
1231
|
const value = revealed.get(key);
|
|
1130
1232
|
// loadRevealedSecrets throws on any unresolved key, so a miss here is
|