@codyswann/lisa 4.22.0 → 4.22.2

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.
Files changed (79) hide show
  1. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  2. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  3. package/dist/core/upstream-evidence-manifest.js +6 -4
  4. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  5. package/package.json +2 -2
  6. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  8. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/SKILL.md +26 -1
  9. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/providers.mjs +90 -2
  10. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +307 -53
  11. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-aws/SKILL.md +20 -3
  12. package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +26 -1
  13. package/plugins/lisa/skills/lisa-secrets-access/scripts/providers.mjs +90 -2
  14. package/plugins/lisa/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +307 -53
  15. package/plugins/lisa/skills/lisa-setup-remote-aws/SKILL.md +20 -3
  16. package/plugins/lisa-agy/plugin.json +1 -1
  17. package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +26 -1
  18. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/providers.mjs +90 -2
  19. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +307 -53
  20. package/plugins/lisa-agy/skills/lisa-setup-remote-aws/SKILL.md +20 -3
  21. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  24. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +26 -1
  28. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/providers.mjs +90 -2
  29. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +307 -53
  30. package/plugins/lisa-copilot/skills/lisa-setup-remote-aws/SKILL.md +20 -3
  31. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +26 -1
  33. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/providers.mjs +90 -2
  34. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +307 -53
  35. package/plugins/lisa-cursor/skills/lisa-setup-remote-aws/SKILL.md +20 -3
  36. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo-agy/plugin.json +1 -1
  39. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  49. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  54. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  59. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails-agy/plugin.json +1 -1
  64. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  69. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  74. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +26 -1
  77. package/plugins/src/base/skills/lisa-secrets-access/scripts/providers.mjs +90 -2
  78. package/plugins/src/base/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +307 -53
  79. package/plugins/src/base/skills/lisa-setup-remote-aws/SKILL.md +20 -3
@@ -281,7 +281,7 @@ Values never enter that process, so its output cannot leak one even if logged.
281
281
 
282
282
  | Provider | Read | Write (rotation only) |
283
283
  | --- | --- | --- |
284
- | `bitwarden` | `bws secret list --output json` → index by `key` | `bws secret edit` |
284
+ | `bitwarden` | `bws secret list --output json` → index by `key` | `bws secret edit`; temporary `create`/`delete` for AWS publication coordination |
285
285
  | `doppler` | `doppler secrets download --no-file --format json` | not implemented |
286
286
  | `env` | environment only; the provider is the environment | n/a |
287
287
  | `1password` | `op read "op://<vault>/<name>/credential"` | not implemented |
@@ -297,6 +297,31 @@ secret you would otherwise store inside it.
297
297
 
298
298
  Unimplemented providers fail with a message naming where to add them, rather than failing obscurely. Do not claim support that does not exist.
299
299
 
300
+ ### AWS bootstrap publication coordination
301
+
302
+ `publish-aws-bootstrap.mjs publish` and `preflight` serialize mutations of
303
+ `LISA_AWS_BOOTSTRAP_JSON` in the provider. Each contender creates a unique,
304
+ non-secret coordination record in the target record's Bitwarden project. The
305
+ oldest active contender wins by provider-issued `creationDate`, with the
306
+ provider id as a deterministic tie-breaker. Later contenders remove themselves
307
+ and stop before the bundle is written.
308
+
309
+ The winner re-reads the target after acquisition, then holds the coordination
310
+ record across the no-op write check, candidate verification, replacement,
311
+ read-back, post-write verification, and any rollback. Cleanup deletes the
312
+ coordination record and proves it no longer appears in the provider. Records
313
+ older than 30 minutes are expired and removed on the next attempt, covering a
314
+ killed host without permitting a permanent lock.
315
+
316
+ Coordination keys start with `LISA_COORDINATION_` and are excluded before
317
+ normal secret selection, so they can never resolve or materialize as
318
+ credentials. Their value is a fixed public sentinel; no credential is copied
319
+ into them. This publication lock is separate from the advisory rotation lease
320
+ record used by `rotate-secret.mjs`.
321
+
322
+ Ordinary session refresh only reads and materializes existing provider values.
323
+ It neither creates coordination records nor writes the AWS bundle.
324
+
300
325
  Cache **in-process only**. Never write a resolved value to disk except through the materialization contract above.
