@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 +144 -53
- package/dist/{mcp-CbNXG37n.js → mcp-t-sJ_SvE.js} +49 -4
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
|
4579
|
-
*
|
|
4580
|
-
*
|
|
4581
|
-
*
|
|
4582
|
-
*
|
|
4583
|
-
*
|
|
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
|
|
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+/,
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
4628
|
-
const
|
|
4700
|
+
/** How long a straggler gets to finish shutting down before SIGKILL. */
|
|
4701
|
+
const STRAGGLER_GRACE_MS = 2e3;
|
|
4629
4702
|
/**
|
|
4630
|
-
*
|
|
4631
|
-
*
|
|
4632
|
-
*
|
|
4633
|
-
*
|
|
4634
|
-
*
|
|
4635
|
-
*
|
|
4636
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
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
|
-
|
|
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-
|
|
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 {
|
|
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
|
|
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()
|