@codyswann/lisa 4.18.0 → 4.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  2. package/dist/core/lisa-owned-hash-ledger.js +14 -0
  3. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  4. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.js +24 -6
  6. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  7. package/expo/copy-overwrite/scripts/bdd/baseline.mjs +10 -2
  8. package/expo/copy-overwrite/scripts/bdd/render.mjs +3 -2
  9. package/expo/copy-overwrite/scripts/bdd-matrix.mjs +3 -3
  10. package/package.json +7 -2
  11. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
  14. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-aws/SKILL.md +23 -5
  15. package/plugins/lisa/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
  16. package/plugins/lisa/skills/lisa-setup-remote-aws/SKILL.md +23 -5
  17. package/plugins/lisa-agy/plugin.json +1 -1
  18. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
  19. package/plugins/lisa-agy/skills/lisa-setup-remote-aws/SKILL.md +23 -5
  20. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  23. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
  27. package/plugins/lisa-copilot/skills/lisa-setup-remote-aws/SKILL.md +23 -5
  28. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
  30. package/plugins/lisa-cursor/skills/lisa-setup-remote-aws/SKILL.md +23 -5
  31. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-expo-agy/plugin.json +1 -1
  34. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  44. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  49. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  54. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-rails-agy/plugin.json +1 -1
  59. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  64. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  69. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/src/base/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
  72. package/plugins/src/base/skills/lisa-setup-remote-aws/SKILL.md +23 -5
  73. package/scripts/check-ui-demo-data.mjs +102 -0
  74. package/ui/README.md +32 -0
  75. package/ui/index.html +901 -115
@@ -184,6 +184,9 @@ function featureFilesAt(root, revision) {
184
184
  * @returns {{available: boolean, error: string|null, contract: object|null, scenarios: object[], scenarioIds: Set<string>}} Base state.
185
185
  */
186
186
  export function loadBaseline(root, revision, headPlatforms = new Set()) {
187
+ const binary = resolveGit();
188
+ const revisionExists =
189
+ binary !== null && git(root, ["cat-file", "-e", `${revision}^{commit}`]).ok;
187
190
  const raw = showAtRevision(root, revision, "bdd/coverage-map.json");
188
191
  let contract = null;
189
192
  let error = null;
@@ -195,8 +198,10 @@ export function loadBaseline(root, revision, headPlatforms = new Set()) {
195
198
  error = `bdd/coverage-map.json at that revision is not valid JSON: ${parseError.message}`;
196
199
  }
197
200
  }
198
- if (raw === null && !resolveGit()) {
201
+ if (binary === null) {
199
202
  error = "git was not found, so no base revision could be read";
203
+ } else if (!revisionExists) {
204
+ error = "the requested base does not name a readable commit";
200
205
  }
201
206
  const documents = featureFilesAt(root, revision)
202
207
  .map(file => ({ file, source: showAtRevision(root, revision, file) }))
@@ -206,7 +211,10 @@ export function loadBaseline(root, revision, headPlatforms = new Set()) {
206
211
  ...headPlatforms,
207
212
  ]);
208
213
  return {
209
- available: error === null && (raw !== null || documents.length > 0),
214
+ // A verified commit with no map and no features is the legitimate empty
215
+ // baseline for a repository's first behavior contract. Commit existence is
216
+ // checked independently so an invalid SHA can never masquerade as bootstrap.
217
+ available: error === null && revisionExists,
210
218
  error,
211
219
  contract,
212
220
  scenarios: documents.flatMap(document =>
@@ -124,7 +124,8 @@ function inventorySection(report) {
124
124
  `| ${cell(item.runner)} | \`${cell(item.file)}\` | ${item.evidence === null ? "*(file has no title)*" : cell(item.evidence)} |`
125
125
  )
126
126
  .join("\n") || "| — | — | None |";
127
- return `${inventory.discovered} tests discovered under ${inventory.roots.map(root => `\`${cell(root)}\``).join(", ")}; ${inventory.disclosed} are named by a mapping or an exclusion. ${inventory.dynamicTitles} carry a computed (template-literal) title, taken verbatim from the source — a runner result cannot be joined to those by title.\n\n| Runner | File | Test not named by the contract |\n|---|---|---|\n${rows}`;
127
+ const carry = inventory.dynamicTitles === 1 ? "carries" : "carry";
128
+ return `${inventory.discovered} tests discovered under ${inventory.roots.map(root => `\`${cell(root)}\``).join(", ")}; ${inventory.disclosed} are named by a mapping or an exclusion. ${inventory.dynamicTitles} ${carry} a computed (template-literal) title, taken verbatim from the source — a runner result cannot be joined to those by title.\n\n| Runner | File | Test not named by the contract |\n|---|---|---|\n${rows}`;
128
129
  }
129
130
 
130
131
  /**
@@ -186,7 +187,7 @@ export function renderBurndown(report) {
186
187
  return `# BDD behavior contract — coverage burndown
187
188
 
188
189
  Generated from \`bdd/features/*.feature\` and \`bdd/coverage-map.json\` as of ${report.asOf ?? "an unrecorded date"}.
189
- Regenerated by \`node scripts/check-bdd-coverage.mjs --write\`; never hand-edited.
190
+ Regenerated by \`BDD_BASE_SHA="$(git merge-base HEAD origin/HEAD)" bun run bdd:coverage:write\`; never hand-edited.
190
191
 
191
192
  ## What each number means
192
193
 
@@ -15,7 +15,7 @@
15
15
  * the mapped column asserts an aligned test exists and still contains its
16
16
  * evidence string — never that it passed.
17
17
  *
18
- * Usage: node scripts/bdd-matrix.mjs [--write] [--results <file>]
18
+ * Usage: BDD_BASE_SHA="$(git merge-base HEAD origin/HEAD)" bun run bdd:matrix [--results <file>]
19
19
  *
20
20
  * @module scripts/bdd-matrix
21
21
  */
@@ -205,7 +205,7 @@ function renderFeature({
205
205
  return `| \`${scenario.id ?? "(no id)"}\` | ${scenario.name} | ${sourceCell(scenario, contract.trackers)} | ${lifecycle} | ${cells.join(" | ")} | ${executionCell(mappings, results, supplied)} |`;
206
206
  })
207
207
  .join("\n");
208
- return `### ${feature}\n\n${header}\n${divider}\n${rows}\n`;
208
+ return `## ${feature}\n\n${header}\n${divider}\n${rows}\n`;
209
209
  }
210
210
 
211
211
  /**
@@ -219,7 +219,7 @@ function preamble(scenarios, supplied) {
219
219
  return `# BDD scenario traceability matrix
220
220
 
221
221
  Every declared behavior, where it came from, and what actually proves it.
222
- Generated by \`node scripts/bdd-matrix.mjs --write\`; never hand-edited.
222
+ Generated by \`BDD_BASE_SHA="$(git merge-base HEAD origin/HEAD)" bun run bdd:matrix\`; never hand-edited.
223
223
 
224
224
  - **Mapped** — aligned automation exists in that runner for the platforms the scenario requires, and its evidence string still resolves. This is **traceability**, not a result: a mapped test that fails still shows \`✓\`.
225
225
  - **Executed** — what the supplied run evidence returned for those mapped tests. ${supplied ? "Run evidence was supplied for this render." : "**No run evidence was supplied for this render**, so this column reads `not supplied` throughout — nothing here asserts any test ran."}
package/package.json CHANGED
@@ -39,6 +39,11 @@
39
39
  "prepublishOnly": "$npm_execpath run build",
40
40
  "check:plugins": "bash scripts/check-plugins-sync.sh",
41
41
  "check:learnings-budget": "bun scripts/check-learnings-budget.ts",
42
+ "check:ui-demo-data": "node scripts/check-ui-demo-data.mjs",
43
+ "check:behavior-contract": "BDD_COVERAGE_ROOT=. node expo/copy-overwrite/scripts/check-bdd-coverage.mjs",
44
+ "bdd:coverage": "bun run check:behavior-contract",
45
+ "bdd:coverage:write": "BDD_COVERAGE_ROOT=. node expo/copy-overwrite/scripts/check-bdd-coverage.mjs --write",
46
+ "bdd:matrix": "BDD_COVERAGE_ROOT=. node expo/copy-overwrite/scripts/bdd-matrix.mjs --write",
42
47
  "build:upstream-evidence-manifest": "node scripts/generate-upstream-evidence-manifest.mjs",
43
48
  "check:upstream-evidence-manifest": "node scripts/generate-upstream-evidence-manifest.mjs --check",
44
49
  "build:lisa-owned-hash-ledger": "node scripts/generate-lisa-owned-hash-ledger.mjs",
@@ -153,7 +158,7 @@
153
158
  "zod-validation-error": "^4.0.0"
154
159
  },
155
160
  "name": "@codyswann/lisa",
156
- "version": "4.18.0",
161
+ "version": "4.20.0",
157
162
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
158
163
  "main": "dist/index.js",
159
164
  "exports": {
@@ -292,5 +297,5 @@
292
297
  "vite": "^8.0.16"
293
298
  },
294
299
  "type": "module",
295
- "lisaReleaseCommit": "95eda2c247990719c0ddce609e8f895cb25b148a"
300
+ "lisaReleaseCommit": "e520bda3d4a0488faa3c59460c3e14fbd9a67860"
296
301
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.18.0",
3
+ "version": "4.20.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.18.0",
3
+ "version": "4.20.0",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Publish a newly emitted remote-agent AWS bootstrap bundle to the configured
4
+ * secrets provider without creating a silent split brain.
5
+ *
6
+ * Infrastructure owns the IAM access key and usually emits the complete bundle
7
+ * to AWS Secrets Manager. Remote coding environments cannot use that store as
8
+ * their bootstrap provider: reading it already requires AWS credentials. The
9
+ * provider configured by lisa-secrets-access therefore needs the same bundle,
10
+ * and a manual copy leaves two ways for a rotation to stop halfway.
11
+ *
12
+ * This command makes that handoff one fail-closed operation:
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.
19
+ *
20
+ * The candidate arrives on stdin so it never appears in a shell command or a
21
+ * temporary file. No credential value is written to stdout or stderr.
22
+ *
23
+ * Usage:
24
+ * aws secretsmanager get-secret-value ... --query SecretString --output text \
25
+ * | publish-aws-bootstrap.mjs publish
26
+ * publish-aws-bootstrap.mjs verify
27
+ * publish-aws-bootstrap.mjs preflight
28
+ * @module publish-aws-bootstrap
29
+ */
30
+
31
+ import { readFileSync, realpathSync } from "node:fs";
32
+ import { fileURLToPath } from "node:url";
33
+
34
+ import {
35
+ BOOTSTRAP_KEY,
36
+ parseBootstrap,
37
+ readProfiles,
38
+ } from "./aws-bootstrap.mjs";
39
+ import { fetchAll, writeSecret } from "./providers.mjs";
40
+ import { readConfig } from "./surfaces.mjs";
41
+
42
+ import { boundedChildOutput } from "../../lisa-setup-workstation/scripts/bounded-child.mjs";
43
+
44
+ const PROFILE_NAME = /^[\w.@-]+$/;
45
+ const ROLE_ARN =
46
+ /^arn:(?:aws|aws-us-gov|aws-cn):iam::\d{12}:role\/[\w+=,.@/-]+$/;
47
+
48
+ /**
49
+ * Validate and normalize one complete bundle without changing its serialized
50
+ * representation.
51
+ * @param {string} raw Candidate JSON from stdin or the provider.
52
+ * @returns {{raw: string, bundle: object, profiles: Array<{name: string, roleArn: string, region: string}>}}
53
+ * Validated candidate and its role entries.
54
+ */
55
+ export function validateAwsBootstrap(raw) {
56
+ if (typeof raw !== "string" || !raw.trim()) {
57
+ throw new Error(
58
+ `${BOOTSTRAP_KEY} is empty; refusing to replace a working credential with nothing`
59
+ );
60
+ }
61
+
62
+ // AWS CLI's text output ends with one newline. It is transport framing, not
63
+ // part of the SecretString, so do not make every publication differ by one
64
+ // invisible byte from the value AWS emitted.
65
+ const normalized = raw.trim();
66
+ const bundle = parseBootstrap(normalized);
67
+ if (!bundle || Array.isArray(bundle)) {
68
+ throw new Error(`${BOOTSTRAP_KEY} is not a JSON object`);
69
+ }
70
+
71
+ const accessKeyId = bundle.accessKeyId ?? bundle.aws_access_key_id;
72
+ const secretAccessKey =
73
+ bundle.secretAccessKey ?? bundle.aws_secret_access_key;
74
+ for (const [field, value] of [
75
+ ["accessKeyId", accessKeyId],
76
+ ["secretAccessKey", secretAccessKey],
77
+ ["externalId", bundle.externalId],
78
+ ["roleName", bundle.roleName],
79
+ ]) {
80
+ if (typeof value !== "string" || !value.trim()) {
81
+ throw new Error(`${BOOTSTRAP_KEY}.${field} must be a non-empty string`);
82
+ }
83
+ }
84
+
85
+ const entries = Object.entries(readProfiles(bundle));
86
+ if (entries.length === 0) {
87
+ throw new Error(`${BOOTSTRAP_KEY}.profiles must declare at least one role`);
88
+ }
89
+
90
+ const profiles = entries.map(([name, entry]) => {
91
+ if (!PROFILE_NAME.test(name)) {
92
+ throw new Error(`AWS bootstrap profile name is not safe: ${name}`);
93
+ }
94
+ const roleArn = entry?.roleArn ?? entry?.role_arn;
95
+ if (typeof roleArn !== "string" || !ROLE_ARN.test(roleArn)) {
96
+ throw new Error(`AWS bootstrap profile ${name} has an invalid roleArn`);
97
+ }
98
+ if (typeof entry?.region !== "string" || !entry.region.trim()) {
99
+ throw new Error(`AWS bootstrap profile ${name} has no region`);
100
+ }
101
+ return { name, roleArn, region: entry.region };
102
+ });
103
+
104
+ return { raw: normalized, bundle, profiles };
105
+ }
106
+
107
+ /**
108
+ * Build the isolated environment used to prove one candidate. Ambient AWS
109
+ * variables are removed so a green result cannot come from the host identity.
110
+ * @param {object} bundle Validated bundle.
111
+ * @returns {NodeJS.ProcessEnv} Child environment containing only this identity.
112
+ */
113
+ export function candidateAwsEnvironment(bundle) {
114
+ const env = { ...process.env };
115
+ for (const key of [
116
+ "AWS_PROFILE",
117
+ "AWS_DEFAULT_PROFILE",
118
+ "AWS_SESSION_TOKEN",
119
+ "AWS_SECURITY_TOKEN",
120
+ ]) {
121
+ delete env[key];
122
+ }
123
+ env.AWS_ACCESS_KEY_ID = String(
124
+ bundle.accessKeyId ?? bundle.aws_access_key_id
125
+ );
126
+ env.AWS_SECRET_ACCESS_KEY = String(
127
+ bundle.secretAccessKey ?? bundle.aws_secret_access_key
128
+ );
129
+ env.AWS_PAGER = "";
130
+ return env;
131
+ }
132
+
133
+ /**
134
+ * Prove that the candidate authenticates and can assume every declared role.
135
+ * Captures all child output so account ids and credential metadata never reach
136
+ * a shared log.
137
+ * @param {{bundle: object, profiles: Array<object>}} candidate Validated bundle.
138
+ * @param {(bin: string, args: string[], options: object) => string} [run]
139
+ * Injectable child runner for tests.
140
+ */
141
+ export function verifyAwsBootstrap(candidate, run = boundedChildOutput) {
142
+ const env = candidateAwsEnvironment(candidate.bundle);
143
+ for (const profile of candidate.profiles) {
144
+ const sessionName = `lisa-publish-${profile.name}`
145
+ .replace(/[^A-Za-z0-9_+=,.@-]/g, "-")
146
+ .slice(0, 64);
147
+ try {
148
+ run(
149
+ "aws",
150
+ [
151
+ "sts",
152
+ "assume-role",
153
+ "--role-arn",
154
+ profile.roleArn,
155
+ "--role-session-name",
156
+ sessionName,
157
+ "--external-id",
158
+ String(candidate.bundle.externalId),
159
+ "--region",
160
+ profile.region,
161
+ "--query",
162
+ "Credentials.Expiration",
163
+ "--output",
164
+ "text",
165
+ ],
166
+ { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] }
167
+ );
168
+ } catch (error) {
169
+ const reason =
170
+ error instanceof Error ? error.message.split("\n")[0] : String(error);
171
+ throw new Error(
172
+ `AWS bootstrap profile ${profile.name} failed STS verification: ${reason}`
173
+ );
174
+ }
175
+ }
176
+ return candidate.profiles.map(profile => profile.name);
177
+ }
178
+
179
+ /**
180
+ * Locate the provider record that owns the bundle.
181
+ * @param {object} cfg Resolved lisa-secrets-access configuration.
182
+ * @param {(cfg: object) => Map<string, object>} fetch Provider reader.
183
+ * @returns {{id: string, value: string}} Writable provider entry.
184
+ */
185
+ function currentEntry(cfg, fetch) {
186
+ const entry = fetch(cfg).get(BOOTSTRAP_KEY);
187
+ if (!entry) {
188
+ throw new Error(
189
+ `${BOOTSTRAP_KEY} is not available through the configured provider boundary`
190
+ );
191
+ }
192
+ if (!entry.id) {
193
+ throw new Error(
194
+ `${BOOTSTRAP_KEY} has no provider identifier, so it cannot be published`
195
+ );
196
+ }
197
+ return { id: entry.id, value: entry.value };
198
+ }
199
+
200
+ /**
201
+ * Exercise the exact provider write permission without changing its value.
202
+ * @param {object} cfg Resolved configuration.
203
+ * @param {object} [operations] Injectable provider operations for tests.
204
+ * @returns {{id: string, value: string}} Current provider entry.
205
+ */
206
+ export function preflightAwsBootstrap(cfg, operations = {}) {
207
+ const fetch = operations.fetch ?? fetchAll;
208
+ const write = operations.write ?? writeSecret;
209
+ const entry = currentEntry(cfg, fetch);
210
+ try {
211
+ write(cfg, entry.id, entry.value);
212
+ } catch (error) {
213
+ const reason =
214
+ error instanceof Error ? error.message.split("\n")[0] : String(error);
215
+ throw new Error(
216
+ `no write access to ${BOOTSTRAP_KEY}: ${reason}\n` +
217
+ `Refusing to continue because a rotated key that cannot be published would strand every remote session.`
218
+ );
219
+ }
220
+ return entry;
221
+ }
222
+
223
+ /**
224
+ * Restore the previous provider value and prove the restoration.
225
+ * @param {object} cfg Resolved configuration.
226
+ * @param {{id: string, value: string}} previous Previous entry.
227
+ * @param {Function} fetch Provider reader.
228
+ * @param {Function} write Provider writer.
229
+ * @returns {string} Human-readable rollback outcome with no secret material.
230
+ */
231
+ function rollback(cfg, previous, fetch, write) {
232
+ try {
233
+ write(cfg, previous.id, previous.value);
234
+ const restored = currentEntry(cfg, fetch);
235
+ if (restored.value !== previous.value) {
236
+ return "the previous provider value could not be verified after rollback";
237
+ }
238
+ return "the previous provider value was restored";
239
+ } catch (error) {
240
+ const reason =
241
+ error instanceof Error ? error.message.split("\n")[0] : String(error);
242
+ return `rollback failed: ${reason}`;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Publish, read back, and verify a candidate. Any failure after the candidate
248
+ * write triggers a restoration of the previous provider value.
249
+ * @param {string} raw Candidate JSON.
250
+ * @param {object} cfg Resolved configuration.
251
+ * @param {object} [operations] Injectable provider/AWS operations for tests.
252
+ * @returns {{changed: boolean, profiles: string[]}} Publication outcome.
253
+ */
254
+ export function publishAwsBootstrap(raw, cfg, operations = {}) {
255
+ const fetch = operations.fetch ?? fetchAll;
256
+ const write = operations.write ?? writeSecret;
257
+ const verify = operations.verify ?? verifyAwsBootstrap;
258
+ const previous = preflightAwsBootstrap(cfg, { fetch, write });
259
+ const candidate = validateAwsBootstrap(raw);
260
+
261
+ // Verify before the write. A candidate that cannot assume one of its roles
262
+ // never becomes the value every new remote session receives.
263
+ verify(candidate);
264
+
265
+ if (candidate.raw === previous.value) {
266
+ return {
267
+ changed: false,
268
+ profiles: candidate.profiles.map(profile => profile.name),
269
+ };
270
+ }
271
+
272
+ try {
273
+ write(cfg, previous.id, candidate.raw);
274
+ const stored = currentEntry(cfg, fetch);
275
+ if (stored.value !== candidate.raw) {
276
+ throw new Error("provider read-back did not match the candidate exactly");
277
+ }
278
+ const storedCandidate = validateAwsBootstrap(stored.value);
279
+ verify(storedCandidate);
280
+ return {
281
+ changed: true,
282
+ profiles: storedCandidate.profiles.map(profile => profile.name),
283
+ };
284
+ } catch (error) {
285
+ const reason =
286
+ error instanceof Error ? error.message.split("\n")[0] : String(error);
287
+ const rollbackOutcome = rollback(cfg, previous, fetch, write);
288
+ throw new Error(
289
+ `${BOOTSTRAP_KEY} publication failed: ${reason}; ${rollbackOutcome}`
290
+ );
291
+ }
292
+ }
293
+
294
+ /** Read a candidate from stdin without ever placing it in argv. */
295
+ function readCandidate() {
296
+ return readFileSync(0, "utf8");
297
+ }
298
+
299
+ function main() {
300
+ const [operation] = process.argv.slice(2);
301
+ const cfg = readConfig();
302
+
303
+ if (operation === "preflight") {
304
+ const current = preflightAwsBootstrap(cfg);
305
+ const candidate = validateAwsBootstrap(current.value);
306
+ const profiles = verifyAwsBootstrap(candidate);
307
+ console.log(
308
+ `${BOOTSTRAP_KEY}: provider write path and ${profiles.length} AWS profile(s) verified; no value changed`
309
+ );
310
+ return;
311
+ }
312
+
313
+ if (operation === "verify") {
314
+ const current = currentEntry(cfg, fetchAll);
315
+ const candidate = validateAwsBootstrap(current.value);
316
+ const profiles = verifyAwsBootstrap(candidate);
317
+ console.log(`${BOOTSTRAP_KEY}: ${profiles.length} AWS profile(s) verified`);
318
+ return;
319
+ }
320
+
321
+ if (operation === "publish") {
322
+ const result = publishAwsBootstrap(readCandidate(), cfg);
323
+ console.log(
324
+ `${BOOTSTRAP_KEY}: ${result.changed ? "published" : "already current"}; ` +
325
+ `${result.profiles.length} AWS profile(s) verified`
326
+ );
327
+ return;
328
+ }
329
+
330
+ throw new Error("usage: publish-aws-bootstrap.mjs preflight|verify|publish");
331
+ }
332
+
333
+ function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
334
+ if (!argv1) return false;
335
+ try {
336
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
337
+ } catch {
338
+ return false;
339
+ }
340
+ }
341
+
342
+ if (invokedAsScript(import.meta.url)) {
343
+ try {
344
+ main();
345
+ } catch (error) {
346
+ console.error(error instanceof Error ? error.message : String(error));
347
+ process.exit(1);
348
+ }
349
+ }
@@ -49,11 +49,29 @@ it to AWS Secrets Manager as `remote-agent-credentials`, but that is where it is
49
49
  *emitted*, not where it must *live*. Copy it once into the project's configured
50
50
  provider and read it from there.
51
51
 
52
- **Do not keep it in two stores.** A bundle sitting in Secrets Manager *and* in
53
- the platform's secret store is two live copies of one credential: rotate the
54
- IAM key and whichever copy you forget keeps authenticating until it doesn't,
55
- with nothing to say which is current. That is the drift the single-store rule in
56
- `lisa-secrets-access` exists to prevent, and this credential is not exempt.
52
+ **Do not maintain two stores by hand.** A bundle sitting in Secrets Manager
53
+ and in the platform's secret store is two live copies of one credential: rotate
54
+ the IAM key and whichever copy you forget becomes stale, with nothing to say
55
+ which is current. Treat the infrastructure secret as a short-lived deployment
56
+ emission and the configured provider as the consumer-facing source of truth.
57
+
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:
62
+
63
+ ```bash
64
+ aws --profile shared secretsmanager get-secret-value \
65
+ --secret-id remote-agent-credentials \
66
+ --query SecretString \
67
+ --output text \
68
+ | node .claude/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs publish
69
+ ```
70
+
71
+ Resolve the script from the installed runtime or `node_modules` when the
72
+ `.claude/skills` copy is not present. Never print or place the candidate in an
73
+ argument or temporary file. A CDK deployment followed later by a human copy is
74
+ not a completed rotation.
57
75
 
58
76
  **One provider cannot serve this particular secret.** If `secrets.provider` is
59
77
  `aws`, reading the bundle requires AWS credentials — and the bundle *is* the AWS