301
326
 
302
327
  ## Doctor checks worth wiring
@@ -33,6 +33,17 @@ export const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
33
33
  */
34
34
  export const LEASE_KEY = "LISA_ROTATION_LEASES";
35
35
 
36
+ /**
37
+ * Prefix reserved for provider-side coordination records.
38
+ *
39
+ * These records contain no credential material. They exist only long enough
40
+ * to serialize a provider mutation and must never cross the exposure boundary
41
+ * into a shell, file, hook, or CI environment.
42
+ */
43
+ export const COORDINATION_KEY_PREFIX = "LISA_COORDINATION_";
44
+
45
+ const COORDINATION_VALUE = "coordination-only";
46
+
36
47
  /**
37
48
  * The environment variable each provider's CLI reads its own bootstrap from.
38
49
  *
@@ -177,7 +188,7 @@ function fromKeychain(key) {
177
188
  * caller may see, so the set it returns *is* the permitted set. Restating that
178
189
  * as a list in config would duplicate a boundary the provider already enforces.
179
190
  * @param {object} cfg Resolved configuration.
180
- * @returns {Array<{key: string, value: string, note: string, projectId: string|null}>} Rows.
191
+ * @returns {Array<{key: string, value: string, note: string, projectId: string|null, id?: string|null, creationDate?: string|null, revisionDate?: string|null}>} Rows.
181
192
  */
182
193
  export function fetchRaw(cfg) {
183
194
  const env = providerEnv(cfg);
@@ -201,6 +212,8 @@ export function fetchRaw(cfg) {
201
212
  note: s.note ?? "",
202
213
  projectId: s.projectId ?? null,
203
214
  id: s.id ?? null,
215
+ creationDate: s.creationDate ?? null,
216
+ revisionDate: s.revisionDate ?? null,
204
217
  }));
205
218
  }
206
219
 
