@codyswann/lisa 4.4.0 → 4.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codex/mcp-installer.d.ts.map +1 -1
- package/dist/codex/mcp-installer.js +55 -10
- package/dist/codex/mcp-installer.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +3 -1
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +130 -26
- package/plugins/lisa/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +130 -26
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +130 -26
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +130 -26
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +130 -26
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +130 -26
package/package.json
CHANGED
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
"zod-validation-error": "^4.0.0"
|
|
146
146
|
},
|
|
147
147
|
"name": "@codyswann/lisa",
|
|
148
|
-
"version": "4.4.
|
|
148
|
+
"version": "4.4.1",
|
|
149
149
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
150
150
|
"main": "dist/index.js",
|
|
151
151
|
"exports": {
|
package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs
CHANGED
|
@@ -68,11 +68,114 @@ function writeAtomic(destination, contents) {
|
|
|
68
68
|
* @param {object} [cfg] Resolved configuration.
|
|
69
69
|
* @returns {{count: number, dir: string}} What was written, and where.
|
|
70
70
|
*/
|
|
71
|
+
/**
|
|
72
|
+
* Marker version this build writes. Bumping it is SAFE, which is the point.
|
|
73
|
+
*
|
|
74
|
+
* The recognisers below match the FAMILY — the frozen identifier plus any
|
|
75
|
+
* version — rather than one literal string, so a block written by an older
|
|
76
|
+
* build is still found and replaced in place. Before that, the reader looked
|
|
77
|
+
* for the exact text it was about to write, so the first rename made it
|
|
78
|
+
* conclude there was no block here and append a second one.
|
|
79
|
+
*
|
|
80
|
+
* That failure is additive and silent, and this module is the worst place in
|
|
81
|
+
* Lisa for it: it writes into `~/.aws` files and a shell profile, outside any
|
|
82
|
+
* repository, where no apply, diff, or review ever revisits the result. An
|
|
83
|
+
* orphaned block in a shell profile is STILL SOURCED, and profiles apply
|
|
84
|
+
* assignments in order — so whichever block comes last wins, and an operator
|
|
85
|
+
* can end up running under credentials from a block Lisa believes it no longer
|
|
86
|
+
* manages. That is credential selection going wrong with no review step.
|
|
87
|
+
*
|
|
88
|
+
* Everywhere else in the codebase the mitigation is "widen the recogniser and
|
|
89
|
+
* accept the old shape until an apply normalises the file". There is no such
|
|
90
|
+
* operation here: the old population never drains and cannot be counted, so
|
|
91
|
+
* the recogniser must accept every past version permanently.
|
|
92
|
+
*
|
|
93
|
+
* Following `core/apply-receipt`, which treats a `schema_version` it does not
|
|
94
|
+
* recognise as NO RECEIPT rather than one it half-understands: on an
|
|
95
|
+
* unrecognised marker, fail toward redoing the work, not toward assuming it is
|
|
96
|
+
* done. Here that means replacing every family member found, so a file already
|
|
97
|
+
* carrying orphans from a past rename is repaired on the next run rather than
|
|
98
|
+
* accumulating one more.
|
|
99
|
+
*/
|
|
100
|
+
const MARKER_VERSION = "v2";
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The frozen half of each marker. Changing one of these DOES orphan blocks —
|
|
104
|
+
* that is the whole contract, and it is why the version lives beside it.
|
|
105
|
+
*
|
|
106
|
+
* This does not make renaming impossible, it makes it deliberate: an innocuous
|
|
107
|
+
* text edit no longer orphans a block, and the one edit that still would is the
|
|
108
|
+
* one these comments forbid.
|
|
109
|
+
*/
|
|
110
|
+
const PROFILE_FAMILY = "lisa secrets (managed";
|
|
111
|
+
|
|
112
|
+
/** The frozen family identifier for an `~/.aws` managed region. */
|
|
113
|
+
const MANAGED_FAMILY = "managed by lisa-secrets-access";
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build the start/end recognisers for one marker family.
|
|
117
|
+
*
|
|
118
|
+
* Matches the family followed by anything up to the closing delimiter, so
|
|
119
|
+
* `(managed)` — every block in the field today — and `(managed v2)` are both
|
|
120
|
+
* found by the same reader.
|
|
121
|
+
* @param {string} family Frozen family identifier.
|
|
122
|
+
* @returns {{start: RegExp, end: RegExp}} Global recognisers for the family.
|
|
123
|
+
*/
|
|
124
|
+
function familyRecognisers(family) {
|
|
125
|
+
const quoted = family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
126
|
+
return {
|
|
127
|
+
start: new RegExp(`# >>> ${quoted}[^\\n]*>>>`, "g"),
|
|
128
|
+
end: new RegExp(`# <<< ${quoted}[^\\n]*<<<`, "g"),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Index of the first family match at or after `from`, and its length.
|
|
134
|
+
* @param {string} text Haystack.
|
|
135
|
+
* @param {RegExp} recogniser Global family recogniser.
|
|
136
|
+
* @param {number} from Index to search from.
|
|
137
|
+
* @returns {{index: number, length: number}} `index` is -1 when absent.
|
|
138
|
+
*/
|
|
139
|
+
function findFamily(text, recogniser, from = 0) {
|
|
140
|
+
recogniser.lastIndex = from;
|
|
141
|
+
const match = recogniser.exec(text);
|
|
142
|
+
return match === null
|
|
143
|
+
? { index: -1, length: 0 }
|
|
144
|
+
: { index: match.index, length: match[0].length };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Remove every managed block of one family, leaving the rest of the file.
|
|
149
|
+
*
|
|
150
|
+
* Every block, not just the first: a file that already carries orphans from a
|
|
151
|
+
* rename that happened before this fix must come out with exactly one block,
|
|
152
|
+
* or the fix would leave the damage it exists to prevent.
|
|
153
|
+
* @param {string} text File contents.
|
|
154
|
+
* @param {string} family Frozen family identifier.
|
|
155
|
+
* @returns {string} The file with every family block removed.
|
|
156
|
+
*/
|
|
157
|
+
function stripFamilyBlocks(text, family) {
|
|
158
|
+
const { start, end } = familyRecognisers(family);
|
|
159
|
+
let out = text;
|
|
160
|
+
for (;;) {
|
|
161
|
+
const opened = findFamily(out, start);
|
|
162
|
+
if (opened.index === -1) return out;
|
|
163
|
+
const closed = findFamily(out, end, opened.index + opened.length);
|
|
164
|
+
// A truncated block (opened, never closed) would otherwise swallow the rest
|
|
165
|
+
// of the file on every subsequent write.
|
|
166
|
+
const after =
|
|
167
|
+
closed.index === -1
|
|
168
|
+
? ""
|
|
169
|
+
: out.slice(closed.index + closed.length).replace(/^\n/, "");
|
|
170
|
+
out = `${out.slice(0, opened.index)}${after}`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
71
174
|
/** Marks the block this owns, so it is replaced rather than appended twice. */
|
|
72
|
-
const PROFILE_MARKER =
|
|
175
|
+
const PROFILE_MARKER = `# >>> ${PROFILE_FAMILY} ${MARKER_VERSION}) >>>`;
|
|
73
176
|
|
|
74
177
|
/** Closes the managed block. */
|
|
75
|
-
const PROFILE_END =
|
|
178
|
+
const PROFILE_END = `# <<< ${PROFILE_FAMILY} ${MARKER_VERSION}) <<<`;
|
|
76
179
|
|
|
77
180
|
/**
|
|
78
181
|
* Identifies an `~/.aws` file as one this wrote, and may therefore replace.
|
|
@@ -81,10 +184,10 @@ const PROFILE_END = "# <<< lisa secrets (managed) <<<";
|
|
|
81
184
|
* consumer while still being the thing that distinguishes "our file, refresh
|
|
82
185
|
* it" from "someone else's file, leave it alone".
|
|
83
186
|
*/
|
|
84
|
-
const MANAGED_MARKER =
|
|
187
|
+
const MANAGED_MARKER = `# >>> ${MANAGED_FAMILY} ${MARKER_VERSION} >>>`;
|
|
85
188
|
|
|
86
189
|
/** Closes the managed region of an `~/.aws` file. */
|
|
87
|
-
const MANAGED_END =
|
|
190
|
+
const MANAGED_END = `# <<< ${MANAGED_FAMILY} ${MARKER_VERSION} <<<`;
|
|
88
191
|
|
|
89
192
|
/**
|
|
90
193
|
* Replace this module's delimited region in a file, preserving everything else.
|
|
@@ -99,22 +202,21 @@ const MANAGED_END = "# <<< managed by lisa-secrets-access <<<";
|
|
|
99
202
|
*/
|
|
100
203
|
export function upsertManagedBlock(current, body) {
|
|
101
204
|
const block = `${MANAGED_MARKER}\n${body.trimEnd()}\n${MANAGED_END}`;
|
|
102
|
-
const
|
|
205
|
+
const opened = findFamily(current, familyRecognisers(MANAGED_FAMILY).start);
|
|
103
206
|
|
|
104
|
-
if (
|
|
207
|
+
if (opened.index === -1) {
|
|
105
208
|
const prefix =
|
|
106
209
|
current && !current.endsWith("\n") ? `${current}\n` : current;
|
|
107
210
|
return `${prefix}${prefix ? "\n" : ""}${block}\n`;
|
|
108
211
|
}
|
|
109
212
|
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return `${current.slice(0, start)}${block}\n${after}`;
|
|
213
|
+
// Everything this module owns is stripped first, so a file already carrying
|
|
214
|
+
// orphans from a rename that predates the family recogniser comes out with
|
|
215
|
+
// exactly one block rather than one more.
|
|
216
|
+
const withoutOurs = stripFamilyBlocks(current, MANAGED_FAMILY);
|
|
217
|
+
const head = withoutOurs.slice(0, opened.index);
|
|
218
|
+
const tail = withoutOurs.slice(opened.index);
|
|
219
|
+
return `${head}${block}\n${tail}`;
|
|
118
220
|
}
|
|
119
221
|
|
|
120
222
|
/**
|
|
@@ -173,13 +275,18 @@ export function installProfileSourcing(valuesFile, options = {}) {
|
|
|
173
275
|
|
|
174
276
|
// Replace an existing managed block rather than appending another: this
|
|
175
277
|
// runs on every session, and an appended-forever profile is its own bug.
|
|
176
|
-
const
|
|
278
|
+
const opened = findFamily(current, familyRecognisers(PROFILE_FAMILY).start);
|
|
279
|
+
// Stripping EVERY family block before writing one is what makes an already
|
|
280
|
+
// orphaned profile self-heal. It matters more here than anywhere else in
|
|
281
|
+
// Lisa: an orphaned block in a shell profile is still sourced, and the last
|
|
282
|
+
// assignment wins, so a stale block silently selects the wrong credentials.
|
|
283
|
+
const withoutOurs = stripFamilyBlocks(current, PROFILE_FAMILY);
|
|
177
284
|
const next =
|
|
178
|
-
|
|
285
|
+
opened.index === -1
|
|
179
286
|
? `${current}${current.endsWith("\n") || !current ? "" : "\n"}\n${block}\n`
|
|
180
|
-
: `${
|
|
181
|
-
|
|
182
|
-
|
|
287
|
+
: `${withoutOurs.slice(0, opened.index)}${block}\n${withoutOurs
|
|
288
|
+
.slice(opened.index)
|
|
289
|
+
.replace(/^\n/, "")}`;
|
|
183
290
|
|
|
184
291
|
if (next !== current) {
|
|
185
292
|
write(file, next, { mode: 0o600 });
|
|
@@ -214,13 +321,10 @@ export function collidingProfiles(dir, names, io = {}) {
|
|
|
214
321
|
if (!exists(file)) return [];
|
|
215
322
|
|
|
216
323
|
const text = String(read(file, "utf8"));
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
? text
|
|
222
|
-
: text.slice(0, start) +
|
|
223
|
-
(endAt === -1 ? "" : text.slice(endAt + MANAGED_END.length));
|
|
324
|
+
// Every family block, not just the current version's. An orphan left by an
|
|
325
|
+
// older marker is still OUR previous output, so counting its profiles as a
|
|
326
|
+
// host collision would refuse to write the very names we wrote last time.
|
|
327
|
+
const outside = stripFamilyBlocks(text, MANAGED_FAMILY);
|
|
224
328
|
|
|
225
329
|
return names.filter(name =>
|
|
226
330
|
new RegExp(
|
|
@@ -68,11 +68,114 @@ function writeAtomic(destination, contents) {
|
|
|
68
68
|
* @param {object} [cfg] Resolved configuration.
|
|
69
69
|
* @returns {{count: number, dir: string}} What was written, and where.
|
|
70
70
|
*/
|
|
71
|
+
/**
|
|
72
|
+
* Marker version this build writes. Bumping it is SAFE, which is the point.
|
|
73
|
+
*
|
|
74
|
+
* The recognisers below match the FAMILY — the frozen identifier plus any
|
|
75
|
+
* version — rather than one literal string, so a block written by an older
|
|
76
|
+
* build is still found and replaced in place. Before that, the reader looked
|
|
77
|
+
* for the exact text it was about to write, so the first rename made it
|
|
78
|
+
* conclude there was no block here and append a second one.
|
|
79
|
+
*
|
|
80
|
+
* That failure is additive and silent, and this module is the worst place in
|
|
81
|
+
* Lisa for it: it writes into `~/.aws` files and a shell profile, outside any
|
|
82
|
+
* repository, where no apply, diff, or review ever revisits the result. An
|
|
83
|
+
* orphaned block in a shell profile is STILL SOURCED, and profiles apply
|
|
84
|
+
* assignments in order — so whichever block comes last wins, and an operator
|
|
85
|
+
* can end up running under credentials from a block Lisa believes it no longer
|
|
86
|
+
* manages. That is credential selection going wrong with no review step.
|
|
87
|
+
*
|
|
88
|
+
* Everywhere else in the codebase the mitigation is "widen the recogniser and
|
|
89
|
+
* accept the old shape until an apply normalises the file". There is no such
|
|
90
|
+
* operation here: the old population never drains and cannot be counted, so
|
|
91
|
+
* the recogniser must accept every past version permanently.
|
|
92
|
+
*
|
|
93
|
+
* Following `core/apply-receipt`, which treats a `schema_version` it does not
|
|
94
|
+
* recognise as NO RECEIPT rather than one it half-understands: on an
|
|
95
|
+
* unrecognised marker, fail toward redoing the work, not toward assuming it is
|
|
96
|
+
* done. Here that means replacing every family member found, so a file already
|
|
97
|
+
* carrying orphans from a past rename is repaired on the next run rather than
|
|
98
|
+
* accumulating one more.
|
|
99
|
+
*/
|
|
100
|
+
const MARKER_VERSION = "v2";
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The frozen half of each marker. Changing one of these DOES orphan blocks —
|
|
104
|
+
* that is the whole contract, and it is why the version lives beside it.
|
|
105
|
+
*
|
|
106
|
+
* This does not make renaming impossible, it makes it deliberate: an innocuous
|
|
107
|
+
* text edit no longer orphans a block, and the one edit that still would is the
|
|
108
|
+
* one these comments forbid.
|
|
109
|
+
*/
|
|
110
|
+
const PROFILE_FAMILY = "lisa secrets (managed";
|
|
111
|
+
|
|
112
|
+
/** The frozen family identifier for an `~/.aws` managed region. */
|
|
113
|
+
const MANAGED_FAMILY = "managed by lisa-secrets-access";
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build the start/end recognisers for one marker family.
|
|
117
|
+
*
|
|
118
|
+
* Matches the family followed by anything up to the closing delimiter, so
|
|
119
|
+
* `(managed)` — every block in the field today — and `(managed v2)` are both
|
|
120
|
+
* found by the same reader.
|
|
121
|
+
* @param {string} family Frozen family identifier.
|
|
122
|
+
* @returns {{start: RegExp, end: RegExp}} Global recognisers for the family.
|
|
123
|
+
*/
|
|
124
|
+
function familyRecognisers(family) {
|
|
125
|
+
const quoted = family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
126
|
+
return {
|
|
127
|
+
start: new RegExp(`# >>> ${quoted}[^\\n]*>>>`, "g"),
|
|
128
|
+
end: new RegExp(`# <<< ${quoted}[^\\n]*<<<`, "g"),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Index of the first family match at or after `from`, and its length.
|
|
134
|
+
* @param {string} text Haystack.
|
|
135
|
+
* @param {RegExp} recogniser Global family recogniser.
|
|
136
|
+
* @param {number} from Index to search from.
|
|
137
|
+
* @returns {{index: number, length: number}} `index` is -1 when absent.
|
|
138
|
+
*/
|
|
139
|
+
function findFamily(text, recogniser, from = 0) {
|
|
140
|
+
recogniser.lastIndex = from;
|
|
141
|
+
const match = recogniser.exec(text);
|
|
142
|
+
return match === null
|
|
143
|
+
? { index: -1, length: 0 }
|
|
144
|
+
: { index: match.index, length: match[0].length };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Remove every managed block of one family, leaving the rest of the file.
|
|
149
|
+
*
|
|
150
|
+
* Every block, not just the first: a file that already carries orphans from a
|
|
151
|
+
* rename that happened before this fix must come out with exactly one block,
|
|
152
|
+
* or the fix would leave the damage it exists to prevent.
|
|
153
|
+
* @param {string} text File contents.
|
|
154
|
+
* @param {string} family Frozen family identifier.
|
|
155
|
+
* @returns {string} The file with every family block removed.
|
|
156
|
+
*/
|
|
157
|
+
function stripFamilyBlocks(text, family) {
|
|
158
|
+
const { start, end } = familyRecognisers(family);
|
|
159
|
+
let out = text;
|
|
160
|
+
for (;;) {
|
|
161
|
+
const opened = findFamily(out, start);
|
|
162
|
+
if (opened.index === -1) return out;
|
|
163
|
+
const closed = findFamily(out, end, opened.index + opened.length);
|
|
164
|
+
// A truncated block (opened, never closed) would otherwise swallow the rest
|
|
165
|
+
// of the file on every subsequent write.
|
|
166
|
+
const after =
|
|
167
|
+
closed.index === -1
|
|
168
|
+
? ""
|
|
169
|
+
: out.slice(closed.index + closed.length).replace(/^\n/, "");
|
|
170
|
+
out = `${out.slice(0, opened.index)}${after}`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
71
174
|
/** Marks the block this owns, so it is replaced rather than appended twice. */
|
|
72
|
-
const PROFILE_MARKER =
|
|
175
|
+
const PROFILE_MARKER = `# >>> ${PROFILE_FAMILY} ${MARKER_VERSION}) >>>`;
|
|
73
176
|
|
|
74
177
|
/** Closes the managed block. */
|
|
75
|
-
const PROFILE_END =
|
|
178
|
+
const PROFILE_END = `# <<< ${PROFILE_FAMILY} ${MARKER_VERSION}) <<<`;
|
|
76
179
|
|
|
77
180
|
/**
|
|
78
181
|
* Identifies an `~/.aws` file as one this wrote, and may therefore replace.
|
|
@@ -81,10 +184,10 @@ const PROFILE_END = "# <<< lisa secrets (managed) <<<";
|
|
|
81
184
|
* consumer while still being the thing that distinguishes "our file, refresh
|
|
82
185
|
* it" from "someone else's file, leave it alone".
|
|
83
186
|
*/
|
|
84
|
-
const MANAGED_MARKER =
|
|
187
|
+
const MANAGED_MARKER = `# >>> ${MANAGED_FAMILY} ${MARKER_VERSION} >>>`;
|
|
85
188
|
|
|
86
189
|
/** Closes the managed region of an `~/.aws` file. */
|
|
87
|
-
const MANAGED_END =
|
|
190
|
+
const MANAGED_END = `# <<< ${MANAGED_FAMILY} ${MARKER_VERSION} <<<`;
|
|
88
191
|
|
|
89
192
|
/**
|
|
90
193
|
* Replace this module's delimited region in a file, preserving everything else.
|
|
@@ -99,22 +202,21 @@ const MANAGED_END = "# <<< managed by lisa-secrets-access <<<";
|
|
|
99
202
|
*/
|
|
100
203
|
export function upsertManagedBlock(current, body) {
|
|
101
204
|
const block = `${MANAGED_MARKER}\n${body.trimEnd()}\n${MANAGED_END}`;
|
|
102
|
-
const
|
|
205
|
+
const opened = findFamily(current, familyRecognisers(MANAGED_FAMILY).start);
|
|
103
206
|
|
|
104
|
-
if (
|
|
207
|
+
if (opened.index === -1) {
|
|
105
208
|
const prefix =
|
|
106
209
|
current && !current.endsWith("\n") ? `${current}\n` : current;
|
|
107
210
|
return `${prefix}${prefix ? "\n" : ""}${block}\n`;
|
|
108
211
|
}
|
|
109
212
|
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return `${current.slice(0, start)}${block}\n${after}`;
|
|
213
|
+
// Everything this module owns is stripped first, so a file already carrying
|
|
214
|
+
// orphans from a rename that predates the family recogniser comes out with
|
|
215
|
+
// exactly one block rather than one more.
|
|
216
|
+
const withoutOurs = stripFamilyBlocks(current, MANAGED_FAMILY);
|
|
217
|
+
const head = withoutOurs.slice(0, opened.index);
|
|
218
|
+
const tail = withoutOurs.slice(opened.index);
|
|
219
|
+
return `${head}${block}\n${tail}`;
|
|
118
220
|
}
|
|
119
221
|
|
|
120
222
|
/**
|
|
@@ -173,13 +275,18 @@ export function installProfileSourcing(valuesFile, options = {}) {
|
|
|
173
275
|
|
|
174
276
|
// Replace an existing managed block rather than appending another: this
|
|
175
277
|
// runs on every session, and an appended-forever profile is its own bug.
|
|
176
|
-
const
|
|
278
|
+
const opened = findFamily(current, familyRecognisers(PROFILE_FAMILY).start);
|
|
279
|
+
// Stripping EVERY family block before writing one is what makes an already
|
|
280
|
+
// orphaned profile self-heal. It matters more here than anywhere else in
|
|
281
|
+
// Lisa: an orphaned block in a shell profile is still sourced, and the last
|
|
282
|
+
// assignment wins, so a stale block silently selects the wrong credentials.
|
|
283
|
+
const withoutOurs = stripFamilyBlocks(current, PROFILE_FAMILY);
|
|
177
284
|
const next =
|
|
178
|
-
|
|
285
|
+
opened.index === -1
|
|
179
286
|
? `${current}${current.endsWith("\n") || !current ? "" : "\n"}\n${block}\n`
|
|
180
|
-
: `${
|
|
181
|
-
|
|
182
|
-
|
|
287
|
+
: `${withoutOurs.slice(0, opened.index)}${block}\n${withoutOurs
|
|
288
|
+
.slice(opened.index)
|
|
289
|
+
.replace(/^\n/, "")}`;
|
|
183
290
|
|
|
184
291
|
if (next !== current) {
|
|
185
292
|
write(file, next, { mode: 0o600 });
|
|
@@ -214,13 +321,10 @@ export function collidingProfiles(dir, names, io = {}) {
|
|
|
214
321
|
if (!exists(file)) return [];
|
|
215
322
|
|
|
216
323
|
const text = String(read(file, "utf8"));
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
? text
|
|
222
|
-
: text.slice(0, start) +
|
|
223
|
-
(endAt === -1 ? "" : text.slice(endAt + MANAGED_END.length));
|
|
324
|
+
// Every family block, not just the current version's. An orphan left by an
|
|
325
|
+
// older marker is still OUR previous output, so counting its profiles as a
|
|
326
|
+
// host collision would refuse to write the very names we wrote last time.
|
|
327
|
+
const outside = stripFamilyBlocks(text, MANAGED_FAMILY);
|
|
224
328
|
|
|
225
329
|
return names.filter(name =>
|
|
226
330
|
new RegExp(
|
|
@@ -68,11 +68,114 @@ function writeAtomic(destination, contents) {
|
|
|
68
68
|
* @param {object} [cfg] Resolved configuration.
|
|
69
69
|
* @returns {{count: number, dir: string}} What was written, and where.
|
|
70
70
|
*/
|
|
71
|
+
/**
|
|
72
|
+
* Marker version this build writes. Bumping it is SAFE, which is the point.
|
|
73
|
+
*
|
|
74
|
+
* The recognisers below match the FAMILY — the frozen identifier plus any
|
|
75
|
+
* version — rather than one literal string, so a block written by an older
|
|
76
|
+
* build is still found and replaced in place. Before that, the reader looked
|
|
77
|
+
* for the exact text it was about to write, so the first rename made it
|
|
78
|
+
* conclude there was no block here and append a second one.
|
|
79
|
+
*
|
|
80
|
+
* That failure is additive and silent, and this module is the worst place in
|
|
81
|
+
* Lisa for it: it writes into `~/.aws` files and a shell profile, outside any
|
|
82
|
+
* repository, where no apply, diff, or review ever revisits the result. An
|
|
83
|
+
* orphaned block in a shell profile is STILL SOURCED, and profiles apply
|
|
84
|
+
* assignments in order — so whichever block comes last wins, and an operator
|
|
85
|
+
* can end up running under credentials from a block Lisa believes it no longer
|
|
86
|
+
* manages. That is credential selection going wrong with no review step.
|
|
87
|
+
*
|
|
88
|
+
* Everywhere else in the codebase the mitigation is "widen the recogniser and
|
|
89
|
+
* accept the old shape until an apply normalises the file". There is no such
|
|
90
|
+
* operation here: the old population never drains and cannot be counted, so
|
|
91
|
+
* the recogniser must accept every past version permanently.
|
|
92
|
+
*
|
|
93
|
+
* Following `core/apply-receipt`, which treats a `schema_version` it does not
|
|
94
|
+
* recognise as NO RECEIPT rather than one it half-understands: on an
|
|
95
|
+
* unrecognised marker, fail toward redoing the work, not toward assuming it is
|
|
96
|
+
* done. Here that means replacing every family member found, so a file already
|
|
97
|
+
* carrying orphans from a past rename is repaired on the next run rather than
|
|
98
|
+
* accumulating one more.
|
|
99
|
+
*/
|
|
100
|
+
const MARKER_VERSION = "v2";
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The frozen half of each marker. Changing one of these DOES orphan blocks —
|
|
104
|
+
* that is the whole contract, and it is why the version lives beside it.
|
|
105
|
+
*
|
|
106
|
+
* This does not make renaming impossible, it makes it deliberate: an innocuous
|
|
107
|
+
* text edit no longer orphans a block, and the one edit that still would is the
|
|
108
|
+
* one these comments forbid.
|
|
109
|
+
*/
|
|
110
|
+
const PROFILE_FAMILY = "lisa secrets (managed";
|
|
111
|
+
|
|
112
|
+
/** The frozen family identifier for an `~/.aws` managed region. */
|
|
113
|
+
const MANAGED_FAMILY = "managed by lisa-secrets-access";
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build the start/end recognisers for one marker family.
|
|
117
|
+
*
|
|
118
|
+
* Matches the family followed by anything up to the closing delimiter, so
|
|
119
|
+
* `(managed)` — every block in the field today — and `(managed v2)` are both
|
|
120
|
+
* found by the same reader.
|
|
121
|
+
* @param {string} family Frozen family identifier.
|
|
122
|
+
* @returns {{start: RegExp, end: RegExp}} Global recognisers for the family.
|
|
123
|
+
*/
|
|
124
|
+
function familyRecognisers(family) {
|
|
125
|
+
const quoted = family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
126
|
+
return {
|
|
127
|
+
start: new RegExp(`# >>> ${quoted}[^\\n]*>>>`, "g"),
|
|
128
|
+
end: new RegExp(`# <<< ${quoted}[^\\n]*<<<`, "g"),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Index of the first family match at or after `from`, and its length.
|
|
134
|
+
* @param {string} text Haystack.
|
|
135
|
+
* @param {RegExp} recogniser Global family recogniser.
|
|
136
|
+
* @param {number} from Index to search from.
|
|
137
|
+
* @returns {{index: number, length: number}} `index` is -1 when absent.
|
|
138
|
+
*/
|
|
139
|
+
function findFamily(text, recogniser, from = 0) {
|
|
140
|
+
recogniser.lastIndex = from;
|
|
141
|
+
const match = recogniser.exec(text);
|
|
142
|
+
return match === null
|
|
143
|
+
? { index: -1, length: 0 }
|
|
144
|
+
: { index: match.index, length: match[0].length };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Remove every managed block of one family, leaving the rest of the file.
|
|
149
|
+
*
|
|
150
|
+
* Every block, not just the first: a file that already carries orphans from a
|
|
151
|
+
* rename that happened before this fix must come out with exactly one block,
|
|
152
|
+
* or the fix would leave the damage it exists to prevent.
|
|
153
|
+
* @param {string} text File contents.
|
|
154
|
+
* @param {string} family Frozen family identifier.
|
|
155
|
+
* @returns {string} The file with every family block removed.
|
|
156
|
+
*/
|
|
157
|
+
function stripFamilyBlocks(text, family) {
|
|
158
|
+
const { start, end } = familyRecognisers(family);
|
|
159
|
+
let out = text;
|
|
160
|
+
for (;;) {
|
|
161
|
+
const opened = findFamily(out, start);
|
|
162
|
+
if (opened.index === -1) return out;
|
|
163
|
+
const closed = findFamily(out, end, opened.index + opened.length);
|
|
164
|
+
// A truncated block (opened, never closed) would otherwise swallow the rest
|
|
165
|
+
// of the file on every subsequent write.
|
|
166
|
+
const after =
|
|
167
|
+
closed.index === -1
|
|
168
|
+
? ""
|
|
169
|
+
: out.slice(closed.index + closed.length).replace(/^\n/, "");
|
|
170
|
+
out = `${out.slice(0, opened.index)}${after}`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
71
174
|
/** Marks the block this owns, so it is replaced rather than appended twice. */
|
|
72
|
-
const PROFILE_MARKER =
|
|
175
|
+
const PROFILE_MARKER = `# >>> ${PROFILE_FAMILY} ${MARKER_VERSION}) >>>`;
|
|
73
176
|
|
|
74
177
|
/** Closes the managed block. */
|
|
75
|
-
const PROFILE_END =
|
|
178
|
+
const PROFILE_END = `# <<< ${PROFILE_FAMILY} ${MARKER_VERSION}) <<<`;
|
|
76
179
|
|
|
77
180
|
/**
|
|
78
181
|
* Identifies an `~/.aws` file as one this wrote, and may therefore replace.
|
|
@@ -81,10 +184,10 @@ const PROFILE_END = "# <<< lisa secrets (managed) <<<";
|
|
|
81
184
|
* consumer while still being the thing that distinguishes "our file, refresh
|
|
82
185
|
* it" from "someone else's file, leave it alone".
|
|
83
186
|
*/
|
|
84
|
-
const MANAGED_MARKER =
|
|
187
|
+
const MANAGED_MARKER = `# >>> ${MANAGED_FAMILY} ${MARKER_VERSION} >>>`;
|
|
85
188
|
|
|
86
189
|
/** Closes the managed region of an `~/.aws` file. */
|
|
87
|
-
const MANAGED_END =
|
|
190
|
+
const MANAGED_END = `# <<< ${MANAGED_FAMILY} ${MARKER_VERSION} <<<`;
|
|
88
191
|
|
|
89
192
|
/**
|
|
90
193
|
* Replace this module's delimited region in a file, preserving everything else.
|
|
@@ -99,22 +202,21 @@ const MANAGED_END = "# <<< managed by lisa-secrets-access <<<";
|
|
|
99
202
|
*/
|
|
100
203
|
export function upsertManagedBlock(current, body) {
|
|
101
204
|
const block = `${MANAGED_MARKER}\n${body.trimEnd()}\n${MANAGED_END}`;
|
|
102
|
-
const
|
|
205
|
+
const opened = findFamily(current, familyRecognisers(MANAGED_FAMILY).start);
|
|
103
206
|
|
|
104
|
-
if (
|
|
207
|
+
if (opened.index === -1) {
|
|
105
208
|
const prefix =
|
|
106
209
|
current && !current.endsWith("\n") ? `${current}\n` : current;
|
|
107
210
|
return `${prefix}${prefix ? "\n" : ""}${block}\n`;
|
|
108
211
|
}
|
|
109
212
|
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return `${current.slice(0, start)}${block}\n${after}`;
|
|
213
|
+
// Everything this module owns is stripped first, so a file already carrying
|
|
214
|
+
// orphans from a rename that predates the family recogniser comes out with
|
|
215
|
+
// exactly one block rather than one more.
|
|
216
|
+
const withoutOurs = stripFamilyBlocks(current, MANAGED_FAMILY);
|
|
217
|
+
const head = withoutOurs.slice(0, opened.index);
|
|
218
|
+
const tail = withoutOurs.slice(opened.index);
|
|
219
|
+
return `${head}${block}\n${tail}`;
|
|
118
220
|
}
|
|
119
221
|
|
|
120
222
|
/**
|
|
@@ -173,13 +275,18 @@ export function installProfileSourcing(valuesFile, options = {}) {
|
|
|
173
275
|
|
|
174
276
|
// Replace an existing managed block rather than appending another: this
|
|
175
277
|
// runs on every session, and an appended-forever profile is its own bug.
|
|
176
|
-
const
|
|
278
|
+
const opened = findFamily(current, familyRecognisers(PROFILE_FAMILY).start);
|
|
279
|
+
// Stripping EVERY family block before writing one is what makes an already
|
|
280
|
+
// orphaned profile self-heal. It matters more here than anywhere else in
|
|
281
|
+
// Lisa: an orphaned block in a shell profile is still sourced, and the last
|
|
282
|
+
// assignment wins, so a stale block silently selects the wrong credentials.
|
|
283
|
+
const withoutOurs = stripFamilyBlocks(current, PROFILE_FAMILY);
|
|
177
284
|
const next =
|
|
178
|
-
|
|
285
|
+
opened.index === -1
|
|
179
286
|
? `${current}${current.endsWith("\n") || !current ? "" : "\n"}\n${block}\n`
|
|
180
|
-
: `${
|
|
181
|
-
|
|
182
|
-
|
|
287
|
+
: `${withoutOurs.slice(0, opened.index)}${block}\n${withoutOurs
|
|
288
|
+
.slice(opened.index)
|
|
289
|
+
.replace(/^\n/, "")}`;
|
|
183
290
|
|
|
184
291
|
if (next !== current) {
|
|
185
292
|
write(file, next, { mode: 0o600 });
|
|
@@ -214,13 +321,10 @@ export function collidingProfiles(dir, names, io = {}) {
|
|
|
214
321
|
if (!exists(file)) return [];
|
|
215
322
|
|
|
216
323
|
const text = String(read(file, "utf8"));
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
? text
|
|
222
|
-
: text.slice(0, start) +
|
|
223
|
-
(endAt === -1 ? "" : text.slice(endAt + MANAGED_END.length));
|
|
324
|
+
// Every family block, not just the current version's. An orphan left by an
|
|
325
|
+
// older marker is still OUR previous output, so counting its profiles as a
|
|
326
|
+
// host collision would refuse to write the very names we wrote last time.
|
|
327
|
+
const outside = stripFamilyBlocks(text, MANAGED_FAMILY);
|
|
224
328
|
|
|
225
329
|
return names.filter(name =>
|
|
226
330
|
new RegExp(
|