@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.
- package/README.md +9 -5
- package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +8 -7
- package/dist/codex/enforcement-fallback-installer.d.ts +19 -0
- package/dist/codex/enforcement-fallback-installer.d.ts.map +1 -0
- package/dist/codex/enforcement-fallback-installer.js +50 -0
- package/dist/codex/enforcement-fallback-installer.js.map +1 -0
- package/dist/codex/hooks-installer.d.ts.map +1 -1
- package/dist/codex/hooks-installer.js +5 -4
- package/dist/codex/hooks-installer.js.map +1 -1
- package/dist/codex/project-hooks-cleanup.js +1 -1
- package/dist/codex/project-hooks-cleanup.js.map +1 -1
- package/dist/codex/project-overlay.d.ts.map +1 -1
- package/dist/codex/project-overlay.js +7 -4
- package/dist/codex/project-overlay.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +2 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/lisa.js +1 -1
- package/dist/core/lisa.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +16 -5
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -3
- 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/SKILL.md +55 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +55 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
- package/plugins/lisa/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
- package/plugins/lisa/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +55 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- 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/SKILL.md +55 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +55 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- 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/SKILL.md +55 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
- package/plugins/src/base/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
- package/plugins/src/base/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- package/scripts/lisa-enforcement-fallback.sh +8 -7
package/package.json
CHANGED
|
@@ -147,6 +147,7 @@
|
|
|
147
147
|
"js-yaml": "^3.15.1"
|
|
148
148
|
},
|
|
149
149
|
"axios": ">=1.15.2",
|
|
150
|
+
"browserslist": ">=4.28.8",
|
|
150
151
|
"deepmerge-ts": "^8.0.1",
|
|
151
152
|
"esbuild": ">=0.28.1",
|
|
152
153
|
"eslint-plugin-react-hooks": "^7.0.0",
|
|
@@ -163,7 +164,7 @@
|
|
|
163
164
|
"zod-validation-error": "^4.0.0"
|
|
164
165
|
},
|
|
165
166
|
"name": "@codyswann/lisa",
|
|
166
|
-
"version": "4.29.
|
|
167
|
+
"version": "4.29.3",
|
|
167
168
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
168
169
|
"main": "dist/index.js",
|
|
169
170
|
"exports": {
|
|
@@ -311,6 +312,6 @@
|
|
|
311
312
|
"test": "tests"
|
|
312
313
|
},
|
|
313
314
|
"types": "./dist/index.d.ts",
|
|
314
|
-
"lisaReleaseCommit": "
|
|
315
|
-
"gitHead": "
|
|
315
|
+
"lisaReleaseCommit": "86cd76f9dbd28c1ded3ae247c3cbce68af74ac1a",
|
|
316
|
+
"gitHead": "86cd76f9dbd28c1ded3ae247c3cbce68af74ac1a"
|
|
316
317
|
}
|
|
@@ -73,6 +73,61 @@ ${XDG_CONFIG_HOME:-$HOME/.config}/<secrets.namespace>/ # dir 0700
|
|
|
73
73
|
- Each temporary file is created **beside its destination**, because a rename is only atomic within one filesystem.
|
|
74
74
|
- The writer and the parser for `secrets.env` live in one module (`envfile.mjs`) on purpose. A shell sources the file, and the resolver reads it back; if the quoting and the parsing drift apart, every value containing a quote is corrupted silently.
|
|
75
75
|
|
|
76
|
+
## Everything written outside that directory is owned
|
|
77
|
+
|
|
78
|
+
Materialization also writes to paths with **no tenant in them** — `~/.aws/config`, `~/.aws/credentials`, `~/.bashrc`, `~/.profile`. Two projects on one machine therefore wrote the same identifiers, and the second silently replaced the first. Measured with two bundles declaring the same stage names: two profiles survived where four were written, the surviving `agent-dev` named the second project's account, and every shell on the machine sourced the second project's values. Both runs exited 0.
|
|
79
|
+
|
|
80
|
+
Nothing about the surviving profile is malformed — it is a real, working profile that belongs to someone else — so no property check on the profile can detect it. Only a comparison against the intended owner fires.
|
|
81
|
+
|
|
82
|
+
**The owner is `secrets.namespace`.** It already scopes `~/.config/<namespace>`, so two repositories of one tenant share and two tenants do not. Nothing is written without one.
|
|
83
|
+
|
|
84
|
+
| Identifier | How it is protected |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `~/.aws` profile names | Prefixed: `<namespace>-<stage>` (`<namespace>-lisa-bootstrap` for the source profile). Distinct names make a wrong resolution impossible, matching the convention `lisa-setup-remote-aws` writes. |
|
|
87
|
+
| `~/.aws` managed block | Tagged `owner=<namespace>`. Only this owner's block is replaced; another project's is left untouched. |
|
|
88
|
+
| Shell profile block | Tagged the same way, but it **cannot** be namespaced — it exports into every shell and there is no name a consumer selects. So it is claimed only when unowned or already ours; a block held by another project stops the run and names it. `LISA_SECRETS_CLAIM_SHELL_PROFILE=1` takes it over deliberately. |
|
|
89
|
+
|
|
90
|
+
**Blocks written before ownership existed** are attributed rather than assumed, because leaving an orphan behind is its own defect — an orphaned shell block is still sourced, and the last assignment wins.
|
|
91
|
+
|
|
92
|
+
- A legacy **shell** block names `<config root>/<tenant>/secrets.env` in its body, so it states its own owner: ours is replaced in place, another tenant's stops the run.
|
|
93
|
+
- A legacy **`~/.aws`** block carries no such tell, so it is attributed by the accounts in its role ARNs. Matching this bundle's accounts means it is our own past output and is replaced; anything else is reported by name on every run and removed only under `LISA_SECRETS_PRUNE_LEGACY_PROFILES=1`.
|
|
94
|
+
|
|
95
|
+
A stage may declare `expectedAccountId`. This writer never contacts AWS, so it cannot compare against a live `sts:GetCallerIdentity` the way `lisa-setup-remote-aws` does — but a declaration contradicting the account in its own role ARN is caught before anything is written.
|
|
96
|
+
|
|
97
|
+
### Emit vs resolve — the bare names still RESOLVE, and are never EMITTED
|
|
98
|
+
|
|
99
|
+
Two different guarantees, with two different expiry conditions. Conflating them is how a temporary shim becomes permanent by accident.
|
|
100
|
+
|
|
101
|
+
| | Guarantee | Shape | Expires |
|
|
102
|
+
| --- | --- | --- | --- |
|
|
103
|
+
| **Emit** | what this writer produces as canonical output | `<namespace>-<stage>`, assuming from `<namespace>-lisa-bootstrap` | never |
|
|
104
|
+
| **Resolve** | names that must keep working for *readers* mid-migration | the above, **plus** the deprecated bare `<stage>` family | when no caller resolves the bare names |
|
|
105
|
+
|
|
106
|
+
Generators outside this repository emit the bare `<stage>` family and the bare `lisa-bootstrap` source profile independently, and scripts and documentation in caller repositories name them directly. Nothing co-ordinates a rename across those repositories, so emitting only the owned names would leave writer and reader disagreeing: a bare `[profile <stage>]` whose `source_profile = lisa-bootstrap` would point at a section that no longer exists, and every call through it would fail to resolve.
|
|
107
|
+
|
|
108
|
+
So the bare names are written as **deprecated aliases beside** the canonical output — never as canonical output — from one bundle in one pass, inside the same owner-tagged block:
|
|
109
|
+
|
|
110
|
+
```ini
|
|
111
|
+
[profile <namespace>-<stage>] # EMITTED — canonical output
|
|
112
|
+
source_profile = <namespace>-lisa-bootstrap
|
|
113
|
+
|
|
114
|
+
[profile <stage>] # RESOLVE-ONLY, DEPRECATED — an alias
|
|
115
|
+
source_profile = lisa-bootstrap # same role, external id and region
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Nothing in the canonical body ever names the bare source profile, so canonical output does not depend on the deprecated half resolving. `emitSourceProfileFor()` is the only thing that names a source profile in canonical output; `RESOLVE_ONLY_LEGACY_SOURCE_PROFILE` is used by the resolve path alone — not to attribute a block either, since ownership comes from the `owner=` marker and a pre-ownership block is attributed by the accounts in its role ARNs.
|
|
119
|
+
|
|
120
|
+
**The resolve window is not a fix.** The bare family is a *single shared slot* on a machine that may serve several projects — the exact collision the owned names remove. On the resolve path that collision is unfixed:
|
|
121
|
+
|
|
122
|
+
- claimed when nothing else holds them, or when they are already this project's;
|
|
123
|
+
- refused **as a set** when another project or an operator's own section holds any part of them — a half-written family whose source profile belongs to someone else resolves into that project's account;
|
|
124
|
+
- reported by name on every run when refused, because a caller that has not migrated still uses those names and would otherwise get another project's account while reporting success;
|
|
125
|
+
- `LISA_SECRETS_CLAIM_LEGACY_PROFILES=1` takes them deliberately.
|
|
126
|
+
|
|
127
|
+
The emitted names are always written and always correct; refusing an alias never withholds them. The bare names are correct for at most one project per machine.
|
|
128
|
+
|
|
129
|
+
**Removal condition, stated against the resolve path** — the only half that expires. Delete the resolve-only half once nothing outside this repository *resolves* the bare family, i.e. no caller names `<stage>` or `lisa-bootstrap` when selecting a profile. A caller proves it by selecting only `<namespace>-<stage>`; `LISA_SECRETS_NO_LEGACY_PROFILES=1` opts one out ahead of the removal and takes the isolation immediately. The emit path is unaffected by that deletion, because it never used the deprecated name.
|
|
130
|
+
|
|
76
131
|
## Configuration
|
|
77
132
|
|
|
78
133
|
```json
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
* @module aws-bootstrap
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { assertOwner } from "./owner.mjs";
|
|
32
|
+
|
|
31
33
|
/** The names every AWS SDK and the CLI actually read. */
|
|
32
34
|
const ACCESS_KEY = "AWS_ACCESS_KEY_ID";
|
|
33
35
|
const SECRET_KEY = "AWS_SECRET_ACCESS_KEY";
|
|
@@ -40,13 +42,93 @@ const PROFILE = "AWS_PROFILE";
|
|
|
40
42
|
export const BOOTSTRAP_KEY = "LISA_AWS_BOOTSTRAP_JSON";
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
|
-
*
|
|
45
|
+
* EMIT vs RESOLVE — read this before touching either name below.
|
|
46
|
+
*
|
|
47
|
+
* These are two different guarantees, they expire on two different conditions,
|
|
48
|
+
* and conflating them is how a temporary shim becomes permanent by accident.
|
|
49
|
+
*
|
|
50
|
+
* **EMIT** is what this writer produces as its canonical output. Exactly one
|
|
51
|
+
* shape is ever emitted as canonical: `<owner>-<stage>`, assuming from
|
|
52
|
+
* `<owner>-lisa-bootstrap`. That is the fix for the cross-project collision and
|
|
53
|
+
* it has no expiry.
|
|
54
|
+
*
|
|
55
|
+
* **RESOLVE** is the wider set of names that must keep working for READERS
|
|
56
|
+
* during a migration. It currently includes the bare `<stage>` family, which
|
|
57
|
+
* this writer emits only as a deprecated alias beside the canonical output —
|
|
58
|
+
* never as canonical output itself.
|
|
59
|
+
*
|
|
60
|
+
* A reader who mistakes the second for the first will conclude that Lisa still
|
|
61
|
+
* emits bare names and that the rename never happened. It did: every canonical
|
|
62
|
+
* name is owned, and `emitSourceProfileFor` is the only thing that names a
|
|
63
|
+
* source profile in canonical output.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The tail of the EMITTED profile that holds the key pair and is assumed FROM.
|
|
44
68
|
*
|
|
45
69
|
* Named rather than `default` so it can never be picked up by accident: this
|
|
46
70
|
* identity can assume roles and do nothing else, so a call that silently ran as
|
|
47
71
|
* it would fail with a permissions error far from its cause.
|
|
72
|
+
*
|
|
73
|
+
* A SUFFIX rather than the whole name, because one fixed name is one shared
|
|
74
|
+
* slot. Two tenants on a workstation both wrote `[lisa-bootstrap]`, so the
|
|
75
|
+
* second run's key pair replaced the first's and the first tenant's profiles
|
|
76
|
+
* then assumed their roles from the second tenant's identity — which either
|
|
77
|
+
* fails confusingly or, where a trust policy is permissive, succeeds.
|
|
48
78
|
*/
|
|
49
|
-
export const
|
|
79
|
+
export const EMITTED_SOURCE_PROFILE_SUFFIX = "lisa-bootstrap";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A name kept RESOLVING for unmigrated readers. Never emitted as canonical.
|
|
83
|
+
* **DEPRECATED — this constant exists only to be deleted.**
|
|
84
|
+
*
|
|
85
|
+
* Generators outside this repository emit the bare `<stage>` profile family and
|
|
86
|
+
* this bare source profile independently, and scripts and documentation in
|
|
87
|
+
* caller repositories name them directly. Nothing co-ordinates a rename across
|
|
88
|
+
* those repositories, so switching this writer to the owned names alone would
|
|
89
|
+
* leave writer and reader disagreeing: a bare `[profile <stage>]` whose
|
|
90
|
+
* `source_profile = lisa-bootstrap` would point at a section that no longer
|
|
91
|
+
* exists, and every call through it would fail to resolve.
|
|
92
|
+
*
|
|
93
|
+
* So the owned names are EMITTED as canonical and these bare names are kept
|
|
94
|
+
* RESOLVING beside them, both generated from one bundle in one pass. This
|
|
95
|
+
* constant is never used to decide what canonical output looks like, and it is
|
|
96
|
+
* not used to attribute a block either — ownership is read from the `owner=`
|
|
97
|
+
* marker tag, and a pre-ownership block is attributed by the accounts in its
|
|
98
|
+
* role ARNs. Its only job is the resolve path.
|
|
99
|
+
*
|
|
100
|
+
* **The window is not a fix, and this comment must not read like one.** The
|
|
101
|
+
* bare family is a single shared slot on a machine that may serve several
|
|
102
|
+
* projects — which is precisely the collision the owned names remove. During
|
|
103
|
+
* the window that collision is unfixed ON THE RESOLVE PATH ONLY: these names
|
|
104
|
+
* are claimed when unclaimed or already ours, and refused by name when another
|
|
105
|
+
* project holds them, so the failure is loud rather than silent, but two
|
|
106
|
+
* projects still cannot both have them. The emitted names are always correct;
|
|
107
|
+
* these are correct for at most one project per machine.
|
|
108
|
+
*
|
|
109
|
+
* **Removal condition — stated against the RESOLVE path, because that is the
|
|
110
|
+
* only thing that expires.** Delete this constant and every use of it once no
|
|
111
|
+
* caller repository *resolves* the bare family: that is, once nothing outside
|
|
112
|
+
* this repository names `<stage>` or `lisa-bootstrap` when selecting a profile.
|
|
113
|
+
* A caller proves it no longer resolves them by selecting only
|
|
114
|
+
* `<namespace>-<stage>`; `LISA_SECRETS_NO_LEGACY_PROFILES=1` lets one opt out
|
|
115
|
+
* ahead of the removal and take the isolation immediately. Nothing about the
|
|
116
|
+
* emit path changes when this is deleted, because the emit path never used it.
|
|
117
|
+
*/
|
|
118
|
+
export const RESOLVE_ONLY_LEGACY_SOURCE_PROFILE = EMITTED_SOURCE_PROFILE_SUFFIX;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The source profile this writer EMITS for one owner, as canonical output.
|
|
122
|
+
*
|
|
123
|
+
* The only function that names a source profile in canonical output. If a
|
|
124
|
+
* canonical section ever assumes from something this did not return, the emit
|
|
125
|
+
* path has regressed.
|
|
126
|
+
* @param {string} owner Validated owner.
|
|
127
|
+
* @returns {string} The owner's emitted source-profile name.
|
|
128
|
+
*/
|
|
129
|
+
export function emitSourceProfileFor(owner) {
|
|
130
|
+
return `${owner}-${EMITTED_SOURCE_PROFILE_SUFFIX}`;
|
|
131
|
+
}
|
|
50
132
|
|
|
51
133
|
/**
|
|
52
134
|
* Read the bundle's `profiles`, which may be an object OR a JSON string.
|
|
@@ -86,11 +168,27 @@ export function readProfiles(bundle) {
|
|
|
86
168
|
* environment credentials OUTRANK `AWS_PROFILE`, so a session that exports them
|
|
87
169
|
* ignores whichever profile it selects — the profiles would exist and never be
|
|
88
170
|
* used.
|
|
171
|
+
*
|
|
172
|
+
* Every name is prefixed with the OWNER. The bundle's keys name a stage and
|
|
173
|
+
* nothing else, and the kit deliberately keeps role names identical across
|
|
174
|
+
* accounts so profiles differ only by account id — correct within one tenant,
|
|
175
|
+
* and the removal of the only distinguishing feature across two. Prefixing
|
|
176
|
+
* makes the wrong resolution impossible rather than merely detectable, which is
|
|
177
|
+
* the same fix #3440 applied to the sibling remote-agent writer; a workstation
|
|
178
|
+
* carrying both now sees one convention instead of two.
|
|
179
|
+
*
|
|
180
|
+
* The `resolveOnly` half is rendered SEPARATELY, and returned rather than merged,
|
|
181
|
+
* so
|
|
182
|
+
* the installer can drop it as a unit after looking at the filesystem — which
|
|
183
|
+
* this function deliberately cannot see. See `RESOLVE_ONLY_LEGACY_SOURCE_PROFILE` for why
|
|
184
|
+
* that half exists at all and when it goes away.
|
|
89
185
|
* @param {object} bundle Parsed bootstrap bundle.
|
|
90
|
-
* @
|
|
91
|
-
*
|
|
186
|
+
* @param {string} owner Tenant these profiles belong to.
|
|
187
|
+
* @returns {{credentials: string, config: string, profiles: string[], resolveOnly: {credentials: string, config: string, profiles: string[], sourceProfile: string}}|null}
|
|
188
|
+
* Rendered file contents, the profile names, and the deprecated
|
|
189
|
+
* bare-named half; or null when the bundle is unusable.
|
|
92
190
|
*/
|
|
93
|
-
export function renderAwsProfiles(bundle) {
|
|
191
|
+
export function renderAwsProfiles(bundle, owner) {
|
|
94
192
|
if (!bundle) return null;
|
|
95
193
|
|
|
96
194
|
const accessKeyId = bundle.accessKeyId ?? bundle.aws_access_key_id;
|
|
@@ -98,10 +196,15 @@ export function renderAwsProfiles(bundle) {
|
|
|
98
196
|
bundle.secretAccessKey ?? bundle.aws_secret_access_key;
|
|
99
197
|
if (!accessKeyId || !secretAccessKey) return null;
|
|
100
198
|
|
|
199
|
+
// Checked AFTER the bundle is known usable, so a surface with no bundle at
|
|
200
|
+
// all still returns null rather than failing over a missing owner it was
|
|
201
|
+
// never going to use.
|
|
202
|
+
const scope = assertOwner(owner, "~/.aws");
|
|
203
|
+
const sourceProfile = emitSourceProfileFor(scope);
|
|
101
204
|
const profiles = readProfiles(bundle);
|
|
102
205
|
|
|
103
206
|
const credentials = [
|
|
104
|
-
`[${
|
|
207
|
+
`[${sourceProfile}]`,
|
|
105
208
|
`aws_access_key_id = ${accessKeyId}`,
|
|
106
209
|
`aws_secret_access_key = ${secretAccessKey}`,
|
|
107
210
|
"",
|
|
@@ -109,28 +212,105 @@ export function renderAwsProfiles(bundle) {
|
|
|
109
212
|
|
|
110
213
|
const sections = [];
|
|
111
214
|
const names = [];
|
|
112
|
-
|
|
215
|
+
const resolveOnlySections = [];
|
|
216
|
+
const resolveOnlyNames = [];
|
|
217
|
+
for (const [stage, entry] of Object.entries(profiles)) {
|
|
113
218
|
const roleArn = entry?.roleArn ?? entry?.role_arn;
|
|
114
219
|
if (!roleArn) continue;
|
|
115
220
|
|
|
221
|
+
assertDeclaredAccount(stage, entry, roleArn);
|
|
222
|
+
|
|
223
|
+
const name = `${scope}-${stage}`;
|
|
116
224
|
// A name is only usable if it can be written as an ini section header and
|
|
117
225
|
// read back as the same string. Anything with a bracket or newline would
|
|
118
226
|
// either truncate or inject extra lines into ~/.aws/config, and a config
|
|
119
|
-
// file this corrupts is worse than one it never wrote.
|
|
227
|
+
// file this corrupts is worse than one it never wrote. Checked on the FULL
|
|
228
|
+
// name, since that is the string that becomes the header.
|
|
120
229
|
if (!/^[\w.@-]+$/.test(name)) continue;
|
|
121
230
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (entry.region) lines.push(`region = ${entry.region}`);
|
|
126
|
-
sections.push(`${lines.join("\n")}\n`);
|
|
231
|
+
sections.push(
|
|
232
|
+
`${profileSection(name, roleArn, sourceProfile, bundle, entry)}\n`
|
|
233
|
+
);
|
|
127
234
|
// Collected here, where the name is already in scope. Recovering it by
|
|
128
235
|
// re-parsing the rendered text made the header format load-bearing: a
|
|
129
236
|
// change to it would silently corrupt every returned name.
|
|
130
237
|
names.push(name);
|
|
238
|
+
|
|
239
|
+
// The deprecated twin, generated from the SAME bundle in the same pass.
|
|
240
|
+
// Regenerating both from one source is what makes them incapable of
|
|
241
|
+
// drifting apart; a hand-maintained second copy would be a new defect.
|
|
242
|
+
if (!/^[\w.@-]+$/.test(stage)) continue;
|
|
243
|
+
resolveOnlySections.push(
|
|
244
|
+
`${profileSection(stage, roleArn, RESOLVE_ONLY_LEGACY_SOURCE_PROFILE, bundle, entry)}\n`
|
|
245
|
+
);
|
|
246
|
+
resolveOnlyNames.push(stage);
|
|
131
247
|
}
|
|
132
248
|
|
|
133
|
-
return {
|
|
249
|
+
return {
|
|
250
|
+
credentials,
|
|
251
|
+
config: sections.join("\n"),
|
|
252
|
+
profiles: names,
|
|
253
|
+
resolveOnly: {
|
|
254
|
+
credentials: [
|
|
255
|
+
`[${RESOLVE_ONLY_LEGACY_SOURCE_PROFILE}]`,
|
|
256
|
+
`aws_access_key_id = ${accessKeyId}`,
|
|
257
|
+
`aws_secret_access_key = ${secretAccessKey}`,
|
|
258
|
+
"",
|
|
259
|
+
].join("\n"),
|
|
260
|
+
config: resolveOnlySections.join("\n"),
|
|
261
|
+
profiles: resolveOnlyNames,
|
|
262
|
+
sourceProfile: RESOLVE_ONLY_LEGACY_SOURCE_PROFILE,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* One `[profile …]` section.
|
|
269
|
+
*
|
|
270
|
+
* Shared by the canonical and the deprecated renderer so the two cannot differ
|
|
271
|
+
* in anything but their names — the resolve-only profile must reach the same
|
|
272
|
+
* role, with the same external id and region, or it is not a shim but a
|
|
273
|
+
* second, subtly different credential.
|
|
274
|
+
* @param {string} name Section name.
|
|
275
|
+
* @param {string} roleArn The role to assume.
|
|
276
|
+
* @param {string} sourceProfile The profile holding the key pair.
|
|
277
|
+
* @param {object} bundle The bundle, for `externalId`.
|
|
278
|
+
* @param {object} entry The stage entry, for `region`.
|
|
279
|
+
* @returns {string} The rendered section.
|
|
280
|
+
*/
|
|
281
|
+
function profileSection(name, roleArn, sourceProfile, bundle, entry) {
|
|
282
|
+
const lines = [`[profile ${name}]`, `role_arn = ${roleArn}`];
|
|
283
|
+
lines.push(`source_profile = ${sourceProfile}`);
|
|
284
|
+
if (bundle.externalId) lines.push(`external_id = ${bundle.externalId}`);
|
|
285
|
+
if (entry.region) lines.push(`region = ${entry.region}`);
|
|
286
|
+
return lines.join("\n");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Refuse a stage whose declared account contradicts its own role ARN.
|
|
291
|
+
*
|
|
292
|
+
* This writer never contacts AWS — it runs during container setup, before a
|
|
293
|
+
* task exists and often before network policy would allow the call — so the
|
|
294
|
+
* live `sts:GetCallerIdentity` comparison #3440 added is not available here.
|
|
295
|
+
* What IS available is the bundle's own statement about itself: a stage may
|
|
296
|
+
* declare `expectedAccountId`, and a declaration that disagrees with the
|
|
297
|
+
* account embedded in the role ARN is a bundle that cannot be right either way.
|
|
298
|
+
* Catching it before the write costs nothing and is the only identity assertion
|
|
299
|
+
* this surface can make offline.
|
|
300
|
+
* @param {string} stage The bundle key.
|
|
301
|
+
* @param {object} entry The stage's entry.
|
|
302
|
+
* @param {string} roleArn The role ARN about to be written.
|
|
303
|
+
*/
|
|
304
|
+
function assertDeclaredAccount(stage, entry, roleArn) {
|
|
305
|
+
const declared = entry?.expectedAccountId;
|
|
306
|
+
if (!declared) return;
|
|
307
|
+
const inArn = String(roleArn).split(":")[4];
|
|
308
|
+
if (String(declared) === inArn) return;
|
|
309
|
+
throw new Error(
|
|
310
|
+
`bootstrap stage "${stage}" declares expectedAccountId ${declared}, but ` +
|
|
311
|
+
`its roleArn names account ${inArn}. Refusing to write a profile whose ` +
|
|
312
|
+
`own bundle contradicts itself; nothing was changed.`
|
|
313
|
+
);
|
|
134
314
|
}
|
|
135
315
|
|
|
136
316
|
/**
|
|
@@ -157,10 +337,16 @@ export function parseBootstrap(raw) {
|
|
|
157
337
|
*
|
|
158
338
|
* Returns only additions; the caller merges. Nothing here mutates its input,
|
|
159
339
|
* so a caller can decide what to do with the result — including reporting it.
|
|
340
|
+
* Takes the owner for one reason: `AWS_PROFILE` must name a profile that was
|
|
341
|
+
* actually written. Prefixing the written names without prefixing the derived
|
|
342
|
+
* selection would set `AWS_PROFILE=agent-dev` against a config that now defines
|
|
343
|
+
* `acme-agent-dev`, reintroducing the "profile not found" failure this module
|
|
344
|
+
* exists to remove.
|
|
160
345
|
* @param {Map<string, {value: string}>} selected Secrets by exact name.
|
|
346
|
+
* @param {string} owner Tenant these secrets belong to.
|
|
161
347
|
* @returns {Map<string, {value: string, note: string}>} Derived variables.
|
|
162
348
|
*/
|
|
163
|
-
export function deriveAwsEnvironment(selected) {
|
|
349
|
+
export function deriveAwsEnvironment(selected, owner) {
|
|
164
350
|
const derived = new Map();
|
|
165
351
|
const bundle = parseBootstrap(selected.get(BOOTSTRAP_KEY)?.value);
|
|
166
352
|
if (!bundle) return derived;
|
|
@@ -195,7 +381,7 @@ export function deriveAwsEnvironment(selected) {
|
|
|
195
381
|
// that ~/.aws/config never contains — an entry with no roleArn, or a name too
|
|
196
382
|
// exotic to be an ini header — fails with "profile not found", which is the
|
|
197
383
|
// exact failure this whole change removes.
|
|
198
|
-
const names = renderAwsProfiles(bundle)?.profiles ?? [];
|
|
384
|
+
const names = renderAwsProfiles(bundle, owner)?.profiles ?? [];
|
|
199
385
|
|
|
200
386
|
// No usable profiles is not a reason to hand back a session with NOTHING.
|
|
201
387
|
//
|