@seekrit/cli 1.0.0 → 1.1.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.
Files changed (2) hide show
  1. package/dist/index.js +862 -239
  2. package/package.json +5 -3
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from "node:child_process";
3
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { z } from "zod";
5
5
  import { Command } from "commander";
6
6
  import { arch, homedir, hostname, platform, tmpdir, userInfo } from "node:os";
@@ -8,6 +8,7 @@ import { dirname, join, parse, resolve } from "node:path";
8
8
  import { createInterface } from "node:readline";
9
9
  import { Writable } from "node:stream";
10
10
  import { createHash } from "node:crypto";
11
+ import { fileURLToPath } from "node:url";
11
12
  /** Default lifetime of a published bundle (7 days), in seconds. */
12
13
  const POLICY_DEFAULT_TTL_SECONDS = 10080 * 60;
13
14
  /** Bounds on a bundle's lifetime: an hour at the short end, 90 days at the long. */
@@ -4986,7 +4987,7 @@ async function createAgentTaskToken() {
4986
4987
  }
4987
4988
  //#endregion
4988
4989
  //#region package.json
4989
- var version = "1.0.0";
4990
+ var version = "1.1.0";
4990
4991
  //#endregion
4991
4992
  //#region ../../packages/api-client/src/index.ts
4992
4993
  var SeekritApiError = class extends Error {
@@ -9584,6 +9585,224 @@ function registerGroupCommands(program) {
9584
9585
  });
9585
9586
  }
9586
9587
  //#endregion
