@seekrit/cli 0.24.0 → 0.25.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
@@ -58,7 +58,7 @@ Run `seekrit <command> --help` for full flags. The
58
58
  | `init --org --app --env` | Link this directory to an environment (`seekrit.json`). |
59
59
  | `org create` / `app create` / `env create` | Create organizations, apps, and environments. |
60
60
  | `secrets list` | List secret names (never values). |
61
- | `secrets get <name>` | Decrypt and print one secret value. |
61
+ | `secrets get <name> [--raw]` | Decrypt and print one secret value (`--raw` skips `${OTHER_SECRET}` expansion). |
62
62
  | `secrets set <name> [value]` | Encrypt and store a secret (reads stdin if value is omitted or `-`). |
63
63
  | `secrets rm <name>` | Delete a secret. |
64
64
  | `run <command…>` | Run a command with decrypted secrets in its environment. |
@@ -71,6 +71,11 @@ Run `seekrit <command> --help` for full flags. The
71
71
  (e.g. `seekrit run -- node app.js --port 3000`). `secrets set NAME -` reads the
72
72
  value from stdin, so you can pipe: `printf '%s' "$VALUE" | seekrit secrets set NAME -`.
73
73
 
74
+ A value may reference another secret as `${OTHER_SECRET}`: stored literally,
75
+ expanded on read against the merged environment (`--no-interpolate` on
76
+ `run`/`export` opts out). See the
77
+ [references guide](https://seekrit.dev/docs/guides/references).
78
+
74
79
  ## Service tokens (CI, Docker, agents)
75
80
 
76
81
  A service token carries its own private key in the token string; the server
package/dist/index.js CHANGED
@@ -143,6 +143,152 @@ const SUBSCRIPTION_STATUSES = [
143
143
  "canceled",
144
144
  "paused"
145
145
  ];
146
+ //#endregion
147
+ //#region ../../packages/core/src/interpolate.ts
148
+ /**
149
+ * Secret references: `${OTHER_SECRET}` inside a secret value.
150
+ *
151
+ * This is the **canonical specification** of the expansion. It is pure string
152
+ * work over an already-decrypted variable set, so it runs wherever plaintext
153
+ * legitimately exists — the CLI, the browser, the language SDKs, and the Rust
154
+ * clients (`crates/seekrit-core/src/interpolate.rs` mirrors it, pinned by the
155
+ * shared golden fixture in `apps/run/testdata/vectors.json`).
156
+ *
157
+ * Expansion happens at **read time**, on the client, never on write and never
158
+ * on the server: the API only ever holds the ciphertext of the literal
159
+ * `${OTHER_SECRET}` text, so referencing costs nothing against the
160
+ * zero-knowledge invariant. It also means a reference stays live — rotating
161
+ * `DB_PASSWORD` updates every value that references it, with no re-encryption.
162
+ *
163
+ * The rules, in full:
164
+ *
165
+ * - `${NAME}` is replaced with the value of `NAME` in the *same fully-merged
166
+ * set* — after group → app-env → `.env` layering, so a reference always sees
167
+ * the value that layer precedence actually selected.
168
+ * - `NAME` must be a valid secret name (`[A-Za-z_][A-Za-z0-9_]*`, matching
169
+ * `secretNameSchema`). Anything else — `${1}`, `${FOO:-bar}`, `${a.b}` — is
170
+ * left exactly as written, so shell and CI template syntax passes through
171
+ * untouched.
172
+ * - A reference to a name that is not in the set is **left literal** and
173
+ * reported in {@link InterpolationResult.unresolved}. Erroring would mean a
174
+ * stored value that happens to contain `${GITHUB_SHA}` could break a whole
175
+ * environment's resolve; leaving it alone is the safe default, and the report
176
+ * is there to surface typos (`seekrit run --explain` prints it).
177
+ * - Expansion is recursive: a referenced value may itself contain references.
178
+ * - `$${NAME}` is an escape producing the literal text `${NAME}`. A `$$` not
179
+ * followed by `{` is ordinary text (passwords full of `$` are safe).
180
+ * - A reference **cycle** throws {@link InterpolationError}. Unlike an unknown
181
+ * name, a cycle can only be a configuration mistake — every name in it
182
+ * exists — and there is no value that could be correct to emit.
183
+ *
184
+ * `process.env` is deliberately *not* a reference source: `seekrit run` layers
185
+ * the live shell on top of the resolved set afterwards, and letting a stored
186
+ * secret pull in arbitrary host environment variables would be a surprising
187
+ * (and machine-dependent) way to change a secret's value.
188
+ */
189
+ /** A reference name — the same grammar as `secretNameSchema`. */
190
+ const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
191
+ /**
192
+ * Cap on a single expanded value. Nested references can multiply length
193
+ * (`A=${B}${B}`, `B=${C}${C}`, …), which memoization makes fast but does not
194
+ * make small. A megabyte is far above any real secret and far below anything
195
+ * that would exhaust a container.
196
+ */
197
+ const MAX_EXPANDED_LENGTH = 1048576;
198
+ /**
199
+ * Split a value into literal runs and references — the single tokenizer every
200
+ * rule above is expressed in terms of, so expansion and inspection can never
201
+ * disagree about what counts as a reference.
202
+ */
203
+ function* scan(text) {
204
+ let i = 0;
205
+ while (i < text.length) {
206
+ const dollar = text.indexOf("$", i);
207
+ if (dollar === -1) {
208
+ yield { literal: text.slice(i) };
209
+ return;
210
+ }
211
+ if (dollar > i) yield { literal: text.slice(i, dollar) };
212
+ if (text[dollar + 1] === "$" && text[dollar + 2] === "{") {
213
+ yield { literal: "${" };
214
+ i = dollar + 3;
215
+ continue;
216
+ }
217
+ const close = text[dollar + 1] === "{" ? text.indexOf("}", dollar + 2) : -1;
218
+ const reference = close === -1 ? null : text.slice(dollar + 2, close);
219
+ if (reference !== null && REFERENCE_NAME.test(reference)) {
220
+ yield {
221
+ reference,
222
+ raw: text.slice(dollar, close + 1)
223
+ };
224
+ i = close + 1;
225
+ continue;
226
+ }
227
+ yield { literal: "$" };
228
+ i = dollar + 1;
229
+ }
230
+ }
231
+ var InterpolationError = class extends Error {
232
+ code;
233
+ /**
234
+ * For `cycle`, the reference chain that closed on itself, starting and ending
235
+ * on the same name (`["A", "B", "A"]`). For `too_large`, the single name
236
+ * whose expansion blew the cap.
237
+ */
238
+ chain;
239
+ constructor(code, message, chain) {
240
+ super(message);
241
+ this.name = "InterpolationError";
242
+ this.code = code;
243
+ this.chain = chain;
244
+ }
245
+ };
246
+ /**
247
+ * Expand `${NAME}` references throughout a decrypted variable set.
248
+ *
249
+ * Pure: the input is never mutated. Throws {@link InterpolationError} on a
250
+ * reference cycle (see the module comment for the complete rule set).
251
+ */
252
+ function interpolateSecrets(values) {
253
+ const resolved = /* @__PURE__ */ new Map();
254
+ const unresolved = /* @__PURE__ */ new Set();
255
+ const expanded = [];
256
+ /** The chain currently being expanded — the cycle detector. */
257
+ const stack = [];
258
+ /** Expand one present name's value, memoized so each is expanded once. */
259
+ function resolve(name) {
260
+ const cached = resolved.get(name);
261
+ if (cached !== void 0) return cached;
262
+ const cycleAt = stack.indexOf(name);
263
+ if (cycleAt !== -1) {
264
+ const chain = [...stack.slice(cycleAt), name];
265
+ throw new InterpolationError("cycle", `secret reference cycle: ${chain.join(" → ")}`, chain);
266
+ }
267
+ stack.push(name);
268
+ let out = "";
269
+ for (const segment of scan(values[name])) if ("literal" in segment) out += segment.literal;
270
+ else if (Object.hasOwn(values, segment.reference)) out += resolve(segment.reference);
271
+ else {
272
+ unresolved.add(segment.reference);
273
+ out += segment.raw;
274
+ }
275
+ stack.pop();
276
+ if (out.length > MAX_EXPANDED_LENGTH) throw new InterpolationError("too_large", `${name} expands to more than ${MAX_EXPANDED_LENGTH} bytes — check its references`, [name]);
277
+ resolved.set(name, out);
278
+ return out;
279
+ }
280
+ const result = {};
281
+ for (const name of Object.keys(values)) {
282
+ const value = resolve(name);
283
+ result[name] = value;
284
+ if (value !== values[name]) expanded.push(name);
285
+ }
286
+ return {
287
+ values: result,
288
+ expanded,
289
+ unresolved: [...unresolved].sort()
290
+ };
291
+ }
146
292
  z.enum([
147
293
  "postgres",
148
294
  "mysql",
@@ -1978,7 +2124,7 @@ function isServiceToken(value) {
1978
2124
  }
1979
2125
  //#endregion
1980
2126
  //#region package.json
1981
- var version = "0.24.0";
2127
+ var version = "0.25.0";
1982
2128
  //#endregion
1983
2129
  //#region ../../packages/api-client/src/index.ts
1984
2130
  var SeekritApiError = class extends Error {
@@ -4095,11 +4241,41 @@ function collect$1(value, acc) {
4095
4241
  acc.push(value);
4096
4242
  return acc;
4097
4243
  }
4098
- /** Fetch + decrypt every secret in a single environment. */
4099
- async function fetchDecryptedSecrets(ctx, orgId, envId) {
4244
+ /**
4245
+ * Fetch + decrypt every secret in a single environment.
4246
+ *
4247
+ * `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
4248
+ * `interpolate`) unless `raw` is set. Only this environment's own secrets are in
4249
+ * scope here — a reference to a secret inherited from a composed group is left
4250
+ * literal, because the group layers aren't fetched. `materializeEnv` is the
4251
+ * fully-layered view.
4252
+ */
4253
+ async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
4100
4254
  const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
4101
4255
  const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
4102
- return Object.fromEntries(entries);
4256
+ return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
4257
+ }
4258
+ /**
4259
+ * Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
4260
+ * the CLI's standard fatal exit — it is a config bug with no correct value to
4261
+ * emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
4262
+ */
4263
+ function interpolateValues(values, enabled = true) {
4264
+ if (!enabled) return {
4265
+ values,
4266
+ interpolated: [],
4267
+ unresolvedRefs: []
4268
+ };
4269
+ try {
4270
+ const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
4271
+ return {
4272
+ values: expandedValues,
4273
+ interpolated: expanded,
4274
+ unresolvedRefs: unresolved
4275
+ };
4276
+ } catch (err) {
4277
+ return fail(err instanceof Error ? err.message : String(err));
4278
+ }
4103
4279
  }
4104
4280
  /**
4105
4281
  * Decrypt one historical version of a secret. Ciphertext is bound to
@@ -4143,6 +4319,9 @@ async function importSecrets(ctx, orgId, envId, entries) {
4143
4319
  * Each layer's DEK is unwrapped once with the principal's private key and its
4144
4320
  * ciphertext decrypted locally. `process.env` is NOT applied here — callers
4145
4321
  * that spawn a process layer it on top so the live shell always wins.
4322
+ *
4323
+ * `${OTHER_SECRET}` references are expanded last, against the merged set, so a
4324
+ * reference always resolves to whichever layer won the name.
4146
4325
  */
4147
4326
  async function materializeEnv(ctx, opts) {
4148
4327
  const query = {};
@@ -4163,11 +4342,12 @@ async function materializeEnv(ctx, opts) {
4163
4342
  provenance[secret.name] = label;
4164
4343
  }
4165
4344
  }
4345
+ const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
4166
4346
  return {
4167
- values,
4347
+ ...interpolateValues(values, opts.interpolate !== false),
4168
4348
  provenance,
4169
4349
  scope,
4170
- loadedEnvFiles: overlayEnvFiles(values, provenance, opts.envFiles)
4350
+ loadedEnvFiles
4171
4351
  };
4172
4352
  }
4173
4353
  /**
@@ -4188,11 +4368,21 @@ function overlayEnvFiles(values, provenance, envFiles) {
4188
4368
  }
4189
4369
  return loaded;
4190
4370
  }
4191
- /** Print a name → source table to stderr (never the secret values). */
4192
- function printExplain(provenance) {
4371
+ /**
4372
+ * Print a name → source table to stderr (never the secret values). Names whose
4373
+ * value had references expanded are marked, and dangling references are called
4374
+ * out afterwards — a typo'd `${NAME}` is otherwise invisible, since it is
4375
+ * deliberately passed through as literal text.
4376
+ */
4377
+ function printExplain(provenance, refs = {}) {
4378
+ const interpolated = new Set(refs.interpolated ?? []);
4193
4379
  const names = Object.keys(provenance).sort();
4194
4380
  const width = names.reduce((w, n) => Math.max(w, n.length), 0);
4195
- for (const name of names) process.stderr.write(`${name.padEnd(width)} ${provenance[name]}\n`);
4381
+ for (const name of names) {
4382
+ const marker = interpolated.has(name) ? " (interpolated)" : "";
4383
+ process.stderr.write(`${name.padEnd(width)} ${provenance[name]}${marker}\n`);
4384
+ }
4385
+ if (refs.unresolved?.length) process.stderr.write(`\nunresolved reference(s), left as literal text: ${refs.unresolved.join(", ")}\n`);
4196
4386
  }
4197
4387
  //#endregion
4198
4388
  //#region src/ssh.ts
@@ -4521,11 +4711,11 @@ withTarget(secrets.command("list").description("list secret names (no values)"))
4521
4711
  const { secrets: rows } = await ctx.client.listSecrets(orgId, envId);
4522
4712
  for (const row of rows) console.log(`${row.name}\tv${row.version}\t${row.updatedAt}`);
4523
4713
  });
4524
- withTarget(secrets.command("get <name>").description("decrypt and print one secret value").option("--version <n>", "print an earlier version instead of the current one")).action(async (name, options) => {
4714
+ withTarget(secrets.command("get <name>").description("decrypt and print one secret value").option("--raw", "print the stored value without expanding ${OTHER_SECRET} references").option("--version <n>", "print an earlier version instead of the current one")).action(async (name, options) => {
4525
4715
  const ctx = buildContext();
4526
4716
  const { orgId, envId } = await resolveEnvTarget(ctx, options);
4527
4717
  let value;
4528
- if (options.version === void 0) value = (await fetchDecryptedSecrets(ctx, orgId, envId))[name];
4718
+ if (options.version === void 0) value = (await fetchDecryptedSecrets(ctx, orgId, envId, { raw: options.raw }))[name];
4529
4719
  else value = await fetchDecryptedVersion(ctx, orgId, envId, name, parseVersion(options.version));
4530
4720
  if (value === void 0) fail(`no secret named ${name}`);
4531
4721
  process.stdout.write(value);
@@ -4591,7 +4781,8 @@ async function materialize(ctx, options) {
4591
4781
  return materializeEnv(ctx, {
4592
4782
  envId,
4593
4783
  with: options.with,
4594
- envFiles: options.envFile ?? [".env"]
4784
+ envFiles: options.envFile ?? [".env"],
4785
+ interpolate: options.interpolate
4595
4786
  });
4596
4787
  }
4597
4788
  /**
@@ -4617,7 +4808,7 @@ async function materializeForRun(options) {
4617
4808
  const provenance = {};
4618
4809
  overlayEnvFiles(values, provenance, envFiles);
4619
4810
  return {
4620
- values,
4811
+ ...interpolateValues(values, options.interpolate !== false),
4621
4812
  provenance
4622
4813
  };
4623
4814
  }
@@ -4732,13 +4923,16 @@ async function reapStragglers(pids, signal) {
4732
4923
  process.kill(pid, "SIGKILL");
4733
4924
  } catch {}
4734
4925
  }
4735
- program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
4926
+ program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
4736
4927
  const [cmd, ...args] = commandParts;
4737
4928
  if (!cmd) fail("no command given");
4738
- const { values, provenance } = await materializeForRun(options);
4929
+ const { values, provenance, interpolated, unresolvedRefs } = await materializeForRun(options);
4739
4930
  if (options.explain) {
4740
4931
  for (const name of Object.keys(values)) if (process.env[name] !== void 0 && process.env[name] !== values[name]) provenance[name] = "env";
4741
- printExplain(provenance);
4932
+ printExplain(provenance, {
4933
+ interpolated,
4934
+ unresolved: unresolvedRefs
4935
+ });
4742
4936
  }
4743
4937
  const posix = process.platform !== "win32";
4744
4938
  const child = spawn(cmd, args, {
@@ -4781,14 +4975,17 @@ program.command("run").description("run a command with decrypted secrets injecte
4781
4975
  });
4782
4976
  child.on("error", (err) => fail(`failed to start ${cmd}: ${err.message}`));
4783
4977
  });
4784
- program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--with <group=env>", "override one group’s slice", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
4978
+ program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--with <group=env>", "override one group’s slice", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
4785
4979
  if (![
4786
4980
  "dotenv",
4787
4981
  "json",
4788
4982
  "shell"
4789
4983
  ].includes(options.format)) fail("format must be dotenv, json, or shell");
4790
- const { values, provenance } = await materialize(buildContext(), options);
4791
- if (options.explain) printExplain(provenance);
4984
+ const { values, provenance, interpolated, unresolvedRefs } = await materialize(buildContext(), options);
4985
+ if (options.explain) printExplain(provenance, {
4986
+ interpolated,
4987
+ unresolved: unresolvedRefs
4988
+ });
4792
4989
  console.log(formatSecrets(values, options.format));
4793
4990
  });
4794
4991
  program.command("grant").description("give a member or service token access to an environment's key").option("--org <slug>").option("--app <slug>").option("--group <slug>", "grant a group environment instead of an app").requiredOption("--env <slug>").option("--user <email>", "grant to an org member by email").option("--token <tokenId>", "grant to a service token by id (skt_…)").action(async (options) => {
@@ -4909,7 +5106,7 @@ registerMongoCommands(program);
4909
5106
  registerKmsCommands(program);
4910
5107
  registerRecoveryCommands(program);
4911
5108
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
4912
- const { runMcpServer } = await import("./mcp-t-sJ_SvE.js");
5109
+ const { runMcpServer } = await import("./mcp-COWsshZZ.js");
4913
5110
  await runMcpServer();
4914
5111
  });
4915
5112
  program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
@@ -573,7 +573,7 @@ async function runMcpServer(options = {}) {
573
573
  await ctx.client.unlinkEnvGroup(target.orgId, target.envId, g.id);
574
574
  return { ok: true };
575
575
  });
576
- tool("set_secret", "Encrypt a value locally and store it in an environment.", {
576
+ tool("set_secret", "Encrypt a value locally and store it in an environment. A value may reference another secret as ${OTHER_SECRET}: the reference is stored literally and expanded whenever the secret is read, so it tracks the referenced value. Write $${OTHER_SECRET} for a literal.", {
577
577
  ...targetShape,
578
578
  name: z.string(),
579
579
  value: z.string()
@@ -587,10 +587,11 @@ async function runMcpServer(options = {}) {
587
587
  name: o.name
588
588
  };
589
589
  });
590
- tool("get_secret", "Return one secret. By default only reports presence + version; pass reveal:true to decrypt the plaintext into this response (avoid unless the value is actually needed — prefer run_command). Pass `version` to read an earlier version instead of the current one.", {
590
+ tool("get_secret", "Return one secret. By default only reports presence + version; pass reveal:true to decrypt the plaintext into this response (avoid unless the value is actually needed — prefer run_command). A revealed current value has its ${OTHER_SECRET} references expanded against this environment's own secrets; pass raw:true for the stored text instead. Pass `version` to read an earlier version instead of the current one (always as stored, never expanded).", {
591
591
  ...targetShape,
592
592
  name: z.string(),
593
593
  reveal: z.boolean().optional(),
594
+ raw: z.boolean().optional().describe("skip ${OTHER_SECRET} expansion (with reveal)"),
594
595
  version: z.number().int().positive().optional().describe("an earlier version from list_secret_versions (default: current)")
595
596
  }, async (o) => {
596
597
  const ctx = getCtx();
@@ -615,7 +616,7 @@ async function runMcpServer(options = {}) {
615
616
  revealed: true
616
617
  };
617
618
  }
618
- const values = await fetchDecryptedSecrets(ctx, orgId, envId);
619
+ const values = await fetchDecryptedSecrets(ctx, orgId, envId, { raw: o.raw });
619
620
  if (!(o.name in values)) throw new Error(`no secret named ${o.name}`);
620
621
  return {
621
622
  name: o.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -27,8 +27,8 @@
27
27
  "@types/node": "^26.1.0",
28
28
  "tsdown": "^0.22.3",
29
29
  "@seekrit/api-client": "0.0.1",
30
- "@seekrit/core": "0.0.1",
31
- "@seekrit/crypto": "0.0.1"
30
+ "@seekrit/crypto": "0.0.1",
31
+ "@seekrit/core": "0.0.1"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsdown",