envpkt 0.14.1 → 0.16.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 +27 -1
- package/dist/cli.js +398 -99
- package/dist/index.d.ts +69 -5
- package/dist/index.js +221 -54
- package/package.json +10 -10
- package/schemas/envpkt.schema.json +29 -0
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ import { randomBytes } from "node:crypto";
|
|
|
15
15
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
16
16
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
17
17
|
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
18
|
+
import { text } from "node:stream/consumers";
|
|
18
19
|
import { createInterface } from "node:readline";
|
|
19
20
|
//#region src/core/namespace.ts
|
|
20
21
|
const DEFAULT_SEPARATOR = "__";
|
|
@@ -123,14 +124,15 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
|
|
|
123
124
|
const staleWarningDays = lifecycle.stale_warning_days ?? 90;
|
|
124
125
|
const requireExpiration = lifecycle.require_expiration ?? false;
|
|
125
126
|
const requireService = lifecycle.require_service ?? false;
|
|
126
|
-
const keys = fnoxKeys ??
|
|
127
|
+
const keys = fnoxKeys ?? Set$1.empty();
|
|
127
128
|
const secretEntries = config.secret ?? {};
|
|
128
129
|
const nonAliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0);
|
|
129
130
|
const aliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0);
|
|
130
131
|
const nonAliasHealth = nonAliasEntries.map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now));
|
|
131
132
|
const healthByKey = Map$1(nonAliasHealth.map((h) => [h.key, h]));
|
|
132
133
|
const parseTargetKey = (from_key) => {
|
|
133
|
-
|
|
134
|
+
const match = from_key.match(/^secret\.(.+)$/);
|
|
135
|
+
return Option(match?.[1]);
|
|
134
136
|
};
|
|
135
137
|
const aliasHealth = aliasEntries.map(([key, meta]) => {
|
|
136
138
|
const tableEntry = aliasTable?.entries.get(`secret.${key}`);
|
|
@@ -184,8 +186,10 @@ const computeEnvAudit = (config, env = process.env) => {
|
|
|
184
186
|
const envWire = (key) => namer(key, envEntries[key]?.namespace);
|
|
185
187
|
const resolveEffectiveDefault = (entry) => {
|
|
186
188
|
return Do(function* () {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
+
const fk = yield* $(Option(entry.from_key));
|
|
190
|
+
const targetKey = yield* $(Option(fk.match(/^env\.(.+)$/)?.[1]));
|
|
191
|
+
const targetEntry = yield* $(Option(envEntries[targetKey]));
|
|
192
|
+
return yield* $(Option(targetEntry.value));
|
|
189
193
|
}).orElse(entry.value ?? "");
|
|
190
194
|
};
|
|
191
195
|
const entries = Object.entries(envEntries).map(([key, entry]) => {
|
|
@@ -241,6 +245,21 @@ const NamespaceSchema = Type.Object({
|
|
|
241
245
|
description: "Separator between prefix and key. Default '__' (shell-safe). Note: '.' and ':' are not valid in shell identifiers."
|
|
242
246
|
}))
|
|
243
247
|
}, { description: "Optional namespace/package prefix for injected environment variable names" });
|
|
248
|
+
const McpHeaderSchema = Type.Object({
|
|
249
|
+
server: Type.String({
|
|
250
|
+
minLength: 1,
|
|
251
|
+
description: "MCP server name, as keyed in .mcp.json"
|
|
252
|
+
}),
|
|
253
|
+
header: Type.Optional(Type.String({
|
|
254
|
+
default: "Authorization",
|
|
255
|
+
minLength: 1,
|
|
256
|
+
description: "HTTP header name to send the secret in"
|
|
257
|
+
})),
|
|
258
|
+
scheme: Type.Optional(Type.String({
|
|
259
|
+
default: "Bearer",
|
|
260
|
+
description: "Prefix placed before the value (e.g. 'Bearer'). Empty string sends the raw value."
|
|
261
|
+
}))
|
|
262
|
+
}, { description: "Send this secret as an HTTP header to an MCP server" });
|
|
244
263
|
const SecretMetaSchema = Type.Object({
|
|
245
264
|
service: Type.Optional(Type.String({ description: "Service or system this secret authenticates to" })),
|
|
246
265
|
expires: Type.Optional(Type.String({
|
|
@@ -271,7 +290,8 @@ const SecretMetaSchema = Type.Object({
|
|
|
271
290
|
required: Type.Optional(Type.Boolean({ description: "Whether this secret is required for operation" })),
|
|
272
291
|
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
292
|
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." }))
|
|
293
|
+
namespace: Type.Optional(Type.String({ description: "Override the file-level namespace for this entry's injected name. Empty string opts out of any prefix." })),
|
|
294
|
+
mcp: Type.Optional(Type.Array(McpHeaderSchema, { description: "MCP servers this secret authenticates to. Read by `envpkt headers` (Claude Code headersHelper)." }))
|
|
275
295
|
}, { description: "Metadata about a single secret" });
|
|
276
296
|
const LifecycleConfigSchema = Type.Object({
|
|
277
297
|
stale_warning_days: Type.Optional(Type.Number({
|
|
@@ -399,10 +419,13 @@ const discoverConfig = (cwd) => {
|
|
|
399
419
|
template,
|
|
400
420
|
expanded: expandPath(template)
|
|
401
421
|
})).filter(({ expanded }) => expanded !== "" && !expanded.startsWith("/.envpkt")).map(({ expanded }) => expandGlobPath(expanded)).find((matches) => matches.length > 0);
|
|
402
|
-
if (customMatch)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
422
|
+
if (customMatch) {
|
|
423
|
+
const found = {
|
|
424
|
+
path: customMatch[0],
|
|
425
|
+
source: "search"
|
|
426
|
+
};
|
|
427
|
+
return Option(found);
|
|
428
|
+
}
|
|
406
429
|
const platformMatch = buildSearchPaths().find((p) => Fs.existsSync(p));
|
|
407
430
|
if (platformMatch) return Option({
|
|
408
431
|
path: platformMatch,
|
|
@@ -444,9 +467,10 @@ const parseToml = (raw) => Try(() => parse(raw)).fold((err) => Left({
|
|
|
444
467
|
/** Validate parsed data against the TypeBox schema */
|
|
445
468
|
const validateConfig = (data) => {
|
|
446
469
|
if (compiledSchema.Check(data)) return Right(data);
|
|
470
|
+
const errors = List([...compiledSchema.Errors(data)].map((e) => `${e.path}: ${e.message}`));
|
|
447
471
|
return Left({
|
|
448
472
|
_tag: "ValidationError",
|
|
449
|
-
errors
|
|
473
|
+
errors
|
|
450
474
|
});
|
|
451
475
|
};
|
|
452
476
|
/** Load and validate an envpkt.toml from a file path */
|
|
@@ -856,7 +880,8 @@ const runAudit = (options) => {
|
|
|
856
880
|
console.error(formatError(err));
|
|
857
881
|
process.exit(2);
|
|
858
882
|
}, (rawConfig) => {
|
|
859
|
-
|
|
883
|
+
const configDir = dirname(path);
|
|
884
|
+
resolveConfig(rawConfig, configDir).fold((err) => {
|
|
860
885
|
console.error(formatError(err));
|
|
861
886
|
process.exit(2);
|
|
862
887
|
}, (resolveResult) => {
|
|
@@ -898,7 +923,7 @@ const runAuditOnConfig = (config, options) => {
|
|
|
898
923
|
secrets: audit.secrets.filter((s) => !!secretEntries[s.key].encrypted_value)
|
|
899
924
|
};
|
|
900
925
|
})() : audit;
|
|
901
|
-
const
|
|
926
|
+
const afterUnsealed = options.unsealed ? (() => {
|
|
902
927
|
const secretEntries = config.secret ?? {};
|
|
903
928
|
return {
|
|
904
929
|
...afterSealed,
|
|
@@ -906,9 +931,9 @@ const runAuditOnConfig = (config, options) => {
|
|
|
906
931
|
};
|
|
907
932
|
})() : afterSealed;
|
|
908
933
|
const afterStatus = options.status ? {
|
|
909
|
-
...
|
|
910
|
-
secrets:
|
|
911
|
-
} :
|
|
934
|
+
...afterUnsealed,
|
|
935
|
+
secrets: afterUnsealed.secrets.filter((s) => s.status === options.status)
|
|
936
|
+
} : afterUnsealed;
|
|
912
937
|
const filtered = Option(options.expiring).fold(() => afterStatus, (expiring) => ({
|
|
913
938
|
...afterStatus,
|
|
914
939
|
secrets: afterStatus.secrets.filter((s) => s.days_remaining.fold(() => false, (d) => d >= 0 && d <= expiring))
|
|
@@ -920,8 +945,10 @@ const runAuditOnConfig = (config, options) => {
|
|
|
920
945
|
if (options.format === "json") console.log(formatAuditJson(sorted));
|
|
921
946
|
else if (options.format === "minimal") console.log(formatAuditMinimal(sorted));
|
|
922
947
|
else console.log(formatAudit(sorted));
|
|
923
|
-
if (options.all)
|
|
924
|
-
|
|
948
|
+
if (options.all) {
|
|
949
|
+
if (options.format === "json") console.log(formatEnvAuditJson(config));
|
|
950
|
+
else formatEnvAuditTable(config);
|
|
951
|
+
}
|
|
925
952
|
const code = options.strict ? exitCodeForAudit(audit) : audit.status === "critical" ? 2 : 0;
|
|
926
953
|
process.exit(code);
|
|
927
954
|
};
|
|
@@ -1497,7 +1524,8 @@ const bootSafe = (options) => {
|
|
|
1497
1524
|
_tag: "ReadError",
|
|
1498
1525
|
message: "unexpected"
|
|
1499
1526
|
}));
|
|
1500
|
-
const
|
|
1527
|
+
const fnoxKeys = detectFnoxKeys(configDir);
|
|
1528
|
+
const audit = computeAudit(config, fnoxKeys, void 0, aliasTable);
|
|
1501
1529
|
const sealIdentity = hasSealedValues ? resolveSealIdentity(config, configDir) : Option(void 0);
|
|
1502
1530
|
if (hasSealedValues && sealIdentity.isEmpty) return Left({
|
|
1503
1531
|
_tag: "SealKeyUnavailable",
|
|
@@ -1539,37 +1567,39 @@ const bootSafe = (options) => {
|
|
|
1539
1567
|
}
|
|
1540
1568
|
});
|
|
1541
1569
|
const remainingKeys = nonAliasMetaKeys.filter((k) => !sealedKeys.has(k));
|
|
1542
|
-
if (remainingKeys.length > 0)
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
warnings.push(`fnox export failed: ${err.message}`);
|
|
1548
|
-
skipped.push(...remainingKeys);
|
|
1549
|
-
}, (exported) => {
|
|
1550
|
-
const found = remainingKeys.filter((key) => key in exported);
|
|
1551
|
-
const notFound = remainingKeys.filter((key) => !(key in exported));
|
|
1552
|
-
found.forEach((key) => {
|
|
1553
|
-
secrets[key] = exported[key];
|
|
1554
|
-
log.debug("phase.fnox.resolved", {
|
|
1555
|
-
key,
|
|
1556
|
-
profile: opts.profile
|
|
1570
|
+
if (remainingKeys.length > 0) {
|
|
1571
|
+
if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
|
|
1572
|
+
log.warn("phase.fnox.export_failed", {
|
|
1573
|
+
message: err.message,
|
|
1574
|
+
skipped: remainingKeys.length
|
|
1557
1575
|
});
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1576
|
+
warnings.push(`fnox export failed: ${err.message}`);
|
|
1577
|
+
skipped.push(...remainingKeys);
|
|
1578
|
+
}, (exported) => {
|
|
1579
|
+
const found = remainingKeys.filter((key) => key in exported);
|
|
1580
|
+
const notFound = remainingKeys.filter((key) => !(key in exported));
|
|
1581
|
+
found.forEach((key) => {
|
|
1582
|
+
secrets[key] = exported[key];
|
|
1583
|
+
log.debug("phase.fnox.resolved", {
|
|
1584
|
+
key,
|
|
1585
|
+
profile: opts.profile
|
|
1586
|
+
});
|
|
1563
1587
|
});
|
|
1588
|
+
notFound.forEach((key) => {
|
|
1589
|
+
log.debug("phase.fnox.not_in_export", {
|
|
1590
|
+
key,
|
|
1591
|
+
profile: opts.profile
|
|
1592
|
+
});
|
|
1593
|
+
});
|
|
1594
|
+
injected.push(...found);
|
|
1595
|
+
skipped.push(...notFound);
|
|
1564
1596
|
});
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
else warnings.push("fnox not available — unsealed secrets could not be resolved");
|
|
1572
|
-
skipped.push(...remainingKeys);
|
|
1597
|
+
else {
|
|
1598
|
+
log.debug("phase.fnox.unavailable", { skipped: remainingKeys.length });
|
|
1599
|
+
if (!hasSealedValues) warnings.push("fnox not available — no secrets injected");
|
|
1600
|
+
else warnings.push("fnox not available — unsealed secrets could not be resolved");
|
|
1601
|
+
skipped.push(...remainingKeys);
|
|
1602
|
+
}
|
|
1573
1603
|
}
|
|
1574
1604
|
aliasSecretKeys.forEach((aliasKey) => {
|
|
1575
1605
|
const entry = aliasTable.entries.get(`secret.${aliasKey}`);
|
|
@@ -1635,7 +1665,8 @@ const tomlInlineTable = (rec) => {
|
|
|
1635
1665
|
};
|
|
1636
1666
|
/**
|
|
1637
1667
|
* The SecretMeta to write into the destination on copy.
|
|
1638
|
-
* - `created`
|
|
1668
|
+
* - `created` comes from the caller, never the source: today for an entry new to the
|
|
1669
|
+
* destination, or the destination's own `created` when the copy overwrites it.
|
|
1639
1670
|
* - `last_rotated_at` is dropped — it's the source's rotation history, not the copy's.
|
|
1640
1671
|
* - `encryptedValue` re-derives the ciphertext: `Some(cipher)` sets the resealed value,
|
|
1641
1672
|
* `None` strips it entirely (a metadata-only copy of a secret with no sealed value).
|
|
@@ -1644,10 +1675,10 @@ const copyableSecretMeta = (meta, opts) => {
|
|
|
1644
1675
|
const { last_rotated_at: _lra, encrypted_value: _ev, ...rest } = meta;
|
|
1645
1676
|
return opts.encryptedValue.fold(() => ({
|
|
1646
1677
|
...rest,
|
|
1647
|
-
created: opts.
|
|
1678
|
+
created: opts.created
|
|
1648
1679
|
}), (cipher) => ({
|
|
1649
1680
|
...rest,
|
|
1650
|
-
created: opts.
|
|
1681
|
+
created: opts.created,
|
|
1651
1682
|
encrypted_value: cipher
|
|
1652
1683
|
}));
|
|
1653
1684
|
};
|
|
@@ -1722,11 +1753,28 @@ const findSectionRange = (lines, sectionHeader) => {
|
|
|
1722
1753
|
end: List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end
|
|
1723
1754
|
};
|
|
1724
1755
|
};
|
|
1756
|
+
/**
|
|
1757
|
+
* End (exclusive) of a section's own lines: through its last field, before the blank and
|
|
1758
|
+
* comment lines that run up to the next header. Those trailing lines separate it from its
|
|
1759
|
+
* neighbour (or are the neighbour's doc comment), so edits to this section leave them alone.
|
|
1760
|
+
*/
|
|
1761
|
+
const sectionBodyEnd = (lines, range) => {
|
|
1762
|
+
const lastContent = lines.slice(range.start, range.end).findLastIndex((l) => {
|
|
1763
|
+
const t = l.trim();
|
|
1764
|
+
return t !== "" && !t.startsWith("#");
|
|
1765
|
+
});
|
|
1766
|
+
return range.start + lastContent + 1;
|
|
1767
|
+
};
|
|
1768
|
+
const dropTrailingBlanks = (xs) => xs.slice(0, xs.findLastIndex((l) => l.trim() !== "") + 1);
|
|
1769
|
+
const dropLeadingBlanks = (xs) => {
|
|
1770
|
+
const first = xs.findIndex((l) => l.trim() !== "");
|
|
1771
|
+
return first === -1 ? [] : xs.slice(first);
|
|
1772
|
+
};
|
|
1725
1773
|
/** Check whether a section header exists in the raw TOML */
|
|
1726
1774
|
const sectionExists = (lines, sectionHeader) => lines.some((l) => l.trim() === sectionHeader);
|
|
1727
1775
|
/**
|
|
1728
|
-
* Remove a TOML section (e.g. `[secret.X]`) and all its fields
|
|
1729
|
-
*
|
|
1776
|
+
* Remove a TOML section (e.g. `[secret.X]`) and all its fields. A comment above the next
|
|
1777
|
+
* section stays. The sections on either side end up one blank line apart.
|
|
1730
1778
|
*/
|
|
1731
1779
|
const removeSection = (raw, sectionHeader) => {
|
|
1732
1780
|
const lines = raw.split("\n");
|
|
@@ -1735,11 +1783,33 @@ const removeSection = (raw, sectionHeader) => {
|
|
|
1735
1783
|
_tag: "SectionNotFound",
|
|
1736
1784
|
section: sectionHeader
|
|
1737
1785
|
});
|
|
1738
|
-
const
|
|
1739
|
-
const
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
return Either.right(
|
|
1786
|
+
const before = dropTrailingBlanks(lines.slice(0, range.start));
|
|
1787
|
+
const after = dropLeadingBlanks(lines.slice(sectionBodyEnd(lines, range)));
|
|
1788
|
+
if (after.length === 0) return Either.right([...before, ...raw.endsWith("\n") ? [""] : []].join("\n"));
|
|
1789
|
+
if (before.length === 0) return Either.right(after.join("\n"));
|
|
1790
|
+
return Either.right([
|
|
1791
|
+
...before,
|
|
1792
|
+
"",
|
|
1793
|
+
...after
|
|
1794
|
+
].join("\n"));
|
|
1795
|
+
};
|
|
1796
|
+
/**
|
|
1797
|
+
* Replace a TOML section with `block`, in place. Lines outside the section, including the
|
|
1798
|
+
* blank and comment lines between it and the next header, are left as they were.
|
|
1799
|
+
*/
|
|
1800
|
+
const replaceSection = (raw, sectionHeader, block) => {
|
|
1801
|
+
const lines = raw.split("\n");
|
|
1802
|
+
const range = findSectionRange(lines, sectionHeader);
|
|
1803
|
+
if (!range) return Either.left({
|
|
1804
|
+
_tag: "SectionNotFound",
|
|
1805
|
+
section: sectionHeader
|
|
1806
|
+
});
|
|
1807
|
+
const replaced = [
|
|
1808
|
+
...lines.slice(0, range.start),
|
|
1809
|
+
...block.trimEnd().split("\n"),
|
|
1810
|
+
...lines.slice(sectionBodyEnd(lines, range))
|
|
1811
|
+
];
|
|
1812
|
+
return Either.right(replaced.join("\n"));
|
|
1743
1813
|
};
|
|
1744
1814
|
/**
|
|
1745
1815
|
* Rename a TOML section header (e.g. `[secret.OLD]` → `[secret.NEW]`).
|
|
@@ -1964,16 +2034,114 @@ const sortConfigToml = (raw) => {
|
|
|
1964
2034
|
return `${parts.join("\n\n")}\n`;
|
|
1965
2035
|
};
|
|
1966
2036
|
//#endregion
|
|
2037
|
+
//#region src/core/mcp-headers.ts
|
|
2038
|
+
const DEFAULT_HEADER = "Authorization";
|
|
2039
|
+
const DEFAULT_SCHEME = "Bearer";
|
|
2040
|
+
/** RFC 9110 `token`: the legal characters of a header name (and of an auth scheme). */
|
|
2041
|
+
const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
2042
|
+
/** Characters that would split or corrupt an HTTP header value. */
|
|
2043
|
+
const UNSAFE_VALUE_RE = /[\r\n\0]/;
|
|
2044
|
+
/** Flatten every `[secret.*].mcp` entry into one binding per (secret, server), with defaults applied. */
|
|
2045
|
+
const collectBindings = (config) => Object.entries(config.secret ?? {}).flatMap(([key, meta]) => (meta.mcp ?? []).map((m) => ({
|
|
2046
|
+
key,
|
|
2047
|
+
server: m.server,
|
|
2048
|
+
header: m.header ?? DEFAULT_HEADER,
|
|
2049
|
+
scheme: m.scheme ?? DEFAULT_SCHEME
|
|
2050
|
+
})));
|
|
2051
|
+
/**
|
|
2052
|
+
* Check bindings for structural problems: header names and schemes that are not HTTP
|
|
2053
|
+
* tokens, and two bindings claiming the same (server, header) pair. Header names compare
|
|
2054
|
+
* case-insensitively, as HTTP does.
|
|
2055
|
+
*/
|
|
2056
|
+
const validateBindings = (bindings) => {
|
|
2057
|
+
const badHeader = bindings.find((b) => !TOKEN_RE.test(b.header));
|
|
2058
|
+
if (badHeader) return Left({
|
|
2059
|
+
_tag: "McpHeaderInvalid",
|
|
2060
|
+
key: badHeader.key,
|
|
2061
|
+
server: badHeader.server,
|
|
2062
|
+
header: badHeader.header
|
|
2063
|
+
});
|
|
2064
|
+
const badScheme = bindings.find((b) => b.scheme !== "" && !TOKEN_RE.test(b.scheme));
|
|
2065
|
+
if (badScheme) return Left({
|
|
2066
|
+
_tag: "McpSchemeInvalid",
|
|
2067
|
+
key: badScheme.key,
|
|
2068
|
+
server: badScheme.server,
|
|
2069
|
+
scheme: badScheme.scheme
|
|
2070
|
+
});
|
|
2071
|
+
const claims = bindings.reduce((acc, b) => {
|
|
2072
|
+
const id = `${b.server}\u0000${b.header.toLowerCase()}`;
|
|
2073
|
+
return {
|
|
2074
|
+
...acc,
|
|
2075
|
+
[id]: [...acc[id] ?? [], b]
|
|
2076
|
+
};
|
|
2077
|
+
}, {});
|
|
2078
|
+
const clash = Object.values(claims).find((group) => group.length > 1);
|
|
2079
|
+
if (clash) {
|
|
2080
|
+
const first = clash[0];
|
|
2081
|
+
return Left({
|
|
2082
|
+
_tag: "McpHeaderDuplicate",
|
|
2083
|
+
server: first.server,
|
|
2084
|
+
header: first.header,
|
|
2085
|
+
keys: Set$1(clash.map((b) => b.key)).toArray()
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
return Right(void 0);
|
|
2089
|
+
};
|
|
2090
|
+
/** Check every `mcp` binding in a config. Used by `envpkt validate` and the write gate. */
|
|
2091
|
+
const validateMcpHeaders = (config) => validateBindings(collectBindings(config));
|
|
2092
|
+
/**
|
|
2093
|
+
* Build the HTTP header object for one MCP server: every secret whose `mcp` list names
|
|
2094
|
+
* `server`, merged into one object. `values` is keyed by logical secret name (as in
|
|
2095
|
+
* `BootResult.secrets`), so namespaces and aliases are already resolved by the caller.
|
|
2096
|
+
* Only this server's bindings are checked, so a problem on another server never blocks it.
|
|
2097
|
+
*/
|
|
2098
|
+
const buildMcpHeaders = (config, values, server) => {
|
|
2099
|
+
const matches = collectBindings(config).filter((b) => b.server === server);
|
|
2100
|
+
if (matches.length === 0) return Left({
|
|
2101
|
+
_tag: "McpServerUnknown",
|
|
2102
|
+
server
|
|
2103
|
+
});
|
|
2104
|
+
return validateBindings(matches).flatMap(() => {
|
|
2105
|
+
const unresolved = matches.filter((b) => !values[b.key]).map((b) => b.key);
|
|
2106
|
+
if (unresolved.length > 0) return Left({
|
|
2107
|
+
_tag: "McpValueUnresolved",
|
|
2108
|
+
server,
|
|
2109
|
+
keys: unresolved
|
|
2110
|
+
});
|
|
2111
|
+
const unsafe = matches.filter((b) => UNSAFE_VALUE_RE.test(values[b.key])).map((b) => b.key);
|
|
2112
|
+
if (unsafe.length > 0) return Left({
|
|
2113
|
+
_tag: "McpValueInvalid",
|
|
2114
|
+
server,
|
|
2115
|
+
keys: unsafe
|
|
2116
|
+
});
|
|
2117
|
+
return Right(Object.fromEntries(matches.map((b) => {
|
|
2118
|
+
const value = values[b.key];
|
|
2119
|
+
return [b.header, b.scheme === "" ? value : `${b.scheme} ${value}`];
|
|
2120
|
+
})));
|
|
2121
|
+
});
|
|
2122
|
+
};
|
|
2123
|
+
/** Human-readable one-liner for any McpHeadersError tag. Never includes a secret value. */
|
|
2124
|
+
const formatMcpHeadersError = (error) => {
|
|
2125
|
+
switch (error._tag) {
|
|
2126
|
+
case "McpHeaderInvalid": return `[secret.${error.key}] mcp server "${error.server}": header "${error.header}" is not a valid HTTP header name`;
|
|
2127
|
+
case "McpSchemeInvalid": return `[secret.${error.key}] mcp server "${error.server}": scheme "${error.scheme}" must be a single word or ""`;
|
|
2128
|
+
case "McpHeaderDuplicate": return `MCP server "${error.server}": header "${error.header}" is claimed by more than one binding: ${error.keys.join(", ")}`;
|
|
2129
|
+
case "McpServerUnknown": return `No secret declares mcp server "${error.server}"`;
|
|
2130
|
+
case "McpValueUnresolved": return `MCP server "${error.server}": no value resolved for ${error.keys.join(", ")}`;
|
|
2131
|
+
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`;
|
|
2132
|
+
}
|
|
2133
|
+
};
|
|
2134
|
+
//#endregion
|
|
1967
2135
|
//#region src/core/validate.ts
|
|
1968
2136
|
/**
|
|
1969
|
-
* Validate a raw TOML string as a complete envpkt config: parse → schema → aliases.
|
|
2137
|
+
* Validate a raw TOML string as a complete envpkt config: parse → schema → aliases → mcp.
|
|
1970
2138
|
*
|
|
1971
2139
|
* Used by write-path CLI commands to verify the post-edit file would still be
|
|
1972
2140
|
* structurally valid before persisting. Catalog resolution is intentionally
|
|
1973
2141
|
* excluded — catalog issues depend on external files, not on the local edit,
|
|
1974
2142
|
* and `envpkt validate` covers them as a separate explicit check.
|
|
1975
2143
|
*/
|
|
1976
|
-
const validateRawConfig = (raw) => parseToml(raw).flatMap(validateConfig).flatMap((config) => validateAliases(config).map(() => config));
|
|
2144
|
+
const validateRawConfig = (raw) => parseToml(raw).flatMap(validateConfig).flatMap((config) => validateAliases(config).map(() => config)).flatMap((config) => validateMcpHeaders(config).map(() => config));
|
|
1977
2145
|
/** Human-readable one-liner for any ValidationError tag. */
|
|
1978
2146
|
const formatValidationError = (err) => {
|
|
1979
2147
|
switch (err._tag) {
|
|
@@ -1987,6 +2155,9 @@ const formatValidationError = (err) => {
|
|
|
1987
2155
|
case "AliasChained":
|
|
1988
2156
|
case "AliasCrossType":
|
|
1989
2157
|
case "AliasValueConflict": return formatAliasError(err);
|
|
2158
|
+
case "McpHeaderInvalid":
|
|
2159
|
+
case "McpSchemeInvalid":
|
|
2160
|
+
case "McpHeaderDuplicate": return formatMcpHeadersError(err);
|
|
1990
2161
|
}
|
|
1991
2162
|
};
|
|
1992
2163
|
//#endregion
|
|
@@ -2043,14 +2214,15 @@ const todayIso$2 = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0
|
|
|
2043
2214
|
* Build the `[secret.<destName>]` block for a copy: unseal the source value with the
|
|
2044
2215
|
* source identity and reseal it for the destination recipient. Secrets with no sealed
|
|
2045
2216
|
* value are copied as metadata only (with a warning) — there's nothing to reseal.
|
|
2217
|
+
* Overwriting an existing destination entry keeps its `created`; a new entry gets today.
|
|
2046
2218
|
*/
|
|
2047
2219
|
const sealedSecretBlock = (key, destName, srcConfig, srcPath, destConfig) => {
|
|
2048
2220
|
const meta = srcConfig.secret[key];
|
|
2049
|
-
const
|
|
2221
|
+
const created = destConfig.secret?.[destName]?.created ?? todayIso$2();
|
|
2050
2222
|
if (meta.encrypted_value === void 0 || meta.encrypted_value === "") {
|
|
2051
2223
|
console.error(`${YELLOW}Warning:${RESET} secret "${key}" has no sealed value — copying metadata only.`);
|
|
2052
2224
|
return serializeSecretBlock(destName, copyableSecretMeta(meta, {
|
|
2053
|
-
|
|
2225
|
+
created,
|
|
2054
2226
|
encryptedValue: Option.none()
|
|
2055
2227
|
}));
|
|
2056
2228
|
}
|
|
@@ -2073,14 +2245,15 @@ const sealedSecretBlock = (key, destName, srcConfig, srcPath, destConfig) => {
|
|
|
2073
2245
|
console.error(`${RED}Error:${RESET} "${key}" produced no plaintext on unseal.`);
|
|
2074
2246
|
return process.exit(2);
|
|
2075
2247
|
}
|
|
2248
|
+
const cipher = orExit(ageEncrypt(plaintext, recipient), (err) => `${RED}Error:${RESET} reseal failed for "${destName}": ${err.message}`, 2);
|
|
2076
2249
|
return serializeSecretBlock(destName, copyableSecretMeta(meta, {
|
|
2077
|
-
|
|
2078
|
-
encryptedValue: Option(
|
|
2250
|
+
created,
|
|
2251
|
+
encryptedValue: Option(cipher)
|
|
2079
2252
|
}));
|
|
2080
2253
|
};
|
|
2081
2254
|
const writeBlock = (destPath, header, block, overwrite, dryRun, successMsg) => {
|
|
2082
2255
|
const raw = readFileSync(destPath, "utf-8");
|
|
2083
|
-
const updated =
|
|
2256
|
+
const updated = overwrite ? orExit(replaceSection(raw, header, block), (err) => `${RED}Error:${RESET} ${err._tag}: ${err.section}`, 2) : appendSection(raw, block);
|
|
2084
2257
|
if (dryRun) {
|
|
2085
2258
|
previewIfValid(updated, block);
|
|
2086
2259
|
return;
|
|
@@ -2121,7 +2294,8 @@ const runCopy = (key, options) => {
|
|
|
2121
2294
|
process.exit(1);
|
|
2122
2295
|
}
|
|
2123
2296
|
const block = inSecret ? sealedSecretBlock(key, destName, srcConfig, src.path, destConfig) : serializeEnvBlock(destName, srcConfig.env[key]);
|
|
2124
|
-
const
|
|
2297
|
+
const renamed = destName !== key ? ` → ${BOLD}${destName}${RESET}` : "";
|
|
2298
|
+
const successMsg = `${GREEN}✓${RESET} Copied ${kindWord} ${BOLD}${key}${RESET}${renamed} to ${CYAN}${dest.path}${RESET}`;
|
|
2125
2299
|
writeBlock(dest.path, `[${kindWord}.${destName}]`, block, existsInDest, options.dryRun === true, successMsg);
|
|
2126
2300
|
};
|
|
2127
2301
|
//#endregion
|
|
@@ -2906,7 +3080,8 @@ const VALUE_SHAPE_PATTERNS = [
|
|
|
2906
3080
|
];
|
|
2907
3081
|
/** Detect service from value prefix/shape */
|
|
2908
3082
|
const matchValueShape = (value) => {
|
|
2909
|
-
|
|
3083
|
+
const match = VALUE_SHAPE_PATTERNS.find((vp) => value.startsWith(vp.prefix));
|
|
3084
|
+
return Option(match).map((vp) => ({
|
|
2910
3085
|
service: vp.service,
|
|
2911
3086
|
description: vp.description
|
|
2912
3087
|
}));
|
|
@@ -2946,7 +3121,8 @@ const matchEnvVar = (name, value) => {
|
|
|
2946
3121
|
matchedBy: `exact:${exactMatch.pattern}`
|
|
2947
3122
|
});
|
|
2948
3123
|
return matchValueShape(value).fold(() => {
|
|
2949
|
-
|
|
3124
|
+
const suffixMatch = SUFFIX_PATTERNS.find((sp) => name.endsWith(sp.suffix));
|
|
3125
|
+
return Option(suffixMatch).map((sp) => ({
|
|
2950
3126
|
envVar: name,
|
|
2951
3127
|
value,
|
|
2952
3128
|
service: Option(deriveServiceFromName(name)),
|
|
@@ -3148,7 +3324,8 @@ const runEnvCheck = (options) => {
|
|
|
3148
3324
|
console.error(formatError(err));
|
|
3149
3325
|
process.exit(2);
|
|
3150
3326
|
}, (rawConfig) => {
|
|
3151
|
-
|
|
3327
|
+
const configDir = dirname(path);
|
|
3328
|
+
resolveConfig(rawConfig, configDir).fold((err) => {
|
|
3152
3329
|
console.error(formatError(err));
|
|
3153
3330
|
process.exit(2);
|
|
3154
3331
|
}, (resolveResult) => {
|
|
@@ -3319,7 +3496,9 @@ const runEnvAdd = (name, value, options) => {
|
|
|
3319
3496
|
console.log(block);
|
|
3320
3497
|
return;
|
|
3321
3498
|
}
|
|
3322
|
-
|
|
3499
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
3500
|
+
const updated = appendSection(raw, block);
|
|
3501
|
+
writeIfValid(configPath, updated, `${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
|
|
3323
3502
|
});
|
|
3324
3503
|
});
|
|
3325
3504
|
};
|
|
@@ -3440,7 +3619,9 @@ const runEnvAlias = (name, options) => {
|
|
|
3440
3619
|
return;
|
|
3441
3620
|
}
|
|
3442
3621
|
const raw = readFileSync(configPath, "utf-8");
|
|
3443
|
-
|
|
3622
|
+
const base = existing ? removeSection(raw, `[env.${name}]`).fold(() => raw, (r) => r) : raw;
|
|
3623
|
+
const updated = appendSection(base, block);
|
|
3624
|
+
writeIfValid(configPath, updated, `${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
|
|
3444
3625
|
});
|
|
3445
3626
|
});
|
|
3446
3627
|
};
|
|
@@ -3565,7 +3746,7 @@ function* findEnvpktFiles(dir, maxDepth, currentDepth = 0) {
|
|
|
3565
3746
|
}
|
|
3566
3747
|
const scanFleet = (rootDir, options) => {
|
|
3567
3748
|
const maxDepth = options?.maxDepth ?? 3;
|
|
3568
|
-
const
|
|
3749
|
+
const agents = Array.from(findEnvpktFiles(rootDir, maxDepth)).flatMap((configPath) => loadConfig(configPath).fold(() => [], (config) => {
|
|
3569
3750
|
const audit = computeAudit(config);
|
|
3570
3751
|
return [{
|
|
3571
3752
|
path: configPath,
|
|
@@ -3573,7 +3754,8 @@ const scanFleet = (rootDir, options) => {
|
|
|
3573
3754
|
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(),
|
|
3574
3755
|
audit
|
|
3575
3756
|
}];
|
|
3576
|
-
}))
|
|
3757
|
+
}));
|
|
3758
|
+
const agentList = List(agents);
|
|
3577
3759
|
const total_agents = agentList.size;
|
|
3578
3760
|
const total_secrets = agentList.toArray().reduce((acc, a) => acc + a.audit.total, 0);
|
|
3579
3761
|
const expired = agentList.toArray().reduce((acc, a) => acc + a.audit.expired, 0);
|
|
@@ -3599,7 +3781,8 @@ const statusIcon = (status) => {
|
|
|
3599
3781
|
}
|
|
3600
3782
|
};
|
|
3601
3783
|
const runFleet = (options) => {
|
|
3602
|
-
const
|
|
3784
|
+
const rootDir = resolve(options.dir ?? ".");
|
|
3785
|
+
const fleet = scanFleet(rootDir, { maxDepth: options.depth });
|
|
3603
3786
|
if (options.format === "json") {
|
|
3604
3787
|
console.log(formatFleetJson(fleet));
|
|
3605
3788
|
process.exit(fleet.status === "critical" ? 2 : 0);
|
|
@@ -3618,6 +3801,36 @@ const runFleet = (options) => {
|
|
|
3618
3801
|
process.exit(fleet.status === "critical" ? 2 : 0);
|
|
3619
3802
|
};
|
|
3620
3803
|
//#endregion
|
|
3804
|
+
//#region src/cli/commands/headers.ts
|
|
3805
|
+
const SERVER_ENV = "CLAUDE_CODE_MCP_SERVER_NAME";
|
|
3806
|
+
/**
|
|
3807
|
+
* Claude Code `headersHelper` entry point: print the HTTP headers for one MCP server as a
|
|
3808
|
+
* JSON object. stdout carries the JSON and nothing else; every diagnostic goes to stderr,
|
|
3809
|
+
* because Claude parses stdout verbatim.
|
|
3810
|
+
*/
|
|
3811
|
+
const runHeaders = (serverArg, options) => {
|
|
3812
|
+
const server = serverArg ?? process.env[SERVER_ENV];
|
|
3813
|
+
if (!server) {
|
|
3814
|
+
console.error(`${RED}Error:${RESET} No MCP server named. Pass [server] or set ${SERVER_ENV}.`);
|
|
3815
|
+
process.exit(2);
|
|
3816
|
+
}
|
|
3817
|
+
resolveForEmit(options).fold((err) => {
|
|
3818
|
+
console.error(`MCP server "${server}":\n${formatError(err)}`);
|
|
3819
|
+
process.exit(2);
|
|
3820
|
+
}, (boot) => {
|
|
3821
|
+
emitWarnings(boot);
|
|
3822
|
+
loadConfig(boot.configPath).flatMap((config) => resolveConfig(config, dirname(boot.configPath)).map((r) => r.config)).fold((err) => {
|
|
3823
|
+
console.error(`MCP server "${server}":\n${formatError(err)}`);
|
|
3824
|
+
return process.exit(2);
|
|
3825
|
+
}, (config) => buildMcpHeaders(config, boot.secrets, server)).fold((err) => {
|
|
3826
|
+
console.error(`${RED}Error:${RESET} ${formatMcpHeadersError(err)}`);
|
|
3827
|
+
process.exit(1);
|
|
3828
|
+
}, (map) => {
|
|
3829
|
+
process.stdout.write(`${JSON.stringify(map)}\n`);
|
|
3830
|
+
});
|
|
3831
|
+
});
|
|
3832
|
+
};
|
|
3833
|
+
//#endregion
|
|
3621
3834
|
//#region src/cli/commands/init.ts
|
|
3622
3835
|
const CONFIG_FILENAME = "envpkt.toml";
|
|
3623
3836
|
/** Resolve the top-level `scope` to scaffold: explicit --scope wins; --global implies "shell". */
|
|
@@ -3893,7 +4106,8 @@ const deriveKeyPath = (configPath) => {
|
|
|
3893
4106
|
};
|
|
3894
4107
|
const runKeygen = (options) => {
|
|
3895
4108
|
const configPath = resolve(options.config ?? join(process.cwd(), "envpkt.toml"));
|
|
3896
|
-
|
|
4109
|
+
const outputPath = options.output ?? (options.global ? resolveKeyPath() : deriveKeyPath(configPath));
|
|
4110
|
+
generateKeypair({ outputPath }).fold((err) => {
|
|
3897
4111
|
if (err._tag === "KeyExists") {
|
|
3898
4112
|
console.error(`${YELLOW}Warning:${RESET} Identity file already exists: ${CYAN}${err.path}${RESET}`);
|
|
3899
4113
|
console.error(`${DIM}To replace it: remove the file first, then re-run keygen.${RESET}`);
|
|
@@ -4263,7 +4477,8 @@ const runResolve = (options) => {
|
|
|
4263
4477
|
console.error(formatError(err));
|
|
4264
4478
|
process.exit(2);
|
|
4265
4479
|
}, (config) => {
|
|
4266
|
-
|
|
4480
|
+
const configDir = dirname(configPath);
|
|
4481
|
+
resolveConfig(config, configDir).fold((err) => {
|
|
4267
4482
|
console.error(formatError(err));
|
|
4268
4483
|
process.exit(2);
|
|
4269
4484
|
}, (result) => {
|
|
@@ -4419,13 +4634,18 @@ const applySealedToml = (raw, sealedMeta) => {
|
|
|
4419
4634
|
};
|
|
4420
4635
|
return flushPending(List(lines).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]))).output.join("\n");
|
|
4421
4636
|
};
|
|
4637
|
+
/**
|
|
4638
|
+
* A value with no non-whitespace content. Unsealing trims, so such a value would come back as
|
|
4639
|
+
* `""` — it is treated as empty on both the prompt and stdin paths.
|
|
4640
|
+
*/
|
|
4641
|
+
const isBlank = (value) => value.trim() === "";
|
|
4422
4642
|
/** Affirmative answer to a `[y/N]` confirm prompt. */
|
|
4423
4643
|
const isAffirmative = (answer) => /^y(es)?$/i.test(answer.trim());
|
|
4424
4644
|
/**
|
|
4425
4645
|
* Collect new plaintext values for the `--edit`ed keys via the injected `prompt`. Before
|
|
4426
4646
|
* overwriting an entry that is **already sealed** (`encrypted_value` present), require an
|
|
4427
4647
|
* explicit confirmation — replacing it discards the only ciphertext, and the prior value
|
|
4428
|
-
* can't be recovered without the original key. Declined or empty entries are skipped (reported
|
|
4648
|
+
* can't be recovered without the original key. Declined or empty (whitespace-only) entries are skipped (reported
|
|
4429
4649
|
* via `onSkip`). I/O flows only through `prompt`/`onSkip`, so the confirm/skip logic is
|
|
4430
4650
|
* unit-testable without a TTY.
|
|
4431
4651
|
*/
|
|
@@ -4440,7 +4660,7 @@ const collectEditedValues = async (editKeys, entries, prompt, onSkip) => {
|
|
|
4440
4660
|
}
|
|
4441
4661
|
}
|
|
4442
4662
|
const value = await prompt(`Enter new value for ${key}: `);
|
|
4443
|
-
if (value
|
|
4663
|
+
if (isBlank(value)) {
|
|
4444
4664
|
onSkip?.(key, "empty");
|
|
4445
4665
|
continue;
|
|
4446
4666
|
}
|
|
@@ -4448,6 +4668,23 @@ const collectEditedValues = async (editKeys, entries, prompt, onSkip) => {
|
|
|
4448
4668
|
}
|
|
4449
4669
|
return values;
|
|
4450
4670
|
};
|
|
4671
|
+
/**
|
|
4672
|
+
* Pick the `--edit` input source. Stdin is used when `--stdin` is passed or no terminal is
|
|
4673
|
+
* attached; it carries one value, so it needs exactly one key. Left holds the error message.
|
|
4674
|
+
*/
|
|
4675
|
+
const resolveEditInput = (keyCount, opts) => {
|
|
4676
|
+
if (!opts.stdin && opts.isTTY) return Right("prompt");
|
|
4677
|
+
if (keyCount !== 1) {
|
|
4678
|
+
const why = opts.stdin ? "--stdin" : "Without an interactive terminal, --edit";
|
|
4679
|
+
return Left(`${why} reads one value from stdin, so it takes exactly one key (got ${keyCount}). Seal one key at a time.`);
|
|
4680
|
+
}
|
|
4681
|
+
return Right("stdin");
|
|
4682
|
+
};
|
|
4683
|
+
/** Strip one trailing newline (`\n` or `\r\n`) from piped input. None when nothing but whitespace is left. */
|
|
4684
|
+
const parseStdinValue = (raw) => {
|
|
4685
|
+
const value = raw.replace(/\r?\n$/, "");
|
|
4686
|
+
return isBlank(value) ? Option.none() : Option(value);
|
|
4687
|
+
};
|
|
4451
4688
|
/** Write sealed values back into the TOML file, preserving structure. */
|
|
4452
4689
|
const writeSealedToml = (configPath, sealedMeta) => {
|
|
4453
4690
|
const raw = readFileSync(configPath, "utf-8");
|
|
@@ -4455,6 +4692,35 @@ const writeSealedToml = (configPath, sealedMeta) => {
|
|
|
4455
4692
|
validateOrExit(finalContent);
|
|
4456
4693
|
writeFileSync(configPath, finalContent);
|
|
4457
4694
|
};
|
|
4695
|
+
/**
|
|
4696
|
+
* Read one `--edit` value from stdin. The value is never echoed or logged. Piping a value is
|
|
4697
|
+
* an explicit request to replace it, so a sealed entry is replaced without the confirm prompt.
|
|
4698
|
+
*/
|
|
4699
|
+
const readEditedValueFromStdin = async (key) => {
|
|
4700
|
+
const endHint = process.stdin.isTTY ? " (end with Ctrl-D)" : "";
|
|
4701
|
+
console.error(`${DIM}Reading the value for ${key} from stdin${endHint}${RESET}`);
|
|
4702
|
+
return parseStdinValue(await text(process.stdin)).fold(() => {
|
|
4703
|
+
console.error(`${RED}Error:${RESET} No value on stdin for ${key}`);
|
|
4704
|
+
console.error(`${DIM}Pipe the value in, e.g. ${CYAN}pbpaste | envpkt seal --edit ${key}${RESET}${DIM}, or run in a terminal to be prompted.${RESET}`);
|
|
4705
|
+
process.exit(2);
|
|
4706
|
+
return {};
|
|
4707
|
+
}, (value) => ({ [key]: value }));
|
|
4708
|
+
};
|
|
4709
|
+
/** Prompt for each `--edit` value on the terminal, confirming before replacing a sealed one. */
|
|
4710
|
+
const promptEditedValues = async (editKeys, secretEntries) => {
|
|
4711
|
+
const rl = await import("node:readline").then((m) => m.createInterface({
|
|
4712
|
+
input: process.stdin,
|
|
4713
|
+
output: process.stderr
|
|
4714
|
+
}));
|
|
4715
|
+
const prompt = (question) => new Promise((resolve) => {
|
|
4716
|
+
rl.question(question, (answer) => resolve(answer));
|
|
4717
|
+
});
|
|
4718
|
+
const values = await collectEditedValues(editKeys, secretEntries, prompt, (key, reason) => {
|
|
4719
|
+
console.error(`${YELLOW}Skipped${RESET} ${key} (${reason === "empty" ? "empty value" : "kept existing sealed value"})`);
|
|
4720
|
+
});
|
|
4721
|
+
rl.close();
|
|
4722
|
+
return values;
|
|
4723
|
+
};
|
|
4458
4724
|
const runSeal = async (options) => {
|
|
4459
4725
|
const { path: configPath, source: configSource } = resolveConfigPath(options.config).fold((err) => {
|
|
4460
4726
|
console.error(formatError(err));
|
|
@@ -4490,13 +4756,18 @@ const runSeal = async (options) => {
|
|
|
4490
4756
|
process.exit(2);
|
|
4491
4757
|
}
|
|
4492
4758
|
const identityKey = Option(config.identity.key_file).flatMap((keyFile) => {
|
|
4493
|
-
|
|
4759
|
+
const identityPath = resolve(configDir, expandPath(keyFile));
|
|
4760
|
+
return unwrapAgentKey(identityPath).fold((err) => {
|
|
4494
4761
|
const msg = err._tag === "IdentityNotFound" ? `not found: ${err.path}` : err.message;
|
|
4495
4762
|
console.error(`${YELLOW}Warning:${RESET} Could not unwrap agent key: ${msg}`);
|
|
4496
4763
|
return Option.none();
|
|
4497
4764
|
}, (k) => Option(k));
|
|
4498
4765
|
});
|
|
4499
4766
|
const editKeys = options.edit ? options.edit.split(",").map((k) => k.trim()).filter((k) => k.length > 0) : [];
|
|
4767
|
+
if (options.stdin && editKeys.length === 0) {
|
|
4768
|
+
console.error(`${RED}Error:${RESET} --stdin needs --edit <key> to name the secret the value is for`);
|
|
4769
|
+
process.exit(2);
|
|
4770
|
+
}
|
|
4500
4771
|
if (editKeys.length > 0) {
|
|
4501
4772
|
const allSecretEntries = config.secret ?? {};
|
|
4502
4773
|
const unknownKeys = editKeys.filter((k) => !(k in allSecretEntries));
|
|
@@ -4517,24 +4788,18 @@ const runSeal = async (options) => {
|
|
|
4517
4788
|
console.error(`${DIM}external = true means the value is managed outside envpkt — set it in that store instead.${RESET}`);
|
|
4518
4789
|
process.exit(2);
|
|
4519
4790
|
}
|
|
4520
|
-
|
|
4521
|
-
|
|
4791
|
+
const input = resolveEditInput(editKeys.length, {
|
|
4792
|
+
stdin: options.stdin === true,
|
|
4793
|
+
isTTY: process.stdin.isTTY === true
|
|
4794
|
+
}).fold((msg) => {
|
|
4795
|
+
console.error(`${RED}Error:${RESET} ${msg}`);
|
|
4522
4796
|
process.exit(2);
|
|
4523
|
-
|
|
4797
|
+
return "prompt";
|
|
4798
|
+
}, (i) => i);
|
|
4524
4799
|
const secretEntries = Object.fromEntries(editKeys.map((k) => [k, allSecretEntries[k]]));
|
|
4525
4800
|
console.log(`${BOLD}Editing ${editKeys.length} secret(s)${RESET} with recipient ${CYAN}${recipient.slice(0, 20)}...${RESET}`);
|
|
4526
4801
|
console.log("");
|
|
4527
|
-
const
|
|
4528
|
-
input: process.stdin,
|
|
4529
|
-
output: process.stderr
|
|
4530
|
-
}));
|
|
4531
|
-
const prompt = (question) => new Promise((resolve) => {
|
|
4532
|
-
rl.question(question, (answer) => resolve(answer));
|
|
4533
|
-
});
|
|
4534
|
-
const values = await collectEditedValues(editKeys, secretEntries, prompt, (key, reason) => {
|
|
4535
|
-
console.error(`${YELLOW}Skipped${RESET} ${key} (${reason === "empty" ? "empty value" : "kept existing sealed value"})`);
|
|
4536
|
-
});
|
|
4537
|
-
rl.close();
|
|
4802
|
+
const values = input === "stdin" ? await readEditedValueFromStdin(editKeys[0]) : await promptEditedValues(editKeys, secretEntries);
|
|
4538
4803
|
if (Object.keys(values).length === 0) {
|
|
4539
4804
|
console.error(`${RED}Error:${RESET} No values provided`);
|
|
4540
4805
|
process.exit(2);
|
|
@@ -4586,7 +4851,8 @@ const runSeal = async (options) => {
|
|
|
4586
4851
|
console.error(`${DIM} 3. ~/.envpkt/age-key.txt${RESET}`);
|
|
4587
4852
|
process.exit(2);
|
|
4588
4853
|
}
|
|
4589
|
-
const
|
|
4854
|
+
const sealedEntries = Object.fromEntries(alreadySealed.map((k) => [k, allSecretEntries[k]]));
|
|
4855
|
+
const decrypted = unsealSecrets(sealedEntries, identityPath).fold((err) => {
|
|
4590
4856
|
console.error(`${RED}Error:${RESET} Failed to decrypt existing secrets: ${err.message}`);
|
|
4591
4857
|
process.exit(2);
|
|
4592
4858
|
return {};
|
|
@@ -4723,7 +4989,8 @@ const runSecretAdd = (name, options) => {
|
|
|
4723
4989
|
process.exit(1);
|
|
4724
4990
|
}
|
|
4725
4991
|
const block = buildSecretBlock(name, options);
|
|
4726
|
-
const
|
|
4992
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
4993
|
+
const updated = appendSection(raw, block);
|
|
4727
4994
|
if (options.dryRun) {
|
|
4728
4995
|
previewIfValid(updated, block);
|
|
4729
4996
|
return;
|
|
@@ -4864,7 +5131,9 @@ const runSecretAlias = (name, options) => {
|
|
|
4864
5131
|
return;
|
|
4865
5132
|
}
|
|
4866
5133
|
const raw = readFileSync(configPath, "utf-8");
|
|
4867
|
-
|
|
5134
|
+
const base = existing ? removeSection(raw, `[secret.${name}]`).fold(() => raw, (r) => r) : raw;
|
|
5135
|
+
const updated = appendSection(base, block);
|
|
5136
|
+
writeIfValid(configPath, updated, `${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
|
|
4868
5137
|
});
|
|
4869
5138
|
});
|
|
4870
5139
|
};
|
|
@@ -4947,7 +5216,8 @@ const runSecretRotate = async (name, options) => {
|
|
|
4947
5216
|
process.exit(2);
|
|
4948
5217
|
return "";
|
|
4949
5218
|
}, (ct) => ct);
|
|
4950
|
-
|
|
5219
|
+
const withSealed = applySealedToml(raw, { [name]: { encrypted_value: ciphertext } });
|
|
5220
|
+
updateSectionFields(withSealed, `[secret.${name}]`, { last_rotated_at: `"${today}"` }).fold((err) => {
|
|
4951
5221
|
console.error(`${RED}Error:${RESET} ${err._tag}: ${err.section}`);
|
|
4952
5222
|
process.exit(2);
|
|
4953
5223
|
}, (result) => {
|
|
@@ -5182,6 +5452,7 @@ const CHECK_NAMES = {
|
|
|
5182
5452
|
schema: "Schema",
|
|
5183
5453
|
catalog: "Catalog",
|
|
5184
5454
|
aliases: "Aliases",
|
|
5455
|
+
mcp: "MCP headers",
|
|
5185
5456
|
sealed: "Sealed blocks"
|
|
5186
5457
|
};
|
|
5187
5458
|
/** Structural sanity scan over each [secret.*].encrypted_value PEM block. No decryption. */
|
|
@@ -5210,6 +5481,24 @@ const checkSealedBlocks = (config) => {
|
|
|
5210
5481
|
error: broken.join("; ")
|
|
5211
5482
|
};
|
|
5212
5483
|
};
|
|
5484
|
+
/** `mcp` bindings: valid header names and schemes, no (server, header) pair claimed twice. */
|
|
5485
|
+
const checkMcpHeaders = (config) => {
|
|
5486
|
+
const count = Object.values(config.secret ?? {}).reduce((n, meta) => n + (meta.mcp?.length ?? 0), 0);
|
|
5487
|
+
if (count === 0) return {
|
|
5488
|
+
name: CHECK_NAMES.mcp,
|
|
5489
|
+
status: "na",
|
|
5490
|
+
detail: "no mcp bindings"
|
|
5491
|
+
};
|
|
5492
|
+
return validateMcpHeaders(config).fold((err) => ({
|
|
5493
|
+
name: CHECK_NAMES.mcp,
|
|
5494
|
+
status: "failed",
|
|
5495
|
+
error: formatValidationError(err)
|
|
5496
|
+
}), () => ({
|
|
5497
|
+
name: CHECK_NAMES.mcp,
|
|
5498
|
+
status: "ok",
|
|
5499
|
+
detail: `${count} binding(s)`
|
|
5500
|
+
}));
|
|
5501
|
+
};
|
|
5213
5502
|
const skipped = (name) => ({
|
|
5214
5503
|
name,
|
|
5215
5504
|
status: "skipped"
|
|
@@ -5227,6 +5516,7 @@ const buildReport = (path, raw) => {
|
|
|
5227
5516
|
skipped(CHECK_NAMES.schema),
|
|
5228
5517
|
skipped(CHECK_NAMES.catalog),
|
|
5229
5518
|
skipped(CHECK_NAMES.aliases),
|
|
5519
|
+
skipped(CHECK_NAMES.mcp),
|
|
5230
5520
|
skipped(CHECK_NAMES.sealed)
|
|
5231
5521
|
]
|
|
5232
5522
|
}), (data) => {
|
|
@@ -5246,6 +5536,7 @@ const buildReport = (path, raw) => {
|
|
|
5246
5536
|
},
|
|
5247
5537
|
skipped(CHECK_NAMES.catalog),
|
|
5248
5538
|
skipped(CHECK_NAMES.aliases),
|
|
5539
|
+
skipped(CHECK_NAMES.mcp),
|
|
5249
5540
|
skipped(CHECK_NAMES.sealed)
|
|
5250
5541
|
]
|
|
5251
5542
|
}), (config) => {
|
|
@@ -5275,12 +5566,15 @@ const buildReport = (path, raw) => {
|
|
|
5275
5566
|
status: "ok",
|
|
5276
5567
|
detail: `${table.entries.size} alias(es)`
|
|
5277
5568
|
}));
|
|
5569
|
+
const merged = config.catalog ? resolveConfig(config, dirname(path)).fold(() => config, (r) => r.config) : config;
|
|
5570
|
+
const mcpCheck = checkMcpHeaders(merged);
|
|
5278
5571
|
const sealedCheck = checkSealedBlocks(config);
|
|
5279
5572
|
const checks = [
|
|
5280
5573
|
tomlOk,
|
|
5281
5574
|
schemaOk,
|
|
5282
5575
|
catalogCheck,
|
|
5283
5576
|
aliasCheck,
|
|
5577
|
+
mcpCheck,
|
|
5284
5578
|
sealedCheck
|
|
5285
5579
|
];
|
|
5286
5580
|
return {
|
|
@@ -5303,7 +5597,9 @@ const renderCheckLines = (check) => {
|
|
|
5303
5597
|
}
|
|
5304
5598
|
};
|
|
5305
5599
|
const formatTextReport = (report, sourceMsg) => {
|
|
5306
|
-
const
|
|
5600
|
+
const headerIcon = report.ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
|
|
5601
|
+
const headerLabel = report.ok ? `${GREEN}VALID${RESET}` : `${RED}INVALID${RESET}`;
|
|
5602
|
+
const header = `${headerIcon} ${BOLD}${headerLabel}${RESET} — ${CYAN}${report.configPath}${RESET}`;
|
|
5307
5603
|
const checkLines = report.checks.flatMap((c) => [...renderCheckLines(c)]);
|
|
5308
5604
|
return [
|
|
5309
5605
|
...sourceMsg ? [sourceMsg] : [],
|
|
@@ -5382,13 +5678,13 @@ program.command("init").description("Initialize a new envpkt.toml in the current
|
|
|
5382
5678
|
program.command("keygen").description("Generate an age keypair for sealing secrets — run this before `seal` if you don't have a key yet").option("-c, --config <path>", "Path to envpkt.toml (updates identity.recipient if found)").option("-o, --output <path>", "Output path for identity file (default: ~/.envpkt/<project>-key.txt)").option("--global", "Write key to the shared default path (~/.envpkt/age-key.txt) instead of a project-specific one").action((options) => {
|
|
5383
5679
|
runKeygen(options);
|
|
5384
5680
|
});
|
|
5385
|
-
program.command("audit").description("Audit credential health from envpkt.toml (use --strict in CI pipelines to gate deploys)").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json | minimal", "table").option("--expiring <days>", "Show secrets expiring within N days", parseInt).option("--status <status>", "Filter by status: healthy | expiring_soon | expired | stale | missing").option("--strict", "Exit non-zero on any non-healthy secret").option("--all", "Show both secrets and env defaults").option("--env-only", "Show only env defaults (drift detection)").option("--sealed", "Show only secrets with encrypted_value").option("--
|
|
5681
|
+
program.command("audit").description("Audit credential health from envpkt.toml (use --strict in CI pipelines to gate deploys)").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json | minimal", "table").option("--expiring <days>", "Show secrets expiring within N days", parseInt).option("--status <status>", "Filter by status: healthy | expiring_soon | expired | stale | missing | missing_metadata | external").option("--strict", "Exit non-zero on any non-healthy secret").option("--all", "Show both secrets and env defaults").option("--env-only", "Show only env defaults (drift detection)").option("--sealed", "Show only secrets with encrypted_value").option("--unsealed", "Show only secrets without encrypted_value (resolved at runtime or external)").option("--sort", "Sort secrets alphabetically within each status bucket (no file mutation)").action((options) => {
|
|
5386
5682
|
runAudit(options);
|
|
5387
5683
|
});
|
|
5388
5684
|
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) => {
|
|
5389
5685
|
runFleet(options);
|
|
5390
5686
|
});
|
|
5391
|
-
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) => {
|
|
5687
|
+
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) => {
|
|
5392
5688
|
runValidate(options);
|
|
5393
5689
|
});
|
|
5394
5690
|
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) => {
|
|
@@ -5397,10 +5693,13 @@ program.command("inspect").description("Display structured view of envpkt.toml")
|
|
|
5397
5693
|
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) => {
|
|
5398
5694
|
runExec(args, options);
|
|
5399
5695
|
});
|
|
5696
|
+
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) => {
|
|
5697
|
+
runHeaders(server, options);
|
|
5698
|
+
});
|
|
5400
5699
|
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) => {
|
|
5401
5700
|
runResolve(options);
|
|
5402
5701
|
});
|
|
5403
|
-
program.command("seal").description("Encrypt secret values into envpkt.toml using age — sealed packets are safe to commit to git").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use for value resolution").option("--reseal", "Re-encrypt all secrets, including already sealed (for key rotation)").option("--edit <keys>", "Re-seal specific keys with new values (comma-separated),
|
|
5702
|
+
program.command("seal").description("Encrypt secret values into envpkt.toml using age — sealed packets are safe to commit to git").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use for value resolution").option("--reseal", "Re-encrypt all secrets, including already sealed (for key rotation)").option("--edit <keys>", "Re-seal specific keys with new values (comma-separated). Prompts in a terminal; without one, reads a single key's value from stdin").option("--stdin", "With --edit <key>, read the new value from stdin even in a terminal (one key; one trailing newline is stripped)").action(async (options) => {
|
|
5404
5703
|
await runSeal(options);
|
|
5405
5704
|
});
|
|
5406
5705
|
program.command("mcp").description("Start the envpkt MCP server (stdio transport)").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
|