@seekrit/cli 1.6.5 → 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 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.6.5";
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 = { accept: "application/json" };
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
- setSecret(orgId, envId, name, ciphertext) {
5299
- return this.request("PUT", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`, { ciphertext });
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
- deleteSecret(orgId, envId, name) {
5302
- return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`);
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
- async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
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
- console.error(`${name} saved`);
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
- console.error(`imported ${created.length + updated.length} secret(s) into ${label} (${created.length} new, ${updated.length} updated)`);
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 { secret, restoredFrom } = await ctx.client.restoreSecret(orgId, envId, name, parseVersion(version));
15161
- console.error(`${name} restored from v${restoredFrom} — now v${secret.version}`);
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
- console.error(`${name} deleted`);
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-BvXwiGRv.js");
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 { secret, restoredFrom } = await ctx.client.restoreSecret(orgId, envId, o.name, o.version);
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "1.6.5",
3
+ "version": "1.7.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {