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