@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.
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +14 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +24 -6
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/copy-overwrite/scripts/bdd/baseline.mjs +10 -2
- package/expo/copy-overwrite/scripts/bdd/render.mjs +3 -2
- package/expo/copy-overwrite/scripts/bdd-matrix.mjs +3 -3
- package/package.json +7 -2
- 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/publish-aws-bootstrap.mjs +349 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-aws/SKILL.md +23 -5
- package/plugins/lisa/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
- package/plugins/lisa/skills/lisa-setup-remote-aws/SKILL.md +23 -5
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-aws/SKILL.md +23 -5
- 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/publish-aws-bootstrap.mjs +349 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-aws/SKILL.md +23 -5
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-aws/SKILL.md +23 -5
- 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/publish-aws-bootstrap.mjs +349 -0
- package/plugins/src/base/skills/lisa-setup-remote-aws/SKILL.md +23 -5
- package/scripts/check-ui-demo-data.mjs +102 -0
- package/ui/README.md +32 -0
- 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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 \`
|
|
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:
|
|
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
|
|
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 \`
|
|
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.
|
|
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": "
|
|
300
|
+
"lisaReleaseCommit": "e520bda3d4a0488faa3c59460c3e14fbd9a67860"
|
|
296
301
|
}
|
package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs
ADDED
|
@@ -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
|
|
53
|
-
the platform's secret store is two live copies of one credential: rotate
|
|
54
|
-
IAM key and whichever copy you forget
|
|
55
|
-
|
|
56
|
-
|
|
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
|