@seekrit/cli 0.1.0 → 0.3.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 +86 -21
- package/dist/mcp-hxTidFyj.js +558 -0
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -315,7 +315,7 @@ async function unwrapDek(wrapped, privateKey) {
|
|
|
315
315
|
}
|
|
316
316
|
//#endregion
|
|
317
317
|
//#region package.json
|
|
318
|
-
var version = "0.
|
|
318
|
+
var version = "0.3.0";
|
|
319
319
|
const PROJECT_FILE = "seekrit.json";
|
|
320
320
|
function globalConfigPath() {
|
|
321
321
|
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
|
|
@@ -518,7 +518,17 @@ var SeekritClient = class {
|
|
|
518
518
|
};
|
|
519
519
|
//#endregion
|
|
520
520
|
//#region src/io.ts
|
|
521
|
+
let failThrows = false;
|
|
522
|
+
/**
|
|
523
|
+
* In `seekrit mcp` the process is a long-lived stdio server, so a `fail()`
|
|
524
|
+
* must surface as a catchable error (→ a tool error result) rather than
|
|
525
|
+
* exiting and killing every other tool. Toggled on once at MCP startup.
|
|
526
|
+
*/
|
|
527
|
+
function setFailThrows(value) {
|
|
528
|
+
failThrows = value;
|
|
529
|
+
}
|
|
521
530
|
function fail(message) {
|
|
531
|
+
if (failThrows) throw new Error(message);
|
|
522
532
|
console.error(`error: ${message}`);
|
|
523
533
|
process.exit(1);
|
|
524
534
|
}
|
|
@@ -549,7 +559,12 @@ async function readStdin() {
|
|
|
549
559
|
}
|
|
550
560
|
//#endregion
|
|
551
561
|
//#region src/context.ts
|
|
552
|
-
|
|
562
|
+
/**
|
|
563
|
+
* Build the client context from configured credentials, or return null when
|
|
564
|
+
* none are set. `seekrit run` uses this to degrade to a plain launcher instead
|
|
565
|
+
* of exiting; every other command goes through `buildContext`, which fails.
|
|
566
|
+
*/
|
|
567
|
+
function tryBuildContext() {
|
|
553
568
|
const config = readGlobalConfig();
|
|
554
569
|
const apiUrl = process.env.SEEKRIT_API_URL ?? config.apiUrl ?? "http://localhost:8787";
|
|
555
570
|
const token = process.env.SEEKRIT_TOKEN ?? config.token;
|
|
@@ -563,7 +578,7 @@ function buildContext() {
|
|
|
563
578
|
type: "dev",
|
|
564
579
|
email: devUser
|
|
565
580
|
};
|
|
566
|
-
else
|
|
581
|
+
else return null;
|
|
567
582
|
return {
|
|
568
583
|
client: new SeekritClient({
|
|
569
584
|
baseUrl: apiUrl,
|
|
@@ -572,6 +587,11 @@ function buildContext() {
|
|
|
572
587
|
auth
|
|
573
588
|
};
|
|
574
589
|
}
|
|
590
|
+
function buildContext() {
|
|
591
|
+
const ctx = tryBuildContext();
|
|
592
|
+
if (!ctx) fail("no credentials found — run `seekrit login --token skt_…`, or set SEEKRIT_TOKEN / SEEKRIT_DEV_USER");
|
|
593
|
+
return ctx;
|
|
594
|
+
}
|
|
575
595
|
function isTokenAuth(ctx) {
|
|
576
596
|
return ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token);
|
|
577
597
|
}
|
|
@@ -682,21 +702,30 @@ async function materializeEnv(ctx, opts) {
|
|
|
682
702
|
provenance[secret.name] = label;
|
|
683
703
|
}
|
|
684
704
|
}
|
|
685
|
-
|
|
686
|
-
|
|
705
|
+
return {
|
|
706
|
+
values,
|
|
707
|
+
provenance,
|
|
708
|
+
scope,
|
|
709
|
+
loadedEnvFiles: overlayEnvFiles(values, provenance, opts.envFiles)
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Overlay `.env` files onto an existing value/provenance set (later files win).
|
|
714
|
+
* Missing files are skipped. Returns the files that were actually loaded. Used
|
|
715
|
+
* both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
|
|
716
|
+
* managed secrets are unavailable but `.env` should still apply.
|
|
717
|
+
*/
|
|
718
|
+
function overlayEnvFiles(values, provenance, envFiles) {
|
|
719
|
+
const loaded = [];
|
|
720
|
+
for (const file of envFiles) {
|
|
687
721
|
if (!existsSync(file)) continue;
|
|
688
|
-
|
|
722
|
+
loaded.push(file);
|
|
689
723
|
for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
|
|
690
724
|
values[name] = value;
|
|
691
725
|
provenance[name] = `dotenv:${file}`;
|
|
692
726
|
}
|
|
693
727
|
}
|
|
694
|
-
return
|
|
695
|
-
values,
|
|
696
|
-
provenance,
|
|
697
|
-
scope,
|
|
698
|
-
loadedEnvFiles
|
|
699
|
-
};
|
|
728
|
+
return loaded;
|
|
700
729
|
}
|
|
701
730
|
/** Print a name → source table to stderr (never the secret values). */
|
|
702
731
|
function printExplain(provenance) {
|
|
@@ -990,14 +1019,39 @@ async function materialize(ctx, options) {
|
|
|
990
1019
|
envFiles: options.envFile ?? [".env"]
|
|
991
1020
|
});
|
|
992
1021
|
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Best-effort variant of {@link materialize} for `seekrit run`: if credentials
|
|
1024
|
+
* are missing or seekrit can't be reached (network, auth, or decryption
|
|
1025
|
+
* failure), log why and fall back to the `.env` overlay alone so the command
|
|
1026
|
+
* still runs. This keeps `seekrit run` working across environments that lack a
|
|
1027
|
+
* token or API — mirroring the `seekrit-run` launcher.
|
|
1028
|
+
*/
|
|
1029
|
+
async function materializeForRun(options) {
|
|
1030
|
+
const envFiles = options.envFile ?? [".env"];
|
|
1031
|
+
try {
|
|
1032
|
+
const ctx = tryBuildContext();
|
|
1033
|
+
if (!ctx) throw new Error("no credentials found (set SEEKRIT_TOKEN / SEEKRIT_DEV_USER or run `seekrit login`)");
|
|
1034
|
+
return await materialize(ctx, options);
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1037
|
+
console.error(`seekrit: continuing without seekrit-managed secrets: ${message}`);
|
|
1038
|
+
const values = {};
|
|
1039
|
+
const provenance = {};
|
|
1040
|
+
overlayEnvFiles(values, provenance, envFiles);
|
|
1041
|
+
return {
|
|
1042
|
+
values,
|
|
1043
|
+
provenance
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
993
1047
|
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) => {
|
|
994
|
-
const
|
|
1048
|
+
const [cmd, ...args] = commandParts;
|
|
1049
|
+
if (!cmd) fail("no command given");
|
|
1050
|
+
const { values, provenance } = await materializeForRun(options);
|
|
995
1051
|
if (options.explain) {
|
|
996
1052
|
for (const name of Object.keys(values)) if (process.env[name] !== void 0 && process.env[name] !== values[name]) provenance[name] = "env";
|
|
997
1053
|
printExplain(provenance);
|
|
998
1054
|
}
|
|
999
|
-
const [cmd, ...args] = commandParts;
|
|
1000
|
-
if (!cmd) fail("no command given");
|
|
1001
1055
|
const child = spawn(cmd, args, {
|
|
1002
1056
|
stdio: "inherit",
|
|
1003
1057
|
env: {
|
|
@@ -1058,16 +1112,21 @@ program.command("grant").description("give a member or service token access to a
|
|
|
1058
1112
|
console.error(`granted ${label} access to ${principalId}`);
|
|
1059
1113
|
});
|
|
1060
1114
|
const token = program.command("token").description("manage service tokens (CI, docker, agents)");
|
|
1061
|
-
token.command("create").description("create a service token
|
|
1115
|
+
token.command("create").description("create a service token (runtime, or --admin for provisioning); prints it once").requiredOption("--name <name>", "display name, e.g. ci-deploy").option("--org <slug>").option("--app <slug>", "application to bind the token to (runtime tokens)").option("--env <slug>", "environment to bind the token to (runtime tokens)").option("--admin", "mint an org-scoped admin token that can provision structure (no env binding required)").option("--allow <group=env>", "also grant an alternate group slice (for `run --with`)", collectKv).option("--no-grant", "skip auto-granting the env + composed group keys").action(async (options) => {
|
|
1062
1116
|
const ctx = buildContext();
|
|
1063
|
-
const
|
|
1117
|
+
const role = options.admin ? "admin" : "member";
|
|
1118
|
+
const boundToEnv = Boolean(options.app || options.env);
|
|
1119
|
+
if (!options.admin && !boundToEnv) fail("runtime tokens need --app and --env (or pass --admin for an org-scoped token)");
|
|
1120
|
+
if (boundToEnv && !(options.app && options.env)) fail("pass both --app and --env to bind a token to an environment");
|
|
1121
|
+
const target = boundToEnv ? await resolveAppEnv(ctx, options) : { orgId: (await resolveOrg(ctx, options.org)).id };
|
|
1064
1122
|
const created = await createServiceToken();
|
|
1065
1123
|
await ctx.client.createToken(target.orgId, {
|
|
1066
1124
|
name: options.name,
|
|
1067
1125
|
tokenId: created.tokenId,
|
|
1068
1126
|
tokenHash: created.tokenHash,
|
|
1069
1127
|
publicKeyJwk: created.publicKeyJwk,
|
|
1070
|
-
|
|
1128
|
+
role,
|
|
1129
|
+
environmentId: "envId" in target ? target.envId : null
|
|
1071
1130
|
});
|
|
1072
1131
|
const grantEnv = async (envId) => {
|
|
1073
1132
|
const dek = await getDek(ctx, target.orgId, envId);
|
|
@@ -1077,7 +1136,8 @@ token.command("create").description("create a service token bound to an app envi
|
|
|
1077
1136
|
wrappedDek: await wrapDek(dek, created.publicKeyJwk)
|
|
1078
1137
|
});
|
|
1079
1138
|
};
|
|
1080
|
-
|
|
1139
|
+
const granted = boundToEnv && options.grant !== false;
|
|
1140
|
+
if (granted && "envId" in target) {
|
|
1081
1141
|
await grantEnv(target.envId);
|
|
1082
1142
|
const { groups } = await ctx.client.listEnvGroups(target.orgId, target.envId);
|
|
1083
1143
|
for (const g of groups) {
|
|
@@ -1097,7 +1157,8 @@ token.command("create").description("create a service token bound to an app envi
|
|
|
1097
1157
|
await grantEnv(slice.id);
|
|
1098
1158
|
}
|
|
1099
1159
|
}
|
|
1100
|
-
|
|
1160
|
+
const scope = "appSlug" in target ? `${target.appSlug}/${target.envSlug}` : "org-scoped (admin)";
|
|
1161
|
+
console.error(`${role} token created${granted ? " and granted" : ""} for ${scope} — save it now, it is not stored:`);
|
|
1101
1162
|
console.log(created.token);
|
|
1102
1163
|
});
|
|
1103
1164
|
token.command("list").description("list service tokens").option("--org <slug>").action(async (options) => {
|
|
@@ -1115,6 +1176,10 @@ token.command("revoke <tokenId>").description("revoke a service token").option("
|
|
|
1115
1176
|
await ctx.client.revokeToken(orgRef.id, tokenId);
|
|
1116
1177
|
console.error(`${tokenId} revoked`);
|
|
1117
1178
|
});
|
|
1179
|
+
program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
|
|
1180
|
+
const { runMcpServer } = await import("./mcp-hxTidFyj.js");
|
|
1181
|
+
await runMcpServer();
|
|
1182
|
+
});
|
|
1118
1183
|
program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
|
|
1119
1184
|
const ctx = buildContext();
|
|
1120
1185
|
const orgRef = await resolveOrg(ctx, options.org);
|
|
@@ -1125,4 +1190,4 @@ program.parseAsync().catch((err) => {
|
|
|
1125
1190
|
fail(err instanceof Error ? err.message : String(err));
|
|
1126
1191
|
});
|
|
1127
1192
|
//#endregion
|
|
1128
|
-
export {};
|
|
1193
|
+
export { parseServiceToken as _, encryptAndSetSecret as a, getDek as c, setFailThrows as d, writeProjectConfig as f, isServiceToken as g, createServiceToken as h, resolveOrg as i, isTokenAuth as l, wrapDek as m, resolveEnvTarget as n, fetchDecryptedSecrets as o, version as p, resolveGroup as r, materializeEnv as s, resolveAppEnv as t, tryBuildContext as u, generateDek as v };
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { _ as parseServiceToken, a as encryptAndSetSecret, c as getDek, d as setFailThrows, f as writeProjectConfig, g as isServiceToken, h as createServiceToken, i as resolveOrg, l as isTokenAuth, m as wrapDek, n as resolveEnvTarget, o as fetchDecryptedSecrets, p as version, r as resolveGroup, s as materializeEnv, t as resolveAppEnv, u as tryBuildContext, v as generateDek } from "./index.js";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
//#region src/mcp.ts
|
|
7
|
+
function jsonText(data) {
|
|
8
|
+
return { content: [{
|
|
9
|
+
type: "text",
|
|
10
|
+
text: typeof data === "string" ? data : JSON.stringify(data, null, 2)
|
|
11
|
+
}] };
|
|
12
|
+
}
|
|
13
|
+
function errText(err) {
|
|
14
|
+
return {
|
|
15
|
+
content: [{
|
|
16
|
+
type: "text",
|
|
17
|
+
text: `error: ${err instanceof Error ? err.message : String(err)}`
|
|
18
|
+
}],
|
|
19
|
+
isError: true
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Build the client context or throw a friendly, agent-readable error. */
|
|
23
|
+
function getCtx() {
|
|
24
|
+
const ctx = tryBuildContext();
|
|
25
|
+
if (!ctx) throw new Error("no credentials — set SEEKRIT_TOKEN (a skt_… token) or SEEKRIT_DEV_USER in the MCP server env, or run `seekrit login` first");
|
|
26
|
+
return ctx;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Guard tools that decrypt: under user auth we cannot prompt for a passphrase
|
|
30
|
+
* (stdin is the transport), so it must be supplied out-of-band.
|
|
31
|
+
*/
|
|
32
|
+
function ensureDecryptable(ctx) {
|
|
33
|
+
if (!isTokenAuth(ctx) && !process.env.SEEKRIT_PASSPHRASE) throw new Error("this operation decrypts data; set SEEKRIT_PASSPHRASE in the MCP server env, or use a service token (SEEKRIT_TOKEN)");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The calling principal's public key JWK — needed to wrap a freshly generated
|
|
37
|
+
* DEK when creating an environment. Service tokens carry their private key, so
|
|
38
|
+
* we derive the public half from it; users publish theirs at key setup.
|
|
39
|
+
*/
|
|
40
|
+
async function principalPublicKeyJwk(ctx) {
|
|
41
|
+
if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
|
|
42
|
+
const { privateKey } = await parseServiceToken(ctx.auth.token);
|
|
43
|
+
const { d: _d, key_ops: _ops, ext: _ext, ...pub } = await crypto.subtle.exportKey("jwk", privateKey);
|
|
44
|
+
return JSON.stringify(pub);
|
|
45
|
+
}
|
|
46
|
+
const { user } = await ctx.client.me();
|
|
47
|
+
if (!user.publicKeyJwk) throw new Error("this user has no keypair yet — run `seekrit keys setup` first");
|
|
48
|
+
return user.publicKeyJwk;
|
|
49
|
+
}
|
|
50
|
+
/** Run a child command with `env` and capture its output (values never returned). */
|
|
51
|
+
function runChild(cmd, args, env, cwd) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const child = spawn(cmd, args, {
|
|
54
|
+
env,
|
|
55
|
+
cwd,
|
|
56
|
+
stdio: [
|
|
57
|
+
"ignore",
|
|
58
|
+
"pipe",
|
|
59
|
+
"pipe"
|
|
60
|
+
]
|
|
61
|
+
});
|
|
62
|
+
let stdout = "";
|
|
63
|
+
let stderr = "";
|
|
64
|
+
const cap = 64e3;
|
|
65
|
+
child.stdout.on("data", (d) => {
|
|
66
|
+
if (stdout.length < cap) stdout += d.toString("utf8");
|
|
67
|
+
});
|
|
68
|
+
child.stderr.on("data", (d) => {
|
|
69
|
+
if (stderr.length < cap) stderr += d.toString("utf8");
|
|
70
|
+
});
|
|
71
|
+
child.on("error", reject);
|
|
72
|
+
child.on("close", (code) => resolve({
|
|
73
|
+
exitCode: code ?? 1,
|
|
74
|
+
stdout,
|
|
75
|
+
stderr
|
|
76
|
+
}));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Resolve the layered environment for run/export (token path infers the env). */
|
|
80
|
+
async function materializeFor(ctx, o) {
|
|
81
|
+
let envId;
|
|
82
|
+
if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, o)).envId;
|
|
83
|
+
return materializeEnv(ctx, {
|
|
84
|
+
envId,
|
|
85
|
+
with: o.with,
|
|
86
|
+
envFiles: o.envFile ?? [".env"]
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const targetShape = {
|
|
90
|
+
org: z.string().optional().describe("organization slug or id (defaults to a lone org)"),
|
|
91
|
+
app: z.string().optional().describe("application slug or id (or set via configure_project)"),
|
|
92
|
+
group: z.string().optional().describe("target a group environment instead of an app"),
|
|
93
|
+
env: z.string().optional().describe("environment slug or id (a service token infers its own)")
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Resolve which environment a secret tool addresses. A service token with no
|
|
97
|
+
* explicit app/group targets its own bound environment (no flags needed);
|
|
98
|
+
* everyone else names app|group + env.
|
|
99
|
+
*/
|
|
100
|
+
async function resolveTargetEnv(ctx, o) {
|
|
101
|
+
if (isTokenAuth(ctx) && !o.app && !o.group) {
|
|
102
|
+
const { scope } = await ctx.client.resolve();
|
|
103
|
+
return {
|
|
104
|
+
orgId: scope.orgId,
|
|
105
|
+
envId: scope.envId,
|
|
106
|
+
label: `${scope.appSlug}/${scope.envSlug}`
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return resolveEnvTarget(ctx, o);
|
|
110
|
+
}
|
|
111
|
+
async function runMcpServer() {
|
|
112
|
+
setFailThrows(true);
|
|
113
|
+
const server = new McpServer({
|
|
114
|
+
name: "seekrit",
|
|
115
|
+
version
|
|
116
|
+
});
|
|
117
|
+
/** Register a tool whose handler returns data (serialized) or throws (→ isError). */
|
|
118
|
+
const tool = (name, description, shape, handler) => {
|
|
119
|
+
server.registerTool(name, {
|
|
120
|
+
description,
|
|
121
|
+
inputSchema: shape
|
|
122
|
+
}, (async (args) => {
|
|
123
|
+
try {
|
|
124
|
+
return jsonText(await handler(args));
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return errText(err);
|
|
127
|
+
}
|
|
128
|
+
}));
|
|
129
|
+
};
|
|
130
|
+
tool("whoami", "Show the authenticated identity, its role, and accessible orgs.", {}, async () => {
|
|
131
|
+
const ctx = getCtx();
|
|
132
|
+
if (isTokenAuth(ctx) && ctx.auth.type === "bearer") {
|
|
133
|
+
const { tokenId } = await parseServiceToken(ctx.auth.token);
|
|
134
|
+
const { orgs } = await ctx.client.listOrgs();
|
|
135
|
+
let scope = null;
|
|
136
|
+
try {
|
|
137
|
+
scope = (await ctx.client.resolve()).scope;
|
|
138
|
+
} catch {}
|
|
139
|
+
return {
|
|
140
|
+
kind: "service_token",
|
|
141
|
+
tokenId,
|
|
142
|
+
org: orgs[0]?.slug ?? null,
|
|
143
|
+
boundScope: scope
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
kind: "user",
|
|
148
|
+
...await ctx.client.me()
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
tool("list_orgs", "List organizations the caller can access.", {}, async () => (await getCtx().client.listOrgs()).orgs);
|
|
152
|
+
tool("list_apps", "List applications in an organization.", { org: z.string().optional() }, async ({ org }) => {
|
|
153
|
+
const ctx = getCtx();
|
|
154
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
155
|
+
return (await ctx.client.listApps(orgRef.id)).apps;
|
|
156
|
+
});
|
|
157
|
+
tool("list_envs", "List environments of an application.", {
|
|
158
|
+
org: z.string().optional(),
|
|
159
|
+
app: z.string()
|
|
160
|
+
}, async ({ org, app }) => {
|
|
161
|
+
const ctx = getCtx();
|
|
162
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
163
|
+
const { apps } = await ctx.client.listApps(orgRef.id);
|
|
164
|
+
const appRow = apps.find((a) => a.slug === app || a.id === app);
|
|
165
|
+
if (!appRow) throw new Error(`no app "${app}" in ${orgRef.slug}`);
|
|
166
|
+
return (await ctx.client.listEnvs(orgRef.id, appRow.id)).environments;
|
|
167
|
+
});
|
|
168
|
+
tool("list_groups", "List shared groups (reusable secret bags) in an organization.", { org: z.string().optional() }, async ({ org }) => {
|
|
169
|
+
const ctx = getCtx();
|
|
170
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
171
|
+
return (await ctx.client.listGroups(orgRef.id)).groups;
|
|
172
|
+
});
|
|
173
|
+
tool("list_group_envs", "List a group's environments (per-slug value sets).", {
|
|
174
|
+
org: z.string().optional(),
|
|
175
|
+
group: z.string()
|
|
176
|
+
}, async ({ org, group }) => {
|
|
177
|
+
const ctx = getCtx();
|
|
178
|
+
const g = await resolveGroup(ctx, {
|
|
179
|
+
org,
|
|
180
|
+
group
|
|
181
|
+
});
|
|
182
|
+
return (await ctx.client.listGroupEnvs(g.orgId, g.id)).environments;
|
|
183
|
+
});
|
|
184
|
+
tool("list_env_groups", "List the groups composed into an application environment (precedence order).", {
|
|
185
|
+
org: z.string().optional(),
|
|
186
|
+
app: z.string(),
|
|
187
|
+
env: z.string()
|
|
188
|
+
}, async ({ org, app, env }) => {
|
|
189
|
+
const ctx = getCtx();
|
|
190
|
+
const target = await resolveAppEnv(ctx, {
|
|
191
|
+
org,
|
|
192
|
+
app,
|
|
193
|
+
env
|
|
194
|
+
});
|
|
195
|
+
return (await ctx.client.listEnvGroups(target.orgId, target.envId)).groups;
|
|
196
|
+
});
|
|
197
|
+
tool("list_members", "List organization members and their public keys (for granting access).", { org: z.string().optional() }, async ({ org }) => {
|
|
198
|
+
const ctx = getCtx();
|
|
199
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
200
|
+
return (await ctx.client.listMembers(orgRef.id)).members;
|
|
201
|
+
});
|
|
202
|
+
tool("list_secrets", "List secret names + versions in an environment (never values).", targetShape, async (o) => {
|
|
203
|
+
const ctx = getCtx();
|
|
204
|
+
const { orgId, envId } = await resolveTargetEnv(ctx, o);
|
|
205
|
+
const { secrets } = await ctx.client.listSecrets(orgId, envId);
|
|
206
|
+
return secrets.map((s) => ({
|
|
207
|
+
name: s.name,
|
|
208
|
+
version: s.version,
|
|
209
|
+
updatedAt: s.updatedAt
|
|
210
|
+
}));
|
|
211
|
+
});
|
|
212
|
+
tool("list_tokens", "List an organization's service tokens (never the secret token strings).", { org: z.string().optional() }, async ({ org }) => {
|
|
213
|
+
const ctx = getCtx();
|
|
214
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
215
|
+
return (await ctx.client.listTokens(orgRef.id)).tokens;
|
|
216
|
+
});
|
|
217
|
+
tool("audit", "Read the organization's audit trail (most recent first).", {
|
|
218
|
+
org: z.string().optional(),
|
|
219
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
220
|
+
action: z.string().optional().describe("filter by action, e.g. secret.updated")
|
|
221
|
+
}, async ({ org, limit, action }) => {
|
|
222
|
+
const ctx = getCtx();
|
|
223
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
224
|
+
return (await ctx.client.listAudit(orgRef.id, {
|
|
225
|
+
limit: limit ?? 50,
|
|
226
|
+
action
|
|
227
|
+
})).entries;
|
|
228
|
+
});
|
|
229
|
+
tool("create_org", "Create an organization. Requires a user session — service tokens cannot own a Stytch org.", {
|
|
230
|
+
name: z.string(),
|
|
231
|
+
slug: z.string()
|
|
232
|
+
}, async ({ name, slug }) => (await getCtx().client.createOrg({
|
|
233
|
+
name,
|
|
234
|
+
slug
|
|
235
|
+
})).org);
|
|
236
|
+
tool("create_app", "Create an application in an organization.", {
|
|
237
|
+
org: z.string().optional(),
|
|
238
|
+
name: z.string(),
|
|
239
|
+
slug: z.string()
|
|
240
|
+
}, async ({ org, name, slug }) => {
|
|
241
|
+
const ctx = getCtx();
|
|
242
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
243
|
+
return (await ctx.client.createApp(orgRef.id, {
|
|
244
|
+
name,
|
|
245
|
+
slug
|
|
246
|
+
})).app;
|
|
247
|
+
});
|
|
248
|
+
tool("create_group", "Create a shared group (reusable secret bag) in an organization.", {
|
|
249
|
+
org: z.string().optional(),
|
|
250
|
+
name: z.string(),
|
|
251
|
+
slug: z.string()
|
|
252
|
+
}, async ({ org, name, slug }) => {
|
|
253
|
+
const ctx = getCtx();
|
|
254
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
255
|
+
return (await ctx.client.createGroup(orgRef.id, {
|
|
256
|
+
name,
|
|
257
|
+
slug
|
|
258
|
+
})).group;
|
|
259
|
+
});
|
|
260
|
+
tool("create_env", "Create an application environment. Generates the data key locally and grants it to the caller.", {
|
|
261
|
+
org: z.string().optional(),
|
|
262
|
+
app: z.string(),
|
|
263
|
+
name: z.string(),
|
|
264
|
+
slug: z.string()
|
|
265
|
+
}, async ({ org, app, name, slug }) => {
|
|
266
|
+
const ctx = getCtx();
|
|
267
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
268
|
+
const { apps } = await ctx.client.listApps(orgRef.id);
|
|
269
|
+
const appRow = apps.find((a) => a.slug === app || a.id === app);
|
|
270
|
+
if (!appRow) throw new Error(`no app "${app}" in ${orgRef.slug}`);
|
|
271
|
+
const wrappedDek = await wrapDek(generateDek(), await principalPublicKeyJwk(ctx));
|
|
272
|
+
return (await ctx.client.createEnv(orgRef.id, appRow.id, {
|
|
273
|
+
name,
|
|
274
|
+
slug,
|
|
275
|
+
wrappedDek
|
|
276
|
+
})).environment;
|
|
277
|
+
});
|
|
278
|
+
tool("create_group_env", "Create a group environment. Generates the data key locally and grants it to the caller.", {
|
|
279
|
+
org: z.string().optional(),
|
|
280
|
+
group: z.string(),
|
|
281
|
+
name: z.string(),
|
|
282
|
+
slug: z.string()
|
|
283
|
+
}, async ({ org, group, name, slug }) => {
|
|
284
|
+
const ctx = getCtx();
|
|
285
|
+
const g = await resolveGroup(ctx, {
|
|
286
|
+
org,
|
|
287
|
+
group
|
|
288
|
+
});
|
|
289
|
+
const wrappedDek = await wrapDek(generateDek(), await principalPublicKeyJwk(ctx));
|
|
290
|
+
return (await ctx.client.createGroupEnv(g.orgId, g.id, {
|
|
291
|
+
name,
|
|
292
|
+
slug,
|
|
293
|
+
wrappedDek
|
|
294
|
+
})).environment;
|
|
295
|
+
});
|
|
296
|
+
tool("compose_group", "Compose a group into an application environment (higher position wins on name clashes).", {
|
|
297
|
+
org: z.string().optional(),
|
|
298
|
+
app: z.string(),
|
|
299
|
+
env: z.string(),
|
|
300
|
+
group: z.string(),
|
|
301
|
+
position: z.number().int().min(0).optional()
|
|
302
|
+
}, async ({ org, app, env, group, position }) => {
|
|
303
|
+
const ctx = getCtx();
|
|
304
|
+
const target = await resolveAppEnv(ctx, {
|
|
305
|
+
org,
|
|
306
|
+
app,
|
|
307
|
+
env
|
|
308
|
+
});
|
|
309
|
+
const g = await resolveGroup(ctx, {
|
|
310
|
+
org,
|
|
311
|
+
group
|
|
312
|
+
});
|
|
313
|
+
return (await ctx.client.linkEnvGroup(target.orgId, target.envId, {
|
|
314
|
+
groupId: g.id,
|
|
315
|
+
position
|
|
316
|
+
})).group;
|
|
317
|
+
});
|
|
318
|
+
tool("uncompose_group", "Remove a composed group from an application environment.", {
|
|
319
|
+
org: z.string().optional(),
|
|
320
|
+
app: z.string(),
|
|
321
|
+
env: z.string(),
|
|
322
|
+
group: z.string()
|
|
323
|
+
}, async ({ org, app, env, group }) => {
|
|
324
|
+
const ctx = getCtx();
|
|
325
|
+
const target = await resolveAppEnv(ctx, {
|
|
326
|
+
org,
|
|
327
|
+
app,
|
|
328
|
+
env
|
|
329
|
+
});
|
|
330
|
+
const g = await resolveGroup(ctx, {
|
|
331
|
+
org,
|
|
332
|
+
group
|
|
333
|
+
});
|
|
334
|
+
await ctx.client.unlinkEnvGroup(target.orgId, target.envId, g.id);
|
|
335
|
+
return { ok: true };
|
|
336
|
+
});
|
|
337
|
+
tool("set_secret", "Encrypt a value locally and store it in an environment.", {
|
|
338
|
+
...targetShape,
|
|
339
|
+
name: z.string(),
|
|
340
|
+
value: z.string()
|
|
341
|
+
}, async (o) => {
|
|
342
|
+
const ctx = getCtx();
|
|
343
|
+
ensureDecryptable(ctx);
|
|
344
|
+
const { orgId, envId } = await resolveTargetEnv(ctx, o);
|
|
345
|
+
await encryptAndSetSecret(ctx, orgId, envId, o.name, o.value);
|
|
346
|
+
return {
|
|
347
|
+
ok: true,
|
|
348
|
+
name: o.name
|
|
349
|
+
};
|
|
350
|
+
});
|
|
351
|
+
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).", {
|
|
352
|
+
...targetShape,
|
|
353
|
+
name: z.string(),
|
|
354
|
+
reveal: z.boolean().optional()
|
|
355
|
+
}, async (o) => {
|
|
356
|
+
const ctx = getCtx();
|
|
357
|
+
const { orgId, envId } = await resolveTargetEnv(ctx, o);
|
|
358
|
+
if (!o.reveal) {
|
|
359
|
+
const { secrets } = await ctx.client.listSecrets(orgId, envId);
|
|
360
|
+
const row = secrets.find((s) => s.name === o.name);
|
|
361
|
+
if (!row) throw new Error(`no secret named ${o.name}`);
|
|
362
|
+
return {
|
|
363
|
+
name: row.name,
|
|
364
|
+
version: row.version,
|
|
365
|
+
revealed: false
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
ensureDecryptable(ctx);
|
|
369
|
+
const values = await fetchDecryptedSecrets(ctx, orgId, envId);
|
|
370
|
+
if (!(o.name in values)) throw new Error(`no secret named ${o.name}`);
|
|
371
|
+
return {
|
|
372
|
+
name: o.name,
|
|
373
|
+
value: values[o.name],
|
|
374
|
+
revealed: true
|
|
375
|
+
};
|
|
376
|
+
});
|
|
377
|
+
tool("delete_secret", "Delete a secret from an environment.", {
|
|
378
|
+
...targetShape,
|
|
379
|
+
name: z.string()
|
|
380
|
+
}, async (o) => {
|
|
381
|
+
const ctx = getCtx();
|
|
382
|
+
const { orgId, envId } = await resolveTargetEnv(ctx, o);
|
|
383
|
+
await ctx.client.deleteSecret(orgId, envId, o.name);
|
|
384
|
+
return {
|
|
385
|
+
ok: true,
|
|
386
|
+
name: o.name
|
|
387
|
+
};
|
|
388
|
+
});
|
|
389
|
+
tool("run_command", "Run a command with the resolved secrets injected as environment variables, and return its exit code + captured output. Secret VALUES are never returned — this is the preferred way to use secrets. process env > .env > app env > groups.", {
|
|
390
|
+
command: z.string().describe("executable to run"),
|
|
391
|
+
args: z.array(z.string()).optional(),
|
|
392
|
+
org: z.string().optional(),
|
|
393
|
+
app: z.string().optional(),
|
|
394
|
+
env: z.string().optional().describe("environment slug (token auth infers this)"),
|
|
395
|
+
with: z.record(z.string(), z.string()).optional().describe("group=env slice overrides"),
|
|
396
|
+
envFile: z.array(z.string()).optional().describe(".env files to overlay (default [.env])"),
|
|
397
|
+
cwd: z.string().optional()
|
|
398
|
+
}, async (o) => {
|
|
399
|
+
const ctx = getCtx();
|
|
400
|
+
ensureDecryptable(ctx);
|
|
401
|
+
const { values } = await materializeFor(ctx, o);
|
|
402
|
+
const childEnv = {
|
|
403
|
+
...values,
|
|
404
|
+
...process.env
|
|
405
|
+
};
|
|
406
|
+
return {
|
|
407
|
+
...await runChild(o.command, o.args ?? [], childEnv, o.cwd),
|
|
408
|
+
injectedVarCount: Object.keys(values).length
|
|
409
|
+
};
|
|
410
|
+
});
|
|
411
|
+
tool("export_env", "Write the resolved secrets to a dotenv file on disk and return the variable names written (never the values). Use to materialize a .env for local tooling.", {
|
|
412
|
+
file: z.string().describe("path to write, e.g. .env"),
|
|
413
|
+
org: z.string().optional(),
|
|
414
|
+
app: z.string().optional(),
|
|
415
|
+
env: z.string().optional(),
|
|
416
|
+
with: z.record(z.string(), z.string()).optional()
|
|
417
|
+
}, async (o) => {
|
|
418
|
+
const ctx = getCtx();
|
|
419
|
+
ensureDecryptable(ctx);
|
|
420
|
+
const { values } = await materializeFor(ctx, o);
|
|
421
|
+
const { writeFileSync } = await import("node:fs");
|
|
422
|
+
const body = Object.entries(values).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join("\n");
|
|
423
|
+
writeFileSync(o.file, `${body}\n`, { mode: 384 });
|
|
424
|
+
return {
|
|
425
|
+
file: o.file,
|
|
426
|
+
names: Object.keys(values).sort()
|
|
427
|
+
};
|
|
428
|
+
});
|
|
429
|
+
tool("create_token", "Mint a service token, printed once. Runtime tokens bind to one app environment (auto-granted its keys, so a command/agent can decrypt it). Pass admin:true for an org-scoped provisioning token (create apps/groups/envs, grant, mint tokens) — admin tokens need no env binding.", {
|
|
430
|
+
name: z.string().describe("display name, e.g. ci-deploy or agent-session"),
|
|
431
|
+
org: z.string().optional(),
|
|
432
|
+
app: z.string().optional().describe("bind to this app (runtime tokens)"),
|
|
433
|
+
env: z.string().optional().describe("bind to this env (runtime tokens)"),
|
|
434
|
+
admin: z.boolean().optional().describe("mint an org-scoped admin token")
|
|
435
|
+
}, async ({ name, org, app, env, admin }) => {
|
|
436
|
+
const ctx = getCtx();
|
|
437
|
+
const role = admin ? "admin" : "member";
|
|
438
|
+
const boundToEnv = Boolean(app || env);
|
|
439
|
+
if (!admin && !boundToEnv) throw new Error("runtime tokens need app + env, or pass admin:true for an org-scoped token");
|
|
440
|
+
if (boundToEnv && !(app && env)) throw new Error("pass both app and env to bind a token");
|
|
441
|
+
const target = boundToEnv ? await resolveAppEnv(ctx, {
|
|
442
|
+
org,
|
|
443
|
+
app,
|
|
444
|
+
env
|
|
445
|
+
}) : { orgId: (await resolveOrg(ctx, org)).id };
|
|
446
|
+
const created = await createServiceToken();
|
|
447
|
+
await ctx.client.createToken(target.orgId, {
|
|
448
|
+
name,
|
|
449
|
+
tokenId: created.tokenId,
|
|
450
|
+
tokenHash: created.tokenHash,
|
|
451
|
+
publicKeyJwk: created.publicKeyJwk,
|
|
452
|
+
role,
|
|
453
|
+
environmentId: "envId" in target ? target.envId : null
|
|
454
|
+
});
|
|
455
|
+
if (boundToEnv && "envId" in target) {
|
|
456
|
+
ensureDecryptable(ctx);
|
|
457
|
+
const grantEnv = async (envId) => {
|
|
458
|
+
const dek = await getDek(ctx, target.orgId, envId);
|
|
459
|
+
await ctx.client.grantEnvKey(target.orgId, envId, {
|
|
460
|
+
principalType: "service_token",
|
|
461
|
+
principalId: created.tokenId,
|
|
462
|
+
wrappedDek: await wrapDek(dek, created.publicKeyJwk)
|
|
463
|
+
});
|
|
464
|
+
};
|
|
465
|
+
await grantEnv(target.envId);
|
|
466
|
+
const { groups } = await ctx.client.listEnvGroups(target.orgId, target.envId);
|
|
467
|
+
for (const g of groups) {
|
|
468
|
+
const { environments } = await ctx.client.listGroupEnvs(target.orgId, g.groupId);
|
|
469
|
+
const slice = environments.find((e) => e.slug === target.envSlug);
|
|
470
|
+
if (slice) await grantEnv(slice.id);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
token: created.token,
|
|
475
|
+
tokenId: created.tokenId,
|
|
476
|
+
role,
|
|
477
|
+
note: "save this now — the secret token string is not stored and cannot be retrieved"
|
|
478
|
+
};
|
|
479
|
+
});
|
|
480
|
+
tool("revoke_token", "Revoke a service token by id.", {
|
|
481
|
+
org: z.string().optional(),
|
|
482
|
+
tokenId: z.string()
|
|
483
|
+
}, async ({ org, tokenId }) => {
|
|
484
|
+
const ctx = getCtx();
|
|
485
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
486
|
+
await ctx.client.revokeToken(orgRef.id, tokenId);
|
|
487
|
+
return {
|
|
488
|
+
ok: true,
|
|
489
|
+
tokenId
|
|
490
|
+
};
|
|
491
|
+
});
|
|
492
|
+
tool("grant_env", "Grant a member (by email) or service token (by id) access to an environment's data key. Re-wraps the DEK to the grantee — the caller must already hold the key.", {
|
|
493
|
+
...targetShape,
|
|
494
|
+
user: z.string().optional().describe("org member email"),
|
|
495
|
+
token: z.string().optional().describe("service token id (skt_…)")
|
|
496
|
+
}, async (o) => {
|
|
497
|
+
if (Boolean(o.user) === Boolean(o.token)) throw new Error("pass exactly one of user or token");
|
|
498
|
+
const ctx = getCtx();
|
|
499
|
+
ensureDecryptable(ctx);
|
|
500
|
+
const { orgId, envId, label } = await resolveTargetEnv(ctx, o);
|
|
501
|
+
const dek = await getDek(ctx, orgId, envId);
|
|
502
|
+
let principalType;
|
|
503
|
+
let principalId;
|
|
504
|
+
let publicKeyJwk;
|
|
505
|
+
if (o.user) {
|
|
506
|
+
const { members } = await ctx.client.listMembers(orgId);
|
|
507
|
+
const member = members.find((m) => m.email === o.user);
|
|
508
|
+
if (!member) throw new Error(`no member ${o.user}`);
|
|
509
|
+
if (!member.publicKeyJwk) throw new Error(`${o.user} has not completed key setup`);
|
|
510
|
+
[principalType, principalId, publicKeyJwk] = [
|
|
511
|
+
"user",
|
|
512
|
+
member.userId,
|
|
513
|
+
member.publicKeyJwk
|
|
514
|
+
];
|
|
515
|
+
} else {
|
|
516
|
+
const { tokens } = await ctx.client.listTokens(orgId);
|
|
517
|
+
const t = tokens.find((x) => x.id === o.token);
|
|
518
|
+
if (!t) throw new Error(`no service token ${o.token}`);
|
|
519
|
+
[principalType, principalId, publicKeyJwk] = [
|
|
520
|
+
"service_token",
|
|
521
|
+
t.id,
|
|
522
|
+
t.publicKeyJwk
|
|
523
|
+
];
|
|
524
|
+
}
|
|
525
|
+
await ctx.client.grantEnvKey(orgId, envId, {
|
|
526
|
+
principalType,
|
|
527
|
+
principalId,
|
|
528
|
+
wrappedDek: await wrapDek(dek, publicKeyJwk)
|
|
529
|
+
});
|
|
530
|
+
return {
|
|
531
|
+
ok: true,
|
|
532
|
+
env: label,
|
|
533
|
+
principalType,
|
|
534
|
+
principalId
|
|
535
|
+
};
|
|
536
|
+
});
|
|
537
|
+
tool("configure_project", "Link a directory to an org/app by writing seekrit.json (like `seekrit init`). The environment is chosen by the service token at runtime.", {
|
|
538
|
+
org: z.string(),
|
|
539
|
+
app: z.string(),
|
|
540
|
+
dir: z.string().optional()
|
|
541
|
+
}, async ({ org, app, dir }) => {
|
|
542
|
+
const ctx = getCtx();
|
|
543
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
544
|
+
const { apps } = await ctx.client.listApps(orgRef.id);
|
|
545
|
+
const appRow = apps.find((a) => a.slug === app || a.id === app);
|
|
546
|
+
if (!appRow) throw new Error(`no app "${app}" in ${orgRef.slug}`);
|
|
547
|
+
return {
|
|
548
|
+
path: writeProjectConfig({
|
|
549
|
+
org: orgRef.slug,
|
|
550
|
+
app: appRow.slug
|
|
551
|
+
}, dir),
|
|
552
|
+
guidance: "Run commands with `seekrit run -- <cmd>` (or the seekrit-run binary) and a SEEKRIT_TOKEN bound to the desired environment; the token selects org+app+env."
|
|
553
|
+
};
|
|
554
|
+
});
|
|
555
|
+
await server.connect(new StdioServerTransport());
|
|
556
|
+
}
|
|
557
|
+
//#endregion
|
|
558
|
+
export { runMcpServer };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seekrit/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"typecheck": "tsc --noEmit"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
25
|
+
"commander": "^15.0.0",
|
|
26
|
+
"zod": "^4.4.3"
|
|
25
27
|
},
|
|
26
28
|
"devDependencies": {
|
|
27
29
|
"@seekrit/api-client": "workspace:*",
|