@codyswann/lisa 4.29.1 → 4.29.3

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 (99) hide show
  1. package/README.md +9 -5
  2. package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +8 -7
  3. package/dist/codex/enforcement-fallback-installer.d.ts +19 -0
  4. package/dist/codex/enforcement-fallback-installer.d.ts.map +1 -0
  5. package/dist/codex/enforcement-fallback-installer.js +50 -0
  6. package/dist/codex/enforcement-fallback-installer.js.map +1 -0
  7. package/dist/codex/hooks-installer.d.ts.map +1 -1
  8. package/dist/codex/hooks-installer.js +5 -4
  9. package/dist/codex/hooks-installer.js.map +1 -1
  10. package/dist/codex/project-hooks-cleanup.js +1 -1
  11. package/dist/codex/project-hooks-cleanup.js.map +1 -1
  12. package/dist/codex/project-overlay.d.ts.map +1 -1
  13. package/dist/codex/project-overlay.js +7 -4
  14. package/dist/codex/project-overlay.js.map +1 -1
  15. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  16. package/dist/core/lisa-owned-hash-ledger.js +2 -0
  17. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  18. package/dist/core/lisa.js +1 -1
  19. package/dist/core/lisa.js.map +1 -1
  20. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  21. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  22. package/dist/core/upstream-evidence-manifest.js +16 -5
  23. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  24. package/package.json +4 -3
  25. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/SKILL.md +55 -0
  28. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
  29. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
  30. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
  31. package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +55 -0
  32. package/plugins/lisa/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
  33. package/plugins/lisa/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
  34. package/plugins/lisa/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
  35. package/plugins/lisa-agy/plugin.json +1 -1
  36. package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +55 -0
  37. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
  38. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
  39. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
  40. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  43. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +55 -0
  47. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
  48. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
  49. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
  50. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +55 -0
  52. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
  53. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
  54. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
  55. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-expo-agy/plugin.json +1 -1
  58. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  63. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  68. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  73. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  78. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-rails-agy/plugin.json +1 -1
  83. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  88. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  93. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +55 -0
  96. package/plugins/src/base/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
  97. package/plugins/src/base/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
  98. package/plugins/src/base/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
  99. package/scripts/lisa-enforcement-fallback.sh +8 -7
@@ -36,10 +36,12 @@ import { fileURLToPath } from "node:url";
36
36
  import {
37
37
  BOOTSTRAP_KEY,
38
38
  deriveAwsEnvironment,
39
+ RESOLVE_ONLY_LEGACY_SOURCE_PROFILE,
39
40
  parseBootstrap,
40
41
  renderAwsProfiles,
41
42
  } from "./aws-bootstrap.mjs";
42
43
  import { renderEnv, renderNotes } from "./envfile.mjs";
44
+ import { assertOwner, ownerOf, ownerTag } from "./owner.mjs";
43
45
  import { fetchAll } from "./providers.mjs";
44
46
  import { materializedPaths, readConfig } from "./surfaces.mjs";
45
47
 
