@seekrit/cli 1.6.5 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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.",
@@ -2130,7 +2174,12 @@ z.object({
2130
2174
  z.object({
2131
2175
  groupId: z.string().min(1),
2132
2176
  /** Precedence among an env's groups (higher wins). Appended if omitted. */
2133
- position: z.number().int().min(0).optional()
2177
+ position: z.number().int().min(0).optional(),
2178
+ /** Client-wrapped group DEK for each sync connection newly reading this group. */
2179
+ syncGrants: z.array(z.object({
2180
+ connectionId: z.string().min(1),
2181
+ wrappedDek: z.string().min(1)
2182
+ })).optional()
2134
2183
  });
2135
2184
  z.object({
2136
2185
  name: nameSchema,
@@ -2149,6 +2198,17 @@ z.object({
2149
2198
  ciphertext: z.string().min(1).max(65536) });
2150
2199
  z.object({ version: z.number().int().positive() });
2151
2200
  z.object({ limit: z.coerce.number().int().min(1).max(200).default(50) });
2201
+ z.object({
2202
+ requiredApprovals: z.number().int().min(1).max(10).default(1),
2203
+ allowSelfApproval: z.boolean().default(false)
2204
+ });
2205
+ z.object({ comment: z.string().trim().max(1e3).optional() });
2206
+ z.string().trim().min(1).max(1e3);
2207
+ z.object({
2208
+ status: z.enum(CHANGE_REQUEST_STATUSES).optional(),
2209
+ envId: z.string().min(1).optional(),
2210
+ limit: z.coerce.number().int().min(1).max(200).default(50)
2211
+ });
2152
2212
  z.object({
2153
2213
  publicKeyJwk: z.string().min(1),
2154
2214
  /**
@@ -5055,9 +5115,18 @@ async function createAgentTaskToken() {
5055
5115
  }
5056
5116
  //#endregion
5057
5117
  //#region package.json
5058
- var version = "1.6.5";
5118
+ var version = "1.7.1";
5059
5119
  //#endregion
5060
5120
  //#region ../../packages/api-client/src/index.ts
5121
+ /**
5122
+ * The proposer's note travels as a header, not a body field: an unprotected
5123
+ * environment ignores it, so the same call works either way and no caller has
5124
+ * to know in advance whether a write will be queued.
5125
+ */
5126
+ function changeNoteHeader(note) {
5127
+ const trimmed = note?.trim();
5128
+ return trimmed ? { "x-seekrit-change-note": trimmed } : void 0;
5129
+ }
5061
5130
  var SeekritApiError = class extends Error {
5062
5131
  status;
5063
5132
  code;
@@ -5079,8 +5148,11 @@ var SeekritClient = class {
5079
5148
  this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
5080
5149
  this.client = options.client;
5081
5150
  }
5082
- async request(method, path, body) {
5083
- const headers = { accept: "application/json" };
5151
+ async request(method, path, body, extraHeaders) {
5152
+ const headers = {
5153
+ accept: "application/json",
5154
+ ...extraHeaders
5155
+ };
5084
5156
  if (this.client) headers["x-seekrit-client"] = this.client;
5085
5157
  if (this.auth.type === "bearer") headers.authorization = `Bearer ${this.auth.token}`;
5086
5158
  else if (this.auth.type === "dynamic") {
@@ -5295,11 +5367,21 @@ var SeekritClient = class {
5295
5367
  listSecrets(orgId, envId) {
5296
5368
  return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/secrets`);
5297
5369
  }
5298
- setSecret(orgId, envId, name, ciphertext) {
5299
- return this.request("PUT", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`, { ciphertext });
5370
+ /**
5371
+ * Store a ciphertext. In a **protected** environment this does not write: the
5372
+ * server queues the change for review and answers `{ queued: true,
5373
+ * changeRequest }` (HTTP 202). Callers must branch on `queued` — reporting
5374
+ * success on a queued change tells someone a secret rotated when it has not.
5375
+ *
5376
+ * `note` is the proposer's explanation, carried as a header so it is simply
5377
+ * ignored by an unprotected environment.
5378
+ */
5379
+ setSecret(orgId, envId, name, ciphertext, note) {
5380
+ return this.request("PUT", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`, { ciphertext }, changeNoteHeader(note));
5300
5381
  }
5301
- deleteSecret(orgId, envId, name) {
5302
- return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`);
5382
+ /** Queued for review in a protected environment — see {@link setSecret}. */
5383
+ deleteSecret(orgId, envId, name, note) {
5384
+ return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`, void 0, changeNoteHeader(note));
5303
5385
  }
5304
5386
  /** A secret's append-only history, newest version first. */
5305
5387
  listSecretVersions(orgId, envId, name, query = {}) {
@@ -5311,8 +5393,45 @@ var SeekritClient = class {
5311
5393
  * ciphertext it already stores, so this appends a new version rather than
5312
5394
  * rewinding, and needs no DEK on the caller's side.
5313
5395
  */
5314
- restoreSecret(orgId, envId, name, version) {
5315
- return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}/restore`, { version });
5396
+ restoreSecret(orgId, envId, name, version, note) {
5397
+ return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}/restore`, { version }, changeNoteHeader(note));
5398
+ }
5399
+ /** An environment's protection settings, plus how many changes are waiting. */
5400
+ getEnvironmentProtection(orgId, envId) {
5401
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/protection`);
5402
+ }
5403
+ /** Protect an environment, or change its settings. Admin only. */
5404
+ setEnvironmentProtection(orgId, envId, input = {}) {
5405
+ return this.request("PUT", `/v1/orgs/${orgId}/envs/${envId}/protection`, input);
5406
+ }
5407
+ /** Take the gate down. Queued changes stay queued. Admin only. */
5408
+ deleteEnvironmentProtection(orgId, envId) {
5409
+ return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/protection`);
5410
+ }
5411
+ listChangeRequests(orgId, query = {}) {
5412
+ const params = new URLSearchParams();
5413
+ if (query.status) params.set("status", query.status);
5414
+ if (query.envId) params.set("envId", query.envId);
5415
+ if (query.limit) params.set("limit", String(query.limit));
5416
+ const qs = params.size > 0 ? `?${params}` : "";
5417
+ return this.request("GET", `/v1/orgs/${orgId}/change-requests${qs}`);
5418
+ }
5419
+ getChangeRequest(orgId, id) {
5420
+ return this.request("GET", `/v1/orgs/${orgId}/change-requests/${id}`);
5421
+ }
5422
+ /**
5423
+ * Approve. When this is the approval that meets the threshold the change is
5424
+ * applied in the same call, so `status` comes back `applied` — or
5425
+ * `superseded`, if the secret moved on since the change was proposed.
5426
+ */
5427
+ approveChangeRequest(orgId, id, comment) {
5428
+ return this.request("POST", `/v1/orgs/${orgId}/change-requests/${id}/approve`, { comment });
5429
+ }
5430
+ rejectChangeRequest(orgId, id, comment) {
5431
+ return this.request("POST", `/v1/orgs/${orgId}/change-requests/${id}/reject`, { comment });
5432
+ }
5433
+ withdrawChangeRequest(orgId, id) {
5434
+ return this.request("POST", `/v1/orgs/${orgId}/change-requests/${id}/withdraw`);
5316
5435
  }
5317
5436
  /** The calling principal's wrapped DEK for this environment. */
5318
5437
  getMyEnvKey(orgId, envId) {
@@ -7265,9 +7384,15 @@ async function fetchDecryptedVersion(ctx, orgId, envId, name, version) {
7265
7384
  if (!row) fail(`${name} has no version ${version} in its ${versions.length} newest versions`);
7266
7385
  return decryptSecret(dek, row.ciphertext, secretAad(envId, name));
7267
7386
  }
7268
- async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
7387
+ /**
7388
+ * Encrypt and store one value. Returns the change request when the environment
7389
+ * is protected and the write was queued instead of stored — callers must say so
7390
+ * rather than reporting a write that has not happened.
7391
+ */
7392
+ async function encryptAndSetSecret(ctx, orgId, envId, name, value, note) {
7269
7393
  const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
7270
- await ctx.client.setSecret(orgId, envId, name, ciphertext);
7394
+ const result = await ctx.client.setSecret(orgId, envId, name, ciphertext, note);
7395
+ return result.queued ? result.changeRequest : null;
7271
7396
  }
7272
7397
  /**
7273
7398
  * Encrypt and store many secrets into one environment. The DEK is fetched once
@@ -7276,16 +7401,18 @@ async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
7276
7401
  * overwritten; the result splits them into created vs. updated for a summary.
7277
7402
  * Callers validate the names first — a rejected name aborts before any write.
7278
7403
  */
7279
- async function importSecrets(ctx, orgId, envId, entries) {
7404
+ async function importSecrets(ctx, orgId, envId, entries, note) {
7280
7405
  const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
7281
7406
  const existing = new Set(secrets.map((s) => s.name));
7282
7407
  const result = {
7283
7408
  created: [],
7284
- updated: []
7409
+ updated: [],
7410
+ queued: []
7285
7411
  };
7286
7412
  for (const [name, value] of Object.entries(entries)) {
7287
7413
  const ciphertext = await encryptSecret(dek, value, secretAad(envId, name));
7288
- await ctx.client.setSecret(orgId, envId, name, ciphertext);
7414
+ const written = await ctx.client.setSecret(orgId, envId, name, ciphertext, note);
7415
+ if (written.queued) result.queued.push(written.changeRequest);
7289
7416
  (existing.has(name) ? result.updated : result.created).push(name);
7290
7417
  }
7291
7418
  return result;
@@ -7524,6 +7651,32 @@ function registerEnvDiff(env) {
7524
7651
  });
7525
7652
  }
7526
7653
  //#endregion
7654
+ //#region src/group-sync-grants.ts
7655
+ /** Wrap a newly composed group layer for connections already syncing the app env or its branches. */
7656
+ async function syncGrantsForGroupLink(ctx, orgId, envId, envSlug, groupId) {
7657
+ const [{ groups }, { bindings }, { branches }] = await Promise.all([
7658
+ ctx.client.listEnvGroups(orgId, envId),
7659
+ ctx.client.listSyncBindings(orgId),
7660
+ ctx.client.listBranches(orgId, envId)
7661
+ ]);
7662
+ if (groups.some((group) => group.groupId === groupId)) return void 0;
7663
+ const targets = /* @__PURE__ */ new Set([envId, ...branches.map((branch) => branch.id)]);
7664
+ const connectionIds = new Set(bindings.filter((binding) => targets.has(binding.environmentId)).map((b) => b.connectionId));
7665
+ if (connectionIds.size === 0) return void 0;
7666
+ const [{ environments }, { connections }] = await Promise.all([ctx.client.listGroupEnvs(orgId, groupId), ctx.client.listSyncConnections(orgId)]);
7667
+ const groupEnv = environments.find((entry) => entry.slug === envSlug);
7668
+ if (!groupEnv) fail(`create a ${envSlug} environment in this group before composing it`);
7669
+ const dek = await getDek(ctx, orgId, groupEnv.id);
7670
+ return Promise.all([...connectionIds].map(async (connectionId) => {
7671
+ const connection = connections.find((entry) => entry.id === connectionId);
7672
+ if (!connection) fail("a sync connection could not be found; refresh and retry");
7673
+ return {
7674
+ connectionId,
7675
+ wrappedDek: await wrapDek(dek, connection.publicKeyJwk)
7676
+ };
7677
+ }));
7678
+ }
7679
+ //#endregion
7527
7680
  //#region src/kms.ts
7528
7681
  /** Collect a repeatable option into a list. */
7529
7682
  function collect$8(value, acc = []) {
@@ -8186,9 +8339,11 @@ function registerAppCommands(program) {
8186
8339
  org: options.org,
8187
8340
  group: options.group
8188
8341
  });
8342
+ const syncGrants = await syncGrantsForGroupLink(ctx, target.orgId, target.envId, target.envSlug, group.id);
8189
8343
  await ctx.client.linkEnvGroup(target.orgId, target.envId, {
8190
8344
  groupId: group.id,
8191
- position: options.position === void 0 ? void 0 : Number.parseInt(options.position, 10)
8345
+ position: options.position === void 0 ? void 0 : Number.parseInt(options.position, 10),
8346
+ syncGrants
8192
8347
  });
8193
8348
  console.error(`composed ${group.slug} into ${target.appSlug}/${target.envSlug}`);
8194
8349
  });
@@ -9763,6 +9918,133 @@ function registerBranchCommands(program) {
9763
9918
  console.error(`deleted branch ${app.slug}#${target.slug}`);
9764
9919
  });
9765
9920
  }
9921
+ //#endregion
9922
+ //#region src/changes.ts
9923
+ /**
9924
+ * Change requests: the review queue in front of protected environments, and the
9925
+ * protection settings that create it.
9926
+ *
9927
+ * Reviewing here is keyless by construction — a change request carries
9928
+ * ciphertext, so the CLI can show a reviewer *what* is changing and who
9929
+ * proposed it without ever holding a DEK. `seekrit changes show` therefore
9930
+ * prints no value, and deliberately does not offer to.
9931
+ */
9932
+ function registerChangeCommands(program) {
9933
+ const changes = program.command("changes").description("review changes queued for protected environments");
9934
+ 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) => {
9935
+ const ctx = buildContext();
9936
+ const ref = await resolveOrg(ctx, options.org);
9937
+ const status = options.all ? void 0 : parseStatus(options.status);
9938
+ const limit = Number(options.limit);
9939
+ if (!Number.isInteger(limit) || limit < 1) fail("--limit must be a positive whole number");
9940
+ const { changeRequests } = await ctx.client.listChangeRequests(ref.id, {
9941
+ status,
9942
+ limit: Math.min(limit, 200)
9943
+ });
9944
+ emit(options, { changeRequests }, () => {
9945
+ printTable(changeRequests, [
9946
+ col("id", (r) => r.id),
9947
+ col("change", (r) => describeChange(r.kind, r.secretName, r.restoreFromVersion)),
9948
+ col("status", (r) => r.status),
9949
+ col("proposed by", (r) => `${r.requestedByType}:${r.requestedById}`),
9950
+ col("when", (r) => r.createdAt)
9951
+ ], options.all ? "no change requests" : "nothing waiting for review");
9952
+ });
9953
+ });
9954
+ 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) => {
9955
+ const ctx = buildContext();
9956
+ const ref = await resolveOrg(ctx, options.org);
9957
+ const { changeRequest, reviews } = await ctx.client.getChangeRequest(ref.id, id);
9958
+ emit(options, {
9959
+ changeRequest,
9960
+ reviews
9961
+ }, () => {
9962
+ printFields([
9963
+ ["id", changeRequest.id],
9964
+ ["change", describeChange(changeRequest.kind, changeRequest.secretName, changeRequest.restoreFromVersion)],
9965
+ ["environment", changeRequest.environmentId],
9966
+ ["status", changeRequest.status],
9967
+ ["base version", changeRequest.baseVersion ?? "(new secret)"],
9968
+ ["approvals needed", changeRequest.requiredApprovals],
9969
+ ["proposed by", `${changeRequest.requestedByType}:${changeRequest.requestedById}`],
9970
+ ["note", changeRequest.note ?? "-"]
9971
+ ]);
9972
+ section("reviews");
9973
+ printTable(reviews, [
9974
+ col("decision", (r) => r.decision),
9975
+ col("reviewer", (r) => r.reviewerId ?? "(removed)"),
9976
+ col("comment", (r) => r.comment ?? "-"),
9977
+ col("when", (r) => r.createdAt)
9978
+ ], "no reviews yet");
9979
+ });
9980
+ });
9981
+ 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) => {
9982
+ const ctx = buildContext();
9983
+ const ref = await resolveOrg(ctx, options.org);
9984
+ const result = await ctx.client.approveChangeRequest(ref.id, id, options.comment);
9985
+ emit(options, result, () => {
9986
+ if (result.status === "applied") console.error(`approved and applied${result.version ? ` as v${result.version}` : ""}`);
9987
+ else if (result.status === "superseded") console.error(`not applied: ${result.reason ?? "the secret has changed since"}`);
9988
+ else console.error(`approved — ${result.approvals ?? 0}/${result.required ?? "?"} approvals`);
9989
+ });
9990
+ });
9991
+ 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) => {
9992
+ const ctx = buildContext();
9993
+ const ref = await resolveOrg(ctx, options.org);
9994
+ emit(options, await ctx.client.rejectChangeRequest(ref.id, id, options.comment), () => console.error("rejected"));
9995
+ });
9996
+ 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) => {
9997
+ const ctx = buildContext();
9998
+ const ref = await resolveOrg(ctx, options.org);
9999
+ emit(options, await ctx.client.withdrawChangeRequest(ref.id, id), () => console.error("withdrawn"));
10000
+ });
10001
+ const protection = changes.command("protection").description("require review before an environment's secrets change");
10002
+ 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) => {
10003
+ const ctx = buildContext();
10004
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
10005
+ const result = await ctx.client.getEnvironmentProtection(orgId, envId);
10006
+ emit(options, result, () => {
10007
+ if (!result.protection) {
10008
+ console.log(`${label}: unprotected — writes land immediately`);
10009
+ return;
10010
+ }
10011
+ printFields([
10012
+ ["environment", label],
10013
+ ["approvals required", result.protection.requiredApprovals],
10014
+ ["self-approval", result.protection.allowSelfApproval ? "allowed" : "not allowed"],
10015
+ ["waiting for review", result.openChangeRequests]
10016
+ ]);
10017
+ });
10018
+ });
10019
+ 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) => {
10020
+ const ctx = buildContext();
10021
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
10022
+ const requiredApprovals = Number(options.approvals);
10023
+ if (!Number.isInteger(requiredApprovals) || requiredApprovals < 1) fail("--approvals must be a positive whole number");
10024
+ emit(options, await ctx.client.setEnvironmentProtection(orgId, envId, {
10025
+ requiredApprovals,
10026
+ allowSelfApproval: options.allowSelfApproval ?? false
10027
+ }), () => console.error(`${label} now requires ${requiredApprovals} approval${requiredApprovals === 1 ? "" : "s"} before a secret changes`));
10028
+ });
10029
+ 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) => {
10030
+ const ctx = buildContext();
10031
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
10032
+ emit(options, await ctx.client.deleteEnvironmentProtection(orgId, envId), () => console.error(`${label} no longer requires approval — writes land immediately`));
10033
+ });
10034
+ }
10035
+ function parseStatus(raw) {
10036
+ if (!raw) return void 0;
10037
+ if (!CHANGE_REQUEST_STATUSES.includes(raw)) fail(`unknown status "${raw}" — one of ${CHANGE_REQUEST_STATUSES.join(", ")}`);
10038
+ return raw;
10039
+ }
10040
+ /**
10041
+ * Report a write that was queued instead of performed. Every write path calls
10042
+ * this: a CLI that printed "set FOO" for a change nobody has approved would be
10043
+ * telling the operator the deploy is ready when it is not.
10044
+ */
10045
+ function reportQueued(cr) {
10046
+ console.error(`queued for review: ${describeChange(cr.kind, cr.secretName, cr.restoreFromVersion)} — needs ${cr.requiredApprovals} approval${cr.requiredApprovals === 1 ? "" : "s"} (${cr.id})`);
10047
+ }
9766
10048
  /** Plugin id and integration id declared by `@seekrit/openclaw-plugin`. */
