@seekrit/cli 0.23.4 → 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",
@@ -708,6 +854,8 @@ z.object({
708
854
  z.object({
709
855
  /** Opaque versioned ciphertext blob from @seekrit/crypto. */
710
856
  ciphertext: z.string().min(1).max(65536) });
857
+ z.object({ version: z.number().int().positive() });
858
+ z.object({ limit: z.coerce.number().int().min(1).max(200).default(50) });
711
859
  z.object({
712
860
  publicKeyJwk: z.string().min(1),
713
861
  /**
@@ -1152,7 +1300,7 @@ function encryptAad(ref, context) {
1152
1300
  function dataKeyAad(keyId, version) {
1153
1301
  return `${keyId}/${version}`;
1154
1302
  }
1155
- function parseVersion(versionStr) {
1303
+ function parseVersion$1(versionStr) {
1156
1304
  const version = Number(versionStr);
1157
1305
  if (!Number.isInteger(version) || version < 1) throw new SeekritCryptoError("MALFORMED_BLOB", "invalid key version in KMS blob");
1158
1306
  return version;
@@ -1164,7 +1312,7 @@ function kmsBlobKeyRef(blob) {
1164
1312
  const [keyId, versionStr] = splitBlob(blob, prefix, 4);
1165
1313
  return {
1166
1314
  keyId,
1167
- version: parseVersion(versionStr)
1315
+ version: parseVersion$1(versionStr)
1168
1316
  };
1169
1317
  }
1170
1318
  /** Encrypt a value under a managed key. `context` (bound as AAD) defaults to empty. */
@@ -1193,7 +1341,7 @@ async function kmsDecrypt(material, blob, context = "") {
1193
1341
  const [keyId, versionStr, ivB64, ctB64] = splitBlob(blob, ENCRYPT_PREFIX, 4);
1194
1342
  const ref = {
1195
1343
  keyId,
1196
- version: parseVersion(versionStr)
1344
+ version: parseVersion$1(versionStr)
1197
1345
  };
1198
1346
  const key = await importAesKey(material, "decrypt");
1199
1347
  try {
@@ -1976,7 +2124,7 @@ function isServiceToken(value) {
1976
2124
  }
1977
2125
  //#endregion
1978
2126
  //#region package.json
1979
- var version = "0.23.4";
2127
+ var version = "0.25.0";
1980
2128
  //#endregion
1981
2129
  //#region ../../packages/api-client/src/index.ts
1982
2130
  var SeekritApiError = class extends Error {
@@ -2153,6 +2301,19 @@ var SeekritClient = class {
2153
2301
  deleteSecret(orgId, envId, name) {
2154
2302
  return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`);
2155
2303
  }
2304
+ /** A secret's append-only history, newest version first. */
2305
+ listSecretVersions(orgId, envId, name, query = {}) {
2306
+ const qs = query.limit === void 0 ? "" : `?limit=${query.limit}`;
2307
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}/versions${qs}`);
2308
+ }
2309
+ /**
2310
+ * Roll a secret back to an earlier version. Keyless — the server copies the
2311
+ * ciphertext it already stores, so this appends a new version rather than
2312
+ * rewinding, and needs no DEK on the caller's side.
2313
+ */
2314
+ restoreSecret(orgId, envId, name, version) {
2315
+ return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}/restore`, { version });
2316
+ }
2156
2317
  /** The calling principal's wrapped DEK for this environment. */
2157
2318
  getMyEnvKey(orgId, envId) {
2158
2319
  return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/key`);
@@ -4080,13 +4241,52 @@ function collect$1(value, acc) {
4080
4241
  acc.push(value);
4081
4242
  return acc;
4082
4243
  }
4083
- //#endregion
4084
- //#region src/secrets.ts
4085
- /** Fetch + decrypt every secret in a single environment. */
4086
- 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 = {}) {
4087
4254
  const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
4088
4255
  const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
4089
- 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
+ }
4279
+ }
4280
+ /**
4281
+ * Decrypt one historical version of a secret. Ciphertext is bound to
4282
+ * `(envId, name)` as AAD and neither changes across versions, so an old blob
4283
+ * opens with the environment's current data key — no special handling needed.
4284
+ */
4285
+ async function fetchDecryptedVersion(ctx, orgId, envId, name, version) {
4286
+ const [dek, { versions }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecretVersions(orgId, envId, name, { limit: 200 })]);
4287
+ const row = versions.find((v) => v.version === version);
4288
+ if (!row) fail(`${name} has no version ${version} in its ${versions.length} newest versions`);
4289
+ return decryptSecret(dek, row.ciphertext, secretAad(envId, name));
4090
4290
  }
4091
4291
  async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
4092
4292
  const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
@@ -4119,6 +4319,9 @@ async function importSecrets(ctx, orgId, envId, entries) {
4119
4319
  * Each layer's DEK is unwrapped once with the principal's private key and its
4120
4320
  * ciphertext decrypted locally. `process.env` is NOT applied here — callers
4121
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.
4122
4325
  */
4123
4326
  async function materializeEnv(ctx, opts) {
4124
4327
  const query = {};
@@ -4139,11 +4342,12 @@ async function materializeEnv(ctx, opts) {
4139
4342
  provenance[secret.name] = label;
4140
4343
  }
4141
4344
  }
4345
+ const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
4142
4346
  return {
4143
- values,
4347
+ ...interpolateValues(values, opts.interpolate !== false),
4144
4348
  provenance,
4145
4349
  scope,
4146
- loadedEnvFiles: overlayEnvFiles(values, provenance, opts.envFiles)
4350
+ loadedEnvFiles
4147
4351
  };
4148
4352
  }
4149
4353
  /**
@@ -4164,11 +4368,21 @@ function overlayEnvFiles(values, provenance, envFiles) {
4164
4368
  }
4165
4369
  return loaded;
4166
4370
  }
4167
- /** Print a name → source table to stderr (never the secret values). */
4168
- 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 ?? []);
4169
4379
  const names = Object.keys(provenance).sort();
4170
4380
  const width = names.reduce((w, n) => Math.max(w, n.length), 0);
4171
- 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`);
4172
4386
  }
4173
4387
  //#endregion
4174
4388
  //#region src/ssh.ts
@@ -4480,6 +4694,12 @@ group.command("env").description("manage a group’s environments (per-slug valu
4480
4694
  });
4481
4695
  console.error(`created ${groupRef.slug}@${created.environment.slug} (${created.environment.id})`);
4482
4696
  });
4697
+ /** Parse a positive integer flag/argument (version numbers, page sizes). */
4698
+ function parseVersion(raw) {
4699
+ const n = Number(raw);
4700
+ if (!Number.isInteger(n) || n < 1) fail(`expected a positive whole number, got "${raw}"`);
4701
+ return n;
4702
+ }
4483
4703
  /** Attach the environment-selection flags shared by every `secrets` command. */
4484
4704
  function withTarget(cmd) {
4485
4705
  return cmd.option("--org <slug>", "organization slug").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "target a group environment instead of an app").requiredOption("--env <slug>", "environment slug");
@@ -4491,10 +4711,12 @@ withTarget(secrets.command("list").description("list secret names (no values)"))
4491
4711
  const { secrets: rows } = await ctx.client.listSecrets(orgId, envId);
4492
4712
  for (const row of rows) console.log(`${row.name}\tv${row.version}\t${row.updatedAt}`);
4493
4713
  });
4494
- withTarget(secrets.command("get <name>").description("decrypt and print one secret value")).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) => {
4495
4715
  const ctx = buildContext();
4496
4716
  const { orgId, envId } = await resolveEnvTarget(ctx, options);
4497
- const value = (await fetchDecryptedSecrets(ctx, orgId, envId))[name];
4717
+ let value;
4718
+ if (options.version === void 0) value = (await fetchDecryptedSecrets(ctx, orgId, envId, { raw: options.raw }))[name];
4719
+ else value = await fetchDecryptedVersion(ctx, orgId, envId, name, parseVersion(options.version));
4498
4720
  if (value === void 0) fail(`no secret named ${name}`);
4499
4721
  process.stdout.write(value);
4500
4722
  if (process.stdout.isTTY) process.stdout.write("\n");
@@ -4530,6 +4752,22 @@ withTarget(secrets.command("import [file]").description("bulk-import secrets fro
4530
4752
  const { created, updated } = await importSecrets(ctx, orgId, envId, entries);
4531
4753
  console.error(`imported ${created.length + updated.length} secret(s) into ${label} (${created.length} new, ${updated.length} updated)`);
4532
4754
  });
4755
+ withTarget(secrets.command("history <name>").description("list a secret's versions (metadata only — no values)").option("--limit <n>", `how many versions to show (max 200)`, "20")).action(async (name, options) => {
4756
+ const ctx = buildContext();
4757
+ const { orgId, envId } = await resolveEnvTarget(ctx, options);
4758
+ const { versions, currentVersion } = await ctx.client.listSecretVersions(orgId, envId, name, { limit: parseVersion(options.limit) });
4759
+ for (const v of versions) {
4760
+ const marks = [v.version === currentVersion ? "current" : null, v.restoredFromVersion === null ? null : `restored from v${v.restoredFromVersion}`].filter(Boolean);
4761
+ const note = marks.length > 0 ? `\t${marks.join(", ")}` : "";
4762
+ console.log(`v${v.version}\t${v.createdAt}\t${v.createdByType}:${v.createdById}${note}`);
4763
+ }
4764
+ });
4765
+ withTarget(secrets.command("restore <name> <version>").description("roll a secret back to an earlier version (stored as a new version)")).action(async (name, version, options) => {
4766
+ const ctx = buildContext();
4767
+ const { orgId, envId } = await resolveEnvTarget(ctx, options);
4768
+ const { secret, restoredFrom } = await ctx.client.restoreSecret(orgId, envId, name, parseVersion(version));
4769
+ console.error(`${name} restored from v${restoredFrom} — now v${secret.version}`);
4770
+ });
4533
4771
  withTarget(secrets.command("rm <name>").description("delete a secret")).action(async (name, options) => {
4534
4772
  const ctx = buildContext();
4535
4773
  const { orgId, envId } = await resolveEnvTarget(ctx, options);
@@ -4543,7 +4781,8 @@ async function materialize(ctx, options) {
4543
4781
  return materializeEnv(ctx, {
4544
4782
  envId,
4545
4783
  with: options.with,
4546
- envFiles: options.envFile ?? [".env"]
4784
+ envFiles: options.envFile ?? [".env"],
4785
+ interpolate: options.interpolate
4547
4786
  });
4548
4787
  }
4549
4788
  /**
@@ -4569,7 +4808,7 @@ async function materializeForRun(options) {
4569
4808
  const provenance = {};
4570
4809
  overlayEnvFiles(values, provenance, envFiles);
4571
4810
  return {
4572
- values,
4811
+ ...interpolateValues(values, options.interpolate !== false),
4573
4812
  provenance
4574
4813
  };
4575
4814
  }
@@ -4684,13 +4923,16 @@ async function reapStragglers(pids, signal) {
4684
4923
  process.kill(pid, "SIGKILL");
4685
4924
  } catch {}
4686
4925
  }
4687
- 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) => {
4688
4927
  const [cmd, ...args] = commandParts;
4689
4928
  if (!cmd) fail("no command given");
4690
- const { values, provenance } = await materializeForRun(options);
4929
+ const { values, provenance, interpolated, unresolvedRefs } = await materializeForRun(options);
4691
4930
  if (options.explain) {
4692
4931
  for (const name of Object.keys(values)) if (process.env[name] !== void 0 && process.env[name] !== values[name]) provenance[name] = "env";
4693
- printExplain(provenance);
4932
+ printExplain(provenance, {
4933
+ interpolated,
4934
+ unresolved: unresolvedRefs
4935
+ });
4694
4936
  }
4695
4937
  const posix = process.platform !== "win32";
4696
4938
  const child = spawn(cmd, args, {
@@ -4733,14 +4975,17 @@ program.command("run").description("run a command with decrypted secrets injecte
4733
4975
  });
4734
4976
  child.on("error", (err) => fail(`failed to start ${cmd}: ${err.message}`));
4735
4977
  });
4736
- 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) => {
4737
4979
  if (![
4738
4980
  "dotenv",
4739
4981
  "json",
4740
4982
  "shell"
4741
4983
  ].includes(options.format)) fail("format must be dotenv, json, or shell");
4742
- const { values, provenance } = await materialize(buildContext(), options);
4743
- 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
+ });
4744
4989
  console.log(formatSecrets(values, options.format));
4745
4990
  });
4746
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) => {
@@ -4861,7 +5106,7 @@ registerMongoCommands(program);
4861
5106
  registerKmsCommands(program);
4862
5107
  registerRecoveryCommands(program);
4863
5108
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
4864
- const { runMcpServer } = await import("./mcp-CbNXG37n.js");
5109
+ const { runMcpServer } = await import("./mcp-COWsshZZ.js");
4865
5110
  await runMcpServer();
4866
5111
  });
4867
5112
  program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
@@ -4875,4 +5120,4 @@ program.parseAsync(argv).catch((err) => {
4875
5120
  fail(err instanceof Error ? err.message : String(err));
4876
5121
  });
4877
5122
  //#endregion
4878
- export { generateDataKey as A, importSigningKey as C, verifyMessage as D, signatureKeyRef as E, wrapDek as F, generateDek as I, toBase64 as L, kmsBlobKeyRef as M, kmsDecrypt as N, generatePostgresCredential as O, kmsEncrypt as P, generateSigningKeyMaterial as S, signMessage as T, writeProjectConfig as _, kmsCallerIdentity as a, isServiceToken as b, kmsResolveRecipient as c, resolveGroup as d, resolveOrg as f, setFailThrows as g, tryBuildContext as h, ensureM2mAdminToken as i, generateEncryptKeyMaterial as j, generateMysqlCredential as k, resolveAppEnv as l, isTokenAuth as m, fetchDecryptedSecrets as n, kmsRecoverMaterial as o, getDek as p, materializeEnv as r, kmsResolveKey as s, encryptAndSetSecret as t, resolveEnvTarget as u, version as v, importVerifyingKey as w, parseServiceToken as x, createServiceToken as y };
5123
+ export { generateMysqlCredential as A, generateSigningKeyMaterial as C, signatureKeyRef as D, signMessage as E, kmsEncrypt as F, wrapDek as I, generateDek as L, generateEncryptKeyMaterial as M, kmsBlobKeyRef as N, verifyMessage as O, kmsDecrypt as P, toBase64 as R, parseServiceToken as S, importVerifyingKey as T, setFailThrows as _, ensureM2mAdminToken as a, createServiceToken as b, kmsResolveKey as c, resolveEnvTarget as d, resolveGroup as f, tryBuildContext as g, isTokenAuth as h, materializeEnv as i, generateDataKey as j, generatePostgresCredential as k, kmsResolveRecipient as l, getDek as m, fetchDecryptedSecrets as n, kmsCallerIdentity as o, resolveOrg as p, fetchDecryptedVersion as r, kmsRecoverMaterial as s, encryptAndSetSecret as t, resolveAppEnv as u, writeProjectConfig as v, importSigningKey as w, isServiceToken as x, version as y };
@@ -1,4 +1,4 @@
1
- import { A as generateDataKey, C as importSigningKey, D as verifyMessage, E as signatureKeyRef, F as wrapDek, I as generateDek, L as toBase64, M as kmsBlobKeyRef, N as kmsDecrypt, O as generatePostgresCredential, P as kmsEncrypt, S as generateSigningKeyMaterial, T as signMessage, _ as writeProjectConfig, a as kmsCallerIdentity, b as isServiceToken, c as kmsResolveRecipient, d as resolveGroup, f as resolveOrg, g as setFailThrows, h as tryBuildContext, i as ensureM2mAdminToken, j as generateEncryptKeyMaterial, k as generateMysqlCredential, l as resolveAppEnv, m as isTokenAuth, n as fetchDecryptedSecrets, o as kmsRecoverMaterial, p as getDek, r as materializeEnv, s as kmsResolveKey, t as encryptAndSetSecret, u as resolveEnvTarget, v as version, w as importVerifyingKey, x as parseServiceToken, y as createServiceToken } from "./index.js";
1
+ import { A as generateMysqlCredential, C as generateSigningKeyMaterial, D as signatureKeyRef, E as signMessage, F as kmsEncrypt, I as wrapDek, L as generateDek, M as generateEncryptKeyMaterial, N as kmsBlobKeyRef, O as verifyMessage, P as kmsDecrypt, R as toBase64, S as parseServiceToken, T as importVerifyingKey, _ as setFailThrows, a as ensureM2mAdminToken, b as createServiceToken, c as kmsResolveKey, d as resolveEnvTarget, f as resolveGroup, g as tryBuildContext, h as isTokenAuth, i as materializeEnv, j as generateDataKey, k as generatePostgresCredential, l as kmsResolveRecipient, m as getDek, n as fetchDecryptedSecrets, o as kmsCallerIdentity, p as resolveOrg, r as fetchDecryptedVersion, s as kmsRecoverMaterial, t as encryptAndSetSecret, u as resolveAppEnv, v as writeProjectConfig, w as importSigningKey, x as isServiceToken, y as version } from "./index.js";
2
2
  import { spawn } from "node:child_process";
3
3
  import { z } from "zod";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -73,6 +73,8 @@ function getStartedText() {
73
73
  "## 3. Store secrets",
74
74
  "- `set_secret` — encrypts a value locally and stores the ciphertext.",
75
75
  "- `list_secrets` — confirm names + versions (never returns values).",
76
+ "- `list_secret_versions` + `restore_secret` — undo a bad write by rolling",
77
+ " back to an earlier version (keyless, and history is append-only).",
76
78
  "",
77
79
  "## 4. Use secrets without exposing them",
78
80
  "- `run_command -- <cmd>` — inject secrets into a subprocess; prefer this.",
@@ -571,7 +573,7 @@ async function runMcpServer(options = {}) {
571
573
  await ctx.client.unlinkEnvGroup(target.orgId, target.envId, g.id);
572
574
  return { ok: true };
573
575
  });
574
- 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.", {
575
577
  ...targetShape,
576
578
  name: z.string(),
577
579
  value: z.string()
@@ -585,10 +587,12 @@ async function runMcpServer(options = {}) {
585
587
  name: o.name
586
588
  };
587
589
  });
588
- 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).", {
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).", {
589
591
  ...targetShape,
590
592
  name: z.string(),
591
- reveal: z.boolean().optional()
593
+ reveal: z.boolean().optional(),
594
+ raw: z.boolean().optional().describe("skip ${OTHER_SECRET} expansion (with reveal)"),
595
+ version: z.number().int().positive().optional().describe("an earlier version from list_secret_versions (default: current)")
592
596
  }, async (o) => {
593
597
  const ctx = getCtx();
594
598
  const { orgId, envId } = await resolveTargetEnv(ctx, o);
@@ -598,12 +602,21 @@ async function runMcpServer(options = {}) {
598
602
  if (!row) throw new Error(`no secret named ${o.name}`);
599
603
  return {
600
604
  name: row.name,
601
- version: row.version,
605
+ version: o.version ?? row.version,
602
606
  revealed: false
603
607
  };
604
608
  }
605
609
  ensureDecryptable(ctx);
606
- const values = await fetchDecryptedSecrets(ctx, orgId, envId);
610
+ if (o.version !== void 0) {
611
+ const value = await fetchDecryptedVersion(ctx, orgId, envId, o.name, o.version);
612
+ return {
613
+ name: o.name,
614
+ version: o.version,
615
+ value,
616
+ revealed: true
617
+ };
618
+ }
619
+ const values = await fetchDecryptedSecrets(ctx, orgId, envId, { raw: o.raw });
607
620
  if (!(o.name in values)) throw new Error(`no secret named ${o.name}`);
608
621
  return {
609
622
  name: o.name,
@@ -611,6 +624,39 @@ async function runMcpServer(options = {}) {
611
624
  revealed: true
612
625
  };
613
626
  });
627
+ tool("list_secret_versions", "List a secret's version history: who wrote each version, when, and which ones were restores. Never returns values — pair it with restore_secret to roll back, or get_secret(version, reveal:true) to inspect one.", {
628
+ ...targetShape,
629
+ name: z.string(),
630
+ limit: z.number().int().min(1).max(200).optional().describe("default 20")
631
+ }, async (o) => {
632
+ const ctx = getCtx();
633
+ const { orgId, envId } = await resolveTargetEnv(ctx, o);
634
+ const { versions, currentVersion } = await ctx.client.listSecretVersions(orgId, envId, o.name, { limit: o.limit ?? 20 });
635
+ return {
636
+ currentVersion,
637
+ versions: versions.map((v) => ({
638
+ version: v.version,
639
+ createdAt: v.createdAt,
640
+ createdBy: `${v.createdByType}:${v.createdById}`,
641
+ restoredFromVersion: v.restoredFromVersion
642
+ }))
643
+ };
644
+ });
645
+ tool("restore_secret", "Roll a secret back to an earlier version. The stored ciphertext is replayed as a NEW version (history is append-only, nothing is overwritten). Keyless — no decryption happens, so this works even without a key.", {
646
+ ...targetShape,
647
+ name: z.string(),
648
+ version: z.number().int().positive()
649
+ }, async (o) => {
650
+ const ctx = getCtx();
651
+ const { orgId, envId } = await resolveTargetEnv(ctx, o);
652
+ const { secret, restoredFrom } = await ctx.client.restoreSecret(orgId, envId, o.name, o.version);
653
+ return {
654
+ ok: true,
655
+ name: o.name,
656
+ restoredFrom,
657
+ version: secret.version
658
+ };
659
+ });
614
660
  tool("delete_secret", "Delete a secret from an environment.", {
615
661
  ...targetShape,
616
662
  name: z.string()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.23.4",
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",