envpkt 0.1.0 → 0.2.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 CHANGED
@@ -18,7 +18,7 @@ Secrets managers store values. envpkt stores _metadata about_ those values — w
18
18
  - Automate rotation workflows
19
19
  - Share secret metadata across agents via a central catalog
20
20
 
21
- envpkt never touches secret values. It works alongside your existing secrets manager (Vault, fnox, CI variables, etc.).
21
+ envpkt works alongside your existing secrets manager (Vault, fnox, CI variables, etc.). Optionally, you can embed age-encrypted secret values directly in the TOML via **sealed packets** — making configs fully self-contained and safe to commit to git.
22
22
 
23
23
  ## Quick Start
24
24
 
@@ -154,6 +154,47 @@ This produces a self-contained config with catalog metadata merged in and agent
154
154
  - Omitted fields keep the catalog value
155
155
  - `agent.secrets` is the source of truth for which keys the agent needs
156
156
 
157
+ ## Sealed Packets
158
+
159
+ Sealed packets embed age-encrypted secret values directly in `envpkt.toml`. This makes your config fully self-contained — no external secrets backend needed at runtime.
160
+
161
+ ### Setup
162
+
163
+ ```bash
164
+ # Generate an age keypair
165
+ age-keygen -o identity.txt
166
+ # public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
167
+ ```
168
+
169
+ Add the public key to your config and the identity file to `.gitignore`:
170
+
171
+ ```toml
172
+ [agent]
173
+ name = "my-agent"
174
+ recipient = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"
175
+ identity = "identity.txt"
176
+ ```
177
+
178
+ ### Seal
179
+
180
+ ```bash
181
+ envpkt seal
182
+ ```
183
+
184
+ Each secret gets an `encrypted_value` field with age-armored ciphertext. The TOML (including ciphertext) is safe to commit.
185
+
186
+ ### Boot
187
+
188
+ At runtime, sealed values are automatically decrypted:
189
+
190
+ ```typescript
191
+ import { boot } from "envpkt"
192
+
193
+ const result = boot() // decrypts sealed values, injects into process.env
194
+ ```
195
+
196
+ Mixed mode is supported — sealed values take priority, with fnox as fallback for keys without `encrypted_value`.
197
+
157
198
  ## CLI Commands
158
199
 
159
200
  ### `envpkt init`
@@ -234,6 +275,26 @@ envpkt exec --strict -- ./deploy.sh # Abort if audit is not healthy
234
275
  envpkt exec --profile staging -- ... # Use a specific fnox profile
235
276
  ```
236
277
 
278
+ ### `envpkt seal`
279
+
280
+ Encrypt secret values into `envpkt.toml` using [age](https://age-encryption.org/). Sealed values are safe to commit to git — only the holder of the private key can decrypt them.
281
+
282
+ ```bash
283
+ envpkt seal # Seal all secrets in envpkt.toml
284
+ envpkt seal -c path/to/envpkt.toml # Specify config path
285
+ envpkt seal --profile staging # Use a specific fnox profile for value resolution
286
+ ```
287
+
288
+ Requires `agent.recipient` (age public key) in your config. Values are resolved via cascade:
289
+
290
+ 1. **fnox** (if available)
291
+ 2. **Environment variables** (e.g. `OPENAI_API_KEY` in your shell)
292
+ 3. **Interactive prompt** (asks you to paste each value)
293
+
294
+ After sealing, each secret gets an `encrypted_value` field. At boot time, `envpkt exec` or `boot()` automatically decrypts sealed values using the `agent.identity` file.
295
+
296
+ See [`examples/sealed-agent.toml`](./examples/sealed-agent.toml) for a complete example.
297
+
237
298
  ### `envpkt env scan`
238
299
 
239
300
  Auto-discover credentials from your shell environment. Matches env vars against ~45 known services (exact name), ~13 generic suffix patterns (`*_API_KEY`, `*_SECRET`, `*_TOKEN`, etc.), and ~29 value shape patterns (`sk-*`, `ghp_*`, `AKIA*`, `postgres://`, etc.).
@@ -328,12 +389,13 @@ The schema is published at:
328
389
 
329
390
  Each `[meta.<KEY>]` section describes a secret:
330
391
 
331
- | Tier | Fields | Description |
332
- | --------------- | ----------------------------------------------- | ----------------------------------------- |
333
- | **Scan-first** | `service`, `expires`, `rotation_url` | Key health indicators for audit |
334
- | **Context** | `purpose`, `capabilities`, `created` | Why this secret exists and what it grants |
335
- | **Operational** | `rotates`, `rate_limit`, `model_hint`, `source` | Runtime and provisioning info |
336
- | **Enforcement** | `required`, `tags` | Filtering, grouping, and policy |
392
+ | Tier | Fields | Description |
393
+ | --------------- | ----------------------------------------------- | ------------------------------------------- |
394
+ | **Scan-first** | `service`, `expires`, `rotation_url` | Key health indicators for audit |
395
+ | **Context** | `purpose`, `capabilities`, `created` | Why this secret exists and what it grants |
396
+ | **Operational** | `rotates`, `rate_limit`, `model_hint`, `source` | Runtime and provisioning info |
397
+ | **Sealed** | `encrypted_value` | Age-encrypted secret value (safe to commit) |
398
+ | **Enforcement** | `required`, `tags` | Filtering, grouping, and policy |
337
399
 
338
400
  ### Agent Identity
339
401
 
@@ -460,6 +522,32 @@ matchEnvVar("OPENAI_API_KEY", "sk-test123").fold(
460
522
  )
461
523
  ```
462
524
 
525
+ ### Seal API
526
+
527
+ ```typescript
528
+ import { ageEncrypt, ageDecrypt, sealSecrets, unsealSecrets } from "envpkt"
529
+
530
+ // Encrypt a single value
531
+ const encrypted = ageEncrypt("sk-my-api-key", "age1ql3z7hjy...")
532
+ encrypted.fold(
533
+ (err) => console.error("Encrypt failed:", err.message),
534
+ (ciphertext) => console.log(ciphertext), // -----BEGIN AGE ENCRYPTED FILE-----
535
+ )
536
+
537
+ // Decrypt a single value
538
+ const decrypted = ageDecrypt(ciphertext, "/path/to/identity.txt")
539
+
540
+ // Seal all secrets in a config's meta
541
+ const sealed = sealSecrets(config.meta, { OPENAI_API_KEY: "sk-..." }, recipientPublicKey)
542
+
543
+ // Unseal all encrypted_value entries
544
+ const values = unsealSecrets(config.meta, "/path/to/identity.txt")
545
+ values.fold(
546
+ (err) => console.error("Unseal failed:", err.message),
547
+ (secrets) => console.log(secrets), // { OPENAI_API_KEY: "sk-..." }
548
+ )
549
+ ```
550
+
463
551
  ### Catalog Resolution API
464
552
 
465
553
  ```typescript
package/dist/cli.js CHANGED
@@ -10,6 +10,7 @@ import { execFileSync } from "node:child_process";
10
10
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
11
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
12
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
13
+ import { createInterface } from "node:readline";
13
14
 
14
15
  //#region src/core/audit.ts
15
16
  const MS_PER_DAY = 864e5;
@@ -31,7 +32,8 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
31
32
  const isExpired = daysRemaining.fold(() => false, (d) => d < 0);
32
33
  const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
33
34
  const isStale = daysSinceCreated.fold(() => false, (d) => d > staleWarningDays);
34
- const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key);
35
+ const hasSealed = !!meta?.encrypted_value;
36
+ const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
35
37
  const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
36
38
  if (isExpired) issues.push("Secret has expired");
37
39
  if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
@@ -131,6 +133,7 @@ const SecretMetaSchema = Type.Object({
131
133
  rate_limit: Type.Optional(Type.String({ description: "Rate limit or quota info (e.g. '1000/min')" })),
132
134
  model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
133
135
  source: Type.Optional(Type.String({ description: "Where the secret value originates (e.g. 'vault', 'ci')" })),
136
+ encrypted_value: Type.Optional(Type.String({ description: "Age-encrypted secret value (armored ciphertext, safe to commit)" })),
134
137
  required: Type.Optional(Type.Boolean({ description: "Whether this secret is required for operation" })),
135
138
  tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" }))
136
139
  }, { description: "Metadata about a single secret" });
@@ -1791,7 +1794,8 @@ const printConfig = (config, path, resolveResult, opts) => {
1791
1794
  for (const [key, meta] of Object.entries(config.meta)) {
1792
1795
  const secretValue = opts?.secrets?.[key];
1793
1796
  const valueSuffix = secretValue !== void 0 ? ` = ${YELLOW}${(opts?.secretDisplay ?? "encrypted") === "plaintext" ? secretValue : maskValue(secretValue)}${RESET}` : "";
1794
- console.log(` ${BOLD}${key}${RESET} ${meta.service ?? key}${valueSuffix}`);
1797
+ const sealedTag = meta.encrypted_value ? ` ${CYAN}[sealed]${RESET}` : "";
1798
+ console.log(` ${BOLD}${key}${RESET} → ${meta.service ?? key}${sealedTag}${valueSuffix}`);
1795
1799
  printSecretMeta(meta, " ");
1796
1800
  }
1797
1801
  if (config.lifecycle) {
@@ -2171,6 +2175,245 @@ const runResolve = (options) => {
2171
2175
  });
2172
2176
  };
2173
2177
 
2178
+ //#endregion
2179
+ //#region src/fnox/cli.ts
2180
+ /** Export all secrets from fnox as key=value pairs for a given profile */
2181
+ const fnoxExport = (profile, agentKey) => {
2182
+ const args = profile ? [
2183
+ "export",
2184
+ "--profile",
2185
+ profile
2186
+ ] : ["export"];
2187
+ const env = agentKey ? {
2188
+ ...process.env,
2189
+ FNOX_AGE_KEY: agentKey
2190
+ } : void 0;
2191
+ return Try(() => execFileSync("fnox", args, {
2192
+ stdio: "pipe",
2193
+ encoding: "utf-8",
2194
+ env
2195
+ })).fold((err) => Left({
2196
+ _tag: "FnoxCliError",
2197
+ message: `fnox export failed: ${err}`
2198
+ }), (output) => {
2199
+ const entries = {};
2200
+ for (const line of output.split("\n")) {
2201
+ const eq = line.indexOf("=");
2202
+ if (eq > 0) {
2203
+ const key = line.slice(0, eq).trim();
2204
+ entries[key] = line.slice(eq + 1).trim();
2205
+ }
2206
+ }
2207
+ return Right(entries);
2208
+ });
2209
+ };
2210
+
2211
+ //#endregion
2212
+ //#region src/core/resolve-values.ts
2213
+ /** Resolve plaintext values for the given keys via cascade: fnox → env → interactive prompt */
2214
+ const resolveValues = async (keys, profile, agentKey) => {
2215
+ const result = {};
2216
+ const remaining = new Set(keys);
2217
+ if (fnoxAvailable()) fnoxExport(profile, agentKey).fold(() => {}, (exported) => {
2218
+ for (const key of [...remaining]) if (key in exported) {
2219
+ result[key] = exported[key];
2220
+ remaining.delete(key);
2221
+ }
2222
+ });
2223
+ for (const key of [...remaining]) {
2224
+ const envValue = process.env[key];
2225
+ if (envValue !== void 0 && envValue !== "") {
2226
+ result[key] = envValue;
2227
+ remaining.delete(key);
2228
+ }
2229
+ }
2230
+ if (remaining.size > 0 && process.stdin.isTTY) {
2231
+ const rl = createInterface({
2232
+ input: process.stdin,
2233
+ output: process.stderr
2234
+ });
2235
+ const prompt = (question) => new Promise((resolve) => {
2236
+ rl.question(question, (answer) => resolve(answer));
2237
+ });
2238
+ for (const key of remaining) {
2239
+ const value = await prompt(`Enter value for ${key}: `);
2240
+ if (value !== "") result[key] = value;
2241
+ }
2242
+ rl.close();
2243
+ }
2244
+ return result;
2245
+ };
2246
+
2247
+ //#endregion
2248
+ //#region src/core/seal.ts
2249
+ /** Encrypt a plaintext string using age with the given recipient public key (armored output) */
2250
+ const ageEncrypt = (plaintext, recipient) => {
2251
+ if (!ageAvailable()) return Left({
2252
+ _tag: "AgeNotFound",
2253
+ message: "age CLI not found on PATH"
2254
+ });
2255
+ return Try(() => execFileSync("age", [
2256
+ "--encrypt",
2257
+ "--recipient",
2258
+ recipient,
2259
+ "--armor"
2260
+ ], {
2261
+ input: plaintext,
2262
+ stdio: [
2263
+ "pipe",
2264
+ "pipe",
2265
+ "pipe"
2266
+ ],
2267
+ encoding: "utf-8"
2268
+ })).fold((err) => Left({
2269
+ _tag: "EncryptFailed",
2270
+ key: "",
2271
+ message: `age encrypt failed: ${err}`
2272
+ }), (output) => Right(output.trim()));
2273
+ };
2274
+ /** Seal multiple secrets: encrypt each value with the recipient key and set encrypted_value on meta */
2275
+ const sealSecrets = (meta, values, recipient) => {
2276
+ if (!ageAvailable()) return Left({
2277
+ _tag: "AgeNotFound",
2278
+ message: "age CLI not found on PATH"
2279
+ });
2280
+ const result = {};
2281
+ for (const [key, secretMeta] of Object.entries(meta)) {
2282
+ const plaintext = values[key];
2283
+ if (plaintext === void 0) {
2284
+ result[key] = secretMeta;
2285
+ continue;
2286
+ }
2287
+ const outcome = ageEncrypt(plaintext, recipient).fold((err) => Left({
2288
+ _tag: "EncryptFailed",
2289
+ key,
2290
+ message: err.message
2291
+ }), (ciphertext) => Right(ciphertext));
2292
+ const failed = outcome.fold((err) => err, () => void 0);
2293
+ if (failed) return Left(failed);
2294
+ const ciphertext = outcome.fold(() => "", (v) => v);
2295
+ result[key] = {
2296
+ ...secretMeta,
2297
+ encrypted_value: ciphertext
2298
+ };
2299
+ }
2300
+ return Right(result);
2301
+ };
2302
+
2303
+ //#endregion
2304
+ //#region src/cli/commands/seal.ts
2305
+ /** Write sealed values back into the TOML file, preserving structure */
2306
+ const writeSealedToml = (configPath, sealedMeta) => {
2307
+ const lines = readFileSync(configPath, "utf-8").split("\n");
2308
+ const output = [];
2309
+ let currentMetaKey;
2310
+ let insideMetaBlock = false;
2311
+ let hasEncryptedValue = false;
2312
+ const pendingSeals = /* @__PURE__ */ new Map();
2313
+ for (const [key, meta] of Object.entries(sealedMeta)) if (meta.encrypted_value) pendingSeals.set(key, meta.encrypted_value);
2314
+ const metaSectionRe = /^\[meta\.(.+)\]\s*$/;
2315
+ const encryptedValueRe = /^encrypted_value\s*=/;
2316
+ const newSectionRe = /^\[/;
2317
+ for (let i = 0; i < lines.length; i++) {
2318
+ const line = lines[i];
2319
+ const metaMatch = metaSectionRe.exec(line);
2320
+ if (metaMatch) {
2321
+ if (currentMetaKey && !hasEncryptedValue && pendingSeals.has(currentMetaKey)) {
2322
+ output.push(`encrypted_value = """`);
2323
+ output.push(pendingSeals.get(currentMetaKey));
2324
+ output.push(`"""`);
2325
+ pendingSeals.delete(currentMetaKey);
2326
+ }
2327
+ currentMetaKey = metaMatch[1];
2328
+ insideMetaBlock = true;
2329
+ hasEncryptedValue = false;
2330
+ output.push(line);
2331
+ continue;
2332
+ }
2333
+ if (insideMetaBlock && newSectionRe.test(line) && !metaSectionRe.test(line)) {
2334
+ if (currentMetaKey && !hasEncryptedValue && pendingSeals.has(currentMetaKey)) {
2335
+ output.push(`encrypted_value = """`);
2336
+ output.push(pendingSeals.get(currentMetaKey));
2337
+ output.push(`"""`);
2338
+ pendingSeals.delete(currentMetaKey);
2339
+ }
2340
+ insideMetaBlock = false;
2341
+ currentMetaKey = void 0;
2342
+ output.push(line);
2343
+ continue;
2344
+ }
2345
+ if (insideMetaBlock && encryptedValueRe.test(line)) {
2346
+ hasEncryptedValue = true;
2347
+ if (currentMetaKey && pendingSeals.has(currentMetaKey)) {
2348
+ output.push(`encrypted_value = """`);
2349
+ output.push(pendingSeals.get(currentMetaKey));
2350
+ output.push(`"""`);
2351
+ pendingSeals.delete(currentMetaKey);
2352
+ if (line.includes("\"\"\"") && !line.endsWith("\"\"\"")) {
2353
+ const afterEquals = line.slice(line.indexOf("=") + 1).trim();
2354
+ if (afterEquals.startsWith("\"\"\"") && !afterEquals.slice(3).includes("\"\"\"")) {
2355
+ while (i + 1 < lines.length && !lines[i + 1].includes("\"\"\"")) i++;
2356
+ if (i + 1 < lines.length) i++;
2357
+ }
2358
+ }
2359
+ } else output.push(line);
2360
+ continue;
2361
+ }
2362
+ output.push(line);
2363
+ }
2364
+ if (currentMetaKey && !hasEncryptedValue && pendingSeals.has(currentMetaKey)) {
2365
+ output.push(`encrypted_value = """`);
2366
+ output.push(pendingSeals.get(currentMetaKey));
2367
+ output.push(`"""`);
2368
+ pendingSeals.delete(currentMetaKey);
2369
+ }
2370
+ writeFileSync(configPath, output.join("\n"));
2371
+ };
2372
+ const runSeal = async (options) => {
2373
+ const configPath = resolveConfigPath(options.config).fold((err) => {
2374
+ console.error(formatError(err));
2375
+ process.exit(2);
2376
+ return "";
2377
+ }, (p) => p);
2378
+ const config = loadConfig(configPath).fold((err) => {
2379
+ console.error(formatError(err));
2380
+ process.exit(2);
2381
+ }, (c) => c);
2382
+ if (!config.agent?.recipient) {
2383
+ console.error(`${RED}Error:${RESET} agent.recipient is required for sealing (age public key)`);
2384
+ console.error(`${DIM}Add [agent] section with recipient = "age1..." to your envpkt.toml${RESET}`);
2385
+ process.exit(2);
2386
+ }
2387
+ const recipient = config.agent.recipient;
2388
+ const configDir = dirname(configPath);
2389
+ let agentKey;
2390
+ if (config.agent.identity) agentKey = unwrapAgentKey(resolve(configDir, config.agent.identity)).fold((err) => {
2391
+ const msg = err._tag === "IdentityNotFound" ? `not found: ${err.path}` : err.message;
2392
+ console.error(`${YELLOW}Warning:${RESET} Could not unwrap agent key: ${msg}`);
2393
+ }, (k) => k);
2394
+ const metaKeys = Object.keys(config.meta);
2395
+ console.log(`${BOLD}Sealing ${metaKeys.length} secret(s)${RESET} with recipient ${CYAN}${recipient.slice(0, 20)}...${RESET}`);
2396
+ console.log("");
2397
+ const values = await resolveValues(metaKeys, options.profile, agentKey);
2398
+ const resolved = Object.keys(values).length;
2399
+ const skipped = metaKeys.length - resolved;
2400
+ if (resolved === 0) {
2401
+ console.error(`${RED}Error:${RESET} No values resolved for any secret key`);
2402
+ process.exit(2);
2403
+ }
2404
+ if (skipped > 0) {
2405
+ const skippedKeys = metaKeys.filter((k) => !(k in values));
2406
+ console.log(`${YELLOW}Skipped${RESET} ${skipped} key(s) with no value: ${skippedKeys.join(", ")}`);
2407
+ }
2408
+ sealSecrets(config.meta, values, recipient).fold((err) => {
2409
+ console.error(`${RED}Error:${RESET} Seal failed: ${err.message}`);
2410
+ process.exit(2);
2411
+ }, (sealedMeta) => {
2412
+ writeSealedToml(configPath, sealedMeta);
2413
+ console.log(`${GREEN}Sealed${RESET} ${resolved} secret(s) into ${DIM}${configPath}${RESET}`);
2414
+ });
2415
+ };
2416
+
2174
2417
  //#endregion
2175
2418
  //#region src/cli/commands/shell-hook.ts
2176
2419
  const ZSH_HOOK = `# envpkt shell hook — add to your .zshrc
@@ -2235,6 +2478,9 @@ program.command("exec").description("Run pre-flight audit then execute a command
2235
2478
  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) => {
2236
2479
  runResolve(options);
2237
2480
  });
2481
+ program.command("seal").description("Encrypt secret values into envpkt.toml using age (sealed packets)").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use for value resolution").action(async (options) => {
2482
+ await runSeal(options);
2483
+ });
2238
2484
  program.command("mcp").description("Start the envpkt MCP server (stdio transport)").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
2239
2485
  runMcp(options);
2240
2486
  });
package/dist/index.d.ts CHANGED
@@ -30,6 +30,7 @@ declare const SecretMetaSchema: _sinclair_typebox0.TObject<{
30
30
  rate_limit: _sinclair_typebox0.TOptional<_sinclair_typebox0.TString>;
31
31
  model_hint: _sinclair_typebox0.TOptional<_sinclair_typebox0.TString>;
32
32
  source: _sinclair_typebox0.TOptional<_sinclair_typebox0.TString>;
33
+ encrypted_value: _sinclair_typebox0.TOptional<_sinclair_typebox0.TString>;
33
34
  required: _sinclair_typebox0.TOptional<_sinclair_typebox0.TBoolean>;
34
35
  tags: _sinclair_typebox0.TOptional<_sinclair_typebox0.TRecord<_sinclair_typebox0.TString, _sinclair_typebox0.TString>>;
35
36
  }>;
@@ -73,6 +74,7 @@ declare const EnvpktConfigSchema: _sinclair_typebox0.TObject<{
73
74
  rate_limit: _sinclair_typebox0.TOptional<_sinclair_typebox0.TString>;
74
75
  model_hint: _sinclair_typebox0.TOptional<_sinclair_typebox0.TString>;
75
76
  source: _sinclair_typebox0.TOptional<_sinclair_typebox0.TString>;
77
+ encrypted_value: _sinclair_typebox0.TOptional<_sinclair_typebox0.TString>;
76
78
  required: _sinclair_typebox0.TOptional<_sinclair_typebox0.TBoolean>;
77
79
  tags: _sinclair_typebox0.TOptional<_sinclair_typebox0.TRecord<_sinclair_typebox0.TString, _sinclair_typebox0.TString>>;
78
80
  }>>;
@@ -216,6 +218,21 @@ type IdentityError = {
216
218
  readonly _tag: "IdentityNotFound";
217
219
  readonly path: string;
218
220
  };
221
+ type SealError = {
222
+ readonly _tag: "AgeNotFound";
223
+ readonly message: string;
224
+ } | {
225
+ readonly _tag: "EncryptFailed";
226
+ readonly key: string;
227
+ readonly message: string;
228
+ } | {
229
+ readonly _tag: "DecryptFailed";
230
+ readonly key: string;
231
+ readonly message: string;
232
+ } | {
233
+ readonly _tag: "NoRecipient";
234
+ readonly message: string;
235
+ };
219
236
  //#endregion
220
237
  //#region src/core/config.d.ts
221
238
  /** Find envpkt.toml in the given directory */
@@ -332,6 +349,20 @@ declare class EnvpktBootError extends Error {
332
349
  constructor(error: BootError);
333
350
  }
334
351
  //#endregion
352
+ //#region src/core/seal.d.ts
353
+ /** Encrypt a plaintext string using age with the given recipient public key (armored output) */
354
+ declare const ageEncrypt: (plaintext: string, recipient: string) => Either<SealError, string>;
355
+ /** Decrypt an age-armored ciphertext using the given identity file */
356
+ declare const ageDecrypt: (ciphertext: string, identityPath: string) => Either<SealError, string>;
357
+ /** Seal multiple secrets: encrypt each value with the recipient key and set encrypted_value on meta */
358
+ declare const sealSecrets: (meta: Readonly<Record<string, SecretMeta>>, values: Readonly<Record<string, string>>, recipient: string) => Either<SealError, Record<string, SecretMeta>>;
359
+ /** Unseal secrets: decrypt encrypted_value for each meta entry that has one */
360
+ declare const unsealSecrets: (meta: Readonly<Record<string, SecretMeta>>, identityPath: string) => Either<SealError, Record<string, string>>;
361
+ //#endregion
362
+ //#region src/core/resolve-values.d.ts
363
+ /** Resolve plaintext values for the given keys via cascade: fnox → env → interactive prompt */
364
+ declare const resolveValues: (keys: ReadonlyArray<string>, profile?: string, agentKey?: string) => Promise<Record<string, string>>;
365
+ //#endregion
335
366
  //#region src/core/fleet.d.ts
336
367
  declare const scanFleet: (rootDir: string, options?: {
337
368
  maxDepth?: number;
@@ -389,4 +420,4 @@ type ToolDef = {
389
420
  declare const toolDefinitions: readonly ToolDef[];
390
421
  declare const callTool: (name: string, args: Record<string, unknown>) => CallToolResult;
391
422
  //#endregion
392
- export { type AgentIdentity, AgentIdentitySchema, type AuditResult, type BootError, type BootOptions, type BootResult, type CallbackConfig, CallbackConfigSchema, type CatalogError, type CheckResult, type ConfidenceLevel, type ConfigError, type ConsumerType, type CredentialPattern, type DriftEntry, type DriftStatus, EnvpktBootError, type EnvpktConfig, EnvpktConfigSchema, type FleetAgent, type FleetHealth, type FnoxConfig, type FnoxError, type FnoxSecret, type FormatPacketOptions, type HealthStatus, type IdentityError, type LifecycleConfig, LifecycleConfigSchema, type MatchResult, type ResolveOptions, type ResolveResult, type ScanOptions, type ScanResult, type SecretDisplay, type SecretHealth, type SecretMeta, SecretMetaSchema, type SecretStatus, type ToolsConfig, ToolsConfigSchema, ageAvailable, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, createServer, deriveServiceFromName, detectFnox, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatPacket, generateTomlFromScan, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, readConfigFile, readFnoxConfig, readResource, resolveConfig, resolveConfigPath, resolveSecrets, resourceDefinitions, scanEnv, scanFleet, startServer, toolDefinitions, unwrapAgentKey, validateConfig };
423
+ export { type AgentIdentity, AgentIdentitySchema, type AuditResult, type BootError, type BootOptions, type BootResult, type CallbackConfig, CallbackConfigSchema, type CatalogError, type CheckResult, type ConfidenceLevel, type ConfigError, type ConsumerType, type CredentialPattern, type DriftEntry, type DriftStatus, EnvpktBootError, type EnvpktConfig, EnvpktConfigSchema, type FleetAgent, type FleetHealth, type FnoxConfig, type FnoxError, type FnoxSecret, type FormatPacketOptions, type HealthStatus, type IdentityError, type LifecycleConfig, LifecycleConfigSchema, type MatchResult, type ResolveOptions, type ResolveResult, type ScanOptions, type ScanResult, type SealError, type SecretDisplay, type SecretHealth, type SecretMeta, SecretMetaSchema, type SecretStatus, type ToolsConfig, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, createServer, deriveServiceFromName, detectFnox, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatPacket, generateTomlFromScan, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, readConfigFile, readFnoxConfig, readResource, resolveConfig, resolveConfigPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, validateConfig };
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { TypeCompiler } from "@sinclair/typebox/compiler";
5
5
  import { Cond, Left, List, Option, Right, Try } from "functype";
6
6
  import { TomlDate, parse } from "smol-toml";
7
7
  import { execFileSync } from "node:child_process";
8
+ import { createInterface } from "node:readline";
8
9
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
11
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
@@ -54,6 +55,7 @@ const SecretMetaSchema = Type.Object({
54
55
  rate_limit: Type.Optional(Type.String({ description: "Rate limit or quota info (e.g. '1000/min')" })),
55
56
  model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
56
57
  source: Type.Optional(Type.String({ description: "Where the secret value originates (e.g. 'vault', 'ci')" })),
58
+ encrypted_value: Type.Optional(Type.String({ description: "Age-encrypted secret value (armored ciphertext, safe to commit)" })),
57
59
  required: Type.Optional(Type.Boolean({ description: "Whether this secret is required for operation" })),
58
60
  tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" }))
59
61
  }, { description: "Metadata about a single secret" });
@@ -311,8 +313,9 @@ const formatPacket = (result, options) => {
311
313
  const secretHeader = `secrets: ${metaEntries.length}`;
312
314
  const secretLines = metaEntries.map(([key, meta]) => {
313
315
  const service = meta.service ?? key;
316
+ const sealedTag = meta.encrypted_value ? " [sealed]" : "";
314
317
  const secretValue = options?.secrets?.[key];
315
- const header = ` ${key} → ${service}${secretValue !== void 0 ? ` = ${(options?.secretDisplay ?? "encrypted") === "plaintext" ? secretValue : maskValue(secretValue)}` : ""}`;
318
+ const header = ` ${key} → ${service}${sealedTag}${secretValue !== void 0 ? ` = ${(options?.secretDisplay ?? "encrypted") === "plaintext" ? secretValue : maskValue(secretValue)}` : ""}`;
316
319
  const fields = formatSecretFields(meta, " ");
317
320
  return fields ? `${header}\n${fields}` : header;
318
321
  });
@@ -356,7 +359,8 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
356
359
  const isExpired = daysRemaining.fold(() => false, (d) => d < 0);
357
360
  const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
358
361
  const isStale = daysSinceCreated.fold(() => false, (d) => d > staleWarningDays);
359
- const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key);
362
+ const hasSealed = !!meta?.encrypted_value;
363
+ const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
360
364
  const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
361
365
  if (isExpired) issues.push("Secret has expired");
362
366
  if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
@@ -1304,6 +1308,106 @@ const readFnoxConfig = (path) => Try(() => readFileSync(path, "utf-8")).fold((er
1304
1308
  /** Extract the set of secret key names from a parsed fnox config */
1305
1309
  const extractFnoxKeys = (config) => new Set(Object.keys(config.secrets));
1306
1310
 
1311
+ //#endregion
1312
+ //#region src/core/seal.ts
1313
+ /** Encrypt a plaintext string using age with the given recipient public key (armored output) */
1314
+ const ageEncrypt = (plaintext, recipient) => {
1315
+ if (!ageAvailable()) return Left({
1316
+ _tag: "AgeNotFound",
1317
+ message: "age CLI not found on PATH"
1318
+ });
1319
+ return Try(() => execFileSync("age", [
1320
+ "--encrypt",
1321
+ "--recipient",
1322
+ recipient,
1323
+ "--armor"
1324
+ ], {
1325
+ input: plaintext,
1326
+ stdio: [
1327
+ "pipe",
1328
+ "pipe",
1329
+ "pipe"
1330
+ ],
1331
+ encoding: "utf-8"
1332
+ })).fold((err) => Left({
1333
+ _tag: "EncryptFailed",
1334
+ key: "",
1335
+ message: `age encrypt failed: ${err}`
1336
+ }), (output) => Right(output.trim()));
1337
+ };
1338
+ /** Decrypt an age-armored ciphertext using the given identity file */
1339
+ const ageDecrypt = (ciphertext, identityPath) => {
1340
+ if (!ageAvailable()) return Left({
1341
+ _tag: "AgeNotFound",
1342
+ message: "age CLI not found on PATH"
1343
+ });
1344
+ return Try(() => execFileSync("age", [
1345
+ "--decrypt",
1346
+ "--identity",
1347
+ identityPath
1348
+ ], {
1349
+ input: ciphertext,
1350
+ stdio: [
1351
+ "pipe",
1352
+ "pipe",
1353
+ "pipe"
1354
+ ],
1355
+ encoding: "utf-8"
1356
+ })).fold((err) => Left({
1357
+ _tag: "DecryptFailed",
1358
+ key: "",
1359
+ message: `age decrypt failed: ${err}`
1360
+ }), (output) => Right(output.trim()));
1361
+ };
1362
+ /** Seal multiple secrets: encrypt each value with the recipient key and set encrypted_value on meta */
1363
+ const sealSecrets = (meta, values, recipient) => {
1364
+ if (!ageAvailable()) return Left({
1365
+ _tag: "AgeNotFound",
1366
+ message: "age CLI not found on PATH"
1367
+ });
1368
+ const result = {};
1369
+ for (const [key, secretMeta] of Object.entries(meta)) {
1370
+ const plaintext = values[key];
1371
+ if (plaintext === void 0) {
1372
+ result[key] = secretMeta;
1373
+ continue;
1374
+ }
1375
+ const outcome = ageEncrypt(plaintext, recipient).fold((err) => Left({
1376
+ _tag: "EncryptFailed",
1377
+ key,
1378
+ message: err.message
1379
+ }), (ciphertext) => Right(ciphertext));
1380
+ const failed = outcome.fold((err) => err, () => void 0);
1381
+ if (failed) return Left(failed);
1382
+ const ciphertext = outcome.fold(() => "", (v) => v);
1383
+ result[key] = {
1384
+ ...secretMeta,
1385
+ encrypted_value: ciphertext
1386
+ };
1387
+ }
1388
+ return Right(result);
1389
+ };
1390
+ /** Unseal secrets: decrypt encrypted_value for each meta entry that has one */
1391
+ const unsealSecrets = (meta, identityPath) => {
1392
+ if (!ageAvailable()) return Left({
1393
+ _tag: "AgeNotFound",
1394
+ message: "age CLI not found on PATH"
1395
+ });
1396
+ const result = {};
1397
+ for (const [key, secretMeta] of Object.entries(meta)) {
1398
+ if (!secretMeta.encrypted_value) continue;
1399
+ const outcome = ageDecrypt(secretMeta.encrypted_value, identityPath).fold((err) => Left({
1400
+ _tag: "DecryptFailed",
1401
+ key,
1402
+ message: err.message
1403
+ }), (plaintext) => Right(plaintext));
1404
+ const failed = outcome.fold((err) => err, () => void 0);
1405
+ if (failed) return Left(failed);
1406
+ result[key] = outcome.fold(() => "", (v) => v);
1407
+ }
1408
+ return Right(result);
1409
+ };
1410
+
1307
1411
  //#endregion
1308
1412
  //#region src/core/boot.ts
1309
1413
  const resolveAndLoad = (opts) => resolveConfigPath(opts.configPath).fold((err) => Left(err), (configPath) => loadConfig(configPath).fold((err) => Left(err), (config) => {
@@ -1335,25 +1439,44 @@ const bootSafe = (options) => {
1335
1439
  const inject = opts.inject !== false;
1336
1440
  const failOnExpired = opts.failOnExpired !== false;
1337
1441
  const warnOnly = opts.warnOnly ?? false;
1338
- return resolveAndLoad(opts).flatMap(({ config, configDir }) => resolveAgentKey(config, configDir).flatMap((agentKey) => {
1442
+ return resolveAndLoad(opts).flatMap(({ config, configDir }) => {
1443
+ const metaKeys = Object.keys(config.meta);
1444
+ const hasSealedValues = metaKeys.some((k) => !!config.meta[k]?.encrypted_value);
1445
+ const agentKeyResult = resolveAgentKey(config, configDir);
1446
+ const agentKey = agentKeyResult.fold(() => void 0, (k) => k);
1447
+ const agentKeyError = agentKeyResult.fold((err) => err, () => void 0);
1448
+ if (agentKeyError && !hasSealedValues) return Left(agentKeyError);
1339
1449
  const audit = computeAudit(config, detectFnoxKeys(configDir));
1340
1450
  return checkExpiration(audit, failOnExpired, warnOnly).map((warnings) => {
1341
1451
  const secrets = {};
1342
1452
  const injected = [];
1343
1453
  const skipped = [];
1344
- const metaKeys = Object.keys(config.meta);
1345
- if (fnoxAvailable()) fnoxExport(opts.profile, agentKey).fold((err) => {
1454
+ const sealedKeys = /* @__PURE__ */ new Set();
1455
+ if (hasSealedValues && config.agent?.identity) {
1456
+ const identityPath = resolve(configDir, config.agent.identity);
1457
+ unsealSecrets(config.meta, identityPath).fold((err) => {
1458
+ warnings.push(`Sealed value decryption failed: ${err.message}`);
1459
+ }, (unsealed) => {
1460
+ for (const [key, value] of Object.entries(unsealed)) {
1461
+ secrets[key] = value;
1462
+ injected.push(key);
1463
+ sealedKeys.add(key);
1464
+ }
1465
+ });
1466
+ }
1467
+ const remainingKeys = metaKeys.filter((k) => !sealedKeys.has(k));
1468
+ if (remainingKeys.length > 0) if (fnoxAvailable()) fnoxExport(opts.profile, agentKey).fold((err) => {
1346
1469
  warnings.push(`fnox export failed: ${err.message}`);
1347
- for (const key of metaKeys) skipped.push(key);
1470
+ for (const key of remainingKeys) skipped.push(key);
1348
1471
  }, (exported) => {
1349
- for (const key of metaKeys) if (key in exported) {
1472
+ for (const key of remainingKeys) if (key in exported) {
1350
1473
  secrets[key] = exported[key];
1351
1474
  injected.push(key);
1352
1475
  } else skipped.push(key);
1353
1476
  });
1354
1477
  else {
1355
- warnings.push("fnox not available — no secrets injected");
1356
- for (const key of metaKeys) skipped.push(key);
1478
+ if (!hasSealedValues) warnings.push("fnox not available — no secrets injected");
1479
+ for (const key of remainingKeys) skipped.push(key);
1357
1480
  }
1358
1481
  if (inject) for (const [key, value] of Object.entries(secrets)) process.env[key] = value;
1359
1482
  return {
@@ -1364,7 +1487,7 @@ const bootSafe = (options) => {
1364
1487
  warnings
1365
1488
  };
1366
1489
  });
1367
- }));
1490
+ });
1368
1491
  };
1369
1492
  /** Programmatic boot — throws EnvpktBootError on failure */
1370
1493
  const boot = (options) => bootSafe(options).fold((err) => {
@@ -1400,6 +1523,42 @@ const formatBootError = (error) => {
1400
1523
  }
1401
1524
  };
1402
1525
 
1526
+ //#endregion
1527
+ //#region src/core/resolve-values.ts
1528
+ /** Resolve plaintext values for the given keys via cascade: fnox → env → interactive prompt */
1529
+ const resolveValues = async (keys, profile, agentKey) => {
1530
+ const result = {};
1531
+ const remaining = new Set(keys);
1532
+ if (fnoxAvailable()) fnoxExport(profile, agentKey).fold(() => {}, (exported) => {
1533
+ for (const key of [...remaining]) if (key in exported) {
1534
+ result[key] = exported[key];
1535
+ remaining.delete(key);
1536
+ }
1537
+ });
1538
+ for (const key of [...remaining]) {
1539
+ const envValue = process.env[key];
1540
+ if (envValue !== void 0 && envValue !== "") {
1541
+ result[key] = envValue;
1542
+ remaining.delete(key);
1543
+ }
1544
+ }
1545
+ if (remaining.size > 0 && process.stdin.isTTY) {
1546
+ const rl = createInterface({
1547
+ input: process.stdin,
1548
+ output: process.stderr
1549
+ });
1550
+ const prompt = (question) => new Promise((resolve) => {
1551
+ rl.question(question, (answer) => resolve(answer));
1552
+ });
1553
+ for (const key of remaining) {
1554
+ const value = await prompt(`Enter value for ${key}: `);
1555
+ if (value !== "") result[key] = value;
1556
+ }
1557
+ rl.close();
1558
+ }
1559
+ return result;
1560
+ };
1561
+
1403
1562
  //#endregion
1404
1563
  //#region src/core/fleet.ts
1405
1564
  const CONFIG_FILENAME = "envpkt.toml";
@@ -1766,4 +1925,4 @@ const startServer = async () => {
1766
1925
  };
1767
1926
 
1768
1927
  //#endregion
1769
- export { AgentIdentitySchema, CallbackConfigSchema, ConsumerType, EnvpktBootError, EnvpktConfigSchema, LifecycleConfigSchema, SecretMetaSchema, ToolsConfigSchema, ageAvailable, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, createServer, deriveServiceFromName, detectFnox, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatPacket, generateTomlFromScan, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, readConfigFile, readFnoxConfig, readResource, resolveConfig, resolveConfigPath, resolveSecrets, resourceDefinitions, scanEnv, scanFleet, startServer, toolDefinitions, unwrapAgentKey, validateConfig };
1928
+ export { AgentIdentitySchema, CallbackConfigSchema, ConsumerType, EnvpktBootError, EnvpktConfigSchema, LifecycleConfigSchema, SecretMetaSchema, ToolsConfigSchema, ageAvailable, ageDecrypt, ageEncrypt, boot, bootSafe, callTool, compareFnoxAndEnvpkt, computeAudit, createServer, deriveServiceFromName, detectFnox, envCheck, envScan, extractFnoxKeys, findConfigPath, fnoxAvailable, fnoxExport, fnoxGet, formatPacket, generateTomlFromScan, loadCatalog, loadConfig, loadConfigFromCwd, maskValue, matchEnvVar, matchValueShape, parseToml, readConfigFile, readFnoxConfig, readResource, resolveConfig, resolveConfigPath, resolveSecrets, resolveValues, resourceDefinitions, scanEnv, scanFleet, sealSecrets, startServer, toolDefinitions, unsealSecrets, unwrapAgentKey, validateConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envpkt",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Credential lifecycle and fleet management for AI agents",
5
5
  "keywords": [
6
6
  "credentials",
@@ -20,6 +20,24 @@
20
20
  "bin": {
21
21
  "envpkt": "dist/cli.js"
22
22
  },
23
+ "scripts": {
24
+ "validate": "ts-builds validate",
25
+ "format": "ts-builds format",
26
+ "format:check": "ts-builds format:check",
27
+ "lint": "ts-builds lint",
28
+ "lint:check": "ts-builds lint:check",
29
+ "typecheck": "ts-builds typecheck",
30
+ "test": "ts-builds test",
31
+ "test:watch": "ts-builds test:watch",
32
+ "test:coverage": "ts-builds test:coverage",
33
+ "build": "ts-builds build",
34
+ "build:schema": "tsx scripts/build-schema.ts",
35
+ "demo": "tsx scripts/generate-demo-html.ts",
36
+ "dev": "ts-builds dev",
37
+ "docs:dev": "pnpm --dir site dev",
38
+ "docs:build": "pnpm --dir site build",
39
+ "prepublishOnly": "pnpm validate"
40
+ },
23
41
  "dependencies": {
24
42
  "@modelcontextprotocol/sdk": "^1.27.1",
25
43
  "@sinclair/typebox": "^0.34.48",
@@ -28,7 +46,7 @@
28
46
  "smol-toml": "^1.6.0"
29
47
  },
30
48
  "devDependencies": {
31
- "@types/node": "^24.10.15",
49
+ "@types/node": "^24.11.0",
32
50
  "ts-builds": "^2.5.0",
33
51
  "tsdown": "^0.20.3",
34
52
  "tsx": "^4.21.0"
@@ -51,21 +69,5 @@
51
69
  "schemas"
52
70
  ],
53
71
  "prettier": "ts-builds/prettier",
54
- "scripts": {
55
- "validate": "ts-builds validate",
56
- "format": "ts-builds format",
57
- "format:check": "ts-builds format:check",
58
- "lint": "ts-builds lint",
59
- "lint:check": "ts-builds lint:check",
60
- "typecheck": "ts-builds typecheck",
61
- "test": "ts-builds test",
62
- "test:watch": "ts-builds test:watch",
63
- "test:coverage": "ts-builds test:coverage",
64
- "build": "ts-builds build",
65
- "build:schema": "tsx scripts/build-schema.ts",
66
- "demo": "tsx scripts/generate-demo-html.ts",
67
- "dev": "ts-builds dev",
68
- "docs:dev": "pnpm --dir site dev",
69
- "docs:build": "pnpm --dir site build"
70
- }
71
- }
72
+ "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017"
73
+ }
@@ -144,6 +144,10 @@
144
144
  "description": "Where the secret value originates (e.g. 'vault', 'ci')",
145
145
  "type": "string"
146
146
  },
147
+ "encrypted_value": {
148
+ "description": "Age-encrypted secret value (armored ciphertext, safe to commit)",
149
+ "type": "string"
150
+ },
147
151
  "required": {
148
152
  "description": "Whether this secret is required for operation",
149
153
  "type": "boolean"