9588
+ //#region src/hermes.ts
9589
+ /**
9590
+ * `seekrit hermes` — wire seekrit into [Hermes Agent](https://hermes-agent.nousresearch.com)
9591
+ * as a **secret source**.
9592
+ *
9593
+ * Hermes reads credentials from `~/.hermes/.env` and the process environment, and
9594
+ * a *secret source* is the documented way to fill that environment from
9595
+ * somewhere else at startup. Implementing one is Python, and it lives in the
9596
+ * Python SDK (`seekrit.hermes`) rather than here — a `SecretSource` subclass and
9597
+ * the `register(ctx)` hook Hermes calls. This command exists for the two parts
9598
+ * that are not Python: the `config.yaml` block, and the plugin directory for a
9599
+ * Hermes install that cannot see the SDK's entry point.
9600
+ *
9601
+ * **`config.yaml` is printed, not written.** It is YAML that belongs to
9602
+ * somebody's agent, with comments and anchors this CLI carries no parser for;
9603
+ * a rewrite through a JSON round trip would delete them. The plugin directory
9604
+ * *is* written, because those two files are entirely ours.
9605
+ *
9606
+ * There are two sources, and which one to use is a real choice rather than a
9607
+ * default. `seekrit` is **bulk**: one environment, whole, nothing to enumerate.
9608
+ * `seekrit_refs` is **mapped**: explicit `VAR: skt://NAME` bindings, which is
9609
+ * what you need to rename a secret, read more than one environment, or win a
9610
+ * contested variable — Hermes lets a mapped claim beat a bulk one.
9611
+ */
9612
+ /** The two source names `seekrit.hermes` registers. */
9613
+ const BULK_SOURCE = "seekrit";
9614
+ const MAPPED_SOURCE = "seekrit_refs";
9615
+ /** Hermes' home: `$HERMES_HOME`, else `~/.hermes`. */
9616
+ function hermesHome(env = process.env) {
9617
+ const home = env.HERMES_HOME?.trim();
9618
+ return home && home.length > 0 ? resolve(home) : join(homedir(), ".hermes");
9619
+ }
9620
+ const NAME_RE$1 = /^[A-Za-z_][A-Za-z0-9_]*$/;
9621
+ /**
9622
+ * Build an `skt://` reference.
9623
+ *
9624
+ * A service token is bound to one environment, so a reference names a *token*
9625
+ * (by the alias its `tokens:` map gives it) and a secret in that token's
9626
+ * environment — not an arbitrary app and env, which nothing on the read path
9627
+ * could resolve.
9628
+ */
9629
+ function reference(name, tokenAlias) {
9630
+ return `skt://${tokenAlias ? `${tokenAlias}/` : ""}${name}`;
9631
+ }
9632
+ /**
9633
+ * The `secrets:` block for `~/.hermes/config.yaml`.
9634
+ *
9635
+ * Hand-rolled rather than serialized, because the shape is fixed and small and
9636
+ * the output is meant to be *read* — a generic emitter would quote keys and
9637
+ * flow-fold lists in ways that make a config someone has to paste look foreign.
9638
+ */
9639
+ function configBlock(options = {}) {
9640
+ const source = options.mapped ? MAPPED_SOURCE : BULK_SOURCE;
9641
+ const lines = [
9642
+ "secrets:",
9643
+ ` sources: [${source}]`,
9644
+ ` ${source}:`,
9645
+ " enabled: true"
9646
+ ];
9647
+ if (options.tokenEnv && options.tokenEnv !== "SEEKRIT_TOKEN") lines.push(` token_env: ${options.tokenEnv}`);
9648
+ if (options.mapped) {
9649
+ const tokens = Object.entries(options.tokens ?? {});
9650
+ if (tokens.length > 0) {
9651
+ lines.push(" tokens:");
9652
+ for (const [alias, variable] of tokens) lines.push(` ${alias}: ${variable}`);
9653
+ }
9654
+ lines.push(" env:");
9655
+ const bindings = options.bindings ?? [];
9656
+ if (bindings.length === 0) lines.push(` OPENAI_API_KEY: ${reference("OPENAI_API_KEY")}`);
9657
+ else for (const b of bindings) lines.push(` ${b.variable}: ${reference(b.name, b.tokenAlias)}`);
9658
+ }
9659
+ return `${lines.join("\n")}\n`;
9660
+ }
9661
+ /** `plugin.yaml` for the directory-install path. */
9662
+ function pluginYaml() {
9663
+ return [
9664
+ "name: seekrit",
9665
+ "description: >-",
9666
+ " Resolve Hermes provider credentials from seekrit — end-to-end encrypted",
9667
+ " secrets, decrypted in this process by your own service token.",
9668
+ ""
9669
+ ].join("\n");
9670
+ }
9671
+ /**
9672
+ * `__init__.py` for the directory-install path.
9673
+ *
9674
+ * Re-exporting `register` is the whole file: the implementation lives in the
9675
+ * published SDK, so a plugin scaffolded once keeps up with SDK releases instead
9676
+ * of pinning a copy of the resolver into somebody's home directory.
9677
+ */
9678
+ function pluginInit() {
9679
+ return [
9680
+ "\"\"\"seekrit secret sources for Hermes Agent.",
9681
+ "",
9682
+ "Hermes calls `register(ctx)` from this module. The implementation lives in",
9683
+ "the `seekrit` package (`pip install seekrit`), so this file stays a re-export",
9684
+ "and never has to be regenerated.",
9685
+ "\"\"\"",
9686
+ "",
9687
+ "from seekrit.hermes import register # noqa: F401",
9688
+ ""
9689
+ ].join("\n");
9690
+ }
9691
+ /**
9692
+ * Parse a `VAR=NAME` or `VAR=alias/NAME` binding for `--bind`.
9693
+ *
9694
+ * The left side is the environment variable Hermes will set; the right side is
9695
+ * the seekrit secret it comes from. They differ often enough — that is most of
9696
+ * why the mapped source exists — that inferring one from the other would be
9697
+ * wrong more than it was convenient.
9698
+ */
9699
+ function parseBinding(raw) {
9700
+ const eq = raw.indexOf("=");
9701
+ if (eq <= 0) return void 0;
9702
+ const variable = raw.slice(0, eq).trim();
9703
+ const target = raw.slice(eq + 1).trim();
9704
+ if (!NAME_RE$1.test(variable) || target.length === 0) return void 0;
9705
+ const slash = target.indexOf("/");
9706
+ if (slash === -1) return NAME_RE$1.test(target) ? {
9707
+ variable,
9708
+ name: target
9709
+ } : void 0;
9710
+ const tokenAlias = target.slice(0, slash).trim();
9711
+ const name = target.slice(slash + 1).trim();
9712
+ if (tokenAlias.length === 0 || !NAME_RE$1.test(name)) return void 0;
9713
+ return {
9714
+ variable,
9715
+ name,
9716
+ tokenAlias
9717
+ };
9718
+ }
9719
+ /** Parse a `--token alias=VAR` pair. */
9720
+ function parseTokenAlias(raw) {
9721
+ const eq = raw.indexOf("=");
9722
+ if (eq <= 0) return void 0;
9723
+ const alias = raw.slice(0, eq).trim();
9724
+ const variable = raw.slice(eq + 1).trim();
9725
+ if (alias.length === 0 || !NAME_RE$1.test(variable)) return void 0;
9726
+ return {
9727
+ alias,
9728
+ variable
9729
+ };
9730
+ }
9731
+ function registerHermesCommands(program) {
9732
+ const hermes = program.command("hermes").description("wire seekrit into a Hermes Agent as a secret source (`seekrit hermes --help`)");
9733
+ hermes.command("init").description("print the Hermes config that resolves credentials from seekrit").option("--mapped", `use the ${MAPPED_SOURCE} source (explicit VAR -> secret bindings)`).option("--token-env <var>", "variable holding the service token", "SEEKRIT_TOKEN").option("--bind <VAR=NAME...>", "a mapped binding, e.g. OPENAI_API_KEY=OPENAI_KEY").option("--token <alias=VAR...>", "name a second token, e.g. billing=SEEKRIT_TOKEN_BILLING").option("--plugin-dir", "also scaffold $HERMES_HOME/plugins/seekrit (for a pip-less install)").option("--home <path>", "Hermes home (default: $HERMES_HOME or ~/.hermes)").option("--json", "machine-readable output").action((options) => {
9734
+ const bindings = (options.bind ?? []).map((raw) => {
9735
+ const parsed = parseBinding(raw);
9736
+ if (!parsed) fail(`--bind must be VAR=NAME or VAR=alias/NAME (got "${raw}")`);
9737
+ return parsed;
9738
+ });
9739
+ const tokens = {};
9740
+ for (const raw of options.token ?? []) {
9741
+ const parsed = parseTokenAlias(raw);
9742
+ if (!parsed) fail(`--token must be alias=VARIABLE (got "${raw}")`);
9743
+ tokens[parsed.alias] = parsed.variable;
9744
+ }
9745
+ for (const binding of bindings) if (binding.tokenAlias && !tokens[binding.tokenAlias]) fail(`--bind ${binding.variable} names token alias "${binding.tokenAlias}", so pass --token ${binding.tokenAlias}=<VARIABLE> too`);
9746
+ if (!options.mapped && (bindings.length > 0 || Object.keys(tokens).length > 0)) fail("--bind and --token describe the mapped source: pass --mapped as well");
9747
+ const source = options.mapped ? MAPPED_SOURCE : BULK_SOURCE;
9748
+ const block = configBlock({
9749
+ mapped: options.mapped,
9750
+ tokenEnv: options.tokenEnv,
9751
+ bindings,
9752
+ tokens
9753
+ });
9754
+ const home = options.home ? resolve(options.home) : hermesHome();
9755
+ const pluginDir = join(home, "plugins", "seekrit");
9756
+ const written = [];
9757
+ if (options.pluginDir) {
9758
+ mkdirSync(pluginDir, { recursive: true });
9759
+ const files = [["plugin.yaml", pluginYaml()], ["__init__.py", pluginInit()]];
9760
+ for (const [name, contents] of files) {
9761
+ const path = join(pluginDir, name);
9762
+ writeFileSync(path, contents);
9763
+ written.push(path);
9764
+ }
9765
+ }
9766
+ emit(options, {
9767
+ source,
9768
+ configPath: join(home, "config.yaml"),
9769
+ block,
9770
+ written
9771
+ }, () => {
9772
+ section("hermes secret source");
9773
+ printFields([
9774
+ ["source", source],
9775
+ ["shape", options.mapped ? "mapped (explicit bindings)" : "bulk (whole environment)"],
9776
+ ["config", join(home, "config.yaml")],
9777
+ ["plugin dir", options.pluginDir ? pluginDir : "not scaffolded"]
9778
+ ]);
9779
+ console.log();
9780
+ console.log("1. Install the SDK into the environment Hermes runs in:");
9781
+ console.log(" pip install seekrit");
9782
+ console.log();
9783
+ console.log("2. Put the service token in ~/.hermes/.env (not config.yaml):");
9784
+ console.log(` ${options.tokenEnv ?? "SEEKRIT_TOKEN"}=skt_...`);
9785
+ console.log();
9786
+ console.log("3. Enable the plugin and merge this into ~/.hermes/config.yaml:");
9787
+ console.log(" hermes plugins enable seekrit");
9788
+ console.log();
9789
+ console.log(block.trimEnd());
9790
+ console.log();
9791
+ if (written.length > 0) {
9792
+ console.log("Scaffolded:");
9793
+ for (const path of written) console.log(` ${path}`);
9794
+ console.log();
9795
+ }
9796
+ console.log("The source overrides values already in .env or your shell, so a rotation wins — list a variable under `preserve_existing` when a local override is the point.");
9797
+ });
9798
+ });
9799
+ hermes.command("ref <name>").description("print the skt:// reference to bind a Hermes variable to").option("--token-alias <alias>", "resolve through a second token named in `tokens:`").option("--json", "machine-readable output").action((name, options) => {
9800
+ if (!NAME_RE$1.test(name)) fail(`not a valid secret name: "${name}" (names are [A-Za-z_][A-Za-z0-9_]*)`);
9801
+ const ref = reference(name, options.tokenAlias);
9802
+ emit(options, { reference: ref }, () => console.log(ref));
9803
+ });
9804
+ }
9805
+ //#endregion
9587
9806
  //#region src/honey.ts
