@seekrit/cli 0.23.3 → 0.24.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/dist/index.js CHANGED
@@ -708,6 +708,8 @@ z.object({
708
708
  z.object({
709
709
  /** Opaque versioned ciphertext blob from @seekrit/crypto. */
710
710
  ciphertext: z.string().min(1).max(65536) });
711
+ z.object({ version: z.number().int().positive() });
712
+ z.object({ limit: z.coerce.number().int().min(1).max(200).default(50) });
711
713
  z.object({
712
714
  publicKeyJwk: z.string().min(1),
713
715
  /**
@@ -1152,7 +1154,7 @@ function encryptAad(ref, context) {
1152
1154
  function dataKeyAad(keyId, version) {
1153
1155
  return `${keyId}/${version}`;
1154
1156
  }
1155
- function parseVersion(versionStr) {
1157
+ function parseVersion$1(versionStr) {
1156
1158
  const version = Number(versionStr);
1157
1159
  if (!Number.isInteger(version) || version < 1) throw new SeekritCryptoError("MALFORMED_BLOB", "invalid key version in KMS blob");
1158
1160
  return version;
@@ -1164,7 +1166,7 @@ function kmsBlobKeyRef(blob) {
1164
1166
  const [keyId, versionStr] = splitBlob(blob, prefix, 4);
1165
1167
  return {
1166
1168
  keyId,
1167
- version: parseVersion(versionStr)
1169
+ version: parseVersion$1(versionStr)
1168
1170
  };
1169
1171
  }
1170
1172
  /** Encrypt a value under a managed key. `context` (bound as AAD) defaults to empty. */
@@ -1193,7 +1195,7 @@ async function kmsDecrypt(material, blob, context = "") {
1193
1195
  const [keyId, versionStr, ivB64, ctB64] = splitBlob(blob, ENCRYPT_PREFIX, 4);
1194
1196
  const ref = {
1195
1197
  keyId,
1196
- version: parseVersion(versionStr)
1198
+ version: parseVersion$1(versionStr)
1197
1199
  };
1198
1200
  const key = await importAesKey(material, "decrypt");
1199
1201
  try {
@@ -1976,7 +1978,7 @@ function isServiceToken(value) {
1976
1978
  }
1977
1979
  //#endregion
1978
1980
  //#region package.json
1979
- var version = "0.23.3";
1981
+ var version = "0.24.0";
1980
1982
  //#endregion
1981
1983
  //#region ../../packages/api-client/src/index.ts
1982
1984
  var SeekritApiError = class extends Error {
@@ -2153,6 +2155,19 @@ var SeekritClient = class {
2153
2155
  deleteSecret(orgId, envId, name) {
2154
2156
  return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`);
2155
2157
  }
2158
+ /** A secret's append-only history, newest version first. */
2159
+ listSecretVersions(orgId, envId, name, query = {}) {
2160
+ const qs = query.limit === void 0 ? "" : `?limit=${query.limit}`;
2161
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}/versions${qs}`);
2162
+ }
2163
+ /**
2164
+ * Roll a secret back to an earlier version. Keyless — the server copies the
2165
+ * ciphertext it already stores, so this appends a new version rather than
2166
+ * rewinding, and needs no DEK on the caller's side.
2167
+ */
2168
+ restoreSecret(orgId, envId, name, version) {
2169
+ return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}/restore`, { version });
2170
+ }
2156
2171
  /** The calling principal's wrapped DEK for this environment. */
2157
2172
  getMyEnvKey(orgId, envId) {
2158
2173
  return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/key`);
@@ -4080,14 +4095,23 @@ function collect$1(value, acc) {
4080
4095
  acc.push(value);
4081
4096
  return acc;
4082
4097
  }
4083
- //#endregion
4084
- //#region src/secrets.ts
4085
4098
  /** Fetch + decrypt every secret in a single environment. */
4086
4099
  async function fetchDecryptedSecrets(ctx, orgId, envId) {
4087
4100
  const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
4088
4101
  const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
4089
4102
  return Object.fromEntries(entries);
4090
4103
  }
4104
+ /**
4105
+ * Decrypt one historical version of a secret. Ciphertext is bound to
4106
+ * `(envId, name)` as AAD and neither changes across versions, so an old blob
4107
+ * opens with the environment's current data key — no special handling needed.
4108
+ */
4109
+ async function fetchDecryptedVersion(ctx, orgId, envId, name, version) {
4110
+ const [dek, { versions }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecretVersions(orgId, envId, name, { limit: 200 })]);
4111
+ const row = versions.find((v) => v.version === version);
4112
+ if (!row) fail(`${name} has no version ${version} in its ${versions.length} newest versions`);
4113
+ return decryptSecret(dek, row.ciphertext, secretAad(envId, name));
4114
+ }
4091
4115
  async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
4092
4116
  const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
4093
4117
  await ctx.client.setSecret(orgId, envId, name, ciphertext);
@@ -4480,6 +4504,12 @@ group.command("env").description("manage a group’s environments (per-slug valu
4480
4504
  });
4481
4505
  console.error(`created ${groupRef.slug}@${created.environment.slug} (${created.environment.id})`);
4482
4506
  });
4507
+ /** Parse a positive integer flag/argument (version numbers, page sizes). */
4508
+ function parseVersion(raw) {
4509
+ const n = Number(raw);
4510
+ if (!Number.isInteger(n) || n < 1) fail(`expected a positive whole number, got "${raw}"`);
4511
+ return n;
4512
+ }
4483
4513
  /** Attach the environment-selection flags shared by every `secrets` command. */
4484
4514
  function withTarget(cmd) {
4485
4515
  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 +4521,12 @@ withTarget(secrets.command("list").description("list secret names (no values)"))
4491
4521
  const { secrets: rows } = await ctx.client.listSecrets(orgId, envId);
4492
4522
  for (const row of rows) console.log(`${row.name}\tv${row.version}\t${row.updatedAt}`);
4493
4523
  });
4494
- withTarget(secrets.command("get <name>").description("decrypt and print one secret value")).action(async (name, options) => {
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) => {
4495
4525
  const ctx = buildContext();
4496
4526
  const { orgId, envId } = await resolveEnvTarget(ctx, options);
4497
- const value = (await fetchDecryptedSecrets(ctx, orgId, envId))[name];
4527
+ let value;
4528
+ if (options.version === void 0) value = (await fetchDecryptedSecrets(ctx, orgId, envId))[name];
4529
+ else value = await fetchDecryptedVersion(ctx, orgId, envId, name, parseVersion(options.version));
4498
4530
  if (value === void 0) fail(`no secret named ${name}`);
4499
4531
  process.stdout.write(value);
4500
4532
  if (process.stdout.isTTY) process.stdout.write("\n");
@@ -4530,6 +4562,22 @@ withTarget(secrets.command("import [file]").description("bulk-import secrets fro
4530
4562
  const { created, updated } = await importSecrets(ctx, orgId, envId, entries);
4531
4563
  console.error(`imported ${created.length + updated.length} secret(s) into ${label} (${created.length} new, ${updated.length} updated)`);
4532
4564
  });
4565
+ 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) => {
4566
+ const ctx = buildContext();
4567
+ const { orgId, envId } = await resolveEnvTarget(ctx, options);
4568
+ const { versions, currentVersion } = await ctx.client.listSecretVersions(orgId, envId, name, { limit: parseVersion(options.limit) });
4569
+ for (const v of versions) {
4570
+ const marks = [v.version === currentVersion ? "current" : null, v.restoredFromVersion === null ? null : `restored from v${v.restoredFromVersion}`].filter(Boolean);
4571
+ const note = marks.length > 0 ? `\t${marks.join(", ")}` : "";
4572
+ console.log(`v${v.version}\t${v.createdAt}\t${v.createdByType}:${v.createdById}${note}`);
4573
+ }
4574
+ });
4575
+ 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) => {
4576
+ const ctx = buildContext();
4577
+ const { orgId, envId } = await resolveEnvTarget(ctx, options);
4578
+ const { secret, restoredFrom } = await ctx.client.restoreSecret(orgId, envId, name, parseVersion(version));
4579
+ console.error(`${name} restored from v${restoredFrom} — now v${secret.version}`);
4580
+ });
4533
4581
  withTarget(secrets.command("rm <name>").description("delete a secret")).action(async (name, options) => {
4534
4582
  const ctx = buildContext();
4535
4583
  const { orgId, envId } = await resolveEnvTarget(ctx, options);
@@ -4575,45 +4623,70 @@ async function materializeForRun(options) {
4575
4623
  }
4576
4624
  }
4577
4625
  /**
4578
- * Every live descendant of `root`, from a single `ps(1)` snapshot of the
4579
- * process table. We follow the parent chain rather than the process group
4580
- * because a process that calls setsid(2)/setpgid(2) leaves the group nodemon
4581
- * does exactly that for the script it watches — while the parent chain still
4582
- * leads to it. Returns nothing if `ps` is unavailable; callers fall back to
4583
- * signalling the immediate child.
4626
+ * Every live process `seekrit run` is responsible for tearing down, from a
4627
+ * single `ps(1)` snapshot: each descendant of `root`, plus when we lead our
4628
+ * own process group every other member of that group.
4629
+ *
4630
+ * Two mechanisms, because each covers the other's blind spot:
4631
+ *
4632
+ * - The parent-chain walk reaches a process that left our group via
4633
+ * setsid(2)/setpgid(2), which no group-wide kill and no tty-generated signal
4634
+ * can touch. But it loses anything whose chain up to `root` has already
4635
+ * broken — teardown kills the middle layers first, and an orphan reparents to
4636
+ * init, out of reach of any walk down from `root`.
4637
+ * - Group membership survives exactly that: a pgid outlives the parent that
4638
+ * passed it down. But it misses the ones that left the group.
4639
+ *
4640
+ * The group half applies only when our pid *is* our pgid, which is the shell-job
4641
+ * case: the shell put this command in a new group, so every other member of it
4642
+ * descends from us. Nested inside a shell script or a CI runner we are not the
4643
+ * leader and the group holds processes we did not start, so there we use the
4644
+ * walk alone.
4645
+ *
4646
+ * Zombies are skipped: they are already dead and waiting to be reaped, so
4647
+ * counting them would stall teardown for the full grace period and then report
4648
+ * a kill that did nothing. Returns nothing if `ps` is unavailable; callers fall
4649
+ * back to signalling the immediate child.
4584
4650
  */
4585
- function descendantsOf(root) {
4651
+ function teardownTargets(root) {
4586
4652
  const ps = spawnSync("ps", [
4587
4653
  "-A",
4588
4654
  "-o",
4589
- "pid=,ppid=,pgid="
4655
+ "pid=,ppid=,pgid=,stat="
4590
4656
  ], { encoding: "utf8" });
4591
4657
  if (ps.status !== 0 || typeof ps.stdout !== "string") return [];
4592
4658
  const childrenOf = /* @__PURE__ */ new Map();
4593
4659
  const groupOf = /* @__PURE__ */ new Map();
4594
4660
  const isId = (value) => value !== void 0 && Number.isInteger(value);
4595
4661
  for (const line of ps.stdout.split("\n")) {
4596
- const [pid, ppid, pgid] = line.trim().split(/\s+/, 3).map(Number);
4597
- if (!isId(pid) || !isId(ppid) || !isId(pgid)) continue;
4598
- const siblings = childrenOf.get(ppid);
4599
- if (siblings) siblings.push(pid);
4600
- else childrenOf.set(ppid, [pid]);
4601
- groupOf.set(pid, pgid);
4602
- }
4603
- const ourGroup = groupOf.get(process.pid);
4604
- const found = [];
4662
+ const [pid, ppid, pgid, stat] = line.trim().split(/\s+/, 4);
4663
+ const [id, parent, group] = [
4664
+ pid,
4665
+ ppid,
4666
+ pgid
4667
+ ].map(Number);
4668
+ if (!isId(id) || !isId(parent) || !isId(group)) continue;
4669
+ if (stat?.startsWith("Z")) continue;
4670
+ const siblings = childrenOf.get(parent);
4671
+ if (siblings) siblings.push(id);
4672
+ else childrenOf.set(parent, [id]);
4673
+ groupOf.set(id, group);
4674
+ }
4675
+ const found = /* @__PURE__ */ new Set();
4605
4676
  const queue = [root];
4606
4677
  const seen = new Set(queue);
4607
4678
  for (let pid = queue.shift(); pid !== void 0; pid = queue.shift()) for (const kid of childrenOf.get(pid) ?? []) {
4608
4679
  if (seen.has(kid)) continue;
4609
4680
  seen.add(kid);
4610
- found.push({
4611
- pid: kid,
4612
- escaped: ourGroup === void 0 || groupOf.get(kid) !== ourGroup
4613
- });
4681
+ found.add(kid);
4614
4682
  queue.push(kid);
4615
4683
  }
4616
- return found;
4684
+ if (groupOf.get(process.pid) === process.pid) {
4685
+ for (const [pid, group] of groupOf) if (group === process.pid) found.add(pid);
4686
+ }
4687
+ found.delete(process.pid);
4688
+ found.delete(root);
4689
+ return [...found];
4617
4690
  }
4618
4691
  /** Whether `pid` still exists — signal 0 tests reachability, delivers nothing. */
4619
4692
  function isAlive(pid) {
@@ -4624,25 +4697,37 @@ function isAlive(pid) {
4624
4697
  return false;
4625
4698
  }
4626
4699
  }
4627
- /** How long a signalled escapee gets to exit on its own before SIGKILL. */
4628
- const ESCAPEE_GRACE_MS = 2e3;
4700
+ /** How long a straggler gets to finish shutting down before SIGKILL. */
4701
+ const STRAGGLER_GRACE_MS = 2e3;
4629
4702
  /**
4630
- * Give already-signalled processes that left our group a moment to finish
4631
- * shutting down, then SIGKILL the holdouts. We are the last process that knows
4632
- * their pids: the tty won't hang them up, and once their parent exits they
4633
- * reparent to init, out of reach of any walk down from our own child — which is
4634
- * how one survives for days, invisible to `ps -a` for want of a tty. Only
4635
- * processes that already ignored a termination signal are killed; the command
4636
- * itself and anything still in our group shut down at their own pace.
4703
+ * Once the command has exited, make sure nothing it started outlives us.
4704
+ *
4705
+ * Anything still alive here has ignored the signal we forwarded *and* lost the
4706
+ * parent that launched it, and we are the last process that knows its pid:
4707
+ * the tty won't hang it up (it is no longer in the foreground group, and the
4708
+ * shell has moved on), so it survives until the machine reboots. That is how
4709
+ * `tsx` and the server under it stacked up for days.
4710
+ *
4711
+ * So escalate rather than trust. SIGTERM first — it costs no extra time and is
4712
+ * a rung the SIGINT-only handlers common in dev servers don't catch — then
4713
+ * SIGKILL whatever is left after the grace period. Nothing is killed unless it
4714
+ * was asked to stop first and declined, and this runs only when we forwarded a
4715
+ * termination signal: a command that exits on its own having deliberately left a
4716
+ * daemon behind keeps working.
4637
4717
  */
4638
- async function reapEscapees(pids) {
4639
- if (pids.size === 0) return;
4640
- const deadline = Date.now() + ESCAPEE_GRACE_MS;
4718
+ async function reapStragglers(pids, signal) {
4641
4719
  let live = [...pids].filter(isAlive);
4720
+ if (live.length === 0) return;
4721
+ for (const pid of live) try {
4722
+ process.kill(pid, "SIGTERM");
4723
+ } catch {}
4724
+ const deadline = Date.now() + STRAGGLER_GRACE_MS;
4642
4725
  while (live.length > 0 && Date.now() < deadline) {
4643
4726
  await new Promise((resolve) => setTimeout(resolve, 50));
4644
4727
  live = live.filter(isAlive);
4645
4728
  }
4729
+ if (live.length === 0) return;
4730
+ console.error(`seekrit: force-killed ${live.length} leftover process(es) that ignored ${signal} and SIGTERM (${live.join(", ")})`);
4646
4731
  for (const pid of live) try {
4647
4732
  process.kill(pid, "SIGKILL");
4648
4733
  } catch {}
@@ -4669,21 +4754,27 @@ program.command("run").description("run a command with decrypted secrets injecte
4669
4754
  "SIGHUP",
4670
4755
  "SIGQUIT"
4671
4756
  ];
4672
- const escapees = /* @__PURE__ */ new Set();
4757
+ const stragglers = /* @__PURE__ */ new Set();
4758
+ let forwarded;
4759
+ const collect = () => {
4760
+ if (!posix || !child.pid) return;
4761
+ for (const pid of teardownTargets(child.pid)) stragglers.add(pid);
4762
+ };
4673
4763
  const forward = (signal) => {
4674
4764
  if (child.exitCode !== null || child.signalCode !== null) return;
4765
+ forwarded ??= signal;
4766
+ collect();
4675
4767
  child.kill(signal);
4676
- if (!posix || !child.pid) return;
4677
- for (const { pid, escaped } of descendantsOf(child.pid)) {
4678
- if (escaped) escapees.add(pid);
4679
- try {
4680
- process.kill(pid, signal);
4681
- } catch {}
4682
- }
4768
+ for (const pid of stragglers) try {
4769
+ process.kill(pid, signal);
4770
+ } catch {}
4683
4771
  };
4684
4772
  for (const signal of signals) process.on(signal, forward);
4685
4773
  child.on("exit", async (code, signal) => {
4686
- await reapEscapees(escapees);
4774
+ if (forwarded) {
4775
+ collect();
4776
+ await reapStragglers(stragglers, forwarded);
4777
+ }
4687
4778
  for (const s of signals) process.off(s, forward);
4688
4779
  if (signal) process.kill(process.pid, signal);
4689
4780
  else process.exit(code ?? 1);
@@ -4818,7 +4909,7 @@ registerMongoCommands(program);
4818
4909
  registerKmsCommands(program);
4819
4910
  registerRecoveryCommands(program);
4820
4911
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
4821
- const { runMcpServer } = await import("./mcp-CbNXG37n.js");
4912
+ const { runMcpServer } = await import("./mcp-t-sJ_SvE.js");
4822
4913
  await runMcpServer();
4823
4914
  });
4824
4915
  program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
@@ -4832,4 +4923,4 @@ program.parseAsync(argv).catch((err) => {
4832
4923
  fail(err instanceof Error ? err.message : String(err));
4833
4924
  });
4834
4925
  //#endregion
4835
- 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 };
4926
+ 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.",
@@ -585,10 +587,11 @@ 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). Pass `version` to read an earlier version instead of the current one.", {
589
591
  ...targetShape,
590
592
  name: z.string(),
591
- reveal: z.boolean().optional()
593
+ reveal: z.boolean().optional(),
594
+ version: z.number().int().positive().optional().describe("an earlier version from list_secret_versions (default: current)")
592
595
  }, async (o) => {
593
596
  const ctx = getCtx();
594
597
  const { orgId, envId } = await resolveTargetEnv(ctx, o);
@@ -598,11 +601,20 @@ async function runMcpServer(options = {}) {
598
601
  if (!row) throw new Error(`no secret named ${o.name}`);
599
602
  return {
600
603
  name: row.name,
601
- version: row.version,
604
+ version: o.version ?? row.version,
602
605
  revealed: false
603
606
  };
604
607
  }
605
608
  ensureDecryptable(ctx);
609
+ if (o.version !== void 0) {
610
+ const value = await fetchDecryptedVersion(ctx, orgId, envId, o.name, o.version);
611
+ return {
612
+ name: o.name,
613
+ version: o.version,
614
+ value,
615
+ revealed: true
616
+ };
617
+ }
606
618
  const values = await fetchDecryptedSecrets(ctx, orgId, envId);
607
619
  if (!(o.name in values)) throw new Error(`no secret named ${o.name}`);
608
620
  return {
@@ -611,6 +623,39 @@ async function runMcpServer(options = {}) {
611
623
  revealed: true
612
624
  };
613
625
  });