@@ -248,7 +261,7 @@ function run(bin, args, env) {
248
261
  * which is neither stable nor visible at the call site.
249
262
  * @param {Array<object>} rows Raw provider rows.
250
263
  * @param {{projectIds: string[], excludeKeys: string[]}} narrow Narrowing controls.
251
- * @returns {Map<string, {value: string, note: string, id: string|null}>} Selected secrets by name.
264
+ * @returns {Map<string, {value: string, note: string, id: string|null, projectId: string|null, creationDate: string|null, revisionDate: string|null}>} Selected secrets by name.
252
265
  */
253
266
  export function normalizeRows(
254
267
  rows,
@@ -260,6 +273,12 @@ export function normalizeRows(
260
273
 
261
274
  for (const row of rows) {
262
275
  if (projects.size && !projects.has(row.projectId)) continue;
276
+ if (
277
+ typeof row.key === "string" &&
278
+ row.key.startsWith(COORDINATION_KEY_PREFIX)
279
+ ) {
280
+ continue;
281
+ }
263
282
  if (typeof row.key !== "string" || !ENV_KEY.test(row.key)) continue;
264
283
  if (excluded.has(row.key)) continue;
265
284
  if (out.has(row.key)) {
@@ -276,6 +295,9 @@ export function normalizeRows(
276
295
  value: row.value,
277
296
  note: row.note ?? "",
278
297
  id: row.id ?? null,
298
+ projectId: row.projectId ?? null,
299
+ creationDate: row.creationDate ?? null,
300
+ revisionDate: row.revisionDate ?? null,
279
301
  });
280
302
  }
281
303
  return out;
@@ -370,3 +392,69 @@ export function writeSecret(cfg, id, value) {
370
392
  `Rotation requires a proven write path; add one in providers.mjs.`
371
393
  );
372
394
  }
395
+
396
+ /**
397
+ * Create one provider-issued publication contender in the target project.
398
+ *
399
+ * The deliberately constrained interface accepts no value: coordination
400
+ * records always contain the same public sentinel, which prevents this helper
401
+ * from becoming an alternate credential-write path.
402
+ * @param {object} cfg Resolved configuration.
403
+ * @param {string} key Unique coordination key.
404
+ * @param {string} projectId Project containing the target provider record.
405
+ * @param {string} note Non-sensitive lifecycle metadata.
406
+ * @returns {object} The provider-issued coordination row.
407
+ */
408
+ export function createCoordinationRecord(cfg, key, projectId, note) {
409
+ const env = providerEnv(cfg);
410
+
411
+ if (cfg.provider === "bitwarden") {
412
+ const raw = run(
413
+ "bws",
414
+ [
415
+ "secret",
416
+ "create",
417
+ key,
418
+ COORDINATION_VALUE,
419
+ projectId,
420
+ "--note",
421
+ note,
422
+ "--output",
423
+ "json",
424
+ ],
425
+ env
426
+ );
427
+ const created = JSON.parse(raw);
428
+ return {
429
+ key: created.key,
430
+ value: created.value,
431
+ note: created.note ?? "",
432
+ projectId: created.projectId ?? null,
433
+ id: created.id ?? null,
434
+ creationDate: created.creationDate ?? null,
435
+ revisionDate: created.revisionDate ?? null,
436
+ };
437
+ }
438
+ throw new Error(
439
+ `provider "${cfg.provider}" has no coordination-record creation implemented.\n` +
440
+ `AWS bootstrap publication requires a provider-backed single-writer lock.`
441
+ );
442
+ }
443
+
444
+ /**
445
+ * Remove one provider-side coordination record by its provider identifier.
446
+ * @param {object} cfg Resolved configuration.
447
+ * @param {string} id Provider-side identifier for the coordination record.
448
+ */
449
+ export function removeCoordinationRecord(cfg, id) {
450
+ const env = providerEnv(cfg);
451
+
452
+ if (cfg.provider === "bitwarden") {
453
+ run("bws", ["secret", "delete", id, "--output", "none"], env);
454
+ return;
455
+ }
456
+ throw new Error(
457
+ `provider "${cfg.provider}" has no coordination-record deletion implemented.\n` +
458
+ `AWS bootstrap publication requires verified lock cleanup.`
459
+ );
460
+ }
@@ -11,11 +11,13 @@
11
11
  *
12
12
  * This command makes that handoff one fail-closed operation:
13
13
  *
14
- * 1. prove the existing provider record is writable with a no-op write;
15
- * 2. validate the candidate and assume every declared role with it;
16
- * 3. replace the provider value;
17
- * 4. read the value back exactly and assume every role again; and
18
- * 5. restore the previous provider value if a post-write check fails.
14
+ * 1. acquire a provider-backed lock for the exact bundle record;
15
+ * 2. re-read and prove the record is writable with a no-op write;
16
+ * 3. validate the candidate and assume every declared role with it;
17
+ * 4. replace the provider value;
18
+ * 5. read the value back exactly and assume every role again;
19
+ * 6. restore the previous value if a post-write check fails; and
20
+ * 7. release the lock only after publication or rollback is complete.
19
21
  *
20
22
  * The candidate arrives on stdin so it never appears in a shell command or a
21
23
  * temporary file. No credential value is written to stdout or stderr.
@@ -28,6 +30,7 @@
28
30
  * @module publish-aws-bootstrap
29
31
  */
30
32
 
33
+ import { randomUUID } from "node:crypto";
31
34
  import { readFileSync, realpathSync } from "node:fs";
32
35
  import { fileURLToPath } from "node:url";
33
36
 
@@ -36,7 +39,14 @@ import {
36
39
  parseBootstrap,
37
40
  readProfiles,
38
41
  } from "./aws-bootstrap.mjs";
39
- import { fetchAll, writeSecret } from "./providers.mjs";
42
+ import {
43
+ COORDINATION_KEY_PREFIX,
44
+ createCoordinationRecord,
45
+ fetchAll,
46
+ fetchRaw as providerFetchRaw,
47
+ removeCoordinationRecord,
48
+ writeSecret,
49
+ } from "./providers.mjs";
40
50
  import { readConfig } from "./surfaces.mjs";
41
51
 
42
52
  import { boundedChildOutput } from "../../lisa-setup-workstation/scripts/bounded-child.mjs";
@@ -44,6 +54,8 @@ import { boundedChildOutput } from "../../lisa-setup-workstation/scripts/bounded
44
54
  const PROFILE_NAME = /^[\w.@-]+$/;
45
55
  const ROLE_ARN =
46
56
  /^arn:(?:aws|aws-us-gov|aws-cn):iam::\d{12}:role\/[\w+=,.@/-]+$/;
57
+ const PUBLICATION_COORDINATION_SCOPE = "AWS_BOOTSTRAP_";
58
+ const PUBLICATION_LOCK_TTL_MS = 30 * 60 * 1000;
47
59
 
48
60
  /**
49
61
  * Validate and normalize one complete bundle without changing its serialized
@@ -188,7 +200,7 @@ export function verifyAwsBootstrap(candidate, run = boundedChildOutput) {
188
200
  * Locate the provider record that owns the bundle.
189
201
  * @param {object} cfg Resolved lisa-secrets-access configuration.
190
202
  * @param {(cfg: object) => Map<string, object>} fetch Provider reader.
191
- * @returns {{id: string, value: string}} Writable provider entry.
203
+ * @returns {{id: string, value: string, projectId: string|null}} Writable provider entry.
192
204
  */
193
205
  function currentEntry(cfg, fetch) {
194
206
  const entry = fetch(cfg).get(BOOTSTRAP_KEY);
@@ -202,32 +214,272 @@ function currentEntry(cfg, fetch) {
202
214
  `${BOOTSTRAP_KEY} has no provider identifier, so it cannot be published`
203
215
  );
204
216
  }
205
- return { id: entry.id, value: entry.value };
217
+ return {
218
+ id: entry.id,
219
+ value: entry.value,
220
+ projectId: entry.projectId ?? null,
221
+ };
222
+ }
223
+
224
+ function firstLine(error) {
225
+ return (error instanceof Error ? error.message : String(error)).split(
226
+ "\n"
227
+ )[0];
228
+ }
229
+
230
+ function lockPrefix(targetId) {
231
+ return `${COORDINATION_KEY_PREFIX}${PUBLICATION_COORDINATION_SCOPE}${targetId}_`;
232
+ }
233
+
234
+ function deleteCoordination(cfg, row, fetchRaw, remove) {
235
+ remove(cfg, row.id);
236
+ if (fetchRaw(cfg).some(candidate => candidate.id === row.id)) {
237
+ throw new Error(
238
+ `publication lock ${row.id} is still present after deletion`
239
+ );
240
+ }
241
+ }
242
+
243
+ function writeProviderValue(cfg, id, value, write) {
244
+ try {
245
+ write(cfg, id, value);
246
+ } catch (error) {
247
+ const status =
248
+ typeof error === "object" &&
249
+ error !== null &&
250
+ Number.isInteger(error.status)
251
+ ? ` (exit ${error.status})`
252
+ : "";
253
+ // `bws secret edit` has no stdin form, so the value is present in argv and
254
+ // therefore in Node's child-process error message. Never propagate that
255
+ // message: doing so would print the credential this command protects.
256
+ throw new Error(`provider write command failed${status}`);
257
+ }
206
258
  }
207
259
 
208
260
  /**
209
- * Exercise the exact provider write permission without changing its value.
261
+ * Enter the provider election for one AWS bootstrap record.
262
+ *
263
+ * Every publisher creates a unique contender. The provider's creation time,
264
+ * with its immutable id as a tie-breaker, elects exactly one oldest active
265
+ * contender. Unlike a read-then-create mutex, no participant can overwrite or
266
+ * mistake another participant's record for its own.
210
267
  * @param {object} cfg Resolved configuration.
268
+ * @param {{id: string, projectId: string|null}} target Target provider entry.
211
269
  * @param {object} [operations] Injectable provider operations for tests.
212
- * @returns {{id: string, value: string}} Current provider entry.
270
+ * @returns {object} The winning coordination row.
213
271
  */
214
- export function preflightAwsBootstrap(cfg, operations = {}) {
272
+ export function acquirePublicationLock(cfg, target, operations = {}) {
273
+ if (!target.projectId) {
274
+ throw new Error(
275
+ `${BOOTSTRAP_KEY} has no provider project identifier, so publication cannot be serialized`
276
+ );
277
+ }
278
+
279
+ const create = operations.createCoordination ?? createCoordinationRecord;
280
+ const fetchRaw = operations.fetchRaw ?? providerFetchRaw;
281
+ const remove = operations.removeCoordination ?? removeCoordinationRecord;
282
+ const holderId = (operations.holderId ?? randomUUID)();
283
+ const prefix = lockPrefix(target.id);
284
+ const key = `${prefix}${holderId}`;
285
+ let contender;
286
+
287
+ try {
288
+ contender = create(
289
+ cfg,
290
+ key,
291
+ target.projectId,
292
+ "Lisa AWS bootstrap publication contender; expires after 30 minutes"
293
+ );
294
+ } catch (error) {
295
+ throw new Error(`publication lock creation failed: ${firstLine(error)}`);
296
+ }
297
+
298
+ if (!contender?.id || contender.key !== key) {
299
+ if (contender?.id) {
300
+ try {
301
+ deleteCoordination(cfg, contender, fetchRaw, remove);
302
+ } catch {
303
+ // The validation failure below remains the actionable root cause.
304
+ }
305
+ }
306
+ throw new Error("provider returned an invalid publication lock contender");
307
+ }
308
+
309
+ let rows;
310
+ try {
311
+ rows = fetchRaw(cfg).filter(
312
+ row =>
313
+ row.projectId === target.projectId &&
314
+ typeof row.key === "string" &&
315
+ row.key.startsWith(prefix)
316
+ );
317
+ } catch (error) {
318
+ try {
319
+ deleteCoordination(cfg, contender, fetchRaw, remove);
320
+ } catch (cleanupError) {
321
+ throw new Error(
322
+ `publication lock read failed: ${firstLine(error)}; contender cleanup failed: ${firstLine(cleanupError)}`
323
+ );
324
+ }
325
+ throw new Error(`publication lock read failed: ${firstLine(error)}`);
326
+ }
327
+
328
+ const visible = rows.find(row => row.id === contender.id);
329
+ if (!visible) {
330
+ try {
331
+ deleteCoordination(cfg, contender, fetchRaw, remove);
332
+ } catch (cleanupError) {
333
+ throw new Error(
334
+ `the publication lock contender could not be observed after creation; contender cleanup failed: ${firstLine(cleanupError)}`
335
+ );
336
+ }
337
+ throw new Error(
338
+ "the publication lock contender could not be observed after creation"
339
+ );
340
+ }
341
+
342
+ const providerNow = Date.parse(visible.creationDate ?? "");
343
+ if (!Number.isFinite(providerNow)) {
344
+ try {
345
+ deleteCoordination(cfg, contender, fetchRaw, remove);
346
+ } catch (cleanupError) {
347
+ throw new Error(
348
+ `the publication lock contender has no valid provider creation time; contender cleanup failed: ${firstLine(cleanupError)}`
349
+ );
350
+ }
351
+ throw new Error(
352
+ "the publication lock contender has no valid provider creation time"
353
+ );
354
+ }
355
+
356
+ const active = [];
357
+ for (const row of rows) {
358
+ const createdAt = Date.parse(row.creationDate ?? "");
359
+ if (!Number.isFinite(createdAt)) {
360
+ try {
361
+ deleteCoordination(cfg, contender, fetchRaw, remove);
362
+ } catch (cleanupError) {
363
+ throw new Error(
364
+ `publication lock ${row.id ?? "without an id"} has no valid provider creation time; contender cleanup failed: ${firstLine(cleanupError)}`
365
+ );
366
+ }
367
+ throw new Error(
368
+ `publication lock ${row.id ?? "without an id"} has no valid provider creation time`
369
+ );
370
+ }
371
+ // Compare provider time with provider time. A remote host whose wall clock
372
+ // is skewed must not be able to expire another host's active publication.
373
+ if (createdAt <= providerNow - PUBLICATION_LOCK_TTL_MS) {
374
+ try {
375
+ deleteCoordination(cfg, row, fetchRaw, remove);
376
+ } catch (error) {
377
+ throw new Error(
378
+ `expired publication lock cleanup failed: ${firstLine(error)}`
379
+ );
380
+ }
381
+ continue;
382
+ }
383
+ active.push({ row, createdAt });
384
+ }
385
+
386
+ active.sort(
387
+ (left, right) =>
388
+ left.createdAt - right.createdAt ||
389
+ String(left.row.id).localeCompare(String(right.row.id))
390
+ );
391
+ if (active[0]?.row.id !== contender.id) {
392
+ try {
393
+ deleteCoordination(cfg, contender, fetchRaw, remove);
394
+ } catch (error) {
395
+ throw new Error(
396
+ `another publisher holds the provider lock; contender cleanup failed: ${firstLine(error)}`
397
+ );
398
+ }
399
+ throw new Error("another publisher holds the provider lock");
400
+ }
401
+
402
+ return { ...visible, targetId: target.id };
403
+ }
404
+
405
+ /**
406
+ * Delete a publication contender and prove the provider no longer lists it.
407
+ * @param {object} cfg Resolved configuration.
408
+ * @param {{id: string}} lock Acquired coordination row.
409
+ * @param {object} [operations] Injectable provider operations for tests.
410
+ */
411
+ export function releasePublicationLock(cfg, lock, operations = {}) {
412
+ const fetchRaw = operations.fetchRaw ?? providerFetchRaw;
413
+ const remove = operations.removeCoordination ?? removeCoordinationRecord;
414
+ deleteCoordination(cfg, lock, fetchRaw, remove);
415
+ }
416
+
417
+ /**
418
+ * Hold the provider lock around an entire publication or preflight operation.
419
+ * The target is deliberately refreshed after acquisition so a pre-lock
420
+ * snapshot can never overwrite a publication that finished while this process
421
+ * was entering the election.
422
+ */
423
+ function withPublicationLock(cfg, operations, task) {
215
424
  const fetch = operations.fetch ?? fetchAll;
216
- const write = operations.write ?? writeSecret;
217
- const entry = currentEntry(cfg, fetch);
425
+ const acquire = operations.acquireLock ?? acquirePublicationLock;
426
+ const release = operations.releaseLock ?? releasePublicationLock;
427
+ const target = currentEntry(cfg, fetch);
428
+ const lock = acquire(cfg, target, operations);
429
+ let result;
430
+ let taskError;
431
+
218
432
  try {
219
- write(cfg, entry.id, entry.value);
433
+ const current = currentEntry(cfg, fetch);
434
+ if (current.id !== target.id || current.projectId !== target.projectId) {
435
+ throw new Error(
436
+ `${BOOTSTRAP_KEY} moved after publication lock acquisition; retry from the new provider record`
437
+ );
438
+ }
439
+ result = task(current);
440
+ } catch (error) {
441
+ taskError = error;
442
+ }
443
+
444
+ try {
445
+ release(cfg, lock, operations);
446
+ } catch (releaseError) {
447
+ const releaseReason = `publication lock release failed: ${firstLine(releaseError)}`;
448
+ if (taskError) {
449
+ throw new Error(`${firstLine(taskError)}; ${releaseReason}`);
450
+ }
451
+ throw new Error(releaseReason);
452
+ }
453
+
454
+ if (taskError) throw taskError;
455
+ return result;
456
+ }
457
+
458
+ function preflightUnderLock(cfg, entry, write) {
459
+ try {
460
+ writeProviderValue(cfg, entry.id, entry.value, write);
220
461
  } catch (error) {
221
- const reason =
222
- error instanceof Error ? error.message.split("\n")[0] : String(error);
223
462
  throw new Error(
224
- `no write access to ${BOOTSTRAP_KEY}: ${reason}\n` +
463
+ `no write access to ${BOOTSTRAP_KEY}: ${firstLine(error)}\n` +
225
464
  `Refusing to continue because a rotated key that cannot be published would strand every remote session.`
226
465
  );
227
466
  }
228
467
  return entry;
229
468
  }
230
469
 
470
+ /**
471
+ * Exercise the exact provider write permission without changing its value.
472
+ * @param {object} cfg Resolved configuration.
473
+ * @param {object} [operations] Injectable provider operations for tests.
474
+ * @returns {{id: string, value: string}} Current provider entry.
475
+ */
476
+ export function preflightAwsBootstrap(cfg, operations = {}) {
477
+ const write = operations.write ?? writeSecret;
478
+ return withPublicationLock(cfg, operations, entry =>
479
+ preflightUnderLock(cfg, entry, write)
480
+ );
481
+ }
482
+
231
483
  /**
232
484
  * Restore the previous provider value and prove the restoration.
233
485
  * @param {object} cfg Resolved configuration.
@@ -243,7 +495,7 @@ function rollback(cfg, previous, candidateRaw, fetch, write) {
243
495
  if (current.value !== candidateRaw) {
244
496
  return "rollback skipped because the provider changed after this publication";
245
497
  }
246
- write(cfg, previous.id, previous.value);
498
+ writeProviderValue(cfg, previous.id, previous.value, write);
247
499
  const restored = currentEntry(cfg, fetch);
248
500
  if (restored.value !== previous.value) {
249
501
  return "the previous provider value could not be verified after rollback";
@@ -268,46 +520,48 @@ export function publishAwsBootstrap(raw, cfg, operations = {}) {
268
520
  const fetch = operations.fetch ?? fetchAll;
269
521
  const write = operations.write ?? writeSecret;
270
522
  const verify = operations.verify ?? verifyAwsBootstrap;
271
- const previous = preflightAwsBootstrap(cfg, { fetch, write });
272
523
  const candidate = validateAwsBootstrap(raw);
524
+ return withPublicationLock(cfg, operations, previous => {
525
+ preflightUnderLock(cfg, previous, write);
273
526
 
274
- // Verify before the write. A candidate that cannot assume one of its roles
275
- // never becomes the value every new remote session receives.
276
- verify(candidate);
527
+ // Verify before the write. A candidate that cannot assume one of its roles
528
+ // never becomes the value every new remote session receives.
529
+ verify(candidate);
277
530
 
278
- if (candidate.raw === previous.value) {
279
- return {
280
- changed: false,
281
- profiles: candidate.profiles.map(profile => profile.name),
282
- };
283
- }
531
+ if (candidate.raw === previous.value) {
532
+ return {
533
+ changed: false,
534
+ profiles: candidate.profiles.map(profile => profile.name),
535
+ };
536
+ }
284
537
 
285
- try {
286
- write(cfg, previous.id, candidate.raw);
287
- const stored = currentEntry(cfg, fetch);
288
- if (stored.value !== candidate.raw) {
289
- throw new Error("provider read-back did not match the candidate exactly");
538
+ try {
539
+ writeProviderValue(cfg, previous.id, candidate.raw, write);
540
+ const stored = currentEntry(cfg, fetch);
541
+ if (stored.value !== candidate.raw) {
542
+ throw new Error(
543
+ "provider read-back did not match the candidate exactly"
544
+ );
545
+ }
546
+ const storedCandidate = validateAwsBootstrap(stored.value);
547
+ verify(storedCandidate);
548
+ return {
549
+ changed: true,
550
+ profiles: storedCandidate.profiles.map(profile => profile.name),
551
+ };
552
+ } catch (error) {
553
+ const rollbackOutcome = rollback(
554
+ cfg,
555
+ previous,
556
+ candidate.raw,
557
+ fetch,
558
+ write
559
+ );
560
+ throw new Error(
561
+ `${BOOTSTRAP_KEY} publication failed: ${firstLine(error)}; ${rollbackOutcome}`
562
+ );
290
563
  }
291
- const storedCandidate = validateAwsBootstrap(stored.value);
292
- verify(storedCandidate);
293
- return {
294
- changed: true,
295
- profiles: storedCandidate.profiles.map(profile => profile.name),
296
- };
297
- } catch (error) {
298
- const reason =
299
- error instanceof Error ? error.message.split("\n")[0] : String(error);
300
- const rollbackOutcome = rollback(
301
- cfg,
302
- previous,
303
- candidate.raw,
304
- fetch,
305
- write
306
- );
307
- throw new Error(
308
- `${BOOTSTRAP_KEY} publication failed: ${reason}; ${rollbackOutcome}`
309
- );
310
- }
564
+ });
311
565
  }
312
566
 
313
567
  /** Read a candidate from stdin without ever placing it in argv. */
@@ -56,9 +56,12 @@ which is current. Treat the infrastructure secret as a short-lived deployment
56
56
  emission and the configured provider as the consumer-facing source of truth.
57
57
 
58
58
  Publish the emission as part of the same rotation automation. The publisher
59
- reads the candidate on stdin, proves every role before writing, reads the
60
- provider value back exactly, proves every stored role again, and restores the
61
- previous provider value if a post-write check fails:
59
+ reads the candidate on stdin, acquires a provider-backed lock for the exact
60
+ bundle record, re-reads the current value after winning, proves every role
61
+ before writing, reads the provider value back exactly, proves every stored role
62
+ again, and restores the previous provider value if a post-write check fails.
63
+ The lock remains held through rollback and its deletion is verified before the
64
+ command reports success:
62
65
 
63
66
  ```bash
64
67
  aws --profile shared secretsmanager get-secret-value \
@@ -73,10 +76,24 @@ Resolve the script from the installed runtime or `node_modules` when the
73
76
  argument or temporary file. A CDK deployment followed later by a human copy is
74
77
  not a completed rotation.
75
78
 
79
+ The current publisher supports Bitwarden writes. It creates a temporary,
80
+ non-secret contender in the bundle's Bitwarden project, elects the oldest
81
+ active contender using provider-issued creation metadata, and removes the
82
+ contender when the transaction finishes. A contender expires after 30 minutes
83
+ so a killed publisher cannot block future rotations forever. The service
84
+ account therefore needs create and delete access as well as read and edit
85
+ access; `preflight` proves that complete lifecycle without changing the bundle.
86
+
87
+ Normal remote-session setup is read-only. It refreshes the already-published
88
+ bundle but does not enter the publisher election or mutate Bitwarden, so two
89
+ containers starting together cannot cause this publication race.
90
+
76
91
  **One provider cannot serve this particular secret.** If `secrets.provider` is
77
92
  `aws`, reading the bundle requires AWS credentials — and the bundle *is* the AWS
78
93
  credential. Anything else works: Bitwarden, 1Password, Doppler, Vault, or a
79
94
  platform secret store. Pick the one that is not the thing being bootstrapped.
95
+ Automated publication additionally requires a provider with an implemented
96
+ write and coordination path; that is currently Bitwarden.
80
97
 
81
98
  On a surface that materializes (see `lisa-secrets-access`), the bundle arrives
82
99
  in `secrets.env` with everything else, so the project hook can run this setup
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.22.0",
3
+ "version": "4.22.2",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"