@@ -98,7 +100,7 @@ function writeAtomic(destination, contents) {
98
100
  * carrying orphans from a past rename is repaired on the next run rather than
99
101
  * accumulating one more.
100
102
  */
101
- const MARKER_VERSION = "v2";
103
+ const MARKER_VERSION = "v3";
102
104
 
103
105
  /**
104
106
  * The frozen half of each marker. Changing one of these DOES orphan blocks —
@@ -131,37 +133,44 @@ function familyRecognisers(family) {
131
133
  }
132
134
 
133
135
  /**
134
- * Index of the first family match at or after `from`, and its length.
136
+ * Index of the first family match at or after `from`, its length, and its text.
137
+ *
138
+ * The matched text is returned because the owner lives inside the marker, and
139
+ * re-slicing it at every call site is how a reader ends up owner-blind in one
140
+ * of them.
135
141
  * @param {string} text Haystack.
136
142
  * @param {RegExp} recogniser Global family recogniser.
137
143
  * @param {number} from Index to search from.
138
- * @returns {{index: number, length: number}} `index` is -1 when absent.
144
+ * @returns {{index: number, length: number, text: string}} `index` is -1 when absent.
139
145
  */
140
146
  function findFamily(text, recogniser, from = 0) {
141
147
  recogniser.lastIndex = from;
142
148
  const match = recogniser.exec(text);
143
149
  return match === null
144
- ? { index: -1, length: 0 }
145
- : { index: match.index, length: match[0].length };
150
+ ? { index: -1, length: 0, text: "" }
151
+ : { index: match.index, length: match[0].length, text: match[0] };
146
152
  }
147
153
 
148
154
  /**
149
- * Remove every managed block of one family, leaving the rest of the file.
155
+ * Every managed block of one family, in file order, with its owner.
150
156
  *
151
- * Every block, not just the first: a file that already carries orphans from a
152
- * rename that happened before this fix must come out with exactly one block,
153
- * or the fix would leave the damage it exists to prevent.
157
+ * Every block, not just the first: a file may carry orphans from a rename that
158
+ * predates the family recogniser, AND — since a machine can serve more than one
159
+ * tenant — blocks belonging to other projects that must be left exactly alone.
160
+ * Those are different populations with different handling, so the reader
161
+ * enumerates rather than assuming.
154
162
  * @param {string} text File contents.
155
163
  * @param {string} family Frozen family identifier.
156
- * @returns {string} The file with every family block removed.
164
+ * @returns {Array<{start: number, end: number, owner: string|null, body: string}>} Blocks.
157
165
  */
158
- function stripFamilyBlocks(text, family) {
166
+ function familyBlocks(text, family) {
159
167
  const { start, end } = familyRecognisers(family);
160
- let out = text;
168
+ const blocks = [];
169
+ let from = 0;
161
170
  for (;;) {
162
- const opened = findFamily(out, start);
163
- if (opened.index === -1) return out;
164
- const closed = findFamily(out, end, opened.index + opened.length);
171
+ const opened = findFamily(text, start, from);
172
+ if (opened.index === -1) return blocks;
173
+ const closed = findFamily(text, end, opened.index + opened.length);
165
174
  // A truncated block (opened, never closed) is not safe to interpret. Any
166
175
  // guessed boundary could delete operator-authored content after the marker,
167
176
  // so refuse before the caller writes and preserve the original byte-for-byte.
@@ -171,56 +180,141 @@ function stripFamilyBlocks(text, family) {
171
180
  `Repair the marker pair first; no file was changed.`
172
181
  );
173
182
  }
174
- const after = out.slice(closed.index + closed.length).replace(/^\n/, "");
175
- out = `${out.slice(0, opened.index)}${after}`;
183
+ const stop = closed.index + closed.length;
184
+ blocks.push({
185
+ start: opened.index,
186
+ end: stop,
187
+ owner: ownerOf(opened.text),
188
+ body: text.slice(opened.index, stop),
189
+ });
190
+ from = stop;
176
191
  }
177
192
  }
178
193
 
179
- /** Marks the block this owns, so it is replaced rather than appended twice. */
180
- const PROFILE_MARKER = `# >>> ${PROFILE_FAMILY} ${MARKER_VERSION}) >>>`;
194
+ /**
195
+ * Remove the family blocks a predicate selects, leaving the rest of the file.
196
+ *
197
+ * Selective rather than total, and that is the fix. "Our own previous output is
198
+ * meant to be replaced" was true and unqualified by WHOSE: a second tenant's
199
+ * run matched the same unowned marker and stripped the first tenant's block.
200
+ * @param {string} text File contents.
201
+ * @param {string} family Frozen family identifier.
202
+ * @param {(block: {owner: string|null, body: string}) => boolean} selects Which blocks to remove.
203
+ * @returns {string} The file with the selected blocks removed.
204
+ */
205
+ function stripFamilyBlocks(text, family, selects) {
206
+ // Back to front, so an earlier block's offsets are still valid after a later
207
+ // one is cut.
208
+ const doomed = familyBlocks(text, family).filter(block => selects(block));
209
+ let out = text;
210
+ for (const block of doomed.reverse()) {
211
+ const after = out.slice(block.end).replace(/^\n/, "");
212
+ out = `${out.slice(0, block.start)}${after}`;
213
+ }
214
+ return out;
215
+ }
216
+
217
+ /**
218
+ * Marks the block one owner owns, so it is replaced rather than appended twice.
219
+ * @param {string} owner Validated owner.
220
+ * @returns {string} The opening marker.
221
+ */
222
+ function profileMarker(owner) {
223
+ return `# >>> ${PROFILE_FAMILY} ${MARKER_VERSION} ${ownerTag(owner)}) >>>`;
224
+ }
181
225
 
182
- /** Closes the managed block. */
183
- const PROFILE_END = `# <<< ${PROFILE_FAMILY} ${MARKER_VERSION}) <<<`;
226
+ /**
227
+ * Closes one owner's managed shell block.
228
+ * @param {string} owner Validated owner.
229
+ * @returns {string} The closing marker.
230
+ */
231
+ function profileEnd(owner) {
232
+ return `# <<< ${PROFILE_FAMILY} ${MARKER_VERSION} ${ownerTag(owner)}) <<<`;
233
+ }
184
234
 
185
235
  /**
186
- * Identifies an `~/.aws` file as one this wrote, and may therefore replace.
236
+ * Identifies an `~/.aws` region as one this wrote FOR A GIVEN OWNER.
187
237
  *
188
238
  * `#` is a comment in the AWS shared-config format, so this is inert to every
189
239
  * consumer while still being the thing that distinguishes "our file, refresh
190
- * it" from "someone else's file, leave it alone".
240
+ * it" from "someone else's file, leave it alone" — and now also from "another
241
+ * Lisa project's, leave it alone too", which is the case that was missing.
242
+ * @param {string} owner Validated owner.
243
+ * @returns {string} The opening marker.
191
244
  */
192
- const MANAGED_MARKER = `# >>> ${MANAGED_FAMILY} ${MARKER_VERSION} >>>`;
245
+ function managedMarker(owner) {
246
+ return `# >>> ${MANAGED_FAMILY} ${MARKER_VERSION} ${ownerTag(owner)} >>>`;
247
+ }
248
+
249
+ /**
250
+ * Closes one owner's managed region of an `~/.aws` file.
251
+ * @param {string} owner Validated owner.
252
+ * @returns {string} The closing marker.
253
+ */
254
+ function managedEnd(owner) {
255
+ return `# <<< ${MANAGED_FAMILY} ${MARKER_VERSION} ${ownerTag(owner)} <<<`;
256
+ }
193
257
 
194
- /** Closes the managed region of an `~/.aws` file. */
195
- const MANAGED_END = `# <<< ${MANAGED_FAMILY} ${MARKER_VERSION} <<<`;
258
+ /**
259
+ * Whether a block belongs to `owner`, for stripping.
260
+ *
261
+ * An unowned block is NEVER ours here. It predates ownership, so it may belong
262
+ * to any tenant on this machine, and adopting it is exactly the silent
263
+ * consumption this change exists to stop. It is reported instead, and removed
264
+ * only under an explicit prune.
265
+ * @param {string} owner Our owner.
266
+ * @returns {(candidate: string|null) => boolean} Predicate over block owners.
267
+ */
268
+ function ownedBy(owner) {
269
+ return block => block.owner === owner;
270
+ }
196
271
 
197
272
  /**
198
- * Replace this module's delimited region in a file, preserving everything else.
273
+ * Replace one owner's delimited region in a file, preserving everything else.
199
274
  *
200
275
  * Written as a merge rather than a whole-file write because both `~/.aws` files
201
276
  * routinely hold sections nobody here knows about — an operator's own profiles,
202
- * or a container's bare `[default]`. Refusing on their account wrote nothing at
203
- * all; overwriting would delete them. This does neither.
277
+ * a container's bare `[default]`, or another Lisa project's block. Refusing on
278
+ * their account wrote nothing at all; overwriting would delete them. This does
279
+ * neither.
204
280
  * @param {string} current Existing file contents, or "".
205
- * @param {string} body The region this module owns.
281
+ * @param {string} body The region this owner owns.
282
+ * @param {string} owner Validated owner.
283
+ * @param {boolean} [claimLegacy] Also replace pre-ownership blocks.
206
284
  * @returns {string} The merged file.
207
285
  */
208
- export function upsertManagedBlock(current, body) {
209
- const block = `${MANAGED_MARKER}\n${body.trimEnd()}\n${MANAGED_END}`;
210
- const opened = findFamily(current, familyRecognisers(MANAGED_FAMILY).start);
286
+ export function upsertManagedBlock(current, body, owner, claimLegacy = false) {
287
+ const scope = assertOwner(owner, "~/.aws");
288
+ const block = `${managedMarker(scope)}\n${body.trimEnd()}\n${managedEnd(scope)}`;
289
+
290
+ // Legacy first, and OUR position measured after it. Removing a pre-ownership
291
+ // block that sits ahead of ours moves ours, so a position taken before the
292
+ // removal would splice the replacement into the wrong place.
293
+ const withoutLegacy = claimLegacy
294
+ ? stripFamilyBlocks(current, MANAGED_FAMILY, block => block.owner === null)
295
+ : current;
296
+ const mine = familyBlocks(withoutLegacy, MANAGED_FAMILY).find(
297
+ found => found.owner === scope
298
+ );
299
+ // Only ours. Everything else in the family belongs to another project.
300
+ const withoutOurs = stripFamilyBlocks(
301
+ withoutLegacy,
302
+ MANAGED_FAMILY,
303
+ ownedBy(scope)
304
+ );
211
305
 
212
- if (opened.index === -1) {
306
+ if (mine === undefined) {
213
307
  const prefix =
214
- current && !current.endsWith("\n") ? `${current}\n` : current;
308
+ withoutOurs && !withoutOurs.endsWith("\n")
309
+ ? `${withoutOurs}\n`
310
+ : withoutOurs;
215
311
  return `${prefix}${prefix ? "\n" : ""}${block}\n`;
216
312
  }
217
313
 
218
- // Everything this module owns is stripped first, so a file already carrying
219
- // orphans from a rename that predates the family recogniser comes out with
220
- // exactly one block rather than one more.
221
- const withoutOurs = stripFamilyBlocks(current, MANAGED_FAMILY);
222
- const head = withoutOurs.slice(0, opened.index);
223
- const tail = withoutOurs.slice(opened.index);
314
+ // Nothing before our first block is ours, so stripping ours cannot have moved
315
+ // anything ahead of it — the original offset still points where it did.
316
+ const head = withoutOurs.slice(0, mine.start);
317
+ const tail = withoutOurs.slice(mine.start);
224
318
  return `${head}${block}\n${tail}`;
225
319
  }
226
320
 
@@ -244,13 +338,17 @@ export function upsertManagedBlock(current, body) {
244
338
  export function installProfileSourcing(valuesFile, options = {}) {
245
339
  const {
246
340
  home = process.env.HOME || homedir(),
341
+ owner,
342
+ claimShell = process.env.LISA_SECRETS_CLAIM_SHELL_PROFILE === "1",
247
343
  exists = existsSync,
248
344
  read = readFileSync,
249
345
  write = writeFileSync,
250
346
  } = options;
251
347
 
348
+ const scope = assertOwner(owner, "the shell profile block");
349
+
252
350
  const block = [
253
- PROFILE_MARKER,
351
+ profileMarker(scope),
254
352
  // Unset BEFORE sourcing. A cloud container injects its own AWS key pair,
255
353
  // and environment credentials outrank both ~/.aws profiles and AWS_PROFILE
256
354
  // — so leaving them set means the session ignores whichever environment it
@@ -270,27 +368,37 @@ export function installProfileSourcing(valuesFile, options = {}) {
270
368
  ` . "${valuesFile}"`,
271
369
  ` set +a`,
272
370
  `fi`,
273
- PROFILE_END,
371
+ profileEnd(scope),
274
372
  ].join("\n");
275
373
 
276
- const updated = [];
277
- for (const name of [".bashrc", ".profile"]) {
278
- const file = join(home, name);
279
- const current = exists(file) ? String(read(file, "utf8")) : "";
374
+ const files = [".bashrc", ".profile"].map(name => join(home, name));
375
+ const contents = files.map(file =>
376
+ exists(file) ? String(read(file, "utf8")) : ""
377
+ );
280
378
 
281
- // Replace an existing managed block rather than appending another: this
282
- // runs on every session, and an appended-forever profile is its own bug.
283
- const opened = findFamily(current, familyRecognisers(PROFILE_FAMILY).start);
284
- // Stripping EVERY family block before writing one is what makes an already
285
- // orphaned profile self-heal. It matters more here than anywhere else in
286
- // Lisa: an orphaned block in a shell profile is still sourced, and the last
287
- // assignment wins, so a stale block silently selects the wrong credentials.
288
- const withoutOurs = stripFamilyBlocks(current, PROFILE_FAMILY);
379
+ // Every file is inspected BEFORE any is written. A shell profile is not a
380
+ // namespaced resource — it exports into every shell on the machine, so if one
381
+ // of the two belongs to another tenant the whole run must stop, not stop
382
+ // halfway with `.bashrc` rewritten and `.profile` not.
383
+ for (const [index, current] of contents.entries()) {
384
+ assertShellUnclaimed(files[index], current, scope, claimShell);
385
+ }
386
+
387
+ const updated = [];
388
+ for (const [index, file] of files.entries()) {
389
+ const current = contents[index];
390
+ // Ours, plus any legacy block this owner can prove is its own, plus — only
391
+ // under an explicit claim — another tenant's. Stripping every family block
392
+ // unconditionally is what let a second tenant delete the first's.
393
+ const selects = block =>
394
+ claimShell || [scope, null].includes(shellOwnerOf(block));
395
+ const mine = familyBlocks(current, PROFILE_FAMILY).find(selects);
396
+ const withoutOurs = stripFamilyBlocks(current, PROFILE_FAMILY, selects);
289
397
  const next =
290
- opened.index === -1
398
+ mine === undefined
291
399
  ? `${current}${current.endsWith("\n") || !current ? "" : "\n"}\n${block}\n`
292
- : `${withoutOurs.slice(0, opened.index)}${block}\n${withoutOurs
293
- .slice(opened.index)
400
+ : `${withoutOurs.slice(0, mine.start)}${block}\n${withoutOurs
401
+ .slice(mine.start)
294
402
  .replace(/^\n/, "")}`;
295
403
 
296
404
  if (next !== current) {
@@ -301,6 +409,73 @@ export function installProfileSourcing(valuesFile, options = {}) {
301
409
  return updated;
302
410
  }
303
411
 
412
+ /**
413
+ * Who one shell block belongs to, marked or recoverable.
414
+ *
415
+ * A legacy `~/.aws` block carries no tell of who wrote it — bare stage names
416
+ * name a stage and no owner — which is why those are attributed by account id
417
+ * instead. A legacy SHELL block is different: its body sources
418
+ * `<config root>/<tenant>/secrets.env`, so it states its own owner in the one
419
+ * line that matters. Reading it back removes the ambiguity entirely, which is
420
+ * better than a flag, and it lets an existing single-tenant machine upgrade in
421
+ * one silent step rather than failing until an operator intervenes.
422
+ *
423
+ * `null` means unattributable, and that is treated as OURS to claim rather than
424
+ * as a stranger's. Every block this module has ever written names a tenant
425
+ * directory, so a block that names none is not a well-formed block of any
426
+ * tenant — while leaving it behind would break the rule that matters most here:
427
+ * an orphaned block in a shell profile is still sourced, and the last
428
+ * assignment wins.
429
+ * @param {{owner: string|null, body: string}} block One managed block.
430
+ * @returns {string|null} The owner, or null when it cannot be attributed.
431
+ */
432
+ function shellOwnerOf(block) {
433
+ if (block.owner !== null) return block.owner;
434
+ // The tenant directory is the segment before `secrets.env`; that path is the
435
+ // only place a pre-ownership block records whose values it loads.
436
+ const found = /[/\\]([^/\\]+)[/\\]secrets\.env/.exec(block.body);
437
+ return found === null ? null : found[1];
438
+ }
439
+
440
+ /**
441
+ * Stop before redirecting a machine that already serves another tenant.
442
+ *
443
+ * `~/.aws` profiles can be namespaced, so two tenants coexist there and a wrong
444
+ * resolution becomes impossible. A shell profile cannot: it exports into every
445
+ * shell and there is no name for a consumer to select, so it is a single slot
446
+ * two projects contend for — the same problem `[default]` poses in
447
+ * `~/.aws/config`, and it gets the same answer. Claimed when unowned or already
448
+ * ours; otherwise the run stops and names the tenant that holds it.
449
+ *
450
+ * Taking it over silently is the whole defect in miniature: every shell on the
451
+ * machine would start exporting another project's credentials, and the sessions
452
+ * that lost their block would carry on believing they had their own.
453
+ * @param {string} file The shell profile path, for the message.
454
+ * @param {string} current File contents.
455
+ * @param {string} scope Our owner.
456
+ * @param {boolean} claimShell Whether the operator asked to take it over.
457
+ */
458
+ function assertShellUnclaimed(file, current, scope, claimShell) {
459
+ if (claimShell) return;
460
+ const others = [
461
+ ...new Set(
462
+ familyBlocks(current, PROFILE_FAMILY)
463
+ .map(shellOwnerOf)
464
+ .filter(found => found !== null)
465
+ ),
466
+ ].filter(found => found !== scope);
467
+ if (others.length === 0) return;
468
+ throw new Error(
469
+ `${file} already loads secrets for ${others.map(n => `"${n}"`).join(", ")}, ` +
470
+ `not "${scope}".\n` +
471
+ `A shell profile is read by every shell on this machine, so rewriting it ` +
472
+ `would hand ${scope}'s credentials to that project's sessions too — and ` +
473
+ `they would report ready, because the credentials work.\n` +
474
+ `Re-run with LISA_SECRETS_CLAIM_SHELL_PROFILE=1 to switch this machine ` +
475
+ `to "${scope}" deliberately. Nothing was changed.`
476
+ );
477
+ }
478
+
304
479
  /**
305
480
  * Write `~/.aws/credentials` and `~/.aws/config` from the bootstrap bundle.
306
481
  *
@@ -311,37 +486,118 @@ export function installProfileSourcing(valuesFile, options = {}) {
311
486
  * @returns {string[]} The profile names written.
312
487
  */
313
488
  /**
314
- * Profile names already defined OUTSIDE this module's managed block.
489
+ * Profile names already defined outside the block this owner is about to write.
315
490
  *
316
- * Only what lies outside the block counts: our own previous output is meant to
491
+ * Only what lies outside OUR block counts: our own previous output is meant to
317
492
  * be replaced, and treating it as a collision would make the second run fail.
493
+ * What changed is that "ours" is now scoped to one owner, so another project's
494
+ * block reads as a collision instead of as our own output.
495
+ *
496
+ * Each hit reports who holds the name: `null` for a section an operator wrote
497
+ * outside any managed block, or the owning project. The two need different
498
+ * advice, and a message that guessed would send the reader to the wrong file.
318
499
  * @param {string} dir The `.aws` directory.
319
500
  * @param {string[]} names Profile names about to be written.
320
- * @param {object} io `exists` and `read` seams.
321
- * @returns {string[]} Colliding names, in the order given.
501
+ * @param {object} io `exists`, `read`, `owner`, and `includeLegacy` seams.
502
+ * @returns {Array<{name: string, owner: string|null}>} Collisions, in the order given.
322
503
  */
323
504
  export function collidingProfiles(dir, names, io = {}) {
324
- const { exists = existsSync, read = readFileSync } = io;
505
+ const {
506
+ exists = existsSync,
507
+ read = readFileSync,
508
+ owner = null,
509
+ includeLegacy = false,
510
+ } = io;
325
511
  const file = join(dir, "config");
326
512
  if (!exists(file)) return [];
327
513
 
328
514
  const text = String(read(file, "utf8"));
329
- // Every family block, not just the current version's. An orphan left by an
330
- // older marker is still OUR previous output, so counting its profiles as a
331
- // host collision would refuse to write the very names we wrote last time.
332
- const outside = stripFamilyBlocks(text, MANAGED_FAMILY);
333
-
334
- return names.filter(name =>
335
- new RegExp(
336
- `^\\s*\\[profile\\s+${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\]`,
337
- "m"
338
- ).test(outside)
515
+ // Our own previous output is meant to be replaced, so counting its profiles
516
+ // as a collision would refuse to write the very names we wrote last time.
517
+ // "Ours" now means this owner's — another project's block is exactly the case
518
+ // that was being consumed silently — plus, unless the caller says otherwise,
519
+ // pre-ownership blocks, which the caller is about to replace in place.
520
+ const ours = block =>
521
+ (block.owner === owner && owner !== null) ||
522
+ (block.owner === null && !includeLegacy);
523
+ const outside = stripFamilyBlocks(text, MANAGED_FAMILY, ours);
524
+
525
+ const claimed = [];
526
+ for (const block of familyBlocks(text, MANAGED_FAMILY)) {
527
+ if (ours(block)) continue;
528
+ for (const name of profileNamesIn(block.body)) {
529
+ claimed.push({ name, owner: block.owner });
530
+ }
531
+ }
532
+
533
+ return names
534
+ .filter(name => sectionPresent(outside, name))
535
+ .map(name => ({
536
+ name,
537
+ owner: claimed.find(entry => entry.name === name)?.owner ?? null,
538
+ }));
539
+ }
540
+
541
+ /**
542
+ * Whether a `[profile name]` section header appears in some text.
543
+ * @param {string} text Haystack.
544
+ * @param {string} name Exact profile name.
545
+ * @returns {boolean} Whether the section is defined.
546
+ */
547
+ function sectionPresent(text, name) {
548
+ return new RegExp(
549
+ `^\\s*\\[profile\\s+${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\]`,
550
+ "m"
551
+ ).test(text);
552
+ }
553
+
554
+ /**
555
+ * The `[profile …]` names defined in a chunk of shared-config text.
556
+ * @param {string} text Config text.
557
+ * @returns {string[]} Section names, in order.
558
+ */
559
+ function profileNamesIn(text) {
560
+ return [...text.matchAll(/^\s*\[profile\s+([^\]\n]+)\]/gm)].map(found =>
561
+ found[1].trim()
339
562
  );
340
563
  }
341
564
 
565
+ /**
566
+ * Profile names left in `~/.aws/config` by a build that predated ownership.
567
+ *
568
+ * Reported rather than removed, and that asymmetry with the shell block is the
569
+ * point: a legacy `~/.aws` block carries no record of who wrote it. Its stage
570
+ * names name a stage and no owner, and the account ids in its role ARNs are not
571
+ * tied to any tenant name this process knows. Deleting it could therefore
572
+ * remove another project's working profiles — the exact harm being fixed —
573
+ * so it is named on every run and removed only under an explicit prune.
574
+ * @param {string} dir The `.aws` directory.
575
+ * @param {object} [io] `exists` and `read` seams.
576
+ * @returns {string[]} Unowned profile names still present.
577
+ */
578
+ export function legacyManagedProfiles(dir, io = {}) {
579
+ const { exists = existsSync, read = readFileSync } = io;
580
+ const file = join(dir, "config");
581
+ if (!exists(file)) return [];
582
+
583
+ const text = String(read(file, "utf8"));
584
+ return familyBlocks(text, MANAGED_FAMILY)
585
+ .filter(block => block.owner === null)
586
+ .flatMap(block => profileNamesIn(block.body));
587
+ }
588
+
342
589
  export function installAwsProfiles(bundle, options = {}) {
343
590
  const {
344
591
  home = process.env.HOME || homedir(),
592
+ owner,
593
+ pruneLegacy = process.env.LISA_SECRETS_PRUNE_LEGACY_PROFILES === "1",
594
+ // The RESOLVE-ONLY window, on by default and closable from both ends. It
595
+ // never affects what is emitted as canonical output.
596
+ // `NO_LEGACY` is how a caller that has migrated takes the isolation before
597
+ // the window closes; `CLAIM_LEGACY` is how one takes the shared bare slot
598
+ // from another project deliberately.
599
+ noResolveOnly = process.env.LISA_SECRETS_NO_LEGACY_PROFILES === "1",
600
+ claimResolveOnly = process.env.LISA_SECRETS_CLAIM_LEGACY_PROFILES === "1",
345
601
  mkdir = mkdirSync,
346
602
  write = writeAtomic,
347
603
  read = readFileSync,
@@ -349,37 +605,100 @@ export function installAwsProfiles(bundle, options = {}) {
349
605
  chmod = chmodSync,
350
606
  } = options;
351
607
 
352
- const rendered = renderAwsProfiles(bundle);
608
+ const rendered = renderAwsProfiles(bundle, owner);
353
609
  if (!rendered) return [];
610
+ const scope = assertOwner(owner, "~/.aws");
354
611
 
355
612
  const dir = join(home, ".aws");
356
613
 
357
- // Refuse to write a profile name the operator already uses outside our block.
614
+ // Whether a pre-ownership block in this file is THIS project's own past
615
+ // output, or another project's.
616
+ //
617
+ // Leaving one behind is not free. Everywhere else in Lisa the orphan doctrine
618
+ // is absolute — an orphaned block is still read, and the reader cannot tell it
619
+ // from the live one — so a change that started leaving orphans would trade
620
+ // this bug for the one the marker versioning already fixed.
621
+ //
622
+ // The block itself answers the question: the accounts in its role ARNs are
623
+ // this project's if they are the accounts we are about to write. A bundle
624
+ // names its own accounts, so a match is attribution rather than a guess. That
625
+ // makes the ordinary single-tenant upgrade replace its block silently and
626
+ // leave nothing behind, and confines the reported-not-deleted case to a block
627
+ // that provably belongs to a different project.
628
+ const configFile = join(dir, "config");
629
+ const existingConfig = exists(configFile)
630
+ ? String(read(configFile, "utf8"))
631
+ : "";
632
+ const claimLegacy =
633
+ pruneLegacy || legacyIsOurs(existingConfig, rendered.config);
634
+
635
+ // Refuse to write a profile name someone else already uses.
358
636
  //
359
637
  // AWS does not error on a duplicate `[profile x]` — it resolves one and
360
- // ignores the other. So writing `acmeorgd-dev` next to an operator's existing SSO
361
- // `acmeorgd-dev` would silently run some calls as the wrong identity, which is
362
- // worse than either winning outright. Merging protects their sections from
363
- // being deleted; this protects them from being shadowed.
638
+ // ignores the other. So writing a name next to an operator's existing SSO
639
+ // profile of the same name would silently run some calls as the wrong
640
+ // identity, which is worse than either winning outright. Merging protects
641
+ // their sections from being deleted; this protects them from being shadowed.
642
+ //
643
+ // "Someone else" now includes another Lisa project. Prefixing every name with
644
+ // its owner should make that impossible rather than merely detectable, so
645
+ // this arm is the backstop for the case prefixing cannot prevent — two owners
646
+ // whose prefix and stage happen to reduce to one final name.
364
647
  //
365
648
  // Deliberately not resolved by renaming theirs: this module writes its own
366
- // block and nothing else. The operator renames (conventionally to `-sso`) and
367
- // re-runs.
649
+ // block and nothing else. The owner of the existing entry renames it
650
+ // (conventionally to `-sso`) and re-runs.
368
651
  const collisions = collidingProfiles(dir, rendered.profiles, {
369
652
  exists,
370
653
  read,
654
+ owner: scope,
655
+ // A legacy block this run is about to replace is not a collision; one it is
656
+ // leaving in place is, because both sections would then exist and AWS would
657
+ // resolve exactly one of them.
658
+ includeLegacy: !claimLegacy,
371
659
  });
372
660
  if (collisions.length > 0) {
661
+ const quoted = collisions.map(c => `"${c.name}"`).join(", ");
662
+ const holder = collisions[0].owner;
373
663
  throw new Error(
374
- `~/.aws/config already defines ${collisions.map(n => `"${n}"`).join(", ")} ` +
375
- `outside the lisa-managed block.\n` +
376
- `Writing them would create duplicate sections, and AWS resolves only ` +
377
- `one — some calls would silently use the wrong identity.\n` +
378
- `Rename the existing entries (for example to "${collisions[0]}-sso") ` +
379
- `and run this again.`
664
+ holder === null
665
+ ? `~/.aws/config already defines ${quoted} outside any lisa-managed ` +
666
+ `block.\n` +
667
+ `Writing them would create duplicate sections, and AWS resolves ` +
668
+ `only one — some calls would silently use the wrong identity.\n` +
669
+ `Rename the existing entries (for example to ` +
670
+ `"${collisions[0].name}-sso") and run this again.`
671
+ : `~/.aws/config already defines ${quoted} for the Lisa project ` +
672
+ `"${holder}", not "${scope}".\n` +
673
+ `AWS resolves only one section of a duplicated name, so writing ` +
674
+ `these would silently point "${holder}" at ${scope}'s account — ` +
675
+ `and it would authenticate, because the credentials are real.\n` +
676
+ `Rename one project's namespace so the two do not reduce to the ` +
677
+ `same profile name. Nothing was changed.`
380
678
  );
381
679
  }
382
680
 
681
+ // Whether the deprecated bare-named twins go in beside the owned names.
682
+ //
683
+ // ALL OR NOTHING. A half-written legacy family is worse than none: a bare
684
+ // `[profile <stage>]` whose `source_profile` names a bare section belonging to
685
+ // someone else is a profile that resolves into another project's account, and
686
+ // it is exactly the failure the owned names exist to remove.
687
+ const legacyHolder = noResolveOnly
688
+ ? null
689
+ : resolveOnlyHolder(dir, rendered.resolveOnly, {
690
+ exists,
691
+ read,
692
+ owner: scope,
693
+ includeLegacy: !claimLegacy,
694
+ });
695
+ // `undefined` is the only free state. `null` means a section outside every
696
+ // managed block already holds the name — an operator's, or an external
697
+ // generator's — and shadowing that is the duplicate-section failure this
698
+ // module refuses everywhere else.
699
+ const writeResolveOnly =
700
+ !noResolveOnly && (claimResolveOnly || legacyHolder === undefined);
701
+
383
702
  mkdir(dir, { recursive: true, mode: 0o700 });
384
703
  chmod(dir, 0o700);
385
704
 
@@ -397,18 +716,121 @@ export function installAwsProfiles(bundle, options = {}) {
397
716
  // what the guard was actually for. Same delimited-block approach as the shell
398
717
  // profile, and `#` is a comment in the shared-config format so the markers are
399
718
  // inert to every consumer.
400
- for (const [name, body] of [
401
- ["credentials", rendered.credentials],
402
- ["config", rendered.config],
719
+ for (const [name, body, resolveOnlyBody] of [
720
+ ["credentials", rendered.credentials, rendered.resolveOnly.credentials],
721
+ ["config", rendered.config, rendered.resolveOnly.config],
403
722
  ]) {
404
723
  const file = join(dir, name);
405
724
  const current = exists(file) ? String(read(file, "utf8")) : "";
406
- write(file, upsertManagedBlock(current, body));
725
+ // Both halves live INSIDE this owner's block. That is what keeps the
726
+ // resolve-only names owned rather than anonymous — another project's run
727
+ // leaves them alone instead of replacing them, and this project's next run
728
+ // regenerates the whole block, so nothing accumulates or drifts.
729
+ const merged = writeResolveOnly
730
+ ? `${body.trimEnd()}\n\n${resolveOnlyBody}`
731
+ : body;
732
+ write(file, upsertManagedBlock(current, merged, scope, claimLegacy));
407
733
  }
408
734
 
409
735
  return rendered.profiles;
410
736
  }
411
737
 
738
+ /**
739
+ * Who already holds any part of the deprecated bare-named family.
740
+ *
741
+ * Both files are consulted, because the family only works whole: the bare
742
+ * `[profile <stage>]` sections in `config` are useless without the bare source
743
+ * profile in `credentials` that they assume from.
744
+ * @param {string} dir The `.aws` directory.
745
+ * @param {{profiles: string[], sourceProfile: string}} resolveOnly The bare half.
746
+ * @param {object} io `exists`, `read`, `owner`, `includeLegacy` seams.
747
+ * @returns {string|null|undefined} The holding project, `null` when held
748
+ * outside any managed block, or `undefined` when nobody holds it.
749
+ */
750
+ function resolveOnlyHolder(dir, resolveOnly, io) {
751
+ const inConfig = collidingProfiles(dir, resolveOnly.profiles, io);
752
+ if (inConfig.length > 0) return inConfig[0].owner;
753
+ return sourceProfileHolder(dir, resolveOnly.sourceProfile, io);
754
+ }
755
+
756
+ /**
757
+ * Who holds a bare source-profile section in `~/.aws/credentials`.
758
+ *
759
+ * Separate from `collidingProfiles` because a credentials section is spelled
760
+ * `[name]`, not `[profile name]`, and reusing the config reader here would
761
+ * silently match nothing — a guard that always passes.
762
+ * @param {string} dir The `.aws` directory.
763
+ * @param {string} name The section name.
764
+ * @param {object} io `exists`, `read`, `owner`, `includeLegacy` seams.
765
+ * @returns {string|null|undefined} As {@link resolveOnlyHolder}.
766
+ */
767
+ export function sourceProfileHolder(dir, name, io = {}) {
768
+ const {
769
+ exists = existsSync,
770
+ read = readFileSync,
771
+ owner = null,
772
+ includeLegacy = false,
773
+ } = io;
774
+ const file = join(dir, "credentials");
775
+ if (!exists(file)) return undefined;
776
+
777
+ const text = String(read(file, "utf8"));
778
+ const ours = block =>
779
+ (block.owner === owner && owner !== null) ||
780
+ (block.owner === null && !includeLegacy);
781
+ const header = new RegExp(
782
+ `^\\s*\\[${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\]`,
783
+ "m"
784
+ );
785
+
786
+ for (const block of familyBlocks(text, MANAGED_FAMILY)) {
787
+ if (ours(block)) continue;
788
+ if (header.test(block.body)) return block.owner;
789
+ }
790
+ // Outside every managed block: an operator's own section, or one written by a
791
+ // generator that does not go through this module at all.
792
+ return header.test(stripFamilyBlocks(text, MANAGED_FAMILY, () => true))
793
+ ? null
794
+ : undefined;
795
+ }
796
+
797
+ /**
798
+ * The 12-digit accounts named by the role ARNs in some shared-config text.
799
+ * @param {string} text Config text.
800
+ * @returns {Set<string>} Account ids.
801
+ */
802
+ function accountsIn(text) {
803
+ return new Set(
804
+ [...text.matchAll(/^\s*role_arn\s*=\s*arn:[^:]*:iam::(\d+):/gm)].map(
805
+ found => found[1]
806
+ )
807
+ );
808
+ }
809
+
810
+ /**
811
+ * Whether every pre-ownership block present is this project's own past output.
812
+ *
813
+ * Conservative on purpose. A legacy block with no role ARN at all cannot be
814
+ * attributed, so it is not claimed; and one account belonging to someone else is
815
+ * enough to leave the whole population alone, because a partial claim would
816
+ * delete a block this process could not read the ownership of.
817
+ * @param {string} current Existing config contents.
818
+ * @param {string} ours The config body about to be written.
819
+ * @returns {boolean} Whether the legacy blocks may be replaced.
820
+ */
821
+ function legacyIsOurs(current, ours) {
822
+ const legacy = familyBlocks(current, MANAGED_FAMILY).filter(
823
+ block => block.owner === null
824
+ );
825
+ if (legacy.length === 0) return false;
826
+
827
+ const mine = accountsIn(ours);
828
+ return legacy.every(block => {
829
+ const theirs = accountsIn(block.body);
830
+ return theirs.size > 0 && [...theirs].every(account => mine.has(account));
831
+ });
832
+ }
833
+
412
834
  export function materialize(cfg = readConfig(), options = {}) {
413
835
  // `requested` distinguishes an OPERATOR asking from a flow deciding.
414
836
  //
@@ -447,7 +869,12 @@ export function materialize(cfg = readConfig(), options = {}) {
447
869
  // AWS_ACCESS_KEY_ID wins — environment variables outrank profile files in the
448
870
  // credential chain — and every AWS call fails with InvalidClientTokenId even
449
871
  // though the real credential materialized correctly.
450
- const derived = deriveAwsEnvironment(selected);
872
+ // The namespace IS the owner. It already scopes `~/.config/<namespace>`, so
873
+ // two repositories of one tenant share and two tenants do not — exactly the
874
+ // sharing model the shared write paths below were missing.
875
+ const owner = assertOwner(cfg.namespace, "the materialized credentials");
876
+
877
+ const derived = deriveAwsEnvironment(selected, owner);
451
878
  for (const [name, entry] of derived) selected.set(name, entry);
452
879
 
453
880
  const { dir, valuesFile, notesFile } = materializedPaths(cfg.namespace);
@@ -467,14 +894,15 @@ export function materialize(cfg = readConfig(), options = {}) {
467
894
  //
468
895
  // So the shell profile sources it. That is what makes "the credential is
469
896
  // materialized" and "the credential is usable" the same statement.
470
- const sourced = installProfileSourcing(valuesFile);
897
+ const sourced = installProfileSourcing(valuesFile, { owner });
471
898
 
472
899
  // The environments live in separate AWS accounts, reached by assuming a role
473
- // per environment. Without these files `--profile agent-dev` fails with
474
- // "profile not found" and everything silently falls back to the bootstrap
475
- // identity, which can assume roles and do nothing else.
900
+ // per environment. Without these files `--profile <owner>-agent-dev` fails
901
+ // with "profile not found" and everything silently falls back to the
902
+ // bootstrap identity, which can assume roles and do nothing else.
476
903
  const profiles = installAwsProfiles(
477
- parseBootstrap(selected.get(BOOTSTRAP_KEY)?.value)
904
+ parseBootstrap(selected.get(BOOTSTRAP_KEY)?.value),
905
+ { owner }
478
906
  );
479
907
 
480
908
  return {
@@ -483,6 +911,7 @@ export function materialize(cfg = readConfig(), options = {}) {
483
911
  dir,
484
912
  sourced,
485
913
  profiles,
914
+ legacy: legacyManagedProfiles(join(process.env.HOME || homedir(), ".aws")),
486
915
  };
487
916
  }
488
917
 
@@ -509,7 +938,8 @@ function main() {
509
938
  if (process.argv.includes("--aws-profiles-only")) {
510
939
  const selected = fetchAll(cfg);
511
940
  const bundle = parseBootstrap(selected.get(BOOTSTRAP_KEY)?.value);
512
- const written = installAwsProfiles(bundle);
941
+ const owner = assertOwner(cfg.namespace, "~/.aws");
942
+ const written = installAwsProfiles(bundle, { owner });
513
943
 
514
944
  if (written.length === 0) {
515
945
  console.log(
@@ -523,9 +953,11 @@ function main() {
523
953
  );
524
954
  console.log(
525
955
  ` Sourced from a long-lived key pair in ~/.aws/credentials. Existing\n` +
526
- ` sections were preserved — only the lisa-managed block was replaced.\n` +
956
+ ` sections were preserved — only "${owner}"'s block was replaced.\n` +
527
957
  ` Use them explicitly: aws --profile ${written[0]} ...`
528
958
  );
959
+ reportLegacyProfiles();
960
+ reportResolveOnlySlot(cfg.namespace);
529
961
  return;
530
962
  }
531
963
 
@@ -535,7 +967,10 @@ function main() {
535
967
  // the derived names are exactly the ones that override ambient host
536
968
  // credentials, so omitting them hides the most surprising effect of the run
537
969
  // and reports a smaller count than the real write produces.
538
- for (const [name, entry] of deriveAwsEnvironment(selected)) {
970
+ for (const [name, entry] of deriveAwsEnvironment(
971
+ selected,
972
+ assertOwner(cfg.namespace, "the materialized credentials")
973
+ )) {
539
974
  selected.set(name, entry);
540
975
  }
541
976
  const { dir } = materializedPaths(cfg.namespace);
@@ -562,6 +997,60 @@ function main() {
562
997
  `explicit --profile.`
563
998
  );
564
999
  }
1000
+ reportLegacyProfiles();
1001
+ reportResolveOnlySlot(cfg.namespace);
1002
+ }
1003
+
1004
+ /**
1005
+ * Say so when the deprecated bare names went to a different project.
1006
+ *
1007
+ * Silence here would be the worst of both worlds. A caller that has not
1008
+ * migrated still names the bare profiles; if another project on this machine
1009
+ * holds them, those names resolve into that project's account and the run would
1010
+ * otherwise report success. Loud-and-wrong is recoverable, silent-and-wrong is
1011
+ * the defect this whole change is about.
1012
+ * @param {string} owner The tenant this run materialized for.
1013
+ */
1014
+ function reportResolveOnlySlot(owner) {
1015
+ const holder = sourceProfileHolder(
1016
+ join(process.env.HOME || homedir(), ".aws"),
1017
+ RESOLVE_ONLY_LEGACY_SOURCE_PROFILE,
1018
+ { owner }
1019
+ );
1020
+ if (holder === undefined || holder === owner) return;
1021
+ console.log(
1022
+ ` The deprecated bare profile names were NOT written for "${owner}".\n` +
1023
+ ` ${holder === null ? "A section outside any lisa-managed block" : `The project "${holder}"`} ` +
1024
+ `already holds "${RESOLVE_ONLY_LEGACY_SOURCE_PROFILE}".\n` +
1025
+ ` They are one shared slot, so only one project on a machine can have ` +
1026
+ `them.\n Use the owned names — aws --profile ${owner}-<stage> — or ` +
1027
+ `re-run with\n LISA_SECRETS_CLAIM_LEGACY_PROFILES=1 to take the bare ` +
1028
+ `names deliberately.`
1029
+ );
1030
+ }
1031
+
1032
+ /**
1033
+ * Name any pre-ownership profiles still sitting in `~/.aws/config`.
1034
+ *
1035
+ * Reported on every run rather than once, because the population never drains
1036
+ * on its own and nothing else ever revisits this file — there is no apply, diff
1037
+ * or review that would surface it. Never deleted here: an unowned profile may
1038
+ * belong to another project on this machine, and removing it would be the same
1039
+ * silent consumption in the other direction.
1040
+ */
1041
+ function reportLegacyProfiles() {
1042
+ const stale = legacyManagedProfiles(
1043
+ join(process.env.HOME || homedir(), ".aws")
1044
+ );
1045
+ if (stale.length === 0) return;
1046
+ console.log(
1047
+ ` ~/.aws/config still holds ${stale.length} profile(s) written before ` +
1048
+ `Lisa\n recorded which project a profile belongs to: ` +
1049
+ `${stale.join(", ")}.\n` +
1050
+ ` They name a stage but no owner, so on a machine serving more than one ` +
1051
+ `\n project they can resolve to another project's account. Review them, ` +
1052
+ `or\n re-run with LISA_SECRETS_PRUNE_LEGACY_PROFILES=1 to remove them.`
1053
+ );
565
1054
  }
566
1055
 
567
1056
  /**