@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 +6 -1
- package/dist/index.js +271 -26
- package/dist/{mcp-CbNXG37n.js → mcp-COWsshZZ.js} +52 -6
- package/package.json +3 -3
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
|
|
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.
|
|
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
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
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
|
|
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
|
-
/**
|
|
4168
|
-
|
|
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)
|
|
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
|
-
|
|
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-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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/
|
|
31
|
-
"@seekrit/
|
|
30
|
+
"@seekrit/crypto": "0.0.1",
|
|
31
|
+
"@seekrit/core": "0.0.1"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsdown",
|