9588
9807
  /**
9589
9808
  * Honey tokens — decoy credentials that unlock nothing and alert when used.
@@ -10036,61 +10255,650 @@ function registerMysqlCommands(program) {
10036
10255
  const cfg = t.config;
10037
10256
  console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
10038
10257
  }
10039
- });
10040
- target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("-y, --yes", "skip the confirmation").action(async (targetId, options) => {
10041
- const ctx = buildContext();
10042
- const org = await resolveOrg(ctx, options.org);
10043
- await confirmDestructive(options.yes, `Remove target ${targetId}? New leases against it stop working.`);
10044
- await ctx.client.deleteLeaseTarget(org.id, targetId);
10045
- console.error(`removed ${targetId}`);
10046
- });
10047
- mysql.command("lease <target>").description("mint a temporary credential; prints a ready-to-use connection URL").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--user <name>", "user name to create (default: a random tmp_ name)").option("--ttl <duration>", "lifetime, e.g. 30m, 1h, 7d", "1h").option("--json", "print the full connection as JSON").action(async (targetRef, options) => {
10048
- const ctx = buildContext();
10049
- const org = await resolveOrg(ctx, options.org);
10050
- const { targets } = await ctx.client.listLeaseTargets(org.id);
10051
- const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
10052
- if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
10053
- if (target.provider !== "mysql") fail(`target "${targetRef}" is not a MySQL target`);
10054
- const userName = options.user ?? generateUserName$1();
10055
- const ttlSeconds = parseTtlSeconds$3(options.ttl);
10056
- const { password, verifier } = await generateMysqlCredential();
10057
- const { connection } = await ctx.client.mintLease(org.id, {
10058
- provider: "mysql",
10059
- targetId: target.id,
10060
- roleName: userName,
10061
- verifier,
10062
- ttlSeconds
10258
+ });
10259
+ target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("-y, --yes", "skip the confirmation").action(async (targetId, options) => {
10260
+ const ctx = buildContext();
10261
+ const org = await resolveOrg(ctx, options.org);
10262
+ await confirmDestructive(options.yes, `Remove target ${targetId}? New leases against it stop working.`);
10263
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
10264
+ console.error(`removed ${targetId}`);
10265
+ });
10266
+ mysql.command("lease <target>").description("mint a temporary credential; prints a ready-to-use connection URL").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--user <name>", "user name to create (default: a random tmp_ name)").option("--ttl <duration>", "lifetime, e.g. 30m, 1h, 7d", "1h").option("--json", "print the full connection as JSON").action(async (targetRef, options) => {
10267
+ const ctx = buildContext();
10268
+ const org = await resolveOrg(ctx, options.org);
10269
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
10270
+ const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
10271
+ if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
10272
+ if (target.provider !== "mysql") fail(`target "${targetRef}" is not a MySQL target`);
10273
+ const userName = options.user ?? generateUserName$1();
10274
+ const ttlSeconds = parseTtlSeconds$3(options.ttl);
10275
+ const { password, verifier } = await generateMysqlCredential();
10276
+ const { connection } = await ctx.client.mintLease(org.id, {
10277
+ provider: "mysql",
10278
+ targetId: target.id,
10279
+ roleName: userName,
10280
+ verifier,
10281
+ ttlSeconds
10282
+ });
10283
+ const url = `mysql://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
10284
+ console.error(`leased ${userName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
10285
+ if (options.json) console.log(JSON.stringify({
10286
+ ...connection,
10287
+ password,
10288
+ url
10289
+ }, null, 2));
10290
+ else console.log(url);
10291
+ });
10292
+ mysql.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
10293
+ const ctx = buildContext();
10294
+ const org = await resolveOrg(ctx, options.org);
10295
+ const { leases } = await ctx.client.listLeases(org.id);
10296
+ for (const l of leases) {
10297
+ if (l.provider !== "mysql") continue;
10298
+ console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
10299
+ }
10300
+ });
10301
+ mysql.command("revoke <leaseId>").description("revoke a lease now (drops the user immediately)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("-y, --yes", "skip the confirmation").action(async (leaseId, options) => {
10302
+ const ctx = buildContext();
10303
+ const org = await resolveOrg(ctx, options.org);
10304
+ await confirmDestructive(options.yes, `Revoke lease ${leaseId} now? Its MySQL user is dropped immediately.`);
10305
+ await ctx.client.revokeLease(org.id, leaseId);
10306
+ console.error(`revoked ${leaseId}`);
10307
+ });
10308
+ }
10309
+ /** Collect a repeatable option into an array. */
10310
+ function collect$3(value, acc) {
10311
+ acc.push(value);
10312
+ return acc;
10313
+ }
10314
+ /**
10315
+ * Fetch + decrypt every secret in a single environment.
10316
+ *
10317
+ * `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
10318
+ * `interpolate`) unless `raw` is set. Only this environment's own secrets are in
10319
+ * scope here — a reference to a secret inherited from a composed group is left
10320
+ * literal, because the group layers aren't fetched. `materializeEnv` is the
10321
+ * fully-layered view.
10322
+ */
10323
+ async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
10324
+ const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
10325
+ const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
10326
+ return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
10327
+ }
10328
+ /**
10329
+ * Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
10330
+ * the CLI's standard fatal exit — it is a config bug with no correct value to
10331
+ * emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
10332
+ */
10333
+ function interpolateValues(values, enabled = true) {
10334
+ if (!enabled) return {
10335
+ values,
10336
+ interpolated: [],
10337
+ unresolvedRefs: []
10338
+ };
10339
+ try {
10340
+ const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
10341
+ return {
10342
+ values: expandedValues,
10343
+ interpolated: expanded,
10344
+ unresolvedRefs: unresolved
10345
+ };
10346
+ } catch (err) {
10347
+ return fail(err instanceof Error ? err.message : String(err));
10348
+ }
10349
+ }
10350
+ /**
10351
+ * Decrypt one historical version of a secret. Ciphertext is bound to
10352
+ * `(envId, name)` as AAD and neither changes across versions, so an old blob
10353
+ * opens with the environment's current data key — no special handling needed.
10354
+ */
10355
+ async function fetchDecryptedVersion(ctx, orgId, envId, name, version) {
10356
+ const [dek, { versions }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecretVersions(orgId, envId, name, { limit: 200 })]);
10357
+ const row = versions.find((v) => v.version === version);
10358
+ if (!row) fail(`${name} has no version ${version} in its ${versions.length} newest versions`);
10359
+ return decryptSecret(dek, row.ciphertext, secretAad(envId, name));
10360
+ }
10361
+ async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
10362
+ const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
10363
+ await ctx.client.setSecret(orgId, envId, name, ciphertext);
10364
+ }
10365
+ /**
10366
+ * Encrypt and store many secrets into one environment. The DEK is fetched once
10367
+ * (so user auth prompts for the passphrase a single time, not per variable),
10368
+ * then each value is encrypted locally and written. Existing names are
10369
+ * overwritten; the result splits them into created vs. updated for a summary.
10370
+ * Callers validate the names first — a rejected name aborts before any write.
10371
+ */
10372
+ async function importSecrets(ctx, orgId, envId, entries) {
10373
+ const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
10374
+ const existing = new Set(secrets.map((s) => s.name));
10375
+ const result = {
10376
+ created: [],
10377
+ updated: []
10378
+ };
10379
+ for (const [name, value] of Object.entries(entries)) {
10380
+ const ciphertext = await encryptSecret(dek, value, secretAad(envId, name));
10381
+ await ctx.client.setSecret(orgId, envId, name, ciphertext);
10382
+ (existing.has(name) ? result.updated : result.created).push(name);
10383
+ }
10384
+ return result;
10385
+ }
10386
+ /**
10387
+ * Resolve the full, layered environment for a running app: composed group
10388
+ * secrets (lowest precedence) → the app env's own secrets → `.env` files.
10389
+ * Each layer's DEK is unwrapped once with the principal's private key and its
10390
+ * ciphertext decrypted locally. `process.env` is NOT applied here — callers
10391
+ * that spawn a process layer it on top so the live shell always wins.
10392
+ *
10393
+ * `${OTHER_SECRET}` references are expanded last, against the merged set, so a
10394
+ * reference always resolves to whichever layer won the name.
10395
+ */
10396
+ async function materializeEnv(ctx, opts) {
10397
+ const query = {};
10398
+ if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
10399
+ if (opts.branch) query.branch = opts.branch;
10400
+ if (!isTokenAuth(ctx)) {
10401
+ if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
10402
+ query.env = opts.envId;
10403
+ }
10404
+ const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
10405
+ const privateKey = await getPrivateKey(ctx);
10406
+ const values = {};
10407
+ const provenance = {};
10408
+ for (const layer of layers) {
10409
+ const dek = await unwrapDek(layer.wrappedDek, privateKey);
10410
+ let label;
10411
+ if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
10412
+ else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
10413
+ else label = `app:${scope.appSlug}/${layer.slug}`;
10414
+ for (const secret of layer.secrets) {
10415
+ values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
10416
+ provenance[secret.name] = label;
10417
+ }
10418
+ }
10419
+ const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
10420
+ return {
10421
+ ...interpolateValues(values, opts.interpolate !== false),
10422
+ provenance,
10423
+ scope,
10424
+ loadedEnvFiles
10425
+ };
10426
+ }
10427
+ /**
10428
+ * Resolve, going through the last-known-good cache when one is configured.
10429
+ *
10430
+ * Always live first: the cache exists for when the call cannot land, not to
10431
+ * save a round trip, so a recovered network is picked up on the very next
10432
+ * invocation. A *refused* resolve (401/403/…) drops the entry rather than
10433
+ * falling back to it — otherwise revoking a token would keep working offline
10434
+ * until the entry aged out.
10435
+ */
10436
+ async function resolveWithCache(ctx, query, cache) {
10437
+ if (!cache) return ctx.client.resolve(query);
10438
+ try {
10439
+ const response = await ctx.client.resolve(query);
10440
+ try {
10441
+ cache.write(JSON.stringify(response));
10442
+ } catch (err) {
10443
+ warn(`could not update the cache: ${errorMessage(err)}`);
10444
+ }
10445
+ return response;
10446
+ } catch (err) {
10447
+ if (!mayFallBack(err)) {
10448
+ cache.invalidate();
10449
+ throw err;
10450
+ }
10451
+ const found = cache.read();
10452
+ if (found.kind === "hit") {
10453
+ warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
10454
+ return JSON.parse(found.body);
10455
+ }
10456
+ if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
10457
+ else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
10458
+ throw err;
10459
+ }
10460
+ }
10461
+ function warn(text) {
10462
+ process.stderr.write(`seekrit: ${text}\n`);
10463
+ }
10464
+ function errorMessage(err) {
10465
+ return err instanceof Error ? err.message : String(err);
10466
+ }
10467
+ /**
10468
+ * Overlay `.env` files onto an existing value/provenance set (later files win).
10469
+ * Missing files are skipped. Returns the files that were actually loaded. Used
10470
+ * both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
10471
+ * managed secrets are unavailable but `.env` should still apply.
10472
+ */
10473
+ function overlayEnvFiles(values, provenance, envFiles) {
10474
+ const loaded = [];
10475
+ for (const file of envFiles) {
10476
+ if (!existsSync(file)) continue;
10477
+ loaded.push(file);
10478
+ for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
10479
+ values[name] = value;
10480
+ provenance[name] = `dotenv:${file}`;
10481
+ }
10482
+ }
10483
+ return loaded;
10484
+ }
10485
+ /**
10486
+ * Print a name → source table to stderr (never the secret values). Names whose
10487
+ * value had references expanded are marked, and dangling references are called
10488
+ * out afterwards — a typo'd `${NAME}` is otherwise invisible, since it is
10489
+ * deliberately passed through as literal text.
10490
+ */
10491
+ function printExplain(provenance, refs = {}) {
10492
+ const interpolated = new Set(refs.interpolated ?? []);
10493
+ const names = Object.keys(provenance).sort();
10494
+ const width = names.reduce((w, n) => Math.max(w, n.length), 0);
10495
+ for (const name of names) {
10496
+ const marker = interpolated.has(name) ? " (interpolated)" : "";
10497
+ process.stderr.write(`${name.padEnd(width)} ${provenance[name]}${marker}\n`);
10498
+ }
10499
+ if (refs.unresolved?.length) process.stderr.write(`\nunresolved reference(s), left as literal text: ${refs.unresolved.join(", ")}\n`);
10500
+ }
10501
+ /** Plugin id and integration id declared by `@seekrit/openclaw-plugin`. */
10502
+ const PLUGIN_ID = "seekrit";
10503
+ const PLUGIN_INTEGRATION_ID = "seekrit";
10504
+ /** The provider alias written into `secrets.providers`. */
10505
+ const PROVIDER_ALIAS = "seekrit";
10506
+ /**
10507
+ * Environment the resolver needs, and nothing else.
10508
+ *
10509
+ * OpenClaw hands an exec provider an empty environment apart from this
10510
+ * allowlist, which is a feature: it is the difference between "the resolver can
10511
+ * read the credential it needs" and "the resolver inherits the whole gateway
10512
+ * environment". `PATH` is here because Node's own startup needs it, `HOME` for
10513
+ * the CLI's login session, and the `SEEKRIT_*` set is how a machine credential
10514
+ * reaches a process nobody gets to pass flags to.
10515
+ */
10516
+ const PASS_ENV = [
10517
+ "PATH",
10518
+ "HOME",
10519
+ "USERPROFILE",
10520
+ "APPDATA",
10521
+ "LOCALAPPDATA",
10522
+ "TEMP",
10523
+ "TMP",
10524
+ "SYSTEMROOT",
10525
+ "WINDIR",
10526
+ "XDG_CONFIG_HOME",
10527
+ "XDG_CACHE_HOME",
10528
+ "NODE_EXTRA_CA_CERTS",
10529
+ "SEEKRIT_TOKEN",
10530
+ "SEEKRIT_CLIENT_ID",
10531
+ "SEEKRIT_CLIENT_SECRET",
10532
+ "SEEKRIT_API_URL",
10533
+ "SEEKRIT_ORG",
10534
+ "SEEKRIT_APP",
10535
+ "SEEKRIT_ENV",
10536
+ "SEEKRIT_BRANCH"
10537
+ ];
10538
+ /**
10539
+ * Generous, because the first resolve of a cold start does real work — an M2M
10540
+ * token mint, a resolve, and a key unwrap — and OpenClaw fails startup rather
10541
+ * than degrading when this expires. The 1Password integration picks 90s for the
10542
+ * same reason (its CLI may prompt for biometrics); 30s is enough here because
10543
+ * nothing in this path is interactive.
10544
+ */
10545
+ const TIMEOUT_MS = 3e4;
10546
+ /** A seekrit secret name: what `seekrit secrets set` accepts. */
10547
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
10548
+ /** Slugs as the API spells them. */
10549
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
10550
+ /**
10551
+ * Parse an exec SecretRef id.
10552
+ *
10553
+ * Two shapes, mirroring 1Password's `op://vault/item/field` — a bare name for
10554
+ * the environment the credential already points at, and an explicit
10555
+ * `app/env/NAME` triple for a provider serving more than one environment:
10556
+ *
10557
+ * OPENAI_API_KEY
10558
+ * billing-api/production/STRIPE_SECRET_KEY
10559
+ *
10560
+ * A bare name is the common case and the one a service token wants, since the
10561
+ * token is already bound to an environment and there is nothing to disambiguate.
10562
+ */
10563
+ function parseSecretId(id) {
10564
+ const trimmed = id.trim();
10565
+ if (trimmed.length === 0) return void 0;
10566
+ const parts = trimmed.split("/");
10567
+ if (parts.length === 1) {
10568
+ const [name = ""] = parts;
10569
+ return NAME_RE.test(name) ? { name } : void 0;
10570
+ }
10571
+ if (parts.length !== 3) return void 0;
10572
+ const [app = "", env = "", name = ""] = parts;
10573
+ if (!SLUG_RE.test(app) || !SLUG_RE.test(env) || !NAME_RE.test(name)) return void 0;
10574
+ return {
10575
+ app,
10576
+ env,
10577
+ name
10578
+ };
10579
+ }
10580
+ /** The (app, env) pair an id resolves against — `""` for the credential's own. */
10581
+ function scopeKey(id) {
10582
+ return id.app && id.env ? `${id.app}/${id.env}` : "";
10583
+ }
10584
+ /**
10585
+ * Parse a request, tolerantly in exactly one direction.
10586
+ *
10587
+ * Non-string and empty ids are dropped rather than rejected — a request whose
10588
+ * shape we do not recognize should still resolve the ids we do, because the
10589
+ * alternative fails OpenClaw's whole startup over one malformed entry. A body
10590
+ * that is not an object with an `ids` array is a different matter: there is
10591
+ * nothing to answer, so it throws.
10592
+ */
10593
+ function parseExecRequest(input) {
10594
+ const parsed = JSON.parse(input);
10595
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("exec SecretRef request must be a JSON object");
10596
+ const ids = parsed.ids;
10597
+ if (!Array.isArray(ids)) throw new Error("exec SecretRef request must carry an `ids` array");
10598
+ return { ids: ids.filter((id) => typeof id === "string" && id.trim().length > 0) };
10599
+ }
10600
+ /** Assemble a response, omitting `errors` entirely when everything resolved. */
10601
+ function buildExecResponse(values, errors) {
10602
+ return {
10603
+ protocolVersion: 1,
10604
+ values,
10605
+ ...Object.keys(errors).length > 0 ? { errors } : {}
10606
+ };
10607
+ }
10608
+ /**
10609
+ * Resolve every requested id, reading each distinct environment exactly once.
10610
+ *
10611
+ * A batch is normally one environment, but a provider may serve several, and
10612
+ * resolving per id would mean an unwrap per id. Grouping keeps a 40-variable
10613
+ * gateway to one round trip.
10614
+ *
10615
+ * `resolveScope` is injected so the protocol can be tested without a network,
10616
+ * an API, or a key.
10617
+ */
10618
+ async function resolveIds(ids, resolveScope) {
10619
+ const values = {};
10620
+ const errors = {};
10621
+ const byScope = /* @__PURE__ */ new Map();
10622
+ for (const id of ids) {
10623
+ const parsed = parseSecretId(id);
10624
+ if (!parsed) {
10625
+ errors[id] = { code: "INVALID_ID" };
10626
+ continue;
10627
+ }
10628
+ const key = scopeKey(parsed);
10629
+ const bucket = byScope.get(key) ?? {
10630
+ scope: parsed,
10631
+ ids: []
10632
+ };
10633
+ bucket.ids.push({
10634
+ id,
10635
+ name: parsed.name
10636
+ });
10637
+ byScope.set(key, bucket);
10638
+ }
10639
+ for (const { scope, ids: wanted } of byScope.values()) {
10640
+ let resolved;
10641
+ try {
10642
+ resolved = await resolveScope(scope);
10643
+ } catch (err) {
10644
+ const code = classify(err);
10645
+ for (const { id } of wanted) errors[id] = { code };
10646
+ continue;
10647
+ }
10648
+ for (const { id, name } of wanted) {
10649
+ const value = resolved[name];
10650
+ if (value === void 0) errors[id] = { code: "NOT_FOUND" };
10651
+ else values[id] = value;
10652
+ }
10653
+ }
10654
+ return buildExecResponse(values, errors);
10655
+ }
10656
+ /**
10657
+ * Bucket a failure into a code an operator can act on.
10658
+ *
10659
+ * Deliberately coarse and deliberately message-free: it reads the error's own
10660
+ * text to pick a bucket and then throws that text away, because the text may
10661
+ * quote a token.
10662
+ */
10663
+ function classify(err) {
10664
+ const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
10665
+ if (/unauthor|forbidden|token|credential|passphrase|decrypt|sign in|log in|login/.test(message)) return "UNAUTHORIZED";
10666
+ return "UNAVAILABLE";
10667
+ }
10668
+ /** OpenClaw's config directory: `$OPENCLAW_HOME`, else `~/.openclaw`. */
10669
+ function openclawHome(env = process.env) {
10670
+ const home = env.OPENCLAW_HOME?.trim();
10671
+ return home && home.length > 0 ? resolve(home) : join(homedir(), ".openclaw");
10672
+ }
10673
+ /**
10674
+ * The provider block for an installed `@seekrit/openclaw-plugin`.
10675
+ *
10676
+ * Preferred over the exec shape below for one reason that matters on upgrade:
10677
+ * OpenClaw reads the command out of the plugin's manifest at every startup, so
10678
+ * a plugin release that moves its resolver keeps working. A copied command is a
10679
+ * path pinned in someone's config forever.
10680
+ */
10681
+ function pluginProvider() {
10682
+ return {
10683
+ source: "exec",
10684
+ pluginIntegration: {
10685
+ pluginId: PLUGIN_ID,
10686
+ integrationId: PLUGIN_INTEGRATION_ID
10687
+ }
10688
+ };
10689
+ }
10690
+ /**
10691
+ * The provider block for a plain CLI install, with no plugin.
10692
+ *
10693
+ * `realpathSync` on both paths is not tidiness. OpenClaw's guard rejects a
10694
+ * symlinked command outright, and a Node installed by a version manager is
10695
+ * almost always reached through one — so resolving here is the difference
10696
+ * between a provider that works and a startup failure whose message points at
10697
+ * the config rather than at the shim.
10698
+ */
10699
+ function execProvider(cliEntry, nodeBinary = process.execPath) {
10700
+ const command = realTo(nodeBinary);
10701
+ const entry = realTo(cliEntry);
10702
+ return {
10703
+ source: "exec",
10704
+ command,
10705
+ args: [
10706
+ entry,
10707
+ "openclaw",
10708
+ "resolve"
10709
+ ],
10710
+ passEnv: [...PASS_ENV],
10711
+ jsonOnly: true,
10712
+ timeoutMs: TIMEOUT_MS,
10713
+ noOutputTimeoutMs: TIMEOUT_MS,
10714
+ trustedDirs: [.../* @__PURE__ */ new Set([dirname(command), dirname(entry)])]
10715
+ };
10716
+ }
10717
+ function realTo(path) {
10718
+ try {
10719
+ return realpathSync(path);
10720
+ } catch {
10721
+ return resolve(path);
10722
+ }
10723
+ }
10724
+ /** This CLI's own entry file — what `execProvider` hands to Node. */
10725
+ function cliEntryPath() {
10726
+ return fileURLToPath(new URL("index.js", import.meta.url));
10727
+ }
10728
+ /**
10729
+ * Add the seekrit provider to a parsed config without disturbing anything else.
10730
+ *
10731
+ * An existing `seekrit` entry that differs is left alone unless forced, for the
10732
+ * same reason `seekrit paperclip init` leaves an existing MCP server alone:
10733
+ * someone who pinned a timeout or narrowed `passEnv` did it deliberately, and
10734
+ * silently reverting a security narrowing is the worst kind of helpful.
10735
+ */
10736
+ function mergeProvider(existing, provider, force) {
10737
+ const secrets = { ...existing.secrets ?? {} };
10738
+ const providers = { ...secrets.providers ?? {} };
10739
+ const current = providers[PROVIDER_ALIAS];
10740
+ if (current && !force && JSON.stringify(current) !== JSON.stringify(provider)) return {
10741
+ merged: existing,
10742
+ changed: false
10743
+ };
10744
+ if (current && JSON.stringify(current) === JSON.stringify(provider)) return {
10745
+ merged: existing,
10746
+ changed: false
10747
+ };
10748
+ providers[PROVIDER_ALIAS] = provider;
10749
+ return {
10750
+ merged: {
10751
+ ...existing,
10752
+ secrets: {
10753
+ ...secrets,
10754
+ providers
10755
+ }
10756
+ },
10757
+ changed: true
10758
+ };
10759
+ }
10760
+ /** A SecretRef pointing at this provider — what replaces a plaintext key. */
10761
+ function secretRef(id) {
10762
+ return {
10763
+ source: "exec",
10764
+ provider: PROVIDER_ALIAS,
10765
+ id
10766
+ };
10767
+ }
10768
+ /**
10769
+ * Read `openclaw.json`, or decline to.
10770
+ *
10771
+ * The file is JSON5: comments and trailing commas are legal, and a parse/write
10772
+ * round trip through `JSON` would delete every comment in someone's gateway
10773
+ * config. So a file that is not also valid strict JSON is not edited at all —
10774
+ * the caller prints the block to paste instead. Losing a config's comments to
10775
+ * be helpful is worse than asking for one paste.
10776
+ */
10777
+ function readOpenclawConfig(path) {
10778
+ if (!existsSync(path)) return { config: {} };
10779
+ let raw;
10780
+ try {
10781
+ raw = readFileSync(path, "utf8");
10782
+ } catch (err) {
10783
+ fail(`could not read ${path}: ${err instanceof Error ? err.message : String(err)}`);
10784
+ }
10785
+ if (raw.trim().length === 0) return { config: {} };
10786
+ try {
10787
+ const parsed = JSON.parse(raw);
10788
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return { json5: true };
10789
+ return { config: parsed };
10790
+ } catch {
10791
+ return { json5: true };
10792
+ }
10793
+ }
10794
+ function registerOpenclawCommands(program) {
10795
+ const openclaw = program.command("openclaw").description("wire seekrit into OpenClaw as a SecretRef provider (`seekrit openclaw --help`)");
10796
+ openclaw.command("init").description("add the seekrit exec SecretRef provider to openclaw.json").option("--config <path>", "path to openclaw.json (default: $OPENCLAW_HOME/openclaw.json)").option("--plugin", "point at an installed @seekrit/openclaw-plugin (recommended)").option("--exec", "point directly at this CLI, with no plugin installed").option("--write", "write the file instead of printing the block to paste").option("--force", "replace a seekrit provider entry that already differs").option("--json", "machine-readable output").action((options) => {
10797
+ if (options.plugin && options.exec) fail("--plugin and --exec are mutually exclusive");
10798
+ const usePlugin = options.exec !== true;
10799
+ let provider;
10800
+ if (usePlugin) provider = pluginProvider();
10801
+ else {
10802
+ const entry = cliEntryPath();
10803
+ if (!existsSync(entry)) fail(`cannot find this CLI's entry file at ${entry} — --exec needs an installed seekrit (npm i -g @seekrit/cli), or use --plugin`);
10804
+ provider = execProvider(entry);
10805
+ }
10806
+ const path = options.config ? resolve(options.config) : join(openclawHome(), "openclaw.json");
10807
+ const read = readOpenclawConfig(path);
10808
+ const json5 = "json5" in read;
10809
+ const merged = json5 ? void 0 : mergeProvider(read.config, provider, options.force === true);
10810
+ const wrote = Boolean(options.write) && merged?.changed === true;
10811
+ if (wrote && merged) writeFileSync(path, `${JSON.stringify(merged.merged, null, 2)}\n`, { mode: 384 });
10812
+ emit(options, {
10813
+ path,
10814
+ provider,
10815
+ wrote,
10816
+ json5
10817
+ }, () => {
10818
+ section("openclaw secret provider");
10819
+ printFields([
10820
+ ["config", path],
10821
+ ["provider", PROVIDER_ALIAS],
10822
+ ["shape", usePlugin ? "plugin integration" : "exec (this CLI)"],
10823
+ ["written", wrote ? "yes" : "no"]
10824
+ ]);
10825
+ console.log();
10826
+ if (json5) {
10827
+ console.log(`${path} is JSON5 (comments or trailing commas), which a rewrite here would delete.`);
10828
+ console.log("Merge this into it by hand:");
10829
+ } else if (!options.write) console.log("Merge this into your config, or re-run with --write:");
10830
+ else if (!wrote) console.log(`A different \`${PROVIDER_ALIAS}\` provider is already configured — left as it is. Re-run with --force to replace it.`);
10831
+ else console.log("Written. The block now in your config:");
10832
+ console.log();
10833
+ console.log(JSON.stringify({ secrets: { providers: { [PROVIDER_ALIAS]: provider } } }, null, 2));
10834
+ console.log();
10835
+ if (usePlugin) {
10836
+ console.log("Install the plugin, if you have not already:");
10837
+ console.log(" openclaw plugins install npm:@seekrit/openclaw-plugin");
10838
+ console.log(" openclaw plugins enable seekrit");
10839
+ console.log();
10840
+ }
10841
+ console.log("Then replace a plaintext credential with a ref, e.g.:");
10842
+ console.log(` ${JSON.stringify(secretRef("OPENAI_API_KEY"))}`);
10843
+ console.log();
10844
+ console.log("And check your work:");
10845
+ console.log(" openclaw secrets audit --check --allow-exec");
10846
+ console.log();
10847
+ console.log("Resolution is eager: OpenClaw reads every ref once at startup. After a rotation, run `openclaw secrets reload`.");
10063
10848
  });
10064
- const url = `mysql://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
10065
- console.error(`leased ${userName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
10066
- if (options.json) console.log(JSON.stringify({
10067
- ...connection,
10068
- password,
10069
- url
10070
- }, null, 2));
10071
- else console.log(url);
10072
10849
  });
10073
- mysql.command("leases").description("list leases (the ledger never secret material)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").action(async (options) => {
10074
- const ctx = buildContext();
10075
- const org = await resolveOrg(ctx, options.org);
10076
- const { leases } = await ctx.client.listLeases(org.id);
10077
- for (const l of leases) {
10078
- if (l.provider !== "mysql") continue;
10079
- console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
10080
- }
10850
+ openclaw.command("ref <name>").description("print the SecretRef object to paste in place of a plaintext credential").option("--app <slug>", "address a specific application (with --env)").option("--env <slug>", "address a specific environment (with --app)").option("--json", "machine-readable output").action((name, options) => {
10851
+ if (Boolean(options.app) !== Boolean(options.env)) fail("--app and --env go together (an id names both, or neither)");
10852
+ const id = options.app && options.env ? `${options.app}/${options.env}/${name}` : name;
10853
+ if (!parseSecretId(id)) fail(`not a valid SecretRef id: "${id}" (names are [A-Za-z_][A-Za-z0-9_]*)`);
10854
+ const ref = secretRef(id);
10855
+ emit(options, ref, () => console.log(JSON.stringify(ref)));
10081
10856
  });
10082
- mysql.command("revoke <leaseId>").description("revoke a lease now (drops the user immediately)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("-y, --yes", "skip the confirmation").action(async (leaseId, options) => {
10083
- const ctx = buildContext();
10084
- const org = await resolveOrg(ctx, options.org);
10085
- await confirmDestructive(options.yes, `Revoke lease ${leaseId} now? Its MySQL user is dropped immediately.`);
10086
- await ctx.client.revokeLease(org.id, leaseId);
10087
- console.error(`revoked ${leaseId}`);
10857
+ openclaw.command("resolve").description("resolve exec SecretRef ids from stdin (OpenClaw calls this; not for humans)").action(async () => {
10858
+ setFailThrows(true);
10859
+ let request;
10860
+ try {
10861
+ request = parseExecRequest(await readStdin());
10862
+ } catch (err) {
10863
+ process.stderr.write(`seekrit: ${err instanceof Error ? err.message : String(err)}\n`);
10864
+ process.exitCode = 2;
10865
+ return;
10866
+ }
10867
+ const scopes = /* @__PURE__ */ new Map();
10868
+ let ctx;
10869
+ const response = await resolveIds(request.ids, (scope) => {
10870
+ const key = scopeKey(scope);
10871
+ const cached = scopes.get(key);
10872
+ if (cached) return cached;
10873
+ ctx ??= buildContext();
10874
+ const pending = resolveEnvironment(ctx, scope);
10875
+ scopes.set(key, pending);
10876
+ return pending;
10877
+ });
10878
+ process.stdout.write(`${JSON.stringify(response)}\n`);
10088
10879
  });
10089
10880
  }
10090
- /** Collect a repeatable option into an array. */
10091
- function collect$3(value, acc) {
10092
- acc.push(value);
10093
- return acc;
10881
+ /**
10882
+ * Resolve one environment's full variable set.
10883
+ *
10884
+ * `.env` overlays are deliberately switched off (`envFiles: []`). Everywhere
10885
+ * else in this CLI they are a convenience for a developer's shell; here the
10886
+ * working directory belongs to the OpenClaw gateway, and letting a file in it
10887
+ * override a gateway credential would make a stray `.env` a privilege
10888
+ * escalation. Reference expansion stays on — a `${OTHER_SECRET}` reference is
10889
+ * stored data, not a local override.
10890
+ */
10891
+ async function resolveEnvironment(ctx, scope) {
10892
+ let envId;
10893
+ if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, {
10894
+ app: scope.app,
10895
+ env: scope.env
10896
+ })).envId;
10897
+ const { values } = await materializeEnv(ctx, {
10898
+ envId,
10899
+ envFiles: []
10900
+ });
10901
+ return values;
10094
10902
  }
10095
10903
  //#endregion
10096
10904
  //#region src/orgs.ts
@@ -11968,193 +12776,6 @@ function registerRotationCommands(program) {
11968
12776
  console.error(`disabled rotation of ${r.secretName}${rotatorRevoked ? " — rotator key access revoked for this environment" : ""}`);
11969
12777
  });
11970
12778
  }
11971
- /**
11972
- * Fetch + decrypt every secret in a single environment.
11973
- *
11974
- * `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
11975
- * `interpolate`) unless `raw` is set. Only this environment's own secrets are in
11976
- * scope here — a reference to a secret inherited from a composed group is left
11977
- * literal, because the group layers aren't fetched. `materializeEnv` is the
11978
- * fully-layered view.
11979
- */
11980
- async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
11981
- const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
11982
- const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
11983
- return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
11984
- }
11985
- /**
11986
- * Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
11987
- * the CLI's standard fatal exit — it is a config bug with no correct value to
11988
- * emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
11989
- */
11990
- function interpolateValues(values, enabled = true) {
11991
- if (!enabled) return {
11992
- values,
11993
- interpolated: [],
11994
- unresolvedRefs: []
11995
- };
11996
- try {
11997
- const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
11998
- return {
11999
- values: expandedValues,
12000
- interpolated: expanded,
12001
- unresolvedRefs: unresolved
12002
- };
12003
- } catch (err) {
12004
- return fail(err instanceof Error ? err.message : String(err));
12005
- }
12006
- }
12007
- /**
12008
- * Decrypt one historical version of a secret. Ciphertext is bound to
12009
- * `(envId, name)` as AAD and neither changes across versions, so an old blob
12010
- * opens with the environment's current data key — no special handling needed.
12011
- */
12012
- async function fetchDecryptedVersion(ctx, orgId, envId, name, version) {
12013
- const [dek, { versions }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecretVersions(orgId, envId, name, { limit: 200 })]);
12014
- const row = versions.find((v) => v.version === version);
12015
- if (!row) fail(`${name} has no version ${version} in its ${versions.length} newest versions`);
12016
- return decryptSecret(dek, row.ciphertext, secretAad(envId, name));
12017
- }
12018
- async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
12019
- const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
12020
- await ctx.client.setSecret(orgId, envId, name, ciphertext);
12021
- }
12022
- /**
12023
- * Encrypt and store many secrets into one environment. The DEK is fetched once
12024
- * (so user auth prompts for the passphrase a single time, not per variable),
12025
- * then each value is encrypted locally and written. Existing names are
12026
- * overwritten; the result splits them into created vs. updated for a summary.
12027
- * Callers validate the names first — a rejected name aborts before any write.
12028
- */
12029
- async function importSecrets(ctx, orgId, envId, entries) {
12030
- const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
12031
- const existing = new Set(secrets.map((s) => s.name));
12032
- const result = {
12033
- created: [],
12034
- updated: []
12035
- };
12036
- for (const [name, value] of Object.entries(entries)) {
12037
- const ciphertext = await encryptSecret(dek, value, secretAad(envId, name));
12038
- await ctx.client.setSecret(orgId, envId, name, ciphertext);
12039
- (existing.has(name) ? result.updated : result.created).push(name);
12040
- }
12041
- return result;
12042
- }
12043
- /**
12044
- * Resolve the full, layered environment for a running app: composed group
12045
- * secrets (lowest precedence) → the app env's own secrets → `.env` files.
12046
- * Each layer's DEK is unwrapped once with the principal's private key and its
12047
- * ciphertext decrypted locally. `process.env` is NOT applied here — callers
12048
- * that spawn a process layer it on top so the live shell always wins.
12049
- *
12050
- * `${OTHER_SECRET}` references are expanded last, against the merged set, so a
12051
- * reference always resolves to whichever layer won the name.
12052
- */
12053
- async function materializeEnv(ctx, opts) {
12054
- const query = {};
12055
- if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
12056
- if (opts.branch) query.branch = opts.branch;
12057
- if (!isTokenAuth(ctx)) {
12058
- if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
12059
- query.env = opts.envId;
12060
- }
12061
- const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
12062
- const privateKey = await getPrivateKey(ctx);
12063
- const values = {};
12064
- const provenance = {};
12065
- for (const layer of layers) {
12066
- const dek = await unwrapDek(layer.wrappedDek, privateKey);
12067
- let label;
12068
- if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
12069
- else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
12070
- else label = `app:${scope.appSlug}/${layer.slug}`;
12071
- for (const secret of layer.secrets) {
12072
- values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
12073
- provenance[secret.name] = label;
12074
- }
12075
- }
12076
- const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
12077
- return {
12078
- ...interpolateValues(values, opts.interpolate !== false),
12079
- provenance,
12080
- scope,
12081
- loadedEnvFiles
12082
- };
12083
- }
12084
- /**
12085
- * Resolve, going through the last-known-good cache when one is configured.
12086
- *
12087
- * Always live first: the cache exists for when the call cannot land, not to
12088
- * save a round trip, so a recovered network is picked up on the very next
12089
- * invocation. A *refused* resolve (401/403/…) drops the entry rather than
12090
- * falling back to it — otherwise revoking a token would keep working offline
12091
- * until the entry aged out.
12092
- */
12093
- async function resolveWithCache(ctx, query, cache) {
12094
- if (!cache) return ctx.client.resolve(query);
12095
- try {
12096
- const response = await ctx.client.resolve(query);
12097
- try {
12098
- cache.write(JSON.stringify(response));
12099
- } catch (err) {
12100
- warn(`could not update the cache: ${errorMessage(err)}`);
12101
- }
12102
- return response;
12103
- } catch (err) {
12104
- if (!mayFallBack(err)) {
12105
- cache.invalidate();
12106
- throw err;
12107
- }
12108
- const found = cache.read();
12109
- if (found.kind === "hit") {
12110
- warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
12111
- return JSON.parse(found.body);
12112
- }
12113
- if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
12114
- else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
12115
- throw err;
12116
- }
12117
- }
12118
- function warn(text) {
12119
- process.stderr.write(`seekrit: ${text}\n`);
12120
- }
12121
- function errorMessage(err) {
12122
- return err instanceof Error ? err.message : String(err);
12123
- }
12124
- /**
12125
- * Overlay `.env` files onto an existing value/provenance set (later files win).
12126
- * Missing files are skipped. Returns the files that were actually loaded. Used
12127
- * both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
12128
- * managed secrets are unavailable but `.env` should still apply.
12129
- */
12130
- function overlayEnvFiles(values, provenance, envFiles) {
12131
- const loaded = [];
12132
- for (const file of envFiles) {
12133
- if (!existsSync(file)) continue;
12134
- loaded.push(file);
12135
- for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
12136
- values[name] = value;
12137
- provenance[name] = `dotenv:${file}`;
12138
- }
12139
- }
12140
- return loaded;
12141
- }
12142
- /**
12143
- * Print a name → source table to stderr (never the secret values). Names whose
12144
- * value had references expanded are marked, and dangling references are called
12145
- * out afterwards — a typo'd `${NAME}` is otherwise invisible, since it is
12146
- * deliberately passed through as literal text.
12147
- */
12148
- function printExplain(provenance, refs = {}) {
12149
- const interpolated = new Set(refs.interpolated ?? []);
12150
- const names = Object.keys(provenance).sort();
12151
- const width = names.reduce((w, n) => Math.max(w, n.length), 0);
12152
- for (const name of names) {
12153
- const marker = interpolated.has(name) ? " (interpolated)" : "";
12154
- process.stderr.write(`${name.padEnd(width)} ${provenance[name]}${marker}\n`);
12155
- }
12156
- if (refs.unresolved?.length) process.stderr.write(`\nunresolved reference(s), left as literal text: ${refs.unresolved.join(", ")}\n`);
12157
- }
12158
12779
  //#endregion
