@seekrit/cli 1.6.4 → 1.7.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 +293 -29
- package/dist/{mcp-BvXwiGRv.js → mcp-C2YR45UJ.js} +31 -12
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -852,6 +852,12 @@ const ENTITLEMENT_KEYS = Object.keys({
|
|
|
852
852
|
description: "Single sign-on beyond the built-in providers.",
|
|
853
853
|
default: true
|
|
854
854
|
},
|
|
855
|
+
"feature.approvals": {
|
|
856
|
+
kind: "feature",
|
|
857
|
+
label: "Approval workflows",
|
|
858
|
+
description: "Require review before a change to a protected environment lands.",
|
|
859
|
+
default: true
|
|
860
|
+
},
|
|
855
861
|
"feature.sync": {
|
|
856
862
|
kind: "feature",
|
|
857
863
|
label: "Third-party sync",
|
|
@@ -1027,6 +1033,23 @@ function parseBranchTtl(input) {
|
|
|
1027
1033
|
return value * multiplier;
|
|
1028
1034
|
}
|
|
1029
1035
|
//#endregion
|
|
1036
|
+
//#region ../../packages/core/src/change-requests.ts
|
|
1037
|
+
const CHANGE_REQUEST_STATUSES = [
|
|
1038
|
+
"open",
|
|
1039
|
+
"applied",
|
|
1040
|
+
"rejected",
|
|
1041
|
+
"withdrawn",
|
|
1042
|
+
"superseded"
|
|
1043
|
+
];
|
|
1044
|
+
/** Human summary of a queued change, for emails and CLI output. Never a value. */
|
|
1045
|
+
function describeChange(kind, name, restoreFrom) {
|
|
1046
|
+
switch (kind) {
|
|
1047
|
+
case "set": return `set ${name}`;
|
|
1048
|
+
case "delete": return `delete ${name}`;
|
|
1049
|
+
case "restore": return `restore ${name} to v${restoreFrom ?? "?"}`;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
//#endregion
|
|
1030
1053
|
//#region ../../packages/core/src/dotenv.ts
|
|
1031
1054
|
/**
|
|
1032
1055
|
* `.env` parsing and serialization — the canonical definition of what a `.env`
|
|
@@ -1999,7 +2022,16 @@ const AUDIT_ACTIONS = [
|
|
|
1999
2022
|
"agent.policy_published",
|
|
2000
2023
|
"agent.policy_rolled_back",
|
|
2001
2024
|
"agent.task_dispatched",
|
|
2002
|
-
"agent.task_revoked"
|
|
2025
|
+
"agent.task_revoked",
|
|
2026
|
+
"env.protection_enabled",
|
|
2027
|
+
"env.protection_updated",
|
|
2028
|
+
"env.protection_disabled",
|
|
2029
|
+
"change_request.opened",
|
|
2030
|
+
"change_request.approved",
|
|
2031
|
+
"change_request.rejected",
|
|
2032
|
+
"change_request.withdrawn",
|
|
2033
|
+
"change_request.applied",
|
|
2034
|
+
"change_request.superseded"
|
|
2003
2035
|
];
|
|
2004
2036
|
/**
|
|
2005
2037
|
* Transactional notification emails seekrit can send. Each id is one
|
|
@@ -2019,7 +2051,9 @@ const NOTIFICATION_TYPES = [
|
|
|
2019
2051
|
"lease_expired",
|
|
2020
2052
|
"sync_failed",
|
|
2021
2053
|
"honey_token_tripped",
|
|
2022
|
-
"secret_rotation_failed"
|
|
2054
|
+
"secret_rotation_failed",
|
|
2055
|
+
"change_request_opened",
|
|
2056
|
+
"change_request_decided"
|
|
2023
2057
|
];
|
|
2024
2058
|
/** UI-facing copy + default for each notification type. */
|
|
2025
2059
|
const NOTIFICATION_TYPE_META = {
|
|
@@ -2063,6 +2097,16 @@ const NOTIFICATION_TYPE_META = {
|
|
|
2063
2097
|
description: "A temporary (leased) credential you own expired and was automatically revoked.",
|
|
2064
2098
|
defaultEnabled: true
|
|
2065
2099
|
},
|
|
2100
|
+
change_request_opened: {
|
|
2101
|
+
label: "Change request opened",
|
|
2102
|
+
description: "Someone proposed a change to a protected environment and it is waiting for review.",
|
|
2103
|
+
defaultEnabled: true
|
|
2104
|
+
},
|
|
2105
|
+
change_request_decided: {
|
|
2106
|
+
label: "Change request decided",
|
|
2107
|
+
description: "A change you proposed was approved and applied, rejected, or superseded.",
|
|
2108
|
+
defaultEnabled: true
|
|
2109
|
+
},
|
|
2066
2110
|
sync_failed: {
|
|
2067
2111
|
label: "Third-party sync failed",
|
|
2068
2112
|
description: "A sync to an external destination failed repeatedly and stopped retrying. The destination is now holding stale values.",
|
|
@@ -2149,6 +2193,17 @@ z.object({
|
|
|
2149
2193
|
ciphertext: z.string().min(1).max(65536) });
|
|
2150
2194
|
z.object({ version: z.number().int().positive() });
|
|
2151
2195
|
z.object({ limit: z.coerce.number().int().min(1).max(200).default(50) });
|
|
2196
|
+
z.object({
|
|
2197
|
+
requiredApprovals: z.number().int().min(1).max(10).default(1),
|
|
2198
|
+
allowSelfApproval: z.boolean().default(false)
|
|
2199
|
+
});
|
|
2200
|
+
z.object({ comment: z.string().trim().max(1e3).optional() });
|
|
2201
|
+
z.string().trim().min(1).max(1e3);
|
|
2202
|
+
z.object({
|
|
2203
|
+
status: z.enum(CHANGE_REQUEST_STATUSES).optional(),
|
|
2204
|
+
envId: z.string().min(1).optional(),
|
|
2205
|
+
limit: z.coerce.number().int().min(1).max(200).default(50)
|
|
2206
|
+
});
|
|
2152
2207
|
z.object({
|
|
2153
2208
|
publicKeyJwk: z.string().min(1),
|
|
2154
2209
|
/**
|
|
@@ -5055,9 +5110,18 @@ async function createAgentTaskToken() {
|
|
|
5055
5110
|
}
|
|
5056
5111
|
//#endregion
|
|
5057
5112
|
//#region package.json
|
|
5058
|
-
var version = "1.
|
|
5113
|
+
var version = "1.7.0";
|
|
5059
5114
|
//#endregion
|
|
5060
5115
|
//#region ../../packages/api-client/src/index.ts
|
|
5116
|
+
/**
|
|
5117
|
+
* The proposer's note travels as a header, not a body field: an unprotected
|
|
5118
|
+
* environment ignores it, so the same call works either way and no caller has
|
|
5119
|
+
* to know in advance whether a write will be queued.
|
|
5120
|
+
*/
|
|
5121
|
+
function changeNoteHeader(note) {
|
|
5122
|
+
const trimmed = note?.trim();
|
|
5123
|
+
return trimmed ? { "x-seekrit-change-note": trimmed } : void 0;
|
|
5124
|
+
}
|
|
5061
5125
|
var SeekritApiError = class extends Error {
|
|
5062
5126
|
status;
|
|
5063
5127
|
code;
|
|
@@ -5079,8 +5143,11 @@ var SeekritClient = class {
|
|
|
5079
5143
|
this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
|
|
5080
5144
|
this.client = options.client;
|
|
5081
5145
|
}
|
|
5082
|
-
async request(method, path, body) {
|
|
5083
|
-
const headers = {
|
|
5146
|
+
async request(method, path, body, extraHeaders) {
|
|
5147
|
+
const headers = {
|
|
5148
|
+
accept: "application/json",
|
|
5149
|
+
...extraHeaders
|
|
5150
|
+
};
|
|
5084
5151
|
if (this.client) headers["x-seekrit-client"] = this.client;
|
|
5085
5152
|
if (this.auth.type === "bearer") headers.authorization = `Bearer ${this.auth.token}`;
|
|
5086
5153
|
else if (this.auth.type === "dynamic") {
|
|
@@ -5295,11 +5362,21 @@ var SeekritClient = class {
|
|
|
5295
5362
|
listSecrets(orgId, envId) {
|
|
5296
5363
|
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/secrets`);
|
|
5297
5364
|
}
|
|
5298
|
-
|
|
5299
|
-
|
|
5365
|
+
/**
|
|
5366
|
+
* Store a ciphertext. In a **protected** environment this does not write: the
|
|
5367
|
+
* server queues the change for review and answers `{ queued: true,
|
|
5368
|
+
* changeRequest }` (HTTP 202). Callers must branch on `queued` — reporting
|
|
5369
|
+
* success on a queued change tells someone a secret rotated when it has not.
|
|
5370
|
+
*
|
|
5371
|
+
* `note` is the proposer's explanation, carried as a header so it is simply
|
|
5372
|
+
* ignored by an unprotected environment.
|
|
5373
|
+
*/
|
|
5374
|
+
setSecret(orgId, envId, name, ciphertext, note) {
|
|
5375
|
+
return this.request("PUT", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`, { ciphertext }, changeNoteHeader(note));
|
|
5300
5376
|
}
|
|
5301
|
-
|
|
5302
|
-
|
|
5377
|
+
/** Queued for review in a protected environment — see {@link setSecret}. */
|
|
5378
|
+
deleteSecret(orgId, envId, name, note) {
|
|
5379
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`, void 0, changeNoteHeader(note));
|
|
5303
5380
|
}
|
|
5304
5381
|
/** A secret's append-only history, newest version first. */
|
|
5305
5382
|
listSecretVersions(orgId, envId, name, query = {}) {
|
|
@@ -5311,8 +5388,45 @@ var SeekritClient = class {
|
|
|
5311
5388
|
* ciphertext it already stores, so this appends a new version rather than
|
|
5312
5389
|
* rewinding, and needs no DEK on the caller's side.
|
|
5313
5390
|
*/
|
|
5314
|
-
restoreSecret(orgId, envId, name, version) {
|
|
5315
|
-
return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}/restore`, { version });
|
|
5391
|
+
restoreSecret(orgId, envId, name, version, note) {
|
|
5392
|
+
return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}/restore`, { version }, changeNoteHeader(note));
|
|
5393
|
+
}
|
|
5394
|
+
/** An environment's protection settings, plus how many changes are waiting. */
|
|
5395
|
+
getEnvironmentProtection(orgId, envId) {
|
|
5396
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/protection`);
|
|
5397
|
+
}
|
|
5398
|
+
/** Protect an environment, or change its settings. Admin only. */
|
|
5399
|
+
setEnvironmentProtection(orgId, envId, input = {}) {
|
|
5400
|
+
return this.request("PUT", `/v1/orgs/${orgId}/envs/${envId}/protection`, input);
|
|
5401
|
+
}
|
|
5402
|
+
/** Take the gate down. Queued changes stay queued. Admin only. */
|
|
5403
|
+
deleteEnvironmentProtection(orgId, envId) {
|
|
5404
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/protection`);
|
|
5405
|
+
}
|
|
5406
|
+
listChangeRequests(orgId, query = {}) {
|
|
5407
|
+
const params = new URLSearchParams();
|
|
5408
|
+
if (query.status) params.set("status", query.status);
|
|
5409
|
+
if (query.envId) params.set("envId", query.envId);
|
|
5410
|
+
if (query.limit) params.set("limit", String(query.limit));
|
|
5411
|
+
const qs = params.size > 0 ? `?${params}` : "";
|
|
5412
|
+
return this.request("GET", `/v1/orgs/${orgId}/change-requests${qs}`);
|
|
5413
|
+
}
|
|
5414
|
+
getChangeRequest(orgId, id) {
|
|
5415
|
+
return this.request("GET", `/v1/orgs/${orgId}/change-requests/${id}`);
|
|
5416
|
+
}
|
|
5417
|
+
/**
|
|
5418
|
+
* Approve. When this is the approval that meets the threshold the change is
|
|
5419
|
+
* applied in the same call, so `status` comes back `applied` — or
|
|
5420
|
+
* `superseded`, if the secret moved on since the change was proposed.
|
|
5421
|
+
*/
|
|
5422
|
+
approveChangeRequest(orgId, id, comment) {
|
|
5423
|
+
return this.request("POST", `/v1/orgs/${orgId}/change-requests/${id}/approve`, { comment });
|
|
5424
|
+
}
|
|
5425
|
+
rejectChangeRequest(orgId, id, comment) {
|
|
5426
|
+
return this.request("POST", `/v1/orgs/${orgId}/change-requests/${id}/reject`, { comment });
|
|
5427
|
+
}
|
|
5428
|
+
withdrawChangeRequest(orgId, id) {
|
|
5429
|
+
return this.request("POST", `/v1/orgs/${orgId}/change-requests/${id}/withdraw`);
|
|
5316
5430
|
}
|
|
5317
5431
|
/** The calling principal's wrapped DEK for this environment. */
|
|
5318
5432
|
getMyEnvKey(orgId, envId) {
|
|
@@ -7265,9 +7379,15 @@ async function fetchDecryptedVersion(ctx, orgId, envId, name, version) {
|
|
|
7265
7379
|
if (!row) fail(`${name} has no version ${version} in its ${versions.length} newest versions`);
|
|
7266
7380
|
return decryptSecret(dek, row.ciphertext, secretAad(envId, name));
|
|
7267
7381
|
}
|
|
7268
|
-
|
|
7382
|
+
/**
|
|
7383
|
+
* Encrypt and store one value. Returns the change request when the environment
|
|
7384
|
+
* is protected and the write was queued instead of stored — callers must say so
|
|
7385
|
+
* rather than reporting a write that has not happened.
|
|
7386
|
+
*/
|
|
7387
|
+
async function encryptAndSetSecret(ctx, orgId, envId, name, value, note) {
|
|
7269
7388
|
const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
|
|
7270
|
-
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
7389
|
+
const result = await ctx.client.setSecret(orgId, envId, name, ciphertext, note);
|
|
7390
|
+
return result.queued ? result.changeRequest : null;
|
|
7271
7391
|
}
|
|
7272
7392
|
/**
|
|
7273
7393
|
* Encrypt and store many secrets into one environment. The DEK is fetched once
|
|
@@ -7276,16 +7396,18 @@ async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
|
|
|
7276
7396
|
* overwritten; the result splits them into created vs. updated for a summary.
|
|
7277
7397
|
* Callers validate the names first — a rejected name aborts before any write.
|
|
7278
7398
|
*/
|
|
7279
|
-
async function importSecrets(ctx, orgId, envId, entries) {
|
|
7399
|
+
async function importSecrets(ctx, orgId, envId, entries, note) {
|
|
7280
7400
|
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
7281
7401
|
const existing = new Set(secrets.map((s) => s.name));
|
|
7282
7402
|
const result = {
|
|
7283
7403
|
created: [],
|
|
7284
|
-
updated: []
|
|
7404
|
+
updated: [],
|
|
7405
|
+
queued: []
|
|
7285
7406
|
};
|
|
7286
7407
|
for (const [name, value] of Object.entries(entries)) {
|
|
7287
7408
|
const ciphertext = await encryptSecret(dek, value, secretAad(envId, name));
|
|
7288
|
-
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
7409
|
+
const written = await ctx.client.setSecret(orgId, envId, name, ciphertext, note);
|
|
7410
|
+
if (written.queued) result.queued.push(written.changeRequest);
|
|
7289
7411
|
(existing.has(name) ? result.updated : result.created).push(name);
|
|
7290
7412
|
}
|
|
7291
7413
|
return result;
|
|
@@ -9763,6 +9885,133 @@ function registerBranchCommands(program) {
|
|
|
9763
9885
|
console.error(`deleted branch ${app.slug}#${target.slug}`);
|
|
9764
9886
|
});
|
|
9765
9887
|
}
|
|
9888
|
+
//#endregion
|
|
9889
|
+
//#region src/changes.ts
|
|
9890
|
+
/**
|
|
9891
|
+
* Change requests: the review queue in front of protected environments, and the
|
|
9892
|
+
* protection settings that create it.
|
|
9893
|
+
*
|
|
9894
|
+
* Reviewing here is keyless by construction — a change request carries
|
|
9895
|
+
* ciphertext, so the CLI can show a reviewer *what* is changing and who
|
|
9896
|
+
* proposed it without ever holding a DEK. `seekrit changes show` therefore
|
|
9897
|
+
* prints no value, and deliberately does not offer to.
|
|
9898
|
+
*/
|
|
9899
|
+
function registerChangeCommands(program) {
|
|
9900
|
+
const changes = program.command("changes").description("review changes queued for protected environments");
|
|
9901
|
+
changes.command("list", { isDefault: true }).alias("ls").description("list change requests awaiting review").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--status <status>", `filter by status (${CHANGE_REQUEST_STATUSES.join(", ")})`, "open").option("--all", "every status, not just open").option("--limit <n>", "entries to show (max 200)", "50").option("--json", "print the raw API response").action(async (options) => {
|
|
9902
|
+
const ctx = buildContext();
|
|
9903
|
+
const ref = await resolveOrg(ctx, options.org);
|
|
9904
|
+
const status = options.all ? void 0 : parseStatus(options.status);
|
|
9905
|
+
const limit = Number(options.limit);
|
|
9906
|
+
if (!Number.isInteger(limit) || limit < 1) fail("--limit must be a positive whole number");
|
|
9907
|
+
const { changeRequests } = await ctx.client.listChangeRequests(ref.id, {
|
|
9908
|
+
status,
|
|
9909
|
+
limit: Math.min(limit, 200)
|
|
9910
|
+
});
|
|
9911
|
+
emit(options, { changeRequests }, () => {
|
|
9912
|
+
printTable(changeRequests, [
|
|
9913
|
+
col("id", (r) => r.id),
|
|
9914
|
+
col("change", (r) => describeChange(r.kind, r.secretName, r.restoreFromVersion)),
|
|
9915
|
+
col("status", (r) => r.status),
|
|
9916
|
+
col("proposed by", (r) => `${r.requestedByType}:${r.requestedById}`),
|
|
9917
|
+
col("when", (r) => r.createdAt)
|
|
9918
|
+
], options.all ? "no change requests" : "nothing waiting for review");
|
|
9919
|
+
});
|
|
9920
|
+
});
|
|
9921
|
+
changes.command("show <id>").description("show one change request and its reviews (never its value)").option("--org <slug>", "organization slug").option("--json", "print the raw API response").action(async (id, options) => {
|
|
9922
|
+
const ctx = buildContext();
|
|
9923
|
+
const ref = await resolveOrg(ctx, options.org);
|
|
9924
|
+
const { changeRequest, reviews } = await ctx.client.getChangeRequest(ref.id, id);
|
|
9925
|
+
emit(options, {
|
|
9926
|
+
changeRequest,
|
|
9927
|
+
reviews
|
|
9928
|
+
}, () => {
|
|
9929
|
+
printFields([
|
|
9930
|
+
["id", changeRequest.id],
|
|
9931
|
+
["change", describeChange(changeRequest.kind, changeRequest.secretName, changeRequest.restoreFromVersion)],
|
|
9932
|
+
["environment", changeRequest.environmentId],
|
|
9933
|
+
["status", changeRequest.status],
|
|
9934
|
+
["base version", changeRequest.baseVersion ?? "(new secret)"],
|
|
9935
|
+
["approvals needed", changeRequest.requiredApprovals],
|
|
9936
|
+
["proposed by", `${changeRequest.requestedByType}:${changeRequest.requestedById}`],
|
|
9937
|
+
["note", changeRequest.note ?? "-"]
|
|
9938
|
+
]);
|
|
9939
|
+
section("reviews");
|
|
9940
|
+
printTable(reviews, [
|
|
9941
|
+
col("decision", (r) => r.decision),
|
|
9942
|
+
col("reviewer", (r) => r.reviewerId ?? "(removed)"),
|
|
9943
|
+
col("comment", (r) => r.comment ?? "-"),
|
|
9944
|
+
col("when", (r) => r.createdAt)
|
|
9945
|
+
], "no reviews yet");
|
|
9946
|
+
});
|
|
9947
|
+
});
|
|
9948
|
+
changes.command("approve <id>").description("approve a queued change (applies it once it has enough approvals)").option("--org <slug>", "organization slug").option("-m, --comment <text>", "note recorded with your approval").option("--json", "print the raw API response").action(async (id, options) => {
|
|
9949
|
+
const ctx = buildContext();
|
|
9950
|
+
const ref = await resolveOrg(ctx, options.org);
|
|
9951
|
+
const result = await ctx.client.approveChangeRequest(ref.id, id, options.comment);
|
|
9952
|
+
emit(options, result, () => {
|
|
9953
|
+
if (result.status === "applied") console.error(`approved and applied${result.version ? ` as v${result.version}` : ""}`);
|
|
9954
|
+
else if (result.status === "superseded") console.error(`not applied: ${result.reason ?? "the secret has changed since"}`);
|
|
9955
|
+
else console.error(`approved — ${result.approvals ?? 0}/${result.required ?? "?"} approvals`);
|
|
9956
|
+
});
|
|
9957
|
+
});
|
|
9958
|
+
changes.command("reject <id>").description("reject a queued change").option("--org <slug>", "organization slug").option("-m, --comment <text>", "note recorded with your rejection").option("--json", "print the raw API response").action(async (id, options) => {
|
|
9959
|
+
const ctx = buildContext();
|
|
9960
|
+
const ref = await resolveOrg(ctx, options.org);
|
|
9961
|
+
emit(options, await ctx.client.rejectChangeRequest(ref.id, id, options.comment), () => console.error("rejected"));
|
|
9962
|
+
});
|
|
9963
|
+
changes.command("withdraw <id>").description("take your own queued change off the review queue").option("--org <slug>", "organization slug").option("--json", "print the raw API response").action(async (id, options) => {
|
|
9964
|
+
const ctx = buildContext();
|
|
9965
|
+
const ref = await resolveOrg(ctx, options.org);
|
|
9966
|
+
emit(options, await ctx.client.withdrawChangeRequest(ref.id, id), () => console.error("withdrawn"));
|
|
9967
|
+
});
|
|
9968
|
+
const protection = changes.command("protection").description("require review before an environment's secrets change");
|
|
9969
|
+
protection.command("show", { isDefault: true }).description("show whether an environment is protected").option("--org <slug>", "organization slug").option("--app <slug>", "application slug").option("--group <slug>", "group slug (instead of --app)").option("--env <slug>", "environment slug").option("--json", "print the raw API response").action(async (options) => {
|
|
9970
|
+
const ctx = buildContext();
|
|
9971
|
+
const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
|
|
9972
|
+
const result = await ctx.client.getEnvironmentProtection(orgId, envId);
|
|
9973
|
+
emit(options, result, () => {
|
|
9974
|
+
if (!result.protection) {
|
|
9975
|
+
console.log(`${label}: unprotected — writes land immediately`);
|
|
9976
|
+
return;
|
|
9977
|
+
}
|
|
9978
|
+
printFields([
|
|
9979
|
+
["environment", label],
|
|
9980
|
+
["approvals required", result.protection.requiredApprovals],
|
|
9981
|
+
["self-approval", result.protection.allowSelfApproval ? "allowed" : "not allowed"],
|
|
9982
|
+
["waiting for review", result.openChangeRequests]
|
|
9983
|
+
]);
|
|
9984
|
+
});
|
|
9985
|
+
});
|
|
9986
|
+
protection.command("on").description("require approval before secrets in this environment change").option("--org <slug>", "organization slug").option("--app <slug>", "application slug").option("--group <slug>", "group slug (instead of --app)").option("--env <slug>", "environment slug").option("--approvals <n>", "approvals a change needs", "1").option("--allow-self-approval", "let the proposer's own approval count").option("--json", "print the raw API response").action(async (options) => {
|
|
9987
|
+
const ctx = buildContext();
|
|
9988
|
+
const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
|
|
9989
|
+
const requiredApprovals = Number(options.approvals);
|
|
9990
|
+
if (!Number.isInteger(requiredApprovals) || requiredApprovals < 1) fail("--approvals must be a positive whole number");
|
|
9991
|
+
emit(options, await ctx.client.setEnvironmentProtection(orgId, envId, {
|
|
9992
|
+
requiredApprovals,
|
|
9993
|
+
allowSelfApproval: options.allowSelfApproval ?? false
|
|
9994
|
+
}), () => console.error(`${label} now requires ${requiredApprovals} approval${requiredApprovals === 1 ? "" : "s"} before a secret changes`));
|
|
9995
|
+
});
|
|
9996
|
+
protection.command("off").description("stop requiring approval (queued changes stay queued)").option("--org <slug>", "organization slug").option("--app <slug>", "application slug").option("--group <slug>", "group slug (instead of --app)").option("--env <slug>", "environment slug").option("--json", "print the raw API response").action(async (options) => {
|
|
9997
|
+
const ctx = buildContext();
|
|
9998
|
+
const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
|
|
9999
|
+
emit(options, await ctx.client.deleteEnvironmentProtection(orgId, envId), () => console.error(`${label} no longer requires approval — writes land immediately`));
|
|
10000
|
+
});
|
|
10001
|
+
}
|
|
10002
|
+
function parseStatus(raw) {
|
|
10003
|
+
if (!raw) return void 0;
|
|
10004
|
+
if (!CHANGE_REQUEST_STATUSES.includes(raw)) fail(`unknown status "${raw}" — one of ${CHANGE_REQUEST_STATUSES.join(", ")}`);
|
|
10005
|
+
return raw;
|
|
10006
|
+
}
|
|
10007
|
+
/**
|
|
10008
|
+
* Report a write that was queued instead of performed. Every write path calls
|
|
10009
|
+
* this: a CLI that printed "set FOO" for a change nobody has approved would be
|
|
10010
|
+
* telling the operator the deploy is ready when it is not.
|
|
10011
|
+
*/
|
|
10012
|
+
function reportQueued(cr) {
|
|
10013
|
+
console.error(`queued for review: ${describeChange(cr.kind, cr.secretName, cr.restoreFromVersion)} — needs ${cr.requiredApprovals} approval${cr.requiredApprovals === 1 ? "" : "s"} (${cr.id})`);
|
|
10014
|
+
}
|
|
9766
10015
|
/** Plugin id and integration id declared by `@seekrit/openclaw-plugin`. */
|
|
9767
10016
|
const PLUGIN_ID = "seekrit";
|
|
9768
10017
|
const PLUGIN_INTEGRATION_ID = "seekrit";
|
|
@@ -15072,6 +15321,14 @@ function parseVersion(raw) {
|
|
|
15072
15321
|
* line is the whole invocation for CI and for agents. `resolveEnvTarget` fills
|
|
15073
15322
|
* it in from the token, and still insists on it for a user session.
|
|
15074
15323
|
*/
|
|
15324
|
+
/**
|
|
15325
|
+
* Attach `--note` to a command that *writes*. In a protected environment the
|
|
15326
|
+
* write becomes a change request, and the note is what tells the reviewer why —
|
|
15327
|
+
* so it is offered on every write path, and silently ignored everywhere else.
|
|
15328
|
+
*/
|
|
15329
|
+
function withNote(cmd) {
|
|
15330
|
+
return cmd.option("-m, --note <text>", "why (shown to reviewers if the change needs approval)");
|
|
15331
|
+
}
|
|
15075
15332
|
function withTarget(cmd) {
|
|
15076
15333
|
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").option("--branch <slug>", "operate on a branch of --env").option("--env <slug>", "environment slug (a service token infers its own)");
|
|
15077
15334
|
}
|
|
@@ -15102,7 +15359,7 @@ withTarget(secrets.command("get <name>").description("decrypt and print one secr
|
|
|
15102
15359
|
process.stdout.write(options.pretty ? prettyJsonOrRaw(value) : value);
|
|
15103
15360
|
if (process.stdout.isTTY) process.stdout.write("\n");
|
|
15104
15361
|
});
|
|
15105
|
-
withTarget(secrets.command("set <name> [value]").description("encrypt and store a secret (reads stdin when value is omitted or '-')").option("--file <path>", "read the value from a file — the way to store a JSON credential or a PEM key without fighting shell quoting")).action(async (name, value, options) => {
|
|
15362
|
+
withNote(withTarget(secrets.command("set <name> [value]").description("encrypt and store a secret (reads stdin when value is omitted or '-')").option("--file <path>", "read the value from a file — the way to store a JSON credential or a PEM key without fighting shell quoting"))).action(async (name, value, options) => {
|
|
15106
15363
|
if (options.file !== void 0 && value !== void 0) fail("pass a value or --file, not both");
|
|
15107
15364
|
const ctx = buildContext();
|
|
15108
15365
|
const { orgId, envId } = await resolveEnvTarget(ctx, options);
|
|
@@ -15112,10 +15369,11 @@ withTarget(secrets.command("set <name> [value]").description("encrypt and store
|
|
|
15112
15369
|
plaintext = readFileSync(options.file, "utf8").replace(/\n$/, "");
|
|
15113
15370
|
} else if (value === void 0 || value === "-") plaintext = (await readStdin()).replace(/\n$/, "");
|
|
15114
15371
|
else plaintext = value;
|
|
15115
|
-
await encryptAndSetSecret(ctx, orgId, envId, name, plaintext);
|
|
15116
|
-
|
|
15372
|
+
const queued = await encryptAndSetSecret(ctx, orgId, envId, name, plaintext, options.note);
|
|
15373
|
+
if (queued) reportQueued(queued);
|
|
15374
|
+
else console.error(`${name} saved`);
|
|
15117
15375
|
});
|
|
15118
|
-
withTarget(secrets.command("import [file]").description("bulk-import secrets from a .env file (default .env; '-' reads stdin)").option("--dry-run", "list the variable names that would be imported, without writing")).action(async (file, options) => {
|
|
15376
|
+
withNote(withTarget(secrets.command("import [file]").description("bulk-import secrets from a .env file (default .env; '-' reads stdin)").option("--dry-run", "list the variable names that would be imported, without writing"))).action(async (file, options) => {
|
|
15119
15377
|
const source = file ?? ".env";
|
|
15120
15378
|
let content;
|
|
15121
15379
|
if (source === "-") content = await readStdin();
|
|
@@ -15137,8 +15395,11 @@ withTarget(secrets.command("import [file]").description("bulk-import secrets fro
|
|
|
15137
15395
|
for (const name of names.sort()) console.error(` ${name}\t${existing.has(name) ? "update" : "new"}`);
|
|
15138
15396
|
return;
|
|
15139
15397
|
}
|
|
15140
|
-
const { created, updated } = await importSecrets(ctx, orgId, envId, entries);
|
|
15141
|
-
|
|
15398
|
+
const { created, updated, queued } = await importSecrets(ctx, orgId, envId, entries, options.note);
|
|
15399
|
+
if (queued.length > 0) {
|
|
15400
|
+
console.error(`${queued.length} change(s) queued for review in ${label} — nothing has changed yet`);
|
|
15401
|
+
for (const cr of queued) reportQueued(cr);
|
|
15402
|
+
} else console.error(`imported ${created.length + updated.length} secret(s) into ${label} (${created.length} new, ${updated.length} updated)`);
|
|
15142
15403
|
});
|
|
15143
15404
|
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").option("--json", "print the raw API response")).action(async (name, options) => {
|
|
15144
15405
|
const ctx = buildContext();
|
|
@@ -15154,18 +15415,20 @@ withTarget(secrets.command("history <name>").description("list a secret's versio
|
|
|
15154
15415
|
col("note", (v) => [v.version === currentVersion ? "current" : null, v.restoredFromVersion === null ? null : `restored from v${v.restoredFromVersion}`].filter(Boolean).join(", "))
|
|
15155
15416
|
], `no versions of ${name}`));
|
|
15156
15417
|
});
|
|
15157
|
-
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) => {
|
|
15418
|
+
withNote(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) => {
|
|
15158
15419
|
const ctx = buildContext();
|
|
15159
15420
|
const { orgId, envId } = await resolveEnvTarget(ctx, options);
|
|
15160
|
-
const
|
|
15161
|
-
|
|
15421
|
+
const result = await ctx.client.restoreSecret(orgId, envId, name, parseVersion(version), options.note);
|
|
15422
|
+
if (result.queued) reportQueued(result.changeRequest);
|
|
15423
|
+
else console.error(`${name} restored from v${result.restoredFrom} — now v${result.secret.version}`);
|
|
15162
15424
|
});
|
|
15163
|
-
withTarget(secrets.command("rm <name>").description("delete a secret and its whole version history").option("-y, --yes", "skip the confirmation")).action(async (name, options) => {
|
|
15425
|
+
withNote(withTarget(secrets.command("rm <name>").description("delete a secret and its whole version history").option("-y, --yes", "skip the confirmation"))).action(async (name, options) => {
|
|
15164
15426
|
const ctx = buildContext();
|
|
15165
15427
|
const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
|
|
15166
15428
|
await confirmDestructive(options.yes, `Delete ${name} from ${label}, including every earlier version? This cannot be undone.`);
|
|
15167
|
-
await ctx.client.deleteSecret(orgId, envId, name);
|
|
15168
|
-
|
|
15429
|
+
const removed = await ctx.client.deleteSecret(orgId, envId, name, options.note);
|
|
15430
|
+
if (removed.queued) reportQueued(removed.changeRequest);
|
|
15431
|
+
else console.error(`${name} deleted`);
|
|
15169
15432
|
});
|
|
15170
15433
|
/**
|
|
15171
15434
|
* Open the last-known-good cache for this request, or `undefined` when it is
|
|
@@ -15514,6 +15777,7 @@ token.command("delete <tokenId>").description("permanently delete a revoked serv
|
|
|
15514
15777
|
console.error(`${tokenId} deleted`);
|
|
15515
15778
|
});
|
|
15516
15779
|
registerBranchCommands(program);
|
|
15780
|
+
registerChangeCommands(program);
|
|
15517
15781
|
registerPgCommands(program);
|
|
15518
15782
|
registerMysqlCommands(program);
|
|
15519
15783
|
registerRedisCommands(program);
|
|
@@ -15532,7 +15796,7 @@ registerKmsCommands(program);
|
|
|
15532
15796
|
registerRotationCommands(program);
|
|
15533
15797
|
registerRecoveryCommands(program);
|
|
15534
15798
|
program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
|
|
15535
|
-
const { runMcpServer } = await import("./mcp-
|
|
15799
|
+
const { runMcpServer } = await import("./mcp-C2YR45UJ.js");
|
|
15536
15800
|
await runMcpServer();
|
|
15537
15801
|
});
|
|
15538
15802
|
registerAuditCommands(program);
|
|
@@ -645,16 +645,23 @@ async function runMcpServer(options = {}) {
|
|
|
645
645
|
await ctx.client.unlinkEnvGroup(target.orgId, target.envId, g.id);
|
|
646
646
|
return { ok: true };
|
|
647
647
|
});
|
|
648
|
-
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.", { idempotent: false }, {
|
|
648
|
+
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. If the environment requires approval the write is QUEUED, not stored: the result carries queued:true and a changeRequestId, and the secret keeps its old value until a human approves.", { idempotent: false }, {
|
|
649
649
|
...targetShape,
|
|
650
650
|
name: z.string(),
|
|
651
|
-
value: z.string()
|
|
651
|
+
value: z.string(),
|
|
652
|
+
note: z.string().optional()
|
|
652
653
|
}, async (o) => {
|
|
653
654
|
const ctx = getCtx();
|
|
654
655
|
ensureDecryptable(ctx);
|
|
655
656
|
const { orgId, envId } = await resolveEnvTarget(ctx, o);
|
|
656
|
-
await encryptAndSetSecret(ctx, orgId, envId, o.name, o.value);
|
|
657
|
-
return {
|
|
657
|
+
const queued = await encryptAndSetSecret(ctx, orgId, envId, o.name, o.value, o.note);
|
|
658
|
+
return queued ? {
|
|
659
|
+
ok: true,
|
|
660
|
+
queued: true,
|
|
661
|
+
name: o.name,
|
|
662
|
+
changeRequestId: queued.id,
|
|
663
|
+
requiredApprovals: queued.requiredApprovals
|
|
664
|
+
} : {
|
|
658
665
|
ok: true,
|
|
659
666
|
name: o.name
|
|
660
667
|
};
|
|
@@ -717,26 +724,38 @@ async function runMcpServer(options = {}) {
|
|
|
717
724
|
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.", { idempotent: false }, {
|
|
718
725
|
...targetShape,
|
|
719
726
|
name: z.string(),
|
|
720
|
-
version: z.number().int().positive()
|
|
727
|
+
version: z.number().int().positive(),
|
|
728
|
+
note: z.string().optional()
|
|
721
729
|
}, async (o) => {
|
|
722
730
|
const ctx = getCtx();
|
|
723
731
|
const { orgId, envId } = await resolveEnvTarget(ctx, o);
|
|
724
|
-
const
|
|
725
|
-
return {
|
|
732
|
+
const result = await ctx.client.restoreSecret(orgId, envId, o.name, o.version, o.note);
|
|
733
|
+
return result.queued ? {
|
|
734
|
+
ok: true,
|
|
735
|
+
queued: true,
|
|
736
|
+
name: o.name,
|
|
737
|
+
changeRequestId: result.changeRequest.id
|
|
738
|
+
} : {
|
|
726
739
|
ok: true,
|
|
727
740
|
name: o.name,
|
|
728
|
-
restoredFrom,
|
|
729
|
-
version: secret.version
|
|
741
|
+
restoredFrom: result.restoredFrom,
|
|
742
|
+
version: result.secret.version
|
|
730
743
|
};
|
|
731
744
|
});
|
|
732
745
|
tool("delete_secret", "Delete a secret from an environment.", destructive, {
|
|
733
746
|
...targetShape,
|
|
734
|
-
name: z.string()
|
|
747
|
+
name: z.string(),
|
|
748
|
+
note: z.string().optional()
|
|
735
749
|
}, async (o) => {
|
|
736
750
|
const ctx = getCtx();
|
|
737
751
|
const { orgId, envId } = await resolveEnvTarget(ctx, o);
|
|
738
|
-
await ctx.client.deleteSecret(orgId, envId, o.name);
|
|
739
|
-
return {
|
|
752
|
+
const removed = await ctx.client.deleteSecret(orgId, envId, o.name, o.note);
|
|
753
|
+
return removed.queued ? {
|
|
754
|
+
ok: true,
|
|
755
|
+
queued: true,
|
|
756
|
+
name: o.name,
|
|
757
|
+
changeRequestId: removed.changeRequest.id
|
|
758
|
+
} : {
|
|
740
759
|
ok: true,
|
|
741
760
|
name: o.name
|
|
742
761
|
};
|