626
+ 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.", {
627
+ ...targetShape,
628
+ name: z.string(),
629
+ limit: z.number().int().min(1).max(200).optional().describe("default 20")
630
+ }, async (o) => {
631
+ const ctx = getCtx();
632
+ const { orgId, envId } = await resolveTargetEnv(ctx, o);
633
+ const { versions, currentVersion } = await ctx.client.listSecretVersions(orgId, envId, o.name, { limit: o.limit ?? 20 });
634
+ return {
635
+ currentVersion,
636
+ versions: versions.map((v) => ({
637
+ version: v.version,
638
+ createdAt: v.createdAt,
639
+ createdBy: `${v.createdByType}:${v.createdById}`,
640
+ restoredFromVersion: v.restoredFromVersion
641
+ }))
642
+ };
643
+ });
644
+ 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.", {
645
+ ...targetShape,
646
+ name: z.string(),
647
+ version: z.number().int().positive()
648
+ }, async (o) => {
649
+ const ctx = getCtx();
650
+ const { orgId, envId } = await resolveTargetEnv(ctx, o);
651
+ const { secret, restoredFrom } = await ctx.client.restoreSecret(orgId, envId, o.name, o.version);
652
+ return {
653
+ ok: true,
654
+ name: o.name,
655
+ restoredFrom,
656
+ version: secret.version
657
+ };
658
+ });
614
659
  tool("delete_secret", "Delete a secret from an environment.", {
615
660
  ...targetShape,
616
661
  name: z.string()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.23.3",
3
+ "version": "0.24.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {