@alfe.ai/openclaw-secrets 0.1.9 → 0.1.11

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
@@ -2,34 +2,46 @@
2
2
  * openclaw-secrets — OpenClaw plugin for per-scope encrypted secret storage.
3
3
  *
4
4
  * Trust model:
5
- * - Secret values are encrypted and decrypted ON THIS AGENT using AES-256-GCM.
6
- * - Data keys are issued per-secret by the Alfe secrets service; plaintext keys
7
- * are held as Buffers only, used once, and zeroed immediately.
8
- * - The backend never sees plaintext secret values (the dashboard can set values
9
- * through a server-side encrypt path but cannot read them back).
10
- *
11
- * All HTTP traffic goes through `@alfe.ai/agent-api-client` the single
12
- * canonical agent-side HTTP client so routes, auth, and response unwrapping
13
- * stay in lockstep with every other agent endpoint.
5
+ * - Secret VALUES (encrypted fields) are encrypted and decrypted ON THIS
6
+ * AGENT using AES-256-GCM with KMS-issued data keys. The backend never
7
+ * sees plaintext for encrypted fields.
8
+ * - Plaintext fields ride the wire as-is (they're not secret material
9
+ * things like email, URL, username; the suspicious-key denylist on the
10
+ * server rejects obvious mistakes).
11
+ * - Per-field encryption: each encrypted field has its own data key,
12
+ * bound by KMS encryption context to (tenantId, scope, scopeId,
13
+ * secretId, fieldKey). Cross-field swap attacks fail at decrypt.
14
14
  *
15
15
  * Tools:
16
- * - secret_set create a new secret (mints UUID, encrypts, uploads envelope)
17
- * - secret_getfetch + decrypt plaintext by secretId
18
- * - secret_get_by_name list then get (convenience)
19
- * - secret_list list metadata in a scope (never plaintext)
20
- * - secret_list_scopes enumerate scopes the agent can access
21
- * - secret_delete delete a secret
22
- * - secret_rotate re-encrypt with a fresh data key
16
+ * - secret_list_scopes enumerate scopes the agent can access
17
+ * - secret_listlist secret metadata in a scope (with optional filters)
18
+ * - secret_set create a secret with one or more fields
19
+ * - secret_get legacy wrapper: returns the `value` field's plaintext as a string
20
+ * - secret_get_full full aggregate with all fields (plaintext + decrypted encrypted)
21
+ * - secret_get_field fetch and decrypt one field by key
22
+ * - secret_set_field add OR rotate one field
23
+ * - secret_remove_field — remove one field row
24
+ * - secret_update_metadata — name/description/tags/category PATCH
25
+ * - secret_search — facet GSI search by category/tag/fieldKey
26
+ * - secret_get_history — bounded changelog read (metadata only, never values)
27
+ * - secret_delete — delete a secret
23
28
  */
24
29
  import { randomUUID } from "node:crypto";
25
- import { AgentApiClient } from "@alfe.ai/agent-api-client";
30
+ import { AgentApiClient, } from "@alfe.ai/agent-api-client";
26
31
  import { resolveConfig as resolveAlfeConfig } from "@alfe.ai/config";
27
32
  import { decryptSecretEnvelope, encryptSecretValue } from "./crypto.js";
28
33
  import { createRequire } from 'node:module';
29
34
  const require = createRequire(import.meta.url);
30
35
  const pkg = require('../package.json');
31
36
  const VALID_SCOPES = ["org", "team", "project", "agent"];
37
+ const VALID_CATEGORIES = [
38
+ "login", "api_key", "database", "ssh_key", "certificate",
39
+ "secure_note", "credit_card", "identity", "wifi", "other",
40
+ ];
41
+ const VALID_FORMATS = ["text", "email", "url", "phone", "date", "number", "json"];
42
+ const VALID_SENSITIVITIES = ["plaintext", "encrypted"];
32
43
  const NAME_REGEX = /^[a-zA-Z0-9_./-]{1,128}$/;
44
+ const FIELD_KEY_REGEX = /^[a-zA-Z0-9_./-]{1,64}$/;
33
45
  const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
34
46
  function parseScope(raw, field = "scope") {
35
47
  if (typeof raw !== "string" || !VALID_SCOPES.includes(raw)) {
@@ -53,16 +65,53 @@ function parseSecretName(raw) {
53
65
  }
54
66
  return s;
55
67
  }
68
+ function parseFieldKey(raw) {
69
+ const s = parseString(raw, "key", 64);
70
+ if (!FIELD_KEY_REGEX.test(s)) {
71
+ throw new Error("field key must match ^[a-zA-Z0-9_./-]{1,64}$");
72
+ }
73
+ return s;
74
+ }
56
75
  function parseSecretId(raw) {
57
76
  const s = parseString(raw, "secretId", 64);
58
77
  if (!UUID_V4.test(s))
59
78
  throw new Error("secretId must be a UUID v4");
60
79
  return s;
61
80
  }
81
+ function parseFields(raw) {
82
+ if (!Array.isArray(raw) || raw.length === 0) {
83
+ throw new Error("fields must be a non-empty array");
84
+ }
85
+ if (raw.length > 32)
86
+ throw new Error("fields cannot exceed 32 entries");
87
+ return raw.map((f, i) => {
88
+ if (typeof f !== "object" || f === null)
89
+ throw new Error(`fields[${String(i)}] must be an object`);
90
+ const obj = f;
91
+ const key = parseFieldKey(obj.key);
92
+ const sensitivity = (() => {
93
+ if (typeof obj.sensitivity !== "string" || !VALID_SENSITIVITIES.includes(obj.sensitivity)) {
94
+ throw new Error(`fields[${String(i)}].sensitivity must be 'plaintext' or 'encrypted'`);
95
+ }
96
+ return obj.sensitivity;
97
+ })();
98
+ const format = (() => {
99
+ if (obj.format === undefined)
100
+ return undefined;
101
+ if (typeof obj.format !== "string" || !VALID_FORMATS.includes(obj.format)) {
102
+ throw new Error(`fields[${String(i)}].format must be one of ${VALID_FORMATS.join(", ")}`);
103
+ }
104
+ return obj.format;
105
+ })();
106
+ if (typeof obj.value !== "string")
107
+ throw new Error(`fields[${String(i)}].value must be a string`);
108
+ return { key, sensitivity, format, value: obj.value };
109
+ });
110
+ }
62
111
  export default {
63
112
  id: "secrets",
64
113
  name: "Secrets",
65
- description: "Per-scope encrypted secret storage agent-side AES-256-GCM with KMS-issued data keys.",
114
+ description: "Per-scope encrypted secret storage with multi-field aggregates, per-field rotation, and audit history.",
66
115
  version: pkg.version,
67
116
  kind: "secrets",
68
117
  register(api) {
@@ -73,16 +122,22 @@ export default {
73
122
  api.registerMemoryPromptSection(({ availableTools }) => {
74
123
  const lines = ["## Secrets"];
75
124
  if (availableTools.has("secret_list_scopes")) {
76
- lines.push("Use secret_list_scopes to discover which scopes (org/team/project/agent) you can manage secrets in.");
125
+ lines.push("Use secret_list_scopes to discover scopes (org/team/project/agent) you can manage secrets in.");
77
126
  }
78
127
  if (availableTools.has("secret_list")) {
79
- lines.push("Use secret_list to see secret names in a scope. secret_list NEVER returns plaintext.");
128
+ lines.push("Use secret_list to see secret metadata in a scope (filters: category, tag, fieldKey). Never returns values.");
80
129
  }
81
- if (availableTools.has("secret_get")) {
82
- lines.push("Use secret_get to retrieve a plaintext secret value. The output is SENSITIVE — do not echo it to the user or log it.");
130
+ if (availableTools.has("secret_get_full")) {
131
+ lines.push("Use secret_get_full to fetch a secret with all fields decrypted. Output is SENSITIVE — do not echo or log.");
132
+ }
133
+ if (availableTools.has("secret_get_field")) {
134
+ lines.push("Use secret_get_field to fetch one specific field's value (cheaper than secret_get_full when you only need one).");
83
135
  }
84
136
  if (availableTools.has("secret_set")) {
85
- lines.push("Use secret_set to create or update a secret. Prefer scoping to 'agent' for personal secrets.");
137
+ lines.push("Use secret_set to create a secret with one or more fields. Each field has sensitivity (plaintext/encrypted).");
138
+ }
139
+ if (availableTools.has("secret_set_field")) {
140
+ lines.push("Use secret_set_field to add a new field OR rotate an existing one — the same call handles both.");
86
141
  }
87
142
  return lines;
88
143
  });
@@ -91,7 +146,7 @@ export default {
91
146
  api.registerTool(() => ({
92
147
  name: "secret_list_scopes",
93
148
  label: "List Secret Scopes",
94
- description: "List scopes (org/team/project/agent) the agent can manage secrets in. Use this before guessing scopeIds.",
149
+ description: "List scopes (org/team/project/agent) the agent can manage secrets in.",
95
150
  parameters: { type: "object", properties: {} },
96
151
  execute: async () => {
97
152
  const scopes = await client.listSecretScopes();
@@ -102,19 +157,25 @@ export default {
102
157
  api.registerTool(() => ({
103
158
  name: "secret_list",
104
159
  label: "List Secrets",
105
- description: "List secret metadata (never plaintext) in a given scope.",
160
+ description: "List secret metadata in a scope. Optional category/tag/fieldKey filters route through the byFacet GSI.",
106
161
  parameters: {
107
162
  type: "object",
108
163
  properties: {
109
- scope: { type: "string", enum: VALID_SCOPES, description: "Scope level" },
110
- scopeId: { type: "string", description: "Scope identifier (tenantId / teamId / projectId / agentId)" },
164
+ scope: { type: "string", enum: VALID_SCOPES },
165
+ scopeId: { type: "string" },
166
+ category: { type: "string", enum: VALID_CATEGORIES },
167
+ tag: { type: "string" },
168
+ fieldKey: { type: "string" },
111
169
  },
112
170
  required: ["scope", "scopeId"],
113
171
  },
114
172
  execute: async (_id, params) => {
115
173
  const scope = parseScope(params.scope);
116
174
  const scopeId = parseString(params.scopeId, "scopeId");
117
- const secrets = await client.listSecrets({ scope, scopeId });
175
+ const category = typeof params.category === "string" ? params.category : undefined;
176
+ const tag = typeof params.tag === "string" ? params.tag : undefined;
177
+ const fieldKey = typeof params.fieldKey === "string" ? params.fieldKey : undefined;
178
+ const secrets = await client.listSecrets({ scope, scopeId, category, tag, fieldKey });
118
179
  return { secrets };
119
180
  },
120
181
  }), { names: ["secret_list"] });
@@ -122,60 +183,91 @@ export default {
122
183
  api.registerTool(() => ({
123
184
  name: "secret_set",
124
185
  label: "Set Secret",
125
- description: "Create a new secret. Encrypts the value locally and uploads only the envelope.",
186
+ description: "Create a new secret with one or more fields. Encrypted fields are sealed locally before upload.",
126
187
  parameters: {
127
188
  type: "object",
128
189
  properties: {
129
- scope: { type: "string", enum: VALID_SCOPES, description: "Scope level" },
130
- scopeId: { type: "string", description: "Scope identifier" },
190
+ scope: { type: "string", enum: VALID_SCOPES },
191
+ scopeId: { type: "string" },
131
192
  name: { type: "string", description: "Human-readable secret name (unique within scope)" },
132
- value: { type: "string", description: "Secret plaintext value (encrypted before upload)" },
133
- description: { type: "string", description: "Optional description" },
134
- tags: { type: "array", items: { type: "string" }, description: "Optional tags" },
193
+ category: { type: "string", enum: VALID_CATEGORIES, description: "Defaults to 'other'" },
194
+ description: { type: "string" },
195
+ tags: { type: "array", items: { type: "string" } },
196
+ fields: {
197
+ type: "array",
198
+ description: "Fields to include. Each: { key, sensitivity (plaintext|encrypted), format?, value }",
199
+ items: {
200
+ type: "object",
201
+ properties: {
202
+ key: { type: "string" },
203
+ sensitivity: { type: "string", enum: VALID_SENSITIVITIES },
204
+ format: { type: "string", enum: VALID_FORMATS },
205
+ value: { type: "string" },
206
+ },
207
+ required: ["key", "sensitivity", "value"],
208
+ },
209
+ },
210
+ // Backwards-compat shorthand: { value: "..." } synthesises one
211
+ // encrypted field with key="value".
212
+ value: { type: "string", description: "Shorthand for fields=[{ key: 'value', sensitivity: 'encrypted', value }]" },
213
+ reason: { type: "string" },
135
214
  },
136
- required: ["scope", "scopeId", "name", "value"],
215
+ required: ["scope", "scopeId", "name"],
137
216
  },
138
217
  execute: async (_id, params) => {
139
218
  const scope = parseScope(params.scope);
140
219
  const scopeId = parseString(params.scopeId, "scopeId");
141
220
  const name = parseSecretName(params.name);
142
- const value = parseString(params.value, "value", 65_536);
221
+ const category = typeof params.category === "string"
222
+ ? params.category
223
+ : undefined;
143
224
  const description = typeof params.description === "string" ? params.description : undefined;
144
225
  const tags = Array.isArray(params.tags)
145
226
  ? params.tags.filter((t) => typeof t === "string")
146
227
  : undefined;
228
+ const reason = typeof params.reason === "string" ? params.reason : undefined;
229
+ let fields;
230
+ if (Array.isArray(params.fields)) {
231
+ fields = parseFields(params.fields);
232
+ }
233
+ else if (typeof params.value === "string") {
234
+ fields = [{ key: "value", sensitivity: "encrypted", value: params.value }];
235
+ }
236
+ else {
237
+ throw new Error("either `fields` or `value` is required");
238
+ }
147
239
  const secretId = randomUUID();
148
- const envelope = await encryptSecretValue({
149
- client,
150
- scope,
151
- scopeId,
152
- secretId,
153
- plaintext: value,
154
- });
155
- await client.putSecretEnvelope({
156
- scope,
157
- scopeId,
158
- secretId,
240
+ // Pre-seal each encrypted field with its own data key.
241
+ const sealed = await Promise.all(fields.map(async (f) => {
242
+ if (f.sensitivity === "encrypted") {
243
+ const envelope = await encryptSecretValue({
244
+ client, scope, scopeId, secretId, fieldKey: f.key, plaintext: f.value,
245
+ });
246
+ return { ...f, value: undefined, envelope };
247
+ }
248
+ return f;
249
+ }));
250
+ await client.createSecret({
251
+ scope, scopeId, secretId,
159
252
  secretName: name,
160
- envelope,
161
- description,
162
- tags,
253
+ category, description, tags, reason,
254
+ fields: sealed,
163
255
  });
164
- logger.info("Secret created", { scope, scopeId, secretId });
256
+ logger.info("Secret created", { scope, scopeId, secretId, fieldCount: fields.length });
165
257
  return { secretId };
166
258
  },
167
259
  }), { names: ["secret_set"] });
168
- // ─── secret_get ─────────────────────────────────────────────
260
+ // ─── secret_get (legacy: returns `value` field plaintext as a string) ──
169
261
  api.registerTool(() => ({
170
262
  name: "secret_get",
171
- label: "Get Secret",
172
- description: "Fetch and decrypt a secret by secretId. The return value is SENSITIVE; do not echo or log it.",
263
+ label: "Get Secret (Legacy)",
264
+ description: "Fetch and decrypt the `value`-keyed field. The output is SENSITIVE; do not echo or log.",
173
265
  parameters: {
174
266
  type: "object",
175
267
  properties: {
176
268
  scope: { type: "string", enum: VALID_SCOPES },
177
269
  scopeId: { type: "string" },
178
- secretId: { type: "string", description: "UUID v4 of the secret" },
270
+ secretId: { type: "string" },
179
271
  },
180
272
  required: ["scope", "scopeId", "secretId"],
181
273
  },
@@ -183,118 +275,284 @@ export default {
183
275
  const scope = parseScope(params.scope);
184
276
  const scopeId = parseString(params.scopeId, "scopeId");
185
277
  const secretId = parseSecretId(params.secretId);
186
- const row = await client.getSecretEnvelope({ scope, scopeId, secretId });
278
+ const field = await client.getSecretField({ scope, scopeId, secretId, fieldKey: "value" });
279
+ if (field.sensitivity === "plaintext") {
280
+ return { secretId, value: field.value };
281
+ }
282
+ if (!field.envelope)
283
+ throw new Error("encrypted field has no envelope");
187
284
  const plaintext = await decryptSecretEnvelope({
188
- client,
189
- scope,
190
- scopeId,
191
- secretId,
192
- envelope: row.envelope,
285
+ client, scope, scopeId, secretId, fieldKey: "value", envelope: field.envelope,
193
286
  });
287
+ return { secretId, value: plaintext };
288
+ },
289
+ }), { names: ["secret_get"] });
290
+ // ─── secret_get_full ────────────────────────────────────────
291
+ api.registerTool(() => ({
292
+ name: "secret_get_full",
293
+ label: "Get Secret (All Fields)",
294
+ description: "Fetch the secret aggregate with all fields decrypted. Output is SENSITIVE.",
295
+ parameters: {
296
+ type: "object",
297
+ properties: {
298
+ scope: { type: "string", enum: VALID_SCOPES },
299
+ scopeId: { type: "string" },
300
+ secretId: { type: "string" },
301
+ },
302
+ required: ["scope", "scopeId", "secretId"],
303
+ },
304
+ execute: async (_id, params) => {
305
+ const scope = parseScope(params.scope);
306
+ const scopeId = parseString(params.scopeId, "scopeId");
307
+ const secretId = parseSecretId(params.secretId);
308
+ const { aggregate, envelopes } = await client.getSecret({ scope, scopeId, secretId });
309
+ const decryptedFields = await Promise.all(aggregate.fields.map(async (f) => {
310
+ if (f.sensitivity === "plaintext") {
311
+ return { key: f.key, sensitivity: f.sensitivity, format: f.format, value: f.value };
312
+ }
313
+ const envelope = envelopes.find((e) => e.key === f.key)?.envelope;
314
+ if (!envelope)
315
+ return { key: f.key, sensitivity: f.sensitivity, format: f.format, error: "envelope missing" };
316
+ const plaintext = await decryptSecretEnvelope({
317
+ client, scope, scopeId, secretId, fieldKey: f.key, envelope,
318
+ });
319
+ return { key: f.key, sensitivity: f.sensitivity, format: f.format, value: plaintext };
320
+ }));
194
321
  return {
195
322
  secretId,
196
- secretName: row.secretName,
197
- value: plaintext,
323
+ secretName: aggregate.secretName,
324
+ description: aggregate.description,
325
+ tags: aggregate.tags,
326
+ category: aggregate.category,
327
+ fields: decryptedFields,
198
328
  };
199
329
  },
200
- }), { names: ["secret_get"] });
201
- // ─── secret_get_by_name ─────────────────────────────────────
330
+ }), { names: ["secret_get_full"] });
331
+ // ─── secret_get_field ───────────────────────────────────────
202
332
  api.registerTool(() => ({
203
- name: "secret_get_by_name",
204
- label: "Get Secret By Name",
205
- description: "Fetch a secret by its human-readable name. Convenience wrapper around secret_list + secret_get.",
333
+ name: "secret_get_field",
334
+ label: "Get Secret Field",
335
+ description: "Fetch and decrypt one field by key. Cheaper than secret_get_full when you only need one piece.",
206
336
  parameters: {
207
337
  type: "object",
208
338
  properties: {
209
339
  scope: { type: "string", enum: VALID_SCOPES },
210
340
  scopeId: { type: "string" },
211
- name: { type: "string" },
341
+ secretId: { type: "string" },
342
+ fieldKey: { type: "string" },
212
343
  },
213
- required: ["scope", "scopeId", "name"],
344
+ required: ["scope", "scopeId", "secretId", "fieldKey"],
214
345
  },
215
346
  execute: async (_id, params) => {
216
347
  const scope = parseScope(params.scope);
217
348
  const scopeId = parseString(params.scopeId, "scopeId");
218
- const name = parseSecretName(params.name);
219
- const secrets = await client.listSecrets({ scope, scopeId });
220
- const match = secrets.find((s) => s.secretName === name);
221
- if (!match)
222
- return { found: false };
223
- const row = await client.getSecretEnvelope({ scope, scopeId, secretId: match.secretId });
349
+ const secretId = parseSecretId(params.secretId);
350
+ const fieldKey = parseFieldKey(params.fieldKey);
351
+ const field = await client.getSecretField({ scope, scopeId, secretId, fieldKey });
352
+ if (field.sensitivity === "plaintext") {
353
+ return { key: field.key, sensitivity: field.sensitivity, format: field.format, value: field.value };
354
+ }
355
+ if (!field.envelope)
356
+ throw new Error("encrypted field has no envelope");
224
357
  const plaintext = await decryptSecretEnvelope({
225
- client,
226
- scope,
227
- scopeId,
228
- secretId: match.secretId,
229
- envelope: row.envelope,
358
+ client, scope, scopeId, secretId, fieldKey, envelope: field.envelope,
230
359
  });
231
- return { found: true, secretId: match.secretId, secretName: match.secretName, value: plaintext };
360
+ return { key: field.key, sensitivity: field.sensitivity, format: field.format, value: plaintext };
232
361
  },
233
- }), { names: ["secret_get_by_name"] });
234
- // ─── secret_delete ──────────────────────────────────────────
362
+ }), { names: ["secret_get_field"] });
363
+ // ─── secret_set_field ───────────────────────────────────────
235
364
  api.registerTool(() => ({
236
- name: "secret_delete",
237
- label: "Delete Secret",
238
- description: "Delete a secret by secretId.",
365
+ name: "secret_set_field",
366
+ label: "Set Secret Field",
367
+ description: "Add OR rotate one field. Same key as existing = rotate; new key = add. Encrypted fields are sealed locally.",
239
368
  parameters: {
240
369
  type: "object",
241
370
  properties: {
242
371
  scope: { type: "string", enum: VALID_SCOPES },
243
372
  scopeId: { type: "string" },
244
373
  secretId: { type: "string" },
374
+ fieldKey: { type: "string" },
375
+ sensitivity: { type: "string", enum: VALID_SENSITIVITIES },
376
+ format: { type: "string", enum: VALID_FORMATS },
377
+ value: { type: "string" },
378
+ reason: { type: "string" },
245
379
  },
246
- required: ["scope", "scopeId", "secretId"],
380
+ required: ["scope", "scopeId", "secretId", "fieldKey", "sensitivity", "value"],
247
381
  },
248
382
  execute: async (_id, params) => {
249
383
  const scope = parseScope(params.scope);
250
384
  const scopeId = parseString(params.scopeId, "scopeId");
251
385
  const secretId = parseSecretId(params.secretId);
252
- await client.deleteSecret({ scope, scopeId, secretId });
253
- logger.info("Secret deleted", { scope, scopeId, secretId });
386
+ const fieldKey = parseFieldKey(params.fieldKey);
387
+ const sensitivity = (() => {
388
+ if (typeof params.sensitivity !== "string" || !VALID_SENSITIVITIES.includes(params.sensitivity)) {
389
+ throw new Error(`sensitivity must be one of ${VALID_SENSITIVITIES.join(", ")}`);
390
+ }
391
+ return params.sensitivity;
392
+ })();
393
+ const format = (() => {
394
+ if (params.format === undefined)
395
+ return undefined;
396
+ if (typeof params.format !== "string" || !VALID_FORMATS.includes(params.format)) {
397
+ throw new Error(`format must be one of ${VALID_FORMATS.join(", ")}`);
398
+ }
399
+ return params.format;
400
+ })();
401
+ const value = parseString(params.value, "value", 16384);
402
+ const reason = typeof params.reason === "string" ? params.reason : undefined;
403
+ if (sensitivity === "encrypted") {
404
+ const envelope = await encryptSecretValue({
405
+ client, scope, scopeId, secretId, fieldKey, plaintext: value,
406
+ });
407
+ const result = await client.setSecretField({
408
+ scope, scopeId, secretId, fieldKey,
409
+ sensitivity, format, envelope, reason,
410
+ });
411
+ logger.info("Encrypted field upserted", { scope, scopeId, secretId, fieldKey, rotated: result.rotated });
412
+ return result;
413
+ }
414
+ const result = await client.setSecretField({
415
+ scope, scopeId, secretId, fieldKey,
416
+ sensitivity, format, value, reason,
417
+ });
418
+ logger.info("Plaintext field upserted", { scope, scopeId, secretId, fieldKey, rotated: result.rotated });
419
+ return result;
420
+ },
421
+ }), { names: ["secret_set_field"] });
422
+ // ─── secret_remove_field ────────────────────────────────────
423
+ api.registerTool(() => ({
424
+ name: "secret_remove_field",
425
+ label: "Remove Secret Field",
426
+ description: "Remove one field from a secret. Other fields untouched.",
427
+ parameters: {
428
+ type: "object",
429
+ properties: {
430
+ scope: { type: "string", enum: VALID_SCOPES },
431
+ scopeId: { type: "string" },
432
+ secretId: { type: "string" },
433
+ fieldKey: { type: "string" },
434
+ },
435
+ required: ["scope", "scopeId", "secretId", "fieldKey"],
436
+ },
437
+ execute: async (_id, params) => {
438
+ const scope = parseScope(params.scope);
439
+ const scopeId = parseString(params.scopeId, "scopeId");
440
+ const secretId = parseSecretId(params.secretId);
441
+ const fieldKey = parseFieldKey(params.fieldKey);
442
+ await client.removeSecretField({ scope, scopeId, secretId, fieldKey });
443
+ logger.info("Field removed", { scope, scopeId, secretId, fieldKey });
254
444
  return { deleted: true };
255
445
  },
256
- }), { names: ["secret_delete"] });
257
- // ─── secret_rotate ──────────────────────────────────────────
446
+ }), { names: ["secret_remove_field"] });
447
+ // ─── secret_update_metadata ─────────────────────────────────
258
448
  api.registerTool(() => ({
259
- name: "secret_rotate",
260
- label: "Rotate Secret",
261
- description: "Replace an existing secret's value with a new one. Issues a fresh data key and re-encrypts.",
449
+ name: "secret_update_metadata",
450
+ label: "Update Secret Metadata",
451
+ description: "Patch secret-level metadata (name, description, tags, category). Does not touch fields.",
262
452
  parameters: {
263
453
  type: "object",
264
454
  properties: {
265
455
  scope: { type: "string", enum: VALID_SCOPES },
266
456
  scopeId: { type: "string" },
267
457
  secretId: { type: "string" },
268
- newValue: { type: "string", description: "The new plaintext value" },
458
+ secretName: { type: "string" },
459
+ description: { type: "string" },
460
+ tags: { type: "array", items: { type: "string" } },
461
+ category: { type: "string", enum: VALID_CATEGORIES },
462
+ reason: { type: "string" },
269
463
  },
270
- required: ["scope", "scopeId", "secretId", "newValue"],
464
+ required: ["scope", "scopeId", "secretId"],
271
465
  },
272
466
  execute: async (_id, params) => {
273
467
  const scope = parseScope(params.scope);
274
468
  const scopeId = parseString(params.scopeId, "scopeId");
275
469
  const secretId = parseSecretId(params.secretId);
276
- const newValue = parseString(params.newValue, "newValue", 65_536);
277
- const existing = await client.getSecretEnvelope({ scope, scopeId, secretId });
278
- const envelope = await encryptSecretValue({
279
- client,
280
- scope,
281
- scopeId,
282
- secretId,
283
- plaintext: newValue,
284
- });
285
- await client.putSecretEnvelope({
286
- scope,
287
- scopeId,
288
- secretId,
289
- secretName: existing.secretName,
290
- envelope,
291
- description: existing.description,
292
- tags: existing.tags,
470
+ const secretName = typeof params.secretName === "string" ? parseSecretName(params.secretName) : undefined;
471
+ const description = typeof params.description === "string" ? params.description : undefined;
472
+ const tags = Array.isArray(params.tags)
473
+ ? params.tags.filter((t) => typeof t === "string")
474
+ : undefined;
475
+ const category = typeof params.category === "string" ? params.category : undefined;
476
+ const reason = typeof params.reason === "string" ? params.reason : undefined;
477
+ return client.updateSecretMetadata({
478
+ scope, scopeId, secretId, secretName, description, tags, category, reason,
293
479
  });
294
- logger.info("Secret rotated", { scope, scopeId, secretId });
295
- return { secretId, rotated: true };
296
480
  },
297
- }), { names: ["secret_rotate"] });
481
+ }), { names: ["secret_update_metadata"] });
482
+ // ─── secret_search ──────────────────────────────────────────
483
+ api.registerTool(() => ({
484
+ name: "secret_search",
485
+ label: "Search Secrets",
486
+ description: "Find secrets by category, tag, or field key via the byFacet GSI.",
487
+ parameters: {
488
+ type: "object",
489
+ properties: {
490
+ scope: { type: "string", enum: VALID_SCOPES },
491
+ scopeId: { type: "string" },
492
+ category: { type: "string", enum: VALID_CATEGORIES },
493
+ tag: { type: "string" },
494
+ fieldKey: { type: "string" },
495
+ },
496
+ required: ["scope", "scopeId"],
497
+ },
498
+ execute: async (_id, params) => {
499
+ const scope = parseScope(params.scope);
500
+ const scopeId = parseString(params.scopeId, "scopeId");
501
+ const category = typeof params.category === "string" ? params.category : undefined;
502
+ const tag = typeof params.tag === "string" ? params.tag : undefined;
503
+ const fieldKey = typeof params.fieldKey === "string" ? params.fieldKey : undefined;
504
+ const secrets = await client.listSecrets({ scope, scopeId, category, tag, fieldKey });
505
+ return { secrets };
506
+ },
507
+ }), { names: ["secret_search"] });
508
+ // ─── secret_get_history ─────────────────────────────────────
509
+ api.registerTool(() => ({
510
+ name: "secret_get_history",
511
+ label: "Get Secret History",
512
+ description: "Bounded changelog read — metadata-only audit entries (no field values).",
513
+ parameters: {
514
+ type: "object",
515
+ properties: {
516
+ scope: { type: "string", enum: VALID_SCOPES },
517
+ scopeId: { type: "string" },
518
+ secretId: { type: "string" },
519
+ limit: { type: "number" },
520
+ cursor: { type: "string" },
521
+ },
522
+ required: ["scope", "scopeId", "secretId"],
523
+ },
524
+ execute: async (_id, params) => {
525
+ const scope = parseScope(params.scope);
526
+ const scopeId = parseString(params.scopeId, "scopeId");
527
+ const secretId = parseSecretId(params.secretId);
528
+ const limit = typeof params.limit === "number" ? params.limit : undefined;
529
+ const cursor = typeof params.cursor === "string" ? params.cursor : undefined;
530
+ return client.getSecretHistory({ scope, scopeId, secretId, limit, cursor });
531
+ },
532
+ }), { names: ["secret_get_history"] });
533
+ // ─── secret_delete ──────────────────────────────────────────
534
+ api.registerTool(() => ({
535
+ name: "secret_delete",
536
+ label: "Delete Secret",
537
+ description: "Delete a secret and all its fields, tags, and changelog rows.",
538
+ parameters: {
539
+ type: "object",
540
+ properties: {
541
+ scope: { type: "string", enum: VALID_SCOPES },
542
+ scopeId: { type: "string" },
543
+ secretId: { type: "string" },
544
+ },
545
+ required: ["scope", "scopeId", "secretId"],
546
+ },
547
+ execute: async (_id, params) => {
548
+ const scope = parseScope(params.scope);
549
+ const scopeId = parseString(params.scopeId, "scopeId");
550
+ const secretId = parseSecretId(params.secretId);
551
+ await client.deleteSecret({ scope, scopeId, secretId });
552
+ logger.info("Secret deleted", { scope, scopeId, secretId });
553
+ return { deleted: true };
554
+ },
555
+ }), { names: ["secret_delete"] });
298
556
  logger.info("openclaw-secrets plugin registered");
299
557
  },
300
558
  };