9767
10049
  const PLUGIN_ID = "seekrit";
9768
10050
  const PLUGIN_INTEGRATION_ID = "seekrit";
@@ -11599,7 +11881,7 @@ function collect$4(value, acc) {
11599
11881
  * release-please-config.json), so the pin follows the crate without anyone
11600
11882
  * remembering to move it.
11601
11883
  */
11602
- const PROXY_VERSION = "0.12.1";
11884
+ const PROXY_VERSION = "0.12.2";
11603
11885
  const BIN = "seekrit-proxy";
11604
11886
  /**
11605
11887
  * Host → Rust target triple.
@@ -11635,7 +11917,7 @@ function versionPrefix(version) {
11635
11917
  return version.startsWith("v") ? version : `v${version}`;
11636
11918
  }
11637
11919
  function resolveVersion(explicit) {
11638
- return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.12.1";
11920
+ return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.12.2";
11639
11921
  }
11640
11922
  function resolveBaseUrl(explicit) {
11641
11923
  return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
@@ -11793,7 +12075,7 @@ const PLUGIN_BIN = "seekrit-nomad-secrets";
11793
12075
  * entry in release-please-config.json), so the pin follows the crate without
11794
12076
  * anyone remembering to move it — the same arrangement `PROXY_VERSION` has.
11795
12077
  */
11796
- const NOMAD_PLUGIN_VERSION = "0.2.0";
12078
+ const NOMAD_PLUGIN_VERSION = "0.2.1";
11797
12079
  /** Public artifact host. Mirrors GitHub Releases; public, no egress fees. */
11798
12080
  const DEFAULT_BASE_URL = "https://nomad.seekrit.dev";
11799
12081
  /** Where the plugin reads its node config, matching `config::DEFAULT_CONFIG_PATH`. */
@@ -11880,7 +12162,7 @@ async function fetchBytes(url) {
11880
12162
  * let Nomad fingerprint a half-written executable.
11881
12163
  */
11882
12164
  async function installPlugin(pluginDir, options = {}) {
11883
- const version = options.version ?? "0.2.0";
12165
+ const version = options.version ?? "0.2.1";
11884
12166
  const baseUrl = (options.baseUrl ?? "https://nomad.seekrit.dev").replace(/\/+$/, "");
11885
12167
  const { target, exe } = detectTarget();
11886
12168
  if (exe !== "") fail("Nomad secret provider plugins are not supported on Windows clients");
@@ -13585,10 +13867,10 @@ Next:
13585
13867
  });
13586
13868
  console.log(path);
13587
13869
  const { target } = detectTarget();
13588
- process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.12.1")} (${target})\n`);
13870
+ process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.12.2")} (${target})\n`);
13589
13871
  });
13590
13872
  proxy.command("where").description("show which binary `seekrit proxy run` would use, without fetching it").option("--proxy-version <version>", `version to report (default: ${PROXY_VERSION})`).option("--json", "machine-readable output").action((options) => {
13591
- const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.12.1";
13873
+ const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.12.2";
13592
13874
  const override = process.env.SEEKRIT_PROXY_BIN;
13593
13875
  const { target, exe } = detectTarget();
13594
13876
  const path = override ?? proxyBinaryPath(version, target, exe);
@@ -13647,7 +13929,7 @@ Next:
13647
13929
  process.stdout.write(renderComposeSnippet(plan, {
13648
13930
  service: options.service,
13649
13931
  workload: options.workload,
13650
- image: options.image ?? `seekritdev/proxy:0.12.1`,
13932
+ image: options.image ?? `seekritdev/proxy:0.12.2`,
13651
13933
  publish: Boolean(options.publish)
13652
13934
  }));
13653
13935
  });
@@ -15072,6 +15354,14 @@ function parseVersion(raw) {
15072
15354
  * line is the whole invocation for CI and for agents. `resolveEnvTarget` fills
15073
15355
  * it in from the token, and still insists on it for a user session.
15074
15356
  */
15357
+ /**
15358
+ * Attach `--note` to a command that *writes*. In a protected environment the
15359
+ * write becomes a change request, and the note is what tells the reviewer why —
15360
+ * so it is offered on every write path, and silently ignored everywhere else.
15361
+ */
15362
+ function withNote(cmd) {
15363
+ return cmd.option("-m, --note <text>", "why (shown to reviewers if the change needs approval)");
15364
+ }
15075
15365
  function withTarget(cmd) {
15076
15366
  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
15367
  }
@@ -15102,7 +15392,7 @@ withTarget(secrets.command("get <name>").description("decrypt and print one secr
15102
15392
  process.stdout.write(options.pretty ? prettyJsonOrRaw(value) : value);
15103
15393
  if (process.stdout.isTTY) process.stdout.write("\n");
15104
15394
  });
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) => {
15395
+ 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
15396
  if (options.file !== void 0 && value !== void 0) fail("pass a value or --file, not both");
15107
15397
  const ctx = buildContext();
15108
15398
  const { orgId, envId } = await resolveEnvTarget(ctx, options);
@@ -15112,10 +15402,11 @@ withTarget(secrets.command("set <name> [value]").description("encrypt and store
15112
15402
  plaintext = readFileSync(options.file, "utf8").replace(/\n$/, "");
15113
15403
  } else if (value === void 0 || value === "-") plaintext = (await readStdin()).replace(/\n$/, "");
15114
15404
  else plaintext = value;
15115
- await encryptAndSetSecret(ctx, orgId, envId, name, plaintext);
15116
- console.error(`${name} saved`);
15405
+ const queued = await encryptAndSetSecret(ctx, orgId, envId, name, plaintext, options.note);
15406
+ if (queued) reportQueued(queued);
15407
+ else console.error(`${name} saved`);
15117
15408
  });
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) => {
15409
+ 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
15410
  const source = file ?? ".env";
15120
15411
  let content;
15121
15412
  if (source === "-") content = await readStdin();
@@ -15137,8 +15428,11 @@ withTarget(secrets.command("import [file]").description("bulk-import secrets fro
15137
15428
  for (const name of names.sort()) console.error(` ${name}\t${existing.has(name) ? "update" : "new"}`);
15138
15429
  return;
15139
15430
  }
15140
- const { created, updated } = await importSecrets(ctx, orgId, envId, entries);
15141
- console.error(`imported ${created.length + updated.length} secret(s) into ${label} (${created.length} new, ${updated.length} updated)`);
15431
+ const { created, updated, queued } = await importSecrets(ctx, orgId, envId, entries, options.note);
15432
+ if (queued.length > 0) {
15433
+ console.error(`${queued.length} change(s) queued for review in ${label} — nothing has changed yet`);
15434
+ for (const cr of queued) reportQueued(cr);
15435
+ } else console.error(`imported ${created.length + updated.length} secret(s) into ${label} (${created.length} new, ${updated.length} updated)`);
15142
15436
  });
15143
15437
  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
15438
  const ctx = buildContext();
@@ -15154,18 +15448,20 @@ withTarget(secrets.command("history <name>").description("list a secret's versio
15154
15448
  col("note", (v) => [v.version === currentVersion ? "current" : null, v.restoredFromVersion === null ? null : `restored from v${v.restoredFromVersion}`].filter(Boolean).join(", "))
15155
15449
  ], `no versions of ${name}`));
15156
15450
  });
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) => {
15451
+ 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
15452
  const ctx = buildContext();
15159
15453
  const { orgId, envId } = await resolveEnvTarget(ctx, options);
15160
- const { secret, restoredFrom } = await ctx.client.restoreSecret(orgId, envId, name, parseVersion(version));
15161
- console.error(`${name} restored from v${restoredFrom} — now v${secret.version}`);
15454
+ const result = await ctx.client.restoreSecret(orgId, envId, name, parseVersion(version), options.note);
15455
+ if (result.queued) reportQueued(result.changeRequest);
15456
+ else console.error(`${name} restored from v${result.restoredFrom} — now v${result.secret.version}`);
15162
15457
  });
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) => {
15458
+ 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
15459
  const ctx = buildContext();
15165
15460
  const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
15166
15461
  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
- console.error(`${name} deleted`);
15462
+ const removed = await ctx.client.deleteSecret(orgId, envId, name, options.note);
15463
+ if (removed.queued) reportQueued(removed.changeRequest);
15464
+ else console.error(`${name} deleted`);
15169
15465
  });
15170
15466
  /**
15171
15467
  * Open the last-known-good cache for this request, or `undefined` when it is
@@ -15514,6 +15810,7 @@ token.command("delete <tokenId>").description("permanently delete a revoked serv
15514
15810
  console.error(`${tokenId} deleted`);
15515
15811
  });
15516
15812
  registerBranchCommands(program);
15813
+ registerChangeCommands(program);
15517
15814
  registerPgCommands(program);
15518
15815
  registerMysqlCommands(program);
15519
15816
  registerRedisCommands(program);
@@ -15532,7 +15829,7 @@ registerKmsCommands(program);
15532
15829
  registerRotationCommands(program);
15533
15830
  registerRecoveryCommands(program);
15534
15831
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
15535
- const { runMcpServer } = await import("./mcp-BvXwiGRv.js");
15832
+ const { runMcpServer } = await import("./mcp-ZYu1Q6Ft.js");
15536
15833
  await runMcpServer();
15537
15834
  });
15538
15835
  registerAuditCommands(program);
@@ -15547,4 +15844,4 @@ program.parseAsync(argv).catch((err) => {
15547
15844
  fail(describeError(err));
15548
15845
  });
15549
15846
  //#endregion
15550
- export { verifyMessage as A, toBase64 as B, isServiceToken as C, importVerifyingKey as D, importSigningKey as E, kmsBlobKeyRef as F, kmsDecrypt as I, kmsEncrypt as L, generateMysqlCredential as M, generateDataKey as N, signMessage as O, generateEncryptKeyMaterial as P, wrapDek as R, createServiceToken as S, generateSigningKeyMaterial as T, parseBranchTtl as V, isTokenAuth as _, kmsResolveRecipient as a, writeProjectConfig as b, fetchDecryptedVersion as c, resolveAppEnv as d, resolveBranch as f, getDek as g, resolveOrg as h, kmsResolveKey as i, generatePostgresCredential as j, signatureKeyRef as k, materializeEnv as l, resolveGroup as m, kmsCallerIdentity as n, encryptAndSetSecret as o, resolveEnvTarget as p, kmsRecoverMaterial as r, fetchDecryptedSecrets as s, ensureM2mAdminToken as t, resolveApp as u, tryBuildContext as v, parseServiceToken as w, version as x, setFailThrows as y, generateDek as z };
15847
+ export { signatureKeyRef as A, generateDek as B, createServiceToken as C, importSigningKey as D, generateSigningKeyMaterial as E, generateEncryptKeyMaterial as F, parseBranchTtl as H, kmsBlobKeyRef as I, kmsDecrypt as L, generatePostgresCredential as M, generateMysqlCredential as N, importVerifyingKey as O, generateDataKey as P, kmsEncrypt as R, version as S, parseServiceToken as T, toBase64 as V, getDek as _, kmsResolveRecipient as a, setFailThrows as b, fetchDecryptedSecrets as c, resolveApp as d, resolveAppEnv as f, resolveOrg as g, resolveGroup as h, kmsResolveKey as i, verifyMessage as j, signMessage as k, fetchDecryptedVersion as l, resolveEnvTarget as m, kmsCallerIdentity as n, syncGrantsForGroupLink as o, resolveBranch as p, kmsRecoverMaterial as r, encryptAndSetSecret as s, ensureM2mAdminToken as t, materializeEnv as u, isTokenAuth as v, isServiceToken as w, writeProjectConfig as x, tryBuildContext as y, wrapDek as z };
@@ -1,4 +1,4 @@
1
- import { A as verifyMessage, B as toBase64, C as isServiceToken, D as importVerifyingKey, E as importSigningKey, F as kmsBlobKeyRef, I as kmsDecrypt, L as kmsEncrypt, M as generateMysqlCredential, N as generateDataKey, O as signMessage, P as generateEncryptKeyMaterial, R as wrapDek, S as createServiceToken, T as generateSigningKeyMaterial, V as parseBranchTtl, _ as isTokenAuth, a as kmsResolveRecipient, b as writeProjectConfig, c as fetchDecryptedVersion, d as resolveAppEnv, f as resolveBranch, g as getDek, h as resolveOrg, i as kmsResolveKey, j as generatePostgresCredential, k as signatureKeyRef, l as materializeEnv, m as resolveGroup, n as kmsCallerIdentity, o as encryptAndSetSecret, p as resolveEnvTarget, r as kmsRecoverMaterial, s as fetchDecryptedSecrets, t as ensureM2mAdminToken, u as resolveApp, v as tryBuildContext, w as parseServiceToken, x as version, y as setFailThrows, z as generateDek } from "./index.js";
1
+ import { A as signatureKeyRef, B as generateDek, C as createServiceToken, D as importSigningKey, E as generateSigningKeyMaterial, F as generateEncryptKeyMaterial, H as parseBranchTtl, I as kmsBlobKeyRef, L as kmsDecrypt, M as generatePostgresCredential, N as generateMysqlCredential, O as importVerifyingKey, P as generateDataKey, R as kmsEncrypt, S as version, T as parseServiceToken, V as toBase64, _ as getDek, a as kmsResolveRecipient, b as setFailThrows, c as fetchDecryptedSecrets, d as resolveApp, f as resolveAppEnv, g as resolveOrg, h as resolveGroup, i as kmsResolveKey, j as verifyMessage, k as signMessage, l as fetchDecryptedVersion, m as resolveEnvTarget, n as kmsCallerIdentity, o as syncGrantsForGroupLink, p as resolveBranch, r as kmsRecoverMaterial, s as encryptAndSetSecret, t as ensureM2mAdminToken, u as materializeEnv, v as isTokenAuth, w as isServiceToken, x as writeProjectConfig, y as tryBuildContext, z as wrapDek } 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";
@@ -621,9 +621,11 @@ async function runMcpServer(options = {}) {
621
621
  org,
622
622
  group
623
623
  });
624
+ const syncGrants = await syncGrantsForGroupLink(ctx, target.orgId, target.envId, target.envSlug, g.id);
624
625
  return (await ctx.client.linkEnvGroup(target.orgId, target.envId, {
625
626
  groupId: g.id,
626
- position
627
+ position,
628
+ syncGrants
627
629
  })).group;
628
630
  });
629
631
  tool("uncompose_group", "Remove a composed group from an application environment.", { idempotent: true }, {
@@ -645,16 +647,23 @@ async function runMcpServer(options = {}) {
645
647
  await ctx.client.unlinkEnvGroup(target.orgId, target.envId, g.id);
646
648
  return { ok: true };
647
649
  });
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 }, {
650
+ 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
651
  ...targetShape,
650
652
  name: z.string(),
651
- value: z.string()
653
+ value: z.string(),
654
+ note: z.string().optional()
652
655
  }, async (o) => {
653
656
  const ctx = getCtx();
654
657
  ensureDecryptable(ctx);
655
658
  const { orgId, envId } = await resolveEnvTarget(ctx, o);
656
- await encryptAndSetSecret(ctx, orgId, envId, o.name, o.value);
657
- return {
659
+ const queued = await encryptAndSetSecret(ctx, orgId, envId, o.name, o.value, o.note);
660
+ return queued ? {
661
+ ok: true,
662
+ queued: true,
663
+ name: o.name,
664
+ changeRequestId: queued.id,
665
+ requiredApprovals: queued.requiredApprovals
666
+ } : {
658
667
  ok: true,
659
668
  name: o.name
660
669
  };
@@ -717,26 +726,38 @@ async function runMcpServer(options = {}) {
717
726
  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
727
  ...targetShape,
719
728
  name: z.string(),
720
- version: z.number().int().positive()
729
+ version: z.number().int().positive(),
730
+ note: z.string().optional()
721
731
  }, async (o) => {
722
732
  const ctx = getCtx();
723
733
  const { orgId, envId } = await resolveEnvTarget(ctx, o);
724
- const { secret, restoredFrom } = await ctx.client.restoreSecret(orgId, envId, o.name, o.version);
725
- return {
734
+ const result = await ctx.client.restoreSecret(orgId, envId, o.name, o.version, o.note);
735
+ return result.queued ? {
736
+ ok: true,
737
+ queued: true,
738
+ name: o.name,
739
+ changeRequestId: result.changeRequest.id
740
+ } : {
726
741
  ok: true,
727
742
  name: o.name,
728
- restoredFrom,
729
- version: secret.version
743
+ restoredFrom: result.restoredFrom,
744
+ version: result.secret.version
730
745
  };
731
746
  });
732
747
  tool("delete_secret", "Delete a secret from an environment.", destructive, {
733
748
  ...targetShape,
734
- name: z.string()
749
+ name: z.string(),
750
+ note: z.string().optional()
735
751
  }, async (o) => {
736
752
  const ctx = getCtx();
737
753
  const { orgId, envId } = await resolveEnvTarget(ctx, o);
738
- await ctx.client.deleteSecret(orgId, envId, o.name);
739
- return {
754
+ const removed = await ctx.client.deleteSecret(orgId, envId, o.name, o.note);
755
+ return removed.queued ? {
756
+ ok: true,
757
+ queued: true,
758
+ name: o.name,
759
+ changeRequestId: removed.changeRequest.id
760
+ } : {
740
761
  ok: true,
741
762
  name: o.name
742
763
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "1.6.5",
3
+ "version": "1.7.1",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -24,12 +24,12 @@
24
24
  "dependencies": {
25
25
  "@modelcontextprotocol/sdk": "^1.30.0",
26
26
  "commander": "^15.0.0",
27
- "zod": "^4.6.2"
27
+ "zod": "^4.6.5"
28
28
  },
29
29
  "devDependencies": {
30
- "@types/node": "^26.5.1",
30
+ "@types/node": "^26.6.1",
31
31
  "tsdown": "^0.23.0",
32
- "vitest": "^4.1.11",
32
+ "vitest": "^5.0.1",
33
33
  "@seekrit/api-client": "0.0.1",
34
34
  "@seekrit/core": "0.0.1",
35
35
  "@seekrit/crypto": "0.0.1"