12159
12780
  //#region src/ssh.ts
12160
12781
  /**
@@ -13689,6 +14310,8 @@ registerProvisionerCommands(program);
13689
14310
  registerProxyCommands(program);
13690
14311
  registerAgentCommands(program);
13691
14312
  registerPaperclipCommands(program);
14313
+ registerOpenclawCommands(program);
14314
+ registerHermesCommands(program);
13692
14315
  registerSshCommands(program);
13693
14316
  registerAwsCommands(program);
13694
14317
  registerGcpCommands(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -10,7 +10,9 @@
10
10
  "seekrit": "./dist/index.js"
11
11
  },
12
12
  "exports": {
13
+ "./cli-entry": "./dist/index.js",
13
14
  "./mcp": "./src/mcp.ts",
15
+ "./openclaw": "./src/openclaw.ts",
14
16
  "./proxy-launcher": "./src/proxy-binary.ts"
15
17
  },
16
18
  "files": [
@@ -29,8 +31,8 @@
29
31
  "tsdown": "^0.22.3",
30
32
  "vitest": "^4.1.9",
31
33
  "@seekrit/api-client": "0.0.1",
32
- "@seekrit/core": "0.0.1",
33
- "@seekrit/crypto": "0.0.1"
34
+ "@seekrit/crypto": "0.0.1",
35
+ "@seekrit/core": "0.0.1"
34
36
  },
35
37
  "scripts": {
36
38
  "build": "tsdown",