envpkt 0.14.0 → 0.15.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/README.md +15 -0
- package/dist/cli.js +285 -72
- package/dist/index.d.ts +60 -1
- package/dist/index.js +183 -52
- package/package.json +11 -11
- package/schemas/envpkt.schema.json +29 -0
package/README.md
CHANGED
|
@@ -584,6 +584,21 @@ Inject resolved secrets into a GitHub Actions job. Emits `::add-mask::` for each
|
|
|
584
584
|
npx envpkt env github --strict
|
|
585
585
|
```
|
|
586
586
|
|
|
587
|
+
### `envpkt headers`
|
|
588
|
+
|
|
589
|
+
Print the HTTP headers for one MCP server as JSON, for Claude Code's `headersHelper`. Bind a secret to a server with `mcp = [{ server = "civala-github" }]` (optional `header`, default `"Authorization"`; `scheme`, default `"Bearer"`, `""` for the raw value).
|
|
590
|
+
|
|
591
|
+
```bash
|
|
592
|
+
envpkt headers civala-github # {"Authorization":"Bearer ..."}
|
|
593
|
+
envpkt headers # server from $CLAUDE_CODE_MCP_SERVER_NAME
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
```json
|
|
597
|
+
{ "type": "http", "url": "https://github.civala.ai/mcp", "headersHelper": "envpkt headers" }
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
stdout holds only the JSON; warnings and errors go to stderr. It decrypts sealed values through `identity.key_file`, so it works when Claude was launched with a stripped environment and `${VAR}` refs in `.mcp.json` would expand empty. Exits non-zero, naming the server, when nothing matches or a value does not resolve. Without `-c`, it finds `envpkt.toml` like every other command, falling back to the global package when the project has none.
|
|
601
|
+
|
|
587
602
|
### `envpkt mcp`
|
|
588
603
|
|
|
589
604
|
Start the envpkt MCP server (stdio transport) for AI agent integration.
|
package/dist/cli.js
CHANGED
|
@@ -123,14 +123,15 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
|
|
|
123
123
|
const staleWarningDays = lifecycle.stale_warning_days ?? 90;
|
|
124
124
|
const requireExpiration = lifecycle.require_expiration ?? false;
|
|
125
125
|
const requireService = lifecycle.require_service ?? false;
|
|
126
|
-
const keys = fnoxKeys ??
|
|
126
|
+
const keys = fnoxKeys ?? Set$1.empty();
|
|
127
127
|
const secretEntries = config.secret ?? {};
|
|
128
128
|
const nonAliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0);
|
|
129
129
|
const aliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0);
|
|
130
130
|
const nonAliasHealth = nonAliasEntries.map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now));
|
|
131
131
|
const healthByKey = Map$1(nonAliasHealth.map((h) => [h.key, h]));
|
|
132
132
|
const parseTargetKey = (from_key) => {
|
|
133
|
-
|
|
133
|
+
const match = from_key.match(/^secret\.(.+)$/);
|
|
134
|
+
return Option(match?.[1]);
|
|
134
135
|
};
|
|
135
136
|
const aliasHealth = aliasEntries.map(([key, meta]) => {
|
|
136
137
|
const tableEntry = aliasTable?.entries.get(`secret.${key}`);
|
|
@@ -184,8 +185,10 @@ const computeEnvAudit = (config, env = process.env) => {
|
|
|
184
185
|
const envWire = (key) => namer(key, envEntries[key]?.namespace);
|
|
185
186
|
const resolveEffectiveDefault = (entry) => {
|
|
186
187
|
return Do(function* () {
|
|
187
|
-
const
|
|
188
|
-
|
|
188
|
+
const fk = yield* $(Option(entry.from_key));
|
|
189
|
+
const targetKey = yield* $(Option(fk.match(/^env\.(.+)$/)?.[1]));
|
|
190
|
+
const targetEntry = yield* $(Option(envEntries[targetKey]));
|
|
191
|
+
return yield* $(Option(targetEntry.value));
|
|
189
192
|
}).orElse(entry.value ?? "");
|
|
190
193
|
};
|
|
191
194
|
const entries = Object.entries(envEntries).map(([key, entry]) => {
|
|
@@ -241,6 +244,21 @@ const NamespaceSchema = Type.Object({
|
|
|
241
244
|
description: "Separator between prefix and key. Default '__' (shell-safe). Note: '.' and ':' are not valid in shell identifiers."
|
|
242
245
|
}))
|
|
243
246
|
}, { description: "Optional namespace/package prefix for injected environment variable names" });
|
|
247
|
+
const McpHeaderSchema = Type.Object({
|
|
248
|
+
server: Type.String({
|
|
249
|
+
minLength: 1,
|
|
250
|
+
description: "MCP server name, as keyed in .mcp.json"
|
|
251
|
+
}),
|
|
252
|
+
header: Type.Optional(Type.String({
|
|
253
|
+
default: "Authorization",
|
|
254
|
+
minLength: 1,
|
|
255
|
+
description: "HTTP header name to send the secret in"
|
|
256
|
+
})),
|
|
257
|
+
scheme: Type.Optional(Type.String({
|
|
258
|
+
default: "Bearer",
|
|
259
|
+
description: "Prefix placed before the value (e.g. 'Bearer'). Empty string sends the raw value."
|
|
260
|
+
}))
|
|
261
|
+
}, { description: "Send this secret as an HTTP header to an MCP server" });
|
|
244
262
|
const SecretMetaSchema = Type.Object({
|
|
245
263
|
service: Type.Optional(Type.String({ description: "Service or system this secret authenticates to" })),
|
|
246
264
|
expires: Type.Optional(Type.String({
|
|
@@ -271,7 +289,8 @@ const SecretMetaSchema = Type.Object({
|
|
|
271
289
|
required: Type.Optional(Type.Boolean({ description: "Whether this secret is required for operation" })),
|
|
272
290
|
external: Type.Optional(Type.Boolean({ description: "Value is managed outside envpkt (e.g. a Cloudflare/Vault secret). The entry registers the name for inventory and dev/prod parity only: seal never touches it, and audit reports it as 'external' rather than 'missing'." })),
|
|
273
291
|
tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" })),
|
|
274
|
-
namespace: Type.Optional(Type.String({ description: "Override the file-level namespace for this entry's injected name. Empty string opts out of any prefix." }))
|
|
292
|
+
namespace: Type.Optional(Type.String({ description: "Override the file-level namespace for this entry's injected name. Empty string opts out of any prefix." })),
|
|
293
|
+
mcp: Type.Optional(Type.Array(McpHeaderSchema, { description: "MCP servers this secret authenticates to. Read by `envpkt headers` (Claude Code headersHelper)." }))
|
|
275
294
|
}, { description: "Metadata about a single secret" });
|
|
276
295
|
const LifecycleConfigSchema = Type.Object({
|
|
277
296
|
stale_warning_days: Type.Optional(Type.Number({
|
|
@@ -399,10 +418,13 @@ const discoverConfig = (cwd) => {
|
|
|
399
418
|
template,
|
|
400
419
|
expanded: expandPath(template)
|
|
401
420
|
})).filter(({ expanded }) => expanded !== "" && !expanded.startsWith("/.envpkt")).map(({ expanded }) => expandGlobPath(expanded)).find((matches) => matches.length > 0);
|
|
402
|
-
if (customMatch)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
421
|
+
if (customMatch) {
|
|
422
|
+
const found = {
|
|
423
|
+
path: customMatch[0],
|
|
424
|
+
source: "search"
|
|
425
|
+
};
|
|
426
|
+
return Option(found);
|
|
427
|
+
}
|
|
406
428
|
const platformMatch = buildSearchPaths().find((p) => Fs.existsSync(p));
|
|
407
429
|
if (platformMatch) return Option({
|
|
408
430
|
path: platformMatch,
|
|
@@ -444,9 +466,10 @@ const parseToml = (raw) => Try(() => parse(raw)).fold((err) => Left({
|
|
|
444
466
|
/** Validate parsed data against the TypeBox schema */
|
|
445
467
|
const validateConfig = (data) => {
|
|
446
468
|
if (compiledSchema.Check(data)) return Right(data);
|
|
469
|
+
const errors = List([...compiledSchema.Errors(data)].map((e) => `${e.path}: ${e.message}`));
|
|
447
470
|
return Left({
|
|
448
471
|
_tag: "ValidationError",
|
|
449
|
-
errors
|
|
472
|
+
errors
|
|
450
473
|
});
|
|
451
474
|
};
|
|
452
475
|
/** Load and validate an envpkt.toml from a file path */
|
|
@@ -728,12 +751,13 @@ const formatError = (error) => {
|
|
|
728
751
|
case "IdentityNotFound": return `${RED}Error:${RESET} Identity file not found: ${error.path}`;
|
|
729
752
|
case "SealKeyUnavailable": {
|
|
730
753
|
const e = error;
|
|
754
|
+
const restoreLine = e.configuredKeyFile ? ` • Restore the configured key to ${e.configuredKeyFile} (or set ENVPKT_AGE_KEY_FILE / ENVPKT_AGE_KEY)` : ` • Restore your key to ~/.envpkt/age-key.txt (or set ENVPKT_AGE_KEY_FILE / ENVPKT_AGE_KEY)`;
|
|
731
755
|
return [
|
|
732
756
|
`${RED}Error:${RESET} ${e.sealedKeys.length} sealed secret(s) can't be decrypted — no age key found.`,
|
|
733
757
|
`${DIM}Searched (in order):${RESET}`,
|
|
734
758
|
e.searched.map((l) => ` • ${l}`).join("\n"),
|
|
735
759
|
`${DIM}Fix one:${RESET}`,
|
|
736
|
-
|
|
760
|
+
restoreLine,
|
|
737
761
|
` • Re-provision from source: envpkt seal --edit <KEY>`
|
|
738
762
|
].join("\n");
|
|
739
763
|
}
|
|
@@ -855,7 +879,8 @@ const runAudit = (options) => {
|
|
|
855
879
|
console.error(formatError(err));
|
|
856
880
|
process.exit(2);
|
|
857
881
|
}, (rawConfig) => {
|
|
858
|
-
|
|
882
|
+
const configDir = dirname(path);
|
|
883
|
+
resolveConfig(rawConfig, configDir).fold((err) => {
|
|
859
884
|
console.error(formatError(err));
|
|
860
885
|
process.exit(2);
|
|
861
886
|
}, (resolveResult) => {
|
|
@@ -919,8 +944,10 @@ const runAuditOnConfig = (config, options) => {
|
|
|
919
944
|
if (options.format === "json") console.log(formatAuditJson(sorted));
|
|
920
945
|
else if (options.format === "minimal") console.log(formatAuditMinimal(sorted));
|
|
921
946
|
else console.log(formatAudit(sorted));
|
|
922
|
-
if (options.all)
|
|
923
|
-
|
|
947
|
+
if (options.all) {
|
|
948
|
+
if (options.format === "json") console.log(formatEnvAuditJson(config));
|
|
949
|
+
else formatEnvAuditTable(config);
|
|
950
|
+
}
|
|
924
951
|
const code = options.strict ? exitCodeForAudit(audit) : audit.status === "critical" ? 2 : 0;
|
|
925
952
|
process.exit(code);
|
|
926
953
|
};
|
|
@@ -1404,11 +1431,13 @@ const resolveSealIdentity = (config, configDir) => {
|
|
|
1404
1431
|
});
|
|
1405
1432
|
const inlineKey = resolveInlineKey().orUndefined();
|
|
1406
1433
|
if (inlineKey) return Option(materializeInlineKey(inlineKey));
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1434
|
+
if (!config.identity?.key_file) {
|
|
1435
|
+
const defaultPath = join(homedir(), ".envpkt", "age-key.txt");
|
|
1436
|
+
if (existsSync(defaultPath)) return Option({
|
|
1437
|
+
path: defaultPath,
|
|
1438
|
+
dispose: noop
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1412
1441
|
return Option(void 0);
|
|
1413
1442
|
};
|
|
1414
1443
|
/** Describe the seal-identity precedence chain with per-entry status, for a clear no-key error. */
|
|
@@ -1423,7 +1452,7 @@ const describeSealKeySearch = (config, configDir) => {
|
|
|
1423
1452
|
keyFileLine,
|
|
1424
1453
|
envFileLine,
|
|
1425
1454
|
inlineLine,
|
|
1426
|
-
`${defaultPath} (${existsSync(defaultPath) ? "found" : "missing"})`
|
|
1455
|
+
keyFile ? `${defaultPath} (skipped — identity.key_file is set)` : `${defaultPath} (${existsSync(defaultPath) ? "found" : "missing"})`
|
|
1427
1456
|
];
|
|
1428
1457
|
};
|
|
1429
1458
|
const resolveIdentityKey = (config, configDir) => {
|
|
@@ -1494,12 +1523,14 @@ const bootSafe = (options) => {
|
|
|
1494
1523
|
_tag: "ReadError",
|
|
1495
1524
|
message: "unexpected"
|
|
1496
1525
|
}));
|
|
1497
|
-
const
|
|
1526
|
+
const fnoxKeys = detectFnoxKeys(configDir);
|
|
1527
|
+
const audit = computeAudit(config, fnoxKeys, void 0, aliasTable);
|
|
1498
1528
|
const sealIdentity = hasSealedValues ? resolveSealIdentity(config, configDir) : Option(void 0);
|
|
1499
1529
|
if (hasSealedValues && sealIdentity.isEmpty) return Left({
|
|
1500
1530
|
_tag: "SealKeyUnavailable",
|
|
1501
1531
|
sealedKeys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value),
|
|
1502
|
-
searched: describeSealKeySearch(config, configDir)
|
|
1532
|
+
searched: describeSealKeySearch(config, configDir),
|
|
1533
|
+
configuredKeyFile: config.identity?.key_file ? resolve(configDir, expandPath(config.identity.key_file)) : void 0
|
|
1503
1534
|
});
|
|
1504
1535
|
return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
|
|
1505
1536
|
const secrets = {};
|
|
@@ -1535,37 +1566,39 @@ const bootSafe = (options) => {
|
|
|
1535
1566
|
}
|
|
1536
1567
|
});
|
|
1537
1568
|
const remainingKeys = nonAliasMetaKeys.filter((k) => !sealedKeys.has(k));
|
|
1538
|
-
if (remainingKeys.length > 0)
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
warnings.push(`fnox export failed: ${err.message}`);
|
|
1544
|
-
skipped.push(...remainingKeys);
|
|
1545
|
-
}, (exported) => {
|
|
1546
|
-
const found = remainingKeys.filter((key) => key in exported);
|
|
1547
|
-
const notFound = remainingKeys.filter((key) => !(key in exported));
|
|
1548
|
-
found.forEach((key) => {
|
|
1549
|
-
secrets[key] = exported[key];
|
|
1550
|
-
log.debug("phase.fnox.resolved", {
|
|
1551
|
-
key,
|
|
1552
|
-
profile: opts.profile
|
|
1569
|
+
if (remainingKeys.length > 0) {
|
|
1570
|
+
if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
|
|
1571
|
+
log.warn("phase.fnox.export_failed", {
|
|
1572
|
+
message: err.message,
|
|
1573
|
+
skipped: remainingKeys.length
|
|
1553
1574
|
});
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1575
|
+
warnings.push(`fnox export failed: ${err.message}`);
|
|
1576
|
+
skipped.push(...remainingKeys);
|
|
1577
|
+
}, (exported) => {
|
|
1578
|
+
const found = remainingKeys.filter((key) => key in exported);
|
|
1579
|
+
const notFound = remainingKeys.filter((key) => !(key in exported));
|
|
1580
|
+
found.forEach((key) => {
|
|
1581
|
+
secrets[key] = exported[key];
|
|
1582
|
+
log.debug("phase.fnox.resolved", {
|
|
1583
|
+
key,
|
|
1584
|
+
profile: opts.profile
|
|
1585
|
+
});
|
|
1559
1586
|
});
|
|
1587
|
+
notFound.forEach((key) => {
|
|
1588
|
+
log.debug("phase.fnox.not_in_export", {
|
|
1589
|
+
key,
|
|
1590
|
+
profile: opts.profile
|
|
1591
|
+
});
|
|
1592
|
+
});
|
|
1593
|
+
injected.push(...found);
|
|
1594
|
+
skipped.push(...notFound);
|
|
1560
1595
|
});
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
else warnings.push("fnox not available — unsealed secrets could not be resolved");
|
|
1568
|
-
skipped.push(...remainingKeys);
|
|
1596
|
+
else {
|
|
1597
|
+
log.debug("phase.fnox.unavailable", { skipped: remainingKeys.length });
|
|
1598
|
+
if (!hasSealedValues) warnings.push("fnox not available — no secrets injected");
|
|
1599
|
+
else warnings.push("fnox not available — unsealed secrets could not be resolved");
|
|
1600
|
+
skipped.push(...remainingKeys);
|
|
1601
|
+
}
|
|
1569
1602
|
}
|
|
1570
1603
|
aliasSecretKeys.forEach((aliasKey) => {
|
|
1571
1604
|
const entry = aliasTable.entries.get(`secret.${aliasKey}`);
|
|
@@ -1960,16 +1993,114 @@ const sortConfigToml = (raw) => {
|
|
|
1960
1993
|
return `${parts.join("\n\n")}\n`;
|
|
1961
1994
|
};
|
|
1962
1995
|
//#endregion
|
|
1996
|
+
//#region src/core/mcp-headers.ts
|
|
1997
|
+
const DEFAULT_HEADER = "Authorization";
|
|
1998
|
+
const DEFAULT_SCHEME = "Bearer";
|
|
1999
|
+
/** RFC 9110 `token`: the legal characters of a header name (and of an auth scheme). */
|
|
2000
|
+
const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
2001
|
+
/** Characters that would split or corrupt an HTTP header value. */
|
|
2002
|
+
const UNSAFE_VALUE_RE = /[\r\n\0]/;
|
|
2003
|
+
/** Flatten every `[secret.*].mcp` entry into one binding per (secret, server), with defaults applied. */
|
|
2004
|
+
const collectBindings = (config) => Object.entries(config.secret ?? {}).flatMap(([key, meta]) => (meta.mcp ?? []).map((m) => ({
|
|
2005
|
+
key,
|
|
2006
|
+
server: m.server,
|
|
2007
|
+
header: m.header ?? DEFAULT_HEADER,
|
|
2008
|
+
scheme: m.scheme ?? DEFAULT_SCHEME
|
|
2009
|
+
})));
|
|
2010
|
+
/**
|
|
2011
|
+
* Check bindings for structural problems: header names and schemes that are not HTTP
|
|
2012
|
+
* tokens, and two bindings claiming the same (server, header) pair. Header names compare
|
|
2013
|
+
* case-insensitively, as HTTP does.
|
|
2014
|
+
*/
|
|
2015
|
+
const validateBindings = (bindings) => {
|
|
2016
|
+
const badHeader = bindings.find((b) => !TOKEN_RE.test(b.header));
|
|
2017
|
+
if (badHeader) return Left({
|
|
2018
|
+
_tag: "McpHeaderInvalid",
|
|
2019
|
+
key: badHeader.key,
|
|
2020
|
+
server: badHeader.server,
|
|
2021
|
+
header: badHeader.header
|
|
2022
|
+
});
|
|
2023
|
+
const badScheme = bindings.find((b) => b.scheme !== "" && !TOKEN_RE.test(b.scheme));
|
|
2024
|
+
if (badScheme) return Left({
|
|
2025
|
+
_tag: "McpSchemeInvalid",
|
|
2026
|
+
key: badScheme.key,
|
|
2027
|
+
server: badScheme.server,
|
|
2028
|
+
scheme: badScheme.scheme
|
|
2029
|
+
});
|
|
2030
|
+
const claims = bindings.reduce((acc, b) => {
|
|
2031
|
+
const id = `${b.server}\u0000${b.header.toLowerCase()}`;
|
|
2032
|
+
return {
|
|
2033
|
+
...acc,
|
|
2034
|
+
[id]: [...acc[id] ?? [], b]
|
|
2035
|
+
};
|
|
2036
|
+
}, {});
|
|
2037
|
+
const clash = Object.values(claims).find((group) => group.length > 1);
|
|
2038
|
+
if (clash) {
|
|
2039
|
+
const first = clash[0];
|
|
2040
|
+
return Left({
|
|
2041
|
+
_tag: "McpHeaderDuplicate",
|
|
2042
|
+
server: first.server,
|
|
2043
|
+
header: first.header,
|
|
2044
|
+
keys: Set$1(clash.map((b) => b.key)).toArray()
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
return Right(void 0);
|
|
2048
|
+
};
|
|
2049
|
+
/** Check every `mcp` binding in a config. Used by `envpkt validate` and the write gate. */
|
|
2050
|
+
const validateMcpHeaders = (config) => validateBindings(collectBindings(config));
|
|
2051
|
+
/**
|
|
2052
|
+
* Build the HTTP header object for one MCP server: every secret whose `mcp` list names
|
|
2053
|
+
* `server`, merged into one object. `values` is keyed by logical secret name (as in
|
|
2054
|
+
* `BootResult.secrets`), so namespaces and aliases are already resolved by the caller.
|
|
2055
|
+
* Only this server's bindings are checked, so a problem on another server never blocks it.
|
|
2056
|
+
*/
|
|
2057
|
+
const buildMcpHeaders = (config, values, server) => {
|
|
2058
|
+
const matches = collectBindings(config).filter((b) => b.server === server);
|
|
2059
|
+
if (matches.length === 0) return Left({
|
|
2060
|
+
_tag: "McpServerUnknown",
|
|
2061
|
+
server
|
|
2062
|
+
});
|
|
2063
|
+
return validateBindings(matches).flatMap(() => {
|
|
2064
|
+
const unresolved = matches.filter((b) => !values[b.key]).map((b) => b.key);
|
|
2065
|
+
if (unresolved.length > 0) return Left({
|
|
2066
|
+
_tag: "McpValueUnresolved",
|
|
2067
|
+
server,
|
|
2068
|
+
keys: unresolved
|
|
2069
|
+
});
|
|
2070
|
+
const unsafe = matches.filter((b) => UNSAFE_VALUE_RE.test(values[b.key])).map((b) => b.key);
|
|
2071
|
+
if (unsafe.length > 0) return Left({
|
|
2072
|
+
_tag: "McpValueInvalid",
|
|
2073
|
+
server,
|
|
2074
|
+
keys: unsafe
|
|
2075
|
+
});
|
|
2076
|
+
return Right(Object.fromEntries(matches.map((b) => {
|
|
2077
|
+
const value = values[b.key];
|
|
2078
|
+
return [b.header, b.scheme === "" ? value : `${b.scheme} ${value}`];
|
|
2079
|
+
})));
|
|
2080
|
+
});
|
|
2081
|
+
};
|
|
2082
|
+
/** Human-readable one-liner for any McpHeadersError tag. Never includes a secret value. */
|
|
2083
|
+
const formatMcpHeadersError = (error) => {
|
|
2084
|
+
switch (error._tag) {
|
|
2085
|
+
case "McpHeaderInvalid": return `[secret.${error.key}] mcp server "${error.server}": header "${error.header}" is not a valid HTTP header name`;
|
|
2086
|
+
case "McpSchemeInvalid": return `[secret.${error.key}] mcp server "${error.server}": scheme "${error.scheme}" must be a single word or ""`;
|
|
2087
|
+
case "McpHeaderDuplicate": return `MCP server "${error.server}": header "${error.header}" is claimed by more than one binding: ${error.keys.join(", ")}`;
|
|
2088
|
+
case "McpServerUnknown": return `No secret declares mcp server "${error.server}"`;
|
|
2089
|
+
case "McpValueUnresolved": return `MCP server "${error.server}": no value resolved for ${error.keys.join(", ")}`;
|
|
2090
|
+
case "McpValueInvalid": return `MCP server "${error.server}": value of ${error.keys.join(", ")} contains a line break or NUL and cannot be sent as a header`;
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
//#endregion
|
|
1963
2094
|
//#region src/core/validate.ts
|
|
1964
2095
|
/**
|
|
1965
|
-
* Validate a raw TOML string as a complete envpkt config: parse → schema → aliases.
|
|
2096
|
+
* Validate a raw TOML string as a complete envpkt config: parse → schema → aliases → mcp.
|
|
1966
2097
|
*
|
|
1967
2098
|
* Used by write-path CLI commands to verify the post-edit file would still be
|
|
1968
2099
|
* structurally valid before persisting. Catalog resolution is intentionally
|
|
1969
2100
|
* excluded — catalog issues depend on external files, not on the local edit,
|
|
1970
2101
|
* and `envpkt validate` covers them as a separate explicit check.
|
|
1971
2102
|
*/
|
|
1972
|
-
const validateRawConfig = (raw) => parseToml(raw).flatMap(validateConfig).flatMap((config) => validateAliases(config).map(() => config));
|
|
2103
|
+
const validateRawConfig = (raw) => parseToml(raw).flatMap(validateConfig).flatMap((config) => validateAliases(config).map(() => config)).flatMap((config) => validateMcpHeaders(config).map(() => config));
|
|
1973
2104
|
/** Human-readable one-liner for any ValidationError tag. */
|
|
1974
2105
|
const formatValidationError = (err) => {
|
|
1975
2106
|
switch (err._tag) {
|
|
@@ -1983,6 +2114,9 @@ const formatValidationError = (err) => {
|
|
|
1983
2114
|
case "AliasChained":
|
|
1984
2115
|
case "AliasCrossType":
|
|
1985
2116
|
case "AliasValueConflict": return formatAliasError(err);
|
|
2117
|
+
case "McpHeaderInvalid":
|
|
2118
|
+
case "McpSchemeInvalid":
|
|
2119
|
+
case "McpHeaderDuplicate": return formatMcpHeadersError(err);
|
|
1986
2120
|
}
|
|
1987
2121
|
};
|
|
1988
2122
|
//#endregion
|
|
@@ -2069,14 +2203,16 @@ const sealedSecretBlock = (key, destName, srcConfig, srcPath, destConfig) => {
|
|
|
2069
2203
|
console.error(`${RED}Error:${RESET} "${key}" produced no plaintext on unseal.`);
|
|
2070
2204
|
return process.exit(2);
|
|
2071
2205
|
}
|
|
2206
|
+
const cipher = orExit(ageEncrypt(plaintext, recipient), (err) => `${RED}Error:${RESET} reseal failed for "${destName}": ${err.message}`, 2);
|
|
2072
2207
|
return serializeSecretBlock(destName, copyableSecretMeta(meta, {
|
|
2073
2208
|
today,
|
|
2074
|
-
encryptedValue: Option(
|
|
2209
|
+
encryptedValue: Option(cipher)
|
|
2075
2210
|
}));
|
|
2076
2211
|
};
|
|
2077
2212
|
const writeBlock = (destPath, header, block, overwrite, dryRun, successMsg) => {
|
|
2078
2213
|
const raw = readFileSync(destPath, "utf-8");
|
|
2079
|
-
const
|
|
2214
|
+
const baseRaw = overwrite ? orExit(removeSection(raw, header), (err) => `${RED}Error:${RESET} ${err._tag}: ${err.section}`, 2) : raw;
|
|
2215
|
+
const updated = appendSection(baseRaw, block);
|
|
2080
2216
|
if (dryRun) {
|
|
2081
2217
|
previewIfValid(updated, block);
|
|
2082
2218
|
return;
|
|
@@ -2117,7 +2253,8 @@ const runCopy = (key, options) => {
|
|
|
2117
2253
|
process.exit(1);
|
|
2118
2254
|
}
|
|
2119
2255
|
const block = inSecret ? sealedSecretBlock(key, destName, srcConfig, src.path, destConfig) : serializeEnvBlock(destName, srcConfig.env[key]);
|
|
2120
|
-
const
|
|
2256
|
+
const renamed = destName !== key ? ` → ${BOLD}${destName}${RESET}` : "";
|
|
2257
|
+
const successMsg = `${GREEN}✓${RESET} Copied ${kindWord} ${BOLD}${key}${RESET}${renamed} to ${CYAN}${dest.path}${RESET}`;
|
|
2121
2258
|
writeBlock(dest.path, `[${kindWord}.${destName}]`, block, existsInDest, options.dryRun === true, successMsg);
|
|
2122
2259
|
};
|
|
2123
2260
|
//#endregion
|
|
@@ -2902,7 +3039,8 @@ const VALUE_SHAPE_PATTERNS = [
|
|
|
2902
3039
|
];
|
|
2903
3040
|
/** Detect service from value prefix/shape */
|
|
2904
3041
|
const matchValueShape = (value) => {
|
|
2905
|
-
|
|
3042
|
+
const match = VALUE_SHAPE_PATTERNS.find((vp) => value.startsWith(vp.prefix));
|
|
3043
|
+
return Option(match).map((vp) => ({
|
|
2906
3044
|
service: vp.service,
|
|
2907
3045
|
description: vp.description
|
|
2908
3046
|
}));
|
|
@@ -2942,7 +3080,8 @@ const matchEnvVar = (name, value) => {
|
|
|
2942
3080
|
matchedBy: `exact:${exactMatch.pattern}`
|
|
2943
3081
|
});
|
|
2944
3082
|
return matchValueShape(value).fold(() => {
|
|
2945
|
-
|
|
3083
|
+
const suffixMatch = SUFFIX_PATTERNS.find((sp) => name.endsWith(sp.suffix));
|
|
3084
|
+
return Option(suffixMatch).map((sp) => ({
|
|
2946
3085
|
envVar: name,
|
|
2947
3086
|
value,
|
|
2948
3087
|
service: Option(deriveServiceFromName(name)),
|
|
@@ -3144,7 +3283,8 @@ const runEnvCheck = (options) => {
|
|
|
3144
3283
|
console.error(formatError(err));
|
|
3145
3284
|
process.exit(2);
|
|
3146
3285
|
}, (rawConfig) => {
|
|
3147
|
-
|
|
3286
|
+
const configDir = dirname(path);
|
|
3287
|
+
resolveConfig(rawConfig, configDir).fold((err) => {
|
|
3148
3288
|
console.error(formatError(err));
|
|
3149
3289
|
process.exit(2);
|
|
3150
3290
|
}, (resolveResult) => {
|
|
@@ -3315,7 +3455,9 @@ const runEnvAdd = (name, value, options) => {
|
|
|
3315
3455
|
console.log(block);
|
|
3316
3456
|
return;
|
|
3317
3457
|
}
|
|
3318
|
-
|
|
3458
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
3459
|
+
const updated = appendSection(raw, block);
|
|
3460
|
+
writeIfValid(configPath, updated, `${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
|
|
3319
3461
|
});
|
|
3320
3462
|
});
|
|
3321
3463
|
};
|
|
@@ -3436,7 +3578,9 @@ const runEnvAlias = (name, options) => {
|
|
|
3436
3578
|
return;
|
|
3437
3579
|
}
|
|
3438
3580
|
const raw = readFileSync(configPath, "utf-8");
|
|
3439
|
-
|
|
3581
|
+
const base = existing ? removeSection(raw, `[env.${name}]`).fold(() => raw, (r) => r) : raw;
|
|
3582
|
+
const updated = appendSection(base, block);
|
|
3583
|
+
writeIfValid(configPath, updated, `${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
|
|
3440
3584
|
});
|
|
3441
3585
|
});
|
|
3442
3586
|
};
|
|
@@ -3561,7 +3705,7 @@ function* findEnvpktFiles(dir, maxDepth, currentDepth = 0) {
|
|
|
3561
3705
|
}
|
|
3562
3706
|
const scanFleet = (rootDir, options) => {
|
|
3563
3707
|
const maxDepth = options?.maxDepth ?? 3;
|
|
3564
|
-
const
|
|
3708
|
+
const agents = Array.from(findEnvpktFiles(rootDir, maxDepth)).flatMap((configPath) => loadConfig(configPath).fold(() => [], (config) => {
|
|
3565
3709
|
const audit = computeAudit(config);
|
|
3566
3710
|
return [{
|
|
3567
3711
|
path: configPath,
|
|
@@ -3569,7 +3713,8 @@ const scanFleet = (rootDir, options) => {
|
|
|
3569
3713
|
min_expiry_days: audit.secrets.toArray().reduce((min, s) => s.days_remaining.fold(() => min, (d) => min.fold(() => Option(d), (m) => Option(Math.min(m, d)))), Option(void 0)).orUndefined(),
|
|
3570
3714
|
audit
|
|
3571
3715
|
}];
|
|
3572
|
-
}))
|
|
3716
|
+
}));
|
|
3717
|
+
const agentList = List(agents);
|
|
3573
3718
|
const total_agents = agentList.size;
|
|
3574
3719
|
const total_secrets = agentList.toArray().reduce((acc, a) => acc + a.audit.total, 0);
|
|
3575
3720
|
const expired = agentList.toArray().reduce((acc, a) => acc + a.audit.expired, 0);
|
|
@@ -3595,7 +3740,8 @@ const statusIcon = (status) => {
|
|
|
3595
3740
|
}
|
|
3596
3741
|
};
|
|
3597
3742
|
const runFleet = (options) => {
|
|
3598
|
-
const
|
|
3743
|
+
const rootDir = resolve(options.dir ?? ".");
|
|
3744
|
+
const fleet = scanFleet(rootDir, { maxDepth: options.depth });
|
|
3599
3745
|
if (options.format === "json") {
|
|
3600
3746
|
console.log(formatFleetJson(fleet));
|
|
3601
3747
|
process.exit(fleet.status === "critical" ? 2 : 0);
|
|
@@ -3614,6 +3760,36 @@ const runFleet = (options) => {
|
|
|
3614
3760
|
process.exit(fleet.status === "critical" ? 2 : 0);
|
|
3615
3761
|
};
|
|
3616
3762
|
//#endregion
|
|
3763
|
+
//#region src/cli/commands/headers.ts
|
|
3764
|
+
const SERVER_ENV = "CLAUDE_CODE_MCP_SERVER_NAME";
|
|
3765
|
+
/**
|
|
3766
|
+
* Claude Code `headersHelper` entry point: print the HTTP headers for one MCP server as a
|
|
3767
|
+
* JSON object. stdout carries the JSON and nothing else; every diagnostic goes to stderr,
|
|
3768
|
+
* because Claude parses stdout verbatim.
|
|
3769
|
+
*/
|
|
3770
|
+
const runHeaders = (serverArg, options) => {
|
|
3771
|
+
const server = serverArg ?? process.env[SERVER_ENV];
|
|
3772
|
+
if (!server) {
|
|
3773
|
+
console.error(`${RED}Error:${RESET} No MCP server named. Pass [server] or set ${SERVER_ENV}.`);
|
|
3774
|
+
process.exit(2);
|
|
3775
|
+
}
|
|
3776
|
+
resolveForEmit(options).fold((err) => {
|
|
3777
|
+
console.error(`MCP server "${server}":\n${formatError(err)}`);
|
|
3778
|
+
process.exit(2);
|
|
3779
|
+
}, (boot) => {
|
|
3780
|
+
emitWarnings(boot);
|
|
3781
|
+
loadConfig(boot.configPath).flatMap((config) => resolveConfig(config, dirname(boot.configPath)).map((r) => r.config)).fold((err) => {
|
|
3782
|
+
console.error(`MCP server "${server}":\n${formatError(err)}`);
|
|
3783
|
+
return process.exit(2);
|
|
3784
|
+
}, (config) => buildMcpHeaders(config, boot.secrets, server)).fold((err) => {
|
|
3785
|
+
console.error(`${RED}Error:${RESET} ${formatMcpHeadersError(err)}`);
|
|
3786
|
+
process.exit(1);
|
|
3787
|
+
}, (map) => {
|
|
3788
|
+
process.stdout.write(`${JSON.stringify(map)}\n`);
|
|
3789
|
+
});
|
|
3790
|
+
});
|
|
3791
|
+
};
|
|
3792
|
+
//#endregion
|
|
3617
3793
|
//#region src/cli/commands/init.ts
|
|
3618
3794
|
const CONFIG_FILENAME = "envpkt.toml";
|
|
3619
3795
|
/** Resolve the top-level `scope` to scaffold: explicit --scope wins; --global implies "shell". */
|
|
@@ -3889,7 +4065,8 @@ const deriveKeyPath = (configPath) => {
|
|
|
3889
4065
|
};
|
|
3890
4066
|
const runKeygen = (options) => {
|
|
3891
4067
|
const configPath = resolve(options.config ?? join(process.cwd(), "envpkt.toml"));
|
|
3892
|
-
|
|
4068
|
+
const outputPath = options.output ?? (options.global ? resolveKeyPath() : deriveKeyPath(configPath));
|
|
4069
|
+
generateKeypair({ outputPath }).fold((err) => {
|
|
3893
4070
|
if (err._tag === "KeyExists") {
|
|
3894
4071
|
console.error(`${YELLOW}Warning:${RESET} Identity file already exists: ${CYAN}${err.path}${RESET}`);
|
|
3895
4072
|
console.error(`${DIM}To replace it: remove the file first, then re-run keygen.${RESET}`);
|
|
@@ -4259,7 +4436,8 @@ const runResolve = (options) => {
|
|
|
4259
4436
|
console.error(formatError(err));
|
|
4260
4437
|
process.exit(2);
|
|
4261
4438
|
}, (config) => {
|
|
4262
|
-
|
|
4439
|
+
const configDir = dirname(configPath);
|
|
4440
|
+
resolveConfig(config, configDir).fold((err) => {
|
|
4263
4441
|
console.error(formatError(err));
|
|
4264
4442
|
process.exit(2);
|
|
4265
4443
|
}, (result) => {
|
|
@@ -4486,7 +4664,8 @@ const runSeal = async (options) => {
|
|
|
4486
4664
|
process.exit(2);
|
|
4487
4665
|
}
|
|
4488
4666
|
const identityKey = Option(config.identity.key_file).flatMap((keyFile) => {
|
|
4489
|
-
|
|
4667
|
+
const identityPath = resolve(configDir, expandPath(keyFile));
|
|
4668
|
+
return unwrapAgentKey(identityPath).fold((err) => {
|
|
4490
4669
|
const msg = err._tag === "IdentityNotFound" ? `not found: ${err.path}` : err.message;
|
|
4491
4670
|
console.error(`${YELLOW}Warning:${RESET} Could not unwrap agent key: ${msg}`);
|
|
4492
4671
|
return Option.none();
|
|
@@ -4582,7 +4761,8 @@ const runSeal = async (options) => {
|
|
|
4582
4761
|
console.error(`${DIM} 3. ~/.envpkt/age-key.txt${RESET}`);
|
|
4583
4762
|
process.exit(2);
|
|
4584
4763
|
}
|
|
4585
|
-
const
|
|
4764
|
+
const sealedEntries = Object.fromEntries(alreadySealed.map((k) => [k, allSecretEntries[k]]));
|
|
4765
|
+
const decrypted = unsealSecrets(sealedEntries, identityPath).fold((err) => {
|
|
4586
4766
|
console.error(`${RED}Error:${RESET} Failed to decrypt existing secrets: ${err.message}`);
|
|
4587
4767
|
process.exit(2);
|
|
4588
4768
|
return {};
|
|
@@ -4719,7 +4899,8 @@ const runSecretAdd = (name, options) => {
|
|
|
4719
4899
|
process.exit(1);
|
|
4720
4900
|
}
|
|
4721
4901
|
const block = buildSecretBlock(name, options);
|
|
4722
|
-
const
|
|
4902
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
4903
|
+
const updated = appendSection(raw, block);
|
|
4723
4904
|
if (options.dryRun) {
|
|
4724
4905
|
previewIfValid(updated, block);
|
|
4725
4906
|
return;
|
|
@@ -4860,7 +5041,9 @@ const runSecretAlias = (name, options) => {
|
|
|
4860
5041
|
return;
|
|
4861
5042
|
}
|
|
4862
5043
|
const raw = readFileSync(configPath, "utf-8");
|
|
4863
|
-
|
|
5044
|
+
const base = existing ? removeSection(raw, `[secret.${name}]`).fold(() => raw, (r) => r) : raw;
|
|
5045
|
+
const updated = appendSection(base, block);
|
|
5046
|
+
writeIfValid(configPath, updated, `${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
|
|
4864
5047
|
});
|
|
4865
5048
|
});
|
|
4866
5049
|
};
|
|
@@ -4943,7 +5126,8 @@ const runSecretRotate = async (name, options) => {
|
|
|
4943
5126
|
process.exit(2);
|
|
4944
5127
|
return "";
|
|
4945
5128
|
}, (ct) => ct);
|
|
4946
|
-
|
|
5129
|
+
const withSealed = applySealedToml(raw, { [name]: { encrypted_value: ciphertext } });
|
|
5130
|
+
updateSectionFields(withSealed, `[secret.${name}]`, { last_rotated_at: `"${today}"` }).fold((err) => {
|
|
4947
5131
|
console.error(`${RED}Error:${RESET} ${err._tag}: ${err.section}`);
|
|
4948
5132
|
process.exit(2);
|
|
4949
5133
|
}, (result) => {
|
|
@@ -5178,6 +5362,7 @@ const CHECK_NAMES = {
|
|
|
5178
5362
|
schema: "Schema",
|
|
5179
5363
|
catalog: "Catalog",
|
|
5180
5364
|
aliases: "Aliases",
|
|
5365
|
+
mcp: "MCP headers",
|
|
5181
5366
|
sealed: "Sealed blocks"
|
|
5182
5367
|
};
|
|
5183
5368
|
/** Structural sanity scan over each [secret.*].encrypted_value PEM block. No decryption. */
|
|
@@ -5206,6 +5391,24 @@ const checkSealedBlocks = (config) => {
|
|
|
5206
5391
|
error: broken.join("; ")
|
|
5207
5392
|
};
|
|
5208
5393
|
};
|
|
5394
|
+
/** `mcp` bindings: valid header names and schemes, no (server, header) pair claimed twice. */
|
|
5395
|
+
const checkMcpHeaders = (config) => {
|
|
5396
|
+
const count = Object.values(config.secret ?? {}).reduce((n, meta) => n + (meta.mcp?.length ?? 0), 0);
|
|
5397
|
+
if (count === 0) return {
|
|
5398
|
+
name: CHECK_NAMES.mcp,
|
|
5399
|
+
status: "na",
|
|
5400
|
+
detail: "no mcp bindings"
|
|
5401
|
+
};
|
|
5402
|
+
return validateMcpHeaders(config).fold((err) => ({
|
|
5403
|
+
name: CHECK_NAMES.mcp,
|
|
5404
|
+
status: "failed",
|
|
5405
|
+
error: formatValidationError(err)
|
|
5406
|
+
}), () => ({
|
|
5407
|
+
name: CHECK_NAMES.mcp,
|
|
5408
|
+
status: "ok",
|
|
5409
|
+
detail: `${count} binding(s)`
|
|
5410
|
+
}));
|
|
5411
|
+
};
|
|
5209
5412
|
const skipped = (name) => ({
|
|
5210
5413
|
name,
|
|
5211
5414
|
status: "skipped"
|
|
@@ -5223,6 +5426,7 @@ const buildReport = (path, raw) => {
|
|
|
5223
5426
|
skipped(CHECK_NAMES.schema),
|
|
5224
5427
|
skipped(CHECK_NAMES.catalog),
|
|
5225
5428
|
skipped(CHECK_NAMES.aliases),
|
|
5429
|
+
skipped(CHECK_NAMES.mcp),
|
|
5226
5430
|
skipped(CHECK_NAMES.sealed)
|
|
5227
5431
|
]
|
|
5228
5432
|
}), (data) => {
|
|
@@ -5242,6 +5446,7 @@ const buildReport = (path, raw) => {
|
|
|
5242
5446
|
},
|
|
5243
5447
|
skipped(CHECK_NAMES.catalog),
|
|
5244
5448
|
skipped(CHECK_NAMES.aliases),
|
|
5449
|
+
skipped(CHECK_NAMES.mcp),
|
|
5245
5450
|
skipped(CHECK_NAMES.sealed)
|
|
5246
5451
|
]
|
|
5247
5452
|
}), (config) => {
|
|
@@ -5271,12 +5476,15 @@ const buildReport = (path, raw) => {
|
|
|
5271
5476
|
status: "ok",
|
|
5272
5477
|
detail: `${table.entries.size} alias(es)`
|
|
5273
5478
|
}));
|
|
5479
|
+
const merged = config.catalog ? resolveConfig(config, dirname(path)).fold(() => config, (r) => r.config) : config;
|
|
5480
|
+
const mcpCheck = checkMcpHeaders(merged);
|
|
5274
5481
|
const sealedCheck = checkSealedBlocks(config);
|
|
5275
5482
|
const checks = [
|
|
5276
5483
|
tomlOk,
|
|
5277
5484
|
schemaOk,
|
|
5278
5485
|
catalogCheck,
|
|
5279
5486
|
aliasCheck,
|
|
5487
|
+
mcpCheck,
|
|
5280
5488
|
sealedCheck
|
|
5281
5489
|
];
|
|
5282
5490
|
return {
|
|
@@ -5299,7 +5507,9 @@ const renderCheckLines = (check) => {
|
|
|
5299
5507
|
}
|
|
5300
5508
|
};
|
|
5301
5509
|
const formatTextReport = (report, sourceMsg) => {
|
|
5302
|
-
const
|
|
5510
|
+
const headerIcon = report.ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
|
|
5511
|
+
const headerLabel = report.ok ? `${GREEN}VALID${RESET}` : `${RED}INVALID${RESET}`;
|
|
5512
|
+
const header = `${headerIcon} ${BOLD}${headerLabel}${RESET} — ${CYAN}${report.configPath}${RESET}`;
|
|
5303
5513
|
const checkLines = report.checks.flatMap((c) => [...renderCheckLines(c)]);
|
|
5304
5514
|
return [
|
|
5305
5515
|
...sourceMsg ? [sourceMsg] : [],
|
|
@@ -5384,7 +5594,7 @@ program.command("audit").description("Audit credential health from envpkt.toml (
|
|
|
5384
5594
|
program.command("fleet").description("Scan directory tree for envpkt.toml files and aggregate health (use in CI for fleet-wide monitoring)").option("-d, --dir <path>", "Root directory to scan", ".").option("--depth <n>", "Max directory depth", parseInt).option("--format <format>", "Output format: table | json", "table").option("--status <status>", "Filter agents by health status").action((options) => {
|
|
5385
5595
|
runFleet(options);
|
|
5386
5596
|
});
|
|
5387
|
-
program.command("validate").description("Verify envpkt.toml integrity — runs TOML syntax, schema, catalog, alias, and sealed-block structural checks").option("-c, --config <path>", "Path to envpkt.toml").option("--json", "Output structured JSON instead of human-readable text").action((options) => {
|
|
5597
|
+
program.command("validate").description("Verify envpkt.toml integrity — runs TOML syntax, schema, catalog, alias, MCP header, and sealed-block structural checks").option("-c, --config <path>", "Path to envpkt.toml").option("--json", "Output structured JSON instead of human-readable text").action((options) => {
|
|
5388
5598
|
runValidate(options);
|
|
5389
5599
|
});
|
|
5390
5600
|
program.command("inspect").description("Display structured view of envpkt.toml").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json", "table").option("--resolved", "Show resolved view (catalog merged)").option("--secrets", "Show secret values from environment (masked by default)").option("--plaintext", "Show secret values in plaintext (requires --secrets)").option("--sort", "Display secrets and env entries in alphabetical order (no file mutation)").action((options) => {
|
|
@@ -5393,6 +5603,9 @@ program.command("inspect").description("Display structured view of envpkt.toml")
|
|
|
5393
5603
|
program.command("exec").description("Run pre-flight audit then execute a command with injected secrets (sealed → fnox → env cascade)").argument("<command...>", "Command to execute").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("--skip-audit", "Skip the pre-flight audit (alias: --no-check)").option("--no-check", "Skip the pre-flight audit").option("--warn-only", "Warn on critical audit but do not abort").option("--strict", "Abort on any non-healthy secret").action((args, options) => {
|
|
5394
5604
|
runExec(args, options);
|
|
5395
5605
|
});
|
|
5606
|
+
program.command("headers").description("Print HTTP headers for an MCP server as JSON — for Claude Code's headersHelper in .mcp.json").argument("[server]", "MCP server name (default: $CLAUDE_CODE_MCP_SERVER_NAME)").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").action((server, options) => {
|
|
5607
|
+
runHeaders(server, options);
|
|
5608
|
+
});
|
|
5396
5609
|
program.command("resolve").description("Resolve catalog references and output a flat, self-contained config").option("-c, --config <path>", "Path to envpkt.toml").option("-o, --output <path>", "Write resolved config to file (default: stdout)").option("--format <format>", "Output format: toml | json", "toml").option("--dry-run", "Show what would be resolved without writing").action((options) => {
|
|
5397
5610
|
runResolve(options);
|
|
5398
5611
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -31,6 +31,12 @@ declare const AgentIdentitySchema: import("@sinclair/typebox").TObject<{
|
|
|
31
31
|
recipient: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
32
32
|
secrets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
33
33
|
}>;
|
|
34
|
+
declare const McpHeaderSchema: import("@sinclair/typebox").TObject<{
|
|
35
|
+
server: import("@sinclair/typebox").TString;
|
|
36
|
+
header: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
37
|
+
scheme: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
38
|
+
}>;
|
|
39
|
+
type McpHeader = Static<typeof McpHeaderSchema>;
|
|
34
40
|
declare const SecretMetaSchema: import("@sinclair/typebox").TObject<{
|
|
35
41
|
service: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
36
42
|
expires: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
@@ -50,6 +56,11 @@ declare const SecretMetaSchema: import("@sinclair/typebox").TObject<{
|
|
|
50
56
|
external: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
51
57
|
tags: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
52
58
|
namespace: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
59
|
+
mcp: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
60
|
+
server: import("@sinclair/typebox").TString;
|
|
61
|
+
header: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
62
|
+
scheme: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
63
|
+
}>>>;
|
|
53
64
|
}>;
|
|
54
65
|
type SecretMeta = Static<typeof SecretMetaSchema>;
|
|
55
66
|
declare const LifecycleConfigSchema: import("@sinclair/typebox").TObject<{
|
|
@@ -113,6 +124,11 @@ declare const EnvpktConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
113
124
|
external: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
114
125
|
tags: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
115
126
|
namespace: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
127
|
+
mcp: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
128
|
+
server: import("@sinclair/typebox").TString;
|
|
129
|
+
header: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
130
|
+
scheme: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
131
|
+
}>>>;
|
|
116
132
|
}>>>;
|
|
117
133
|
env: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
|
|
118
134
|
value: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
@@ -299,6 +315,35 @@ type AliasError = {
|
|
|
299
315
|
readonly kind: "secret" | "env";
|
|
300
316
|
readonly field: string;
|
|
301
317
|
};
|
|
318
|
+
/** Structural problems in `mcp` bindings — caught by validation, independent of secret values. */
|
|
319
|
+
type McpConfigError = {
|
|
320
|
+
readonly _tag: "McpHeaderInvalid";
|
|
321
|
+
readonly key: string;
|
|
322
|
+
readonly server: string;
|
|
323
|
+
readonly header: string;
|
|
324
|
+
} | {
|
|
325
|
+
readonly _tag: "McpSchemeInvalid";
|
|
326
|
+
readonly key: string;
|
|
327
|
+
readonly server: string;
|
|
328
|
+
readonly scheme: string;
|
|
329
|
+
} | {
|
|
330
|
+
readonly _tag: "McpHeaderDuplicate";
|
|
331
|
+
readonly server: string;
|
|
332
|
+
readonly header: string;
|
|
333
|
+
readonly keys: ReadonlyArray<string>;
|
|
334
|
+
};
|
|
335
|
+
type McpHeadersError = McpConfigError | {
|
|
336
|
+
readonly _tag: "McpServerUnknown";
|
|
337
|
+
readonly server: string;
|
|
338
|
+
} | {
|
|
339
|
+
readonly _tag: "McpValueUnresolved";
|
|
340
|
+
readonly server: string;
|
|
341
|
+
readonly keys: ReadonlyArray<string>;
|
|
342
|
+
} | {
|
|
343
|
+
readonly _tag: "McpValueInvalid";
|
|
344
|
+
readonly server: string;
|
|
345
|
+
readonly keys: ReadonlyArray<string>;
|
|
346
|
+
};
|
|
302
347
|
type BootOptions = {
|
|
303
348
|
readonly configPath?: string;
|
|
304
349
|
readonly profile?: string;
|
|
@@ -339,6 +384,7 @@ type BootError = ConfigError | FnoxError | CatalogError | AliasError | {
|
|
|
339
384
|
readonly _tag: "SealKeyUnavailable";
|
|
340
385
|
readonly sealedKeys: ReadonlyArray<string>;
|
|
341
386
|
readonly searched: ReadonlyArray<string>;
|
|
387
|
+
readonly configuredKeyFile?: string;
|
|
342
388
|
} | IdentityError;
|
|
343
389
|
type IdentityError = {
|
|
344
390
|
readonly _tag: "AgeNotFound";
|
|
@@ -446,6 +492,19 @@ declare const isEnvAlias: (meta: {
|
|
|
446
492
|
/** Format an alias error into a human-readable message */
|
|
447
493
|
declare const formatAliasError: (error: AliasError) => string;
|
|
448
494
|
//#endregion
|
|
495
|
+
//#region src/core/mcp-headers.d.ts
|
|
496
|
+
/** Check every `mcp` binding in a config. Used by `envpkt validate` and the write gate. */
|
|
497
|
+
declare const validateMcpHeaders: (config: EnvpktConfig) => Either<McpConfigError, void>;
|
|
498
|
+
/**
|
|
499
|
+
* Build the HTTP header object for one MCP server: every secret whose `mcp` list names
|
|
500
|
+
* `server`, merged into one object. `values` is keyed by logical secret name (as in
|
|
501
|
+
* `BootResult.secrets`), so namespaces and aliases are already resolved by the caller.
|
|
502
|
+
* Only this server's bindings are checked, so a problem on another server never blocks it.
|
|
503
|
+
*/
|
|
504
|
+
declare const buildMcpHeaders: (config: EnvpktConfig, values: Readonly<Record<string, string>>, server: string) => Either<McpHeadersError, Record<string, string>>;
|
|
505
|
+
/** Human-readable one-liner for any McpHeadersError tag. Never includes a secret value. */
|
|
506
|
+
declare const formatMcpHeadersError: (error: McpHeadersError) => string;
|
|
507
|
+
//#endregion
|
|
449
508
|
//#region src/core/format.d.ts
|
|
450
509
|
type SecretDisplay = "encrypted" | "plaintext";
|
|
451
510
|
type FormatPacketOptions = {
|
|
@@ -720,4 +779,4 @@ type ToolDef = {
|
|
|
720
779
|
declare const toolDefinitions: readonly ToolDef[];
|
|
721
780
|
declare const callTool: (name: string, args: Record<string, unknown>) => CallToolResult;
|
|
722
781
|
//#endregion
|
|
723
|
-
export { type AgentIdentity, AgentIdentitySchema, type AliasError, type AliasTable, type AuditResult, type BootError, type BootOptions, type BootResult, type CallbackConfig, CallbackConfigSchema, type CatalogError, type ChangedEntry, type CheckResult, type ConfidenceLevel, type ConfigDiff, type ConfigError, type ConfigSource, ConsumerType, type CredentialPattern, type DirectLogger, type DirectTestLoggerHandle, type DotenvEntry, type DriftEntry, type DriftStatus, type EnvAuditResult, type EnvDriftEntry, type EnvDriftStatus, type EnvMeta, EnvMetaSchema, EnvpktBootError, type EnvpktConfig, EnvpktConfigSchema, type FieldChange, type FleetAgent, type FleetHealth, type FnoxConfig, type FnoxError, type FnoxSecret, type FormatDotenvOptions, type FormatPacketOptions, type HealthStatus, type Identity, type IdentityError, IdentitySchema, type KeygenError, type KeygenResult, type LifecycleConfig, LifecycleConfigSchema, type LogEntry, type LogLevel, type LogMetadata, type MatchResult, type ResolveOptions, type ResolveResult, type ResolvedPath, type ScanOptions, type ScanResult, type SealError, type SecretDisplay, type SecretHealth, type SecretMeta, SecretMetaSchema, type SecretStatus, type SectionDiff, type TomlEditError, type ToolsConfig, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, copyableSecretMeta, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, diffConfigs, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, quoteDotenvValue, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, serializeEnvBlock, serializeSecretBlock, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig };
|
|
782
|
+
export { type AgentIdentity, AgentIdentitySchema, type AliasError, type AliasTable, type AuditResult, type BootError, type BootOptions, type BootResult, type CallbackConfig, CallbackConfigSchema, type CatalogError, type ChangedEntry, type CheckResult, type ConfidenceLevel, type ConfigDiff, type ConfigError, type ConfigSource, ConsumerType, type CredentialPattern, type DirectLogger, type DirectTestLoggerHandle, type DotenvEntry, type DriftEntry, type DriftStatus, type EnvAuditResult, type EnvDriftEntry, type EnvDriftStatus, type EnvMeta, EnvMetaSchema, EnvpktBootError, type EnvpktConfig, EnvpktConfigSchema, type FieldChange, type FleetAgent, type FleetHealth, type FnoxConfig, type FnoxError, type FnoxSecret, type FormatDotenvOptions, type FormatPacketOptions, type HealthStatus, type Identity, type IdentityError, IdentitySchema, type KeygenError, type KeygenResult, type LifecycleConfig, LifecycleConfigSchema, type LogEntry, type LogLevel, type LogMetadata, type MatchResult, type McpConfigError, type McpHeader, McpHeaderSchema, type McpHeadersError, type ResolveOptions, type ResolveResult, type ResolvedPath, type ScanOptions, type ScanResult, type SealError, type SecretDisplay, type SecretHealth, type SecretMeta, SecretMetaSchema, type SecretStatus, type SectionDiff, type TomlEditError, type ToolsConfig, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, buildMcpHeaders, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, copyableSecretMeta, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, diffConfigs, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatMcpHeadersError, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, quoteDotenvValue, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, serializeEnvBlock, serializeSecretBlock, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig, validateMcpHeaders };
|
package/dist/index.js
CHANGED
|
@@ -46,6 +46,21 @@ const NamespaceSchema = Type.Object({
|
|
|
46
46
|
description: "Separator between prefix and key. Default '__' (shell-safe). Note: '.' and ':' are not valid in shell identifiers."
|
|
47
47
|
}))
|
|
48
48
|
}, { description: "Optional namespace/package prefix for injected environment variable names" });
|
|
49
|
+
const McpHeaderSchema = Type.Object({
|
|
50
|
+
server: Type.String({
|
|
51
|
+
minLength: 1,
|
|
52
|
+
description: "MCP server name, as keyed in .mcp.json"
|
|
53
|
+
}),
|
|
54
|
+
header: Type.Optional(Type.String({
|
|
55
|
+
default: "Authorization",
|
|
56
|
+
minLength: 1,
|
|
57
|
+
description: "HTTP header name to send the secret in"
|
|
58
|
+
})),
|
|
59
|
+
scheme: Type.Optional(Type.String({
|
|
60
|
+
default: "Bearer",
|
|
61
|
+
description: "Prefix placed before the value (e.g. 'Bearer'). Empty string sends the raw value."
|
|
62
|
+
}))
|
|
63
|
+
}, { description: "Send this secret as an HTTP header to an MCP server" });
|
|
49
64
|
const SecretMetaSchema = Type.Object({
|
|
50
65
|
service: Type.Optional(Type.String({ description: "Service or system this secret authenticates to" })),
|
|
51
66
|
expires: Type.Optional(Type.String({
|
|
@@ -76,7 +91,8 @@ const SecretMetaSchema = Type.Object({
|
|
|
76
91
|
required: Type.Optional(Type.Boolean({ description: "Whether this secret is required for operation" })),
|
|
77
92
|
external: Type.Optional(Type.Boolean({ description: "Value is managed outside envpkt (e.g. a Cloudflare/Vault secret). The entry registers the name for inventory and dev/prod parity only: seal never touches it, and audit reports it as 'external' rather than 'missing'." })),
|
|
78
93
|
tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" })),
|
|
79
|
-
namespace: Type.Optional(Type.String({ description: "Override the file-level namespace for this entry's injected name. Empty string opts out of any prefix." }))
|
|
94
|
+
namespace: Type.Optional(Type.String({ description: "Override the file-level namespace for this entry's injected name. Empty string opts out of any prefix." })),
|
|
95
|
+
mcp: Type.Optional(Type.Array(McpHeaderSchema, { description: "MCP servers this secret authenticates to. Read by `envpkt headers` (Claude Code headersHelper)." }))
|
|
80
96
|
}, { description: "Metadata about a single secret" });
|
|
81
97
|
const LifecycleConfigSchema = Type.Object({
|
|
82
98
|
stale_warning_days: Type.Optional(Type.Number({
|
|
@@ -209,10 +225,13 @@ const discoverConfig = (cwd) => {
|
|
|
209
225
|
template,
|
|
210
226
|
expanded: expandPath(template)
|
|
211
227
|
})).filter(({ expanded }) => expanded !== "" && !expanded.startsWith("/.envpkt")).map(({ expanded }) => expandGlobPath(expanded)).find((matches) => matches.length > 0);
|
|
212
|
-
if (customMatch)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
228
|
+
if (customMatch) {
|
|
229
|
+
const found = {
|
|
230
|
+
path: customMatch[0],
|
|
231
|
+
source: "search"
|
|
232
|
+
};
|
|
233
|
+
return Option(found);
|
|
234
|
+
}
|
|
216
235
|
const platformMatch = buildSearchPaths().find((p) => Fs.existsSync(p));
|
|
217
236
|
if (platformMatch) return Option({
|
|
218
237
|
path: platformMatch,
|
|
@@ -254,9 +273,10 @@ const parseToml = (raw) => Try(() => parse(raw)).fold((err) => Left({
|
|
|
254
273
|
/** Validate parsed data against the TypeBox schema */
|
|
255
274
|
const validateConfig = (data) => {
|
|
256
275
|
if (compiledSchema.Check(data)) return Right(data);
|
|
276
|
+
const errors = List([...compiledSchema.Errors(data)].map((e) => `${e.path}: ${e.message}`));
|
|
257
277
|
return Left({
|
|
258
278
|
_tag: "ValidationError",
|
|
259
|
-
errors
|
|
279
|
+
errors
|
|
260
280
|
});
|
|
261
281
|
};
|
|
262
282
|
/** Load and validate an envpkt.toml from a file path */
|
|
@@ -505,6 +525,104 @@ const formatAliasError = (error) => {
|
|
|
505
525
|
}
|
|
506
526
|
};
|
|
507
527
|
//#endregion
|
|
528
|
+
//#region src/core/mcp-headers.ts
|
|
529
|
+
const DEFAULT_HEADER = "Authorization";
|
|
530
|
+
const DEFAULT_SCHEME = "Bearer";
|
|
531
|
+
/** RFC 9110 `token`: the legal characters of a header name (and of an auth scheme). */
|
|
532
|
+
const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
533
|
+
/** Characters that would split or corrupt an HTTP header value. */
|
|
534
|
+
const UNSAFE_VALUE_RE = /[\r\n\0]/;
|
|
535
|
+
/** Flatten every `[secret.*].mcp` entry into one binding per (secret, server), with defaults applied. */
|
|
536
|
+
const collectBindings = (config) => Object.entries(config.secret ?? {}).flatMap(([key, meta]) => (meta.mcp ?? []).map((m) => ({
|
|
537
|
+
key,
|
|
538
|
+
server: m.server,
|
|
539
|
+
header: m.header ?? DEFAULT_HEADER,
|
|
540
|
+
scheme: m.scheme ?? DEFAULT_SCHEME
|
|
541
|
+
})));
|
|
542
|
+
/**
|
|
543
|
+
* Check bindings for structural problems: header names and schemes that are not HTTP
|
|
544
|
+
* tokens, and two bindings claiming the same (server, header) pair. Header names compare
|
|
545
|
+
* case-insensitively, as HTTP does.
|
|
546
|
+
*/
|
|
547
|
+
const validateBindings = (bindings) => {
|
|
548
|
+
const badHeader = bindings.find((b) => !TOKEN_RE.test(b.header));
|
|
549
|
+
if (badHeader) return Left({
|
|
550
|
+
_tag: "McpHeaderInvalid",
|
|
551
|
+
key: badHeader.key,
|
|
552
|
+
server: badHeader.server,
|
|
553
|
+
header: badHeader.header
|
|
554
|
+
});
|
|
555
|
+
const badScheme = bindings.find((b) => b.scheme !== "" && !TOKEN_RE.test(b.scheme));
|
|
556
|
+
if (badScheme) return Left({
|
|
557
|
+
_tag: "McpSchemeInvalid",
|
|
558
|
+
key: badScheme.key,
|
|
559
|
+
server: badScheme.server,
|
|
560
|
+
scheme: badScheme.scheme
|
|
561
|
+
});
|
|
562
|
+
const claims = bindings.reduce((acc, b) => {
|
|
563
|
+
const id = `${b.server}\u0000${b.header.toLowerCase()}`;
|
|
564
|
+
return {
|
|
565
|
+
...acc,
|
|
566
|
+
[id]: [...acc[id] ?? [], b]
|
|
567
|
+
};
|
|
568
|
+
}, {});
|
|
569
|
+
const clash = Object.values(claims).find((group) => group.length > 1);
|
|
570
|
+
if (clash) {
|
|
571
|
+
const first = clash[0];
|
|
572
|
+
return Left({
|
|
573
|
+
_tag: "McpHeaderDuplicate",
|
|
574
|
+
server: first.server,
|
|
575
|
+
header: first.header,
|
|
576
|
+
keys: Set$1(clash.map((b) => b.key)).toArray()
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
return Right(void 0);
|
|
580
|
+
};
|
|
581
|
+
/** Check every `mcp` binding in a config. Used by `envpkt validate` and the write gate. */
|
|
582
|
+
const validateMcpHeaders = (config) => validateBindings(collectBindings(config));
|
|
583
|
+
/**
|
|
584
|
+
* Build the HTTP header object for one MCP server: every secret whose `mcp` list names
|
|
585
|
+
* `server`, merged into one object. `values` is keyed by logical secret name (as in
|
|
586
|
+
* `BootResult.secrets`), so namespaces and aliases are already resolved by the caller.
|
|
587
|
+
* Only this server's bindings are checked, so a problem on another server never blocks it.
|
|
588
|
+
*/
|
|
589
|
+
const buildMcpHeaders = (config, values, server) => {
|
|
590
|
+
const matches = collectBindings(config).filter((b) => b.server === server);
|
|
591
|
+
if (matches.length === 0) return Left({
|
|
592
|
+
_tag: "McpServerUnknown",
|
|
593
|
+
server
|
|
594
|
+
});
|
|
595
|
+
return validateBindings(matches).flatMap(() => {
|
|
596
|
+
const unresolved = matches.filter((b) => !values[b.key]).map((b) => b.key);
|
|
597
|
+
if (unresolved.length > 0) return Left({
|
|
598
|
+
_tag: "McpValueUnresolved",
|
|
599
|
+
server,
|
|
600
|
+
keys: unresolved
|
|
601
|
+
});
|
|
602
|
+
const unsafe = matches.filter((b) => UNSAFE_VALUE_RE.test(values[b.key])).map((b) => b.key);
|
|
603
|
+
if (unsafe.length > 0) return Left({
|
|
604
|
+
_tag: "McpValueInvalid",
|
|
605
|
+
server,
|
|
606
|
+
keys: unsafe
|
|
607
|
+
});
|
|
608
|
+
return Right(Object.fromEntries(matches.map((b) => {
|
|
609
|
+
const value = values[b.key];
|
|
610
|
+
return [b.header, b.scheme === "" ? value : `${b.scheme} ${value}`];
|
|
611
|
+
})));
|
|
612
|
+
});
|
|
613
|
+
};
|
|
614
|
+
/** Human-readable one-liner for any McpHeadersError tag. Never includes a secret value. */
|
|
615
|
+
const formatMcpHeadersError = (error) => {
|
|
616
|
+
switch (error._tag) {
|
|
617
|
+
case "McpHeaderInvalid": return `[secret.${error.key}] mcp server "${error.server}": header "${error.header}" is not a valid HTTP header name`;
|
|
618
|
+
case "McpSchemeInvalid": return `[secret.${error.key}] mcp server "${error.server}": scheme "${error.scheme}" must be a single word or ""`;
|
|
619
|
+
case "McpHeaderDuplicate": return `MCP server "${error.server}": header "${error.header}" is claimed by more than one binding: ${error.keys.join(", ")}`;
|
|
620
|
+
case "McpServerUnknown": return `No secret declares mcp server "${error.server}"`;
|
|
621
|
+
case "McpValueUnresolved": return `MCP server "${error.server}": no value resolved for ${error.keys.join(", ")}`;
|
|
622
|
+
case "McpValueInvalid": return `MCP server "${error.server}": value of ${error.keys.join(", ")} contains a line break or NUL and cannot be sent as a header`;
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
//#endregion
|
|
508
626
|
//#region src/core/format.ts
|
|
509
627
|
const maskValue = (value) => {
|
|
510
628
|
if (value.length > 8) return `${value.slice(0, 3)}${"•".repeat(5)}${value.slice(-4)}`;
|
|
@@ -690,14 +808,15 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
|
|
|
690
808
|
const staleWarningDays = lifecycle.stale_warning_days ?? 90;
|
|
691
809
|
const requireExpiration = lifecycle.require_expiration ?? false;
|
|
692
810
|
const requireService = lifecycle.require_service ?? false;
|
|
693
|
-
const keys = fnoxKeys ??
|
|
811
|
+
const keys = fnoxKeys ?? Set$1.empty();
|
|
694
812
|
const secretEntries = config.secret ?? {};
|
|
695
813
|
const nonAliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0);
|
|
696
814
|
const aliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0);
|
|
697
815
|
const nonAliasHealth = nonAliasEntries.map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now));
|
|
698
816
|
const healthByKey = Map$1(nonAliasHealth.map((h) => [h.key, h]));
|
|
699
817
|
const parseTargetKey = (from_key) => {
|
|
700
|
-
|
|
818
|
+
const match = from_key.match(/^secret\.(.+)$/);
|
|
819
|
+
return Option(match?.[1]);
|
|
701
820
|
};
|
|
702
821
|
const aliasHealth = aliasEntries.map(([key, meta]) => {
|
|
703
822
|
const tableEntry = aliasTable?.entries.get(`secret.${key}`);
|
|
@@ -751,8 +870,10 @@ const computeEnvAudit = (config, env = process.env) => {
|
|
|
751
870
|
const envWire = (key) => namer(key, envEntries[key]?.namespace);
|
|
752
871
|
const resolveEffectiveDefault = (entry) => {
|
|
753
872
|
return Do(function* () {
|
|
754
|
-
const
|
|
755
|
-
|
|
873
|
+
const fk = yield* $(Option(entry.from_key));
|
|
874
|
+
const targetKey = yield* $(Option(fk.match(/^env\.(.+)$/)?.[1]));
|
|
875
|
+
const targetEntry = yield* $(Option(envEntries[targetKey]));
|
|
876
|
+
return yield* $(Option(targetEntry.value));
|
|
756
877
|
}).orElse(entry.value ?? "");
|
|
757
878
|
};
|
|
758
879
|
const entries = Object.entries(envEntries).map(([key, entry]) => {
|
|
@@ -1398,7 +1519,8 @@ const VALUE_SHAPE_PATTERNS = [
|
|
|
1398
1519
|
];
|
|
1399
1520
|
/** Detect service from value prefix/shape */
|
|
1400
1521
|
const matchValueShape = (value) => {
|
|
1401
|
-
|
|
1522
|
+
const match = VALUE_SHAPE_PATTERNS.find((vp) => value.startsWith(vp.prefix));
|
|
1523
|
+
return Option(match).map((vp) => ({
|
|
1402
1524
|
service: vp.service,
|
|
1403
1525
|
description: vp.description
|
|
1404
1526
|
}));
|
|
@@ -1438,7 +1560,8 @@ const matchEnvVar = (name, value) => {
|
|
|
1438
1560
|
matchedBy: `exact:${exactMatch.pattern}`
|
|
1439
1561
|
});
|
|
1440
1562
|
return matchValueShape(value).fold(() => {
|
|
1441
|
-
|
|
1563
|
+
const suffixMatch = SUFFIX_PATTERNS.find((sp) => name.endsWith(sp.suffix));
|
|
1564
|
+
return Option(suffixMatch).map((sp) => ({
|
|
1442
1565
|
envVar: name,
|
|
1443
1566
|
value,
|
|
1444
1567
|
service: Option(deriveServiceFromName(name)),
|
|
@@ -1983,11 +2106,13 @@ const resolveSealIdentity = (config, configDir) => {
|
|
|
1983
2106
|
});
|
|
1984
2107
|
const inlineKey = resolveInlineKey().orUndefined();
|
|
1985
2108
|
if (inlineKey) return Option(materializeInlineKey(inlineKey));
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
2109
|
+
if (!config.identity?.key_file) {
|
|
2110
|
+
const defaultPath = join(homedir(), ".envpkt", "age-key.txt");
|
|
2111
|
+
if (existsSync(defaultPath)) return Option({
|
|
2112
|
+
path: defaultPath,
|
|
2113
|
+
dispose: noop
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
1991
2116
|
return Option(void 0);
|
|
1992
2117
|
};
|
|
1993
2118
|
/** Describe the seal-identity precedence chain with per-entry status, for a clear no-key error. */
|
|
@@ -2002,7 +2127,7 @@ const describeSealKeySearch = (config, configDir) => {
|
|
|
2002
2127
|
keyFileLine,
|
|
2003
2128
|
envFileLine,
|
|
2004
2129
|
inlineLine,
|
|
2005
|
-
`${defaultPath} (${existsSync(defaultPath) ? "found" : "missing"})`
|
|
2130
|
+
keyFile ? `${defaultPath} (skipped — identity.key_file is set)` : `${defaultPath} (${existsSync(defaultPath) ? "found" : "missing"})`
|
|
2006
2131
|
];
|
|
2007
2132
|
};
|
|
2008
2133
|
const resolveIdentityKey = (config, configDir) => {
|
|
@@ -2073,12 +2198,14 @@ const bootSafe = (options) => {
|
|
|
2073
2198
|
_tag: "ReadError",
|
|
2074
2199
|
message: "unexpected"
|
|
2075
2200
|
}));
|
|
2076
|
-
const
|
|
2201
|
+
const fnoxKeys = detectFnoxKeys(configDir);
|
|
2202
|
+
const audit = computeAudit(config, fnoxKeys, void 0, aliasTable);
|
|
2077
2203
|
const sealIdentity = hasSealedValues ? resolveSealIdentity(config, configDir) : Option(void 0);
|
|
2078
2204
|
if (hasSealedValues && sealIdentity.isEmpty) return Left({
|
|
2079
2205
|
_tag: "SealKeyUnavailable",
|
|
2080
2206
|
sealedKeys: nonAliasMetaKeys.filter((k) => !!nonAliasSecretEntries[k]?.encrypted_value),
|
|
2081
|
-
searched: describeSealKeySearch(config, configDir)
|
|
2207
|
+
searched: describeSealKeySearch(config, configDir),
|
|
2208
|
+
configuredKeyFile: config.identity?.key_file ? resolve(configDir, expandPath(config.identity.key_file)) : void 0
|
|
2082
2209
|
});
|
|
2083
2210
|
return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
|
|
2084
2211
|
const secrets = {};
|
|
@@ -2114,37 +2241,39 @@ const bootSafe = (options) => {
|
|
|
2114
2241
|
}
|
|
2115
2242
|
});
|
|
2116
2243
|
const remainingKeys = nonAliasMetaKeys.filter((k) => !sealedKeys.has(k));
|
|
2117
|
-
if (remainingKeys.length > 0)
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
warnings.push(`fnox export failed: ${err.message}`);
|
|
2123
|
-
skipped.push(...remainingKeys);
|
|
2124
|
-
}, (exported) => {
|
|
2125
|
-
const found = remainingKeys.filter((key) => key in exported);
|
|
2126
|
-
const notFound = remainingKeys.filter((key) => !(key in exported));
|
|
2127
|
-
found.forEach((key) => {
|
|
2128
|
-
secrets[key] = exported[key];
|
|
2129
|
-
log.debug("phase.fnox.resolved", {
|
|
2130
|
-
key,
|
|
2131
|
-
profile: opts.profile
|
|
2244
|
+
if (remainingKeys.length > 0) {
|
|
2245
|
+
if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
|
|
2246
|
+
log.warn("phase.fnox.export_failed", {
|
|
2247
|
+
message: err.message,
|
|
2248
|
+
skipped: remainingKeys.length
|
|
2132
2249
|
});
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2250
|
+
warnings.push(`fnox export failed: ${err.message}`);
|
|
2251
|
+
skipped.push(...remainingKeys);
|
|
2252
|
+
}, (exported) => {
|
|
2253
|
+
const found = remainingKeys.filter((key) => key in exported);
|
|
2254
|
+
const notFound = remainingKeys.filter((key) => !(key in exported));
|
|
2255
|
+
found.forEach((key) => {
|
|
2256
|
+
secrets[key] = exported[key];
|
|
2257
|
+
log.debug("phase.fnox.resolved", {
|
|
2258
|
+
key,
|
|
2259
|
+
profile: opts.profile
|
|
2260
|
+
});
|
|
2138
2261
|
});
|
|
2262
|
+
notFound.forEach((key) => {
|
|
2263
|
+
log.debug("phase.fnox.not_in_export", {
|
|
2264
|
+
key,
|
|
2265
|
+
profile: opts.profile
|
|
2266
|
+
});
|
|
2267
|
+
});
|
|
2268
|
+
injected.push(...found);
|
|
2269
|
+
skipped.push(...notFound);
|
|
2139
2270
|
});
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
else warnings.push("fnox not available — unsealed secrets could not be resolved");
|
|
2147
|
-
skipped.push(...remainingKeys);
|
|
2271
|
+
else {
|
|
2272
|
+
log.debug("phase.fnox.unavailable", { skipped: remainingKeys.length });
|
|
2273
|
+
if (!hasSealedValues) warnings.push("fnox not available — no secrets injected");
|
|
2274
|
+
else warnings.push("fnox not available — unsealed secrets could not be resolved");
|
|
2275
|
+
skipped.push(...remainingKeys);
|
|
2276
|
+
}
|
|
2148
2277
|
}
|
|
2149
2278
|
aliasSecretKeys.forEach((aliasKey) => {
|
|
2150
2279
|
const entry = aliasTable.entries.get(`secret.${aliasKey}`);
|
|
@@ -2232,12 +2361,13 @@ const formatBootError = (error) => {
|
|
|
2232
2361
|
case "SealKeyUnavailable": {
|
|
2233
2362
|
const keys = error.sealedKeys.length;
|
|
2234
2363
|
const searched = error.searched.map((line) => ` • ${line}`).join("\n");
|
|
2364
|
+
const restoreLine = error.configuredKeyFile ? ` • Restore the configured key to ${error.configuredKeyFile} (or set ENVPKT_AGE_KEY_FILE / ENVPKT_AGE_KEY)` : ` • Restore your key to ~/.envpkt/age-key.txt (or set ENVPKT_AGE_KEY_FILE / ENVPKT_AGE_KEY)`;
|
|
2235
2365
|
return [
|
|
2236
2366
|
`${keys} sealed secret(s) can't be decrypted — no age key found.`,
|
|
2237
2367
|
`Searched (in order):`,
|
|
2238
2368
|
searched,
|
|
2239
2369
|
`Fix one:`,
|
|
2240
|
-
|
|
2370
|
+
restoreLine,
|
|
2241
2371
|
` • Re-provision from source: envpkt seal --edit <KEY>`,
|
|
2242
2372
|
`Refusing to inject empty values for sealed secrets.`
|
|
2243
2373
|
].join("\n");
|
|
@@ -2580,7 +2710,7 @@ function* findEnvpktFiles(dir, maxDepth, currentDepth = 0) {
|
|
|
2580
2710
|
}
|
|
2581
2711
|
const scanFleet = (rootDir, options) => {
|
|
2582
2712
|
const maxDepth = options?.maxDepth ?? 3;
|
|
2583
|
-
const
|
|
2713
|
+
const agents = Array.from(findEnvpktFiles(rootDir, maxDepth)).flatMap((configPath) => loadConfig(configPath).fold(() => [], (config) => {
|
|
2584
2714
|
const audit = computeAudit(config);
|
|
2585
2715
|
return [{
|
|
2586
2716
|
path: configPath,
|
|
@@ -2588,7 +2718,8 @@ const scanFleet = (rootDir, options) => {
|
|
|
2588
2718
|
min_expiry_days: audit.secrets.toArray().reduce((min, s) => s.days_remaining.fold(() => min, (d) => min.fold(() => Option(d), (m) => Option(Math.min(m, d)))), Option(void 0)).orUndefined(),
|
|
2589
2719
|
audit
|
|
2590
2720
|
}];
|
|
2591
|
-
}))
|
|
2721
|
+
}));
|
|
2722
|
+
const agentList = List(agents);
|
|
2592
2723
|
const total_agents = agentList.size;
|
|
2593
2724
|
const total_secrets = agentList.toArray().reduce((acc, a) => acc + a.audit.total, 0);
|
|
2594
2725
|
const expired = agentList.toArray().reduce((acc, a) => acc + a.audit.expired, 0);
|
|
@@ -2925,4 +3056,4 @@ const startServer = async () => {
|
|
|
2925
3056
|
await server.connect(transport);
|
|
2926
3057
|
};
|
|
2927
3058
|
//#endregion
|
|
2928
|
-
export { AgentIdentitySchema, CallbackConfigSchema, ConsumerType, EnvMetaSchema, EnvpktBootError, EnvpktConfigSchema, IdentitySchema, LifecycleConfigSchema, SecretMetaSchema, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, copyableSecretMeta, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, diffConfigs, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, quoteDotenvValue, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, serializeEnvBlock, serializeSecretBlock, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig };
|
|
3059
|
+
export { AgentIdentitySchema, CallbackConfigSchema, ConsumerType, EnvMetaSchema, EnvpktBootError, EnvpktConfigSchema, IdentitySchema, LifecycleConfigSchema, McpHeaderSchema, SecretMetaSchema, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, appendSection, boot, bootSafe, buildMcpHeaders, callTool, compareFnoxAndEnvpkt, computeAudit, computeEnvAudit, copyableSecretMeta, createDirectConsoleLogger, createDirectTestLogger, createServer, deriveServiceFromName, detectFnox, diffConfigs, directSilentLogger, discoverConfig, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatAliasError, formatDotenv, formatMcpHeadersError, formatPacket, generateKeypair, generateTomlFromScan, isEnvAlias, isSecretAlias, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, quoteDotenvValue, readConfigFile, readFnoxConfig, readResource, removeSection, renameSection, resolveConfig, resolveConfigPath, resolveInlineKey, resolveKeyPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, serializeEnvBlock, serializeSecretBlock, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, updateConfigIdentity, updateSectionFields, validateAliases, validateConfig, validateMcpHeaders };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "envpkt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Credential lifecycle and fleet management for AI agents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"credentials",
|
|
@@ -39,19 +39,19 @@
|
|
|
39
39
|
"prepublishOnly": "pnpm validate"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.32.0",
|
|
43
43
|
"@sinclair/typebox": "^0.34.52",
|
|
44
44
|
"commander": "^15.0.0",
|
|
45
|
-
"functype": "^1.
|
|
46
|
-
"functype-log": "^1.
|
|
47
|
-
"functype-os": "^1.
|
|
48
|
-
"smol-toml": "^1.
|
|
45
|
+
"functype": "^1.13.0",
|
|
46
|
+
"functype-log": "^1.13.0",
|
|
47
|
+
"functype-os": "^1.13.0",
|
|
48
|
+
"smol-toml": "^1.9.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@types/node": "^24.
|
|
52
|
-
"ts-builds": "^3.
|
|
53
|
-
"tsdown": "^0.22.
|
|
54
|
-
"tsx": "^4.23.
|
|
51
|
+
"@types/node": "^24.19.1",
|
|
52
|
+
"ts-builds": "^3.4.3",
|
|
53
|
+
"tsdown": "^0.22.14",
|
|
54
|
+
"tsx": "^4.23.15"
|
|
55
55
|
},
|
|
56
56
|
"type": "module",
|
|
57
57
|
"main": "./dist/index.js",
|
|
@@ -71,5 +71,5 @@
|
|
|
71
71
|
"schemas"
|
|
72
72
|
],
|
|
73
73
|
"prettier": "ts-builds/prettier",
|
|
74
|
-
"packageManager": "pnpm@11.
|
|
74
|
+
"packageManager": "pnpm@11.27.0+sha512.ad12821463bb143295f1fa3ecde77a6b9b97a7990386da800ff57bffc3964dc64b7b1c7559d5dbe5c337aea659ed9c8937d4370bd198ca2009bc6fa0249808e7"
|
|
75
75
|
}
|
|
@@ -211,6 +211,35 @@
|
|
|
211
211
|
"namespace": {
|
|
212
212
|
"description": "Override the file-level namespace for this entry's injected name. Empty string opts out of any prefix.",
|
|
213
213
|
"type": "string"
|
|
214
|
+
},
|
|
215
|
+
"mcp": {
|
|
216
|
+
"description": "MCP servers this secret authenticates to. Read by `envpkt headers` (Claude Code headersHelper).",
|
|
217
|
+
"type": "array",
|
|
218
|
+
"items": {
|
|
219
|
+
"description": "Send this secret as an HTTP header to an MCP server",
|
|
220
|
+
"type": "object",
|
|
221
|
+
"required": [
|
|
222
|
+
"server"
|
|
223
|
+
],
|
|
224
|
+
"properties": {
|
|
225
|
+
"server": {
|
|
226
|
+
"minLength": 1,
|
|
227
|
+
"description": "MCP server name, as keyed in .mcp.json",
|
|
228
|
+
"type": "string"
|
|
229
|
+
},
|
|
230
|
+
"header": {
|
|
231
|
+
"default": "Authorization",
|
|
232
|
+
"minLength": 1,
|
|
233
|
+
"description": "HTTP header name to send the secret in",
|
|
234
|
+
"type": "string"
|
|
235
|
+
},
|
|
236
|
+
"scheme": {
|
|
237
|
+
"default": "Bearer",
|
|
238
|
+
"description": "Prefix placed before the value (e.g. 'Bearer'). Empty string sends the raw value.",
|
|
239
|
+
"type": "string"
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
214
243
|
}
|
|
215
244
|
}
|
|
216
245
|
}
|