@codyswann/lisa 2.331.1 → 2.331.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +12 -3
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/package.json +1 -1
  5. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  6. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +102 -0
  8. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +25 -2
  9. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +22 -1
  10. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +26 -2
  11. package/plugins/lisa/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +102 -0
  12. package/plugins/lisa/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +25 -2
  13. package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +22 -1
  14. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +26 -2
  15. package/plugins/lisa-agy/plugin.json +1 -1
  16. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +102 -0
  17. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +25 -2
  18. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +22 -1
  19. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +26 -2
  20. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  23. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +102 -0
  27. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +25 -2
  28. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +22 -1
  29. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +26 -2
  30. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +102 -0
  32. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +25 -2
  33. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +22 -1
  34. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +26 -2
  35. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-expo-agy/plugin.json +1 -1
  38. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  48. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  53. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  58. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-rails-agy/plugin.json +1 -1
  63. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  68. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  73. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/src/base/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +102 -0
  76. package/plugins/src/base/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +25 -2
  77. package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +22 -1
  78. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +26 -2
@@ -56,7 +56,28 @@ export const SURFACES = {
56
56
  "claude-web": {
57
57
  materialized: true,
58
58
  mayWriteValues: true,
59
- materializeAt: "session-start",
59
+ // BOTH, and the two cover disjoint failures rather than duplicating work.
60
+ //
61
+ // The hook alone was the original design, for a real reason: this surface
62
+ // skips its setup script whenever a cached environment exists, so
63
+ // materializing only there would write values once and never refresh them.
64
+ //
65
+ // What that missed is that the hook is PROJECT-scoped — it lives in a
66
+ // repository's `.claude/settings.json` and only loads when Claude Code's
67
+ // project directory is that repository. A cloud environment configured with
68
+ // more than one repository starts in their parent:
69
+ //
70
+ // HOME=/root PWD=/home/user
71
+ // /home/user/backend /home/user/infrastructure
72
+ //
73
+ // `/home/user` is not a project root, so no settings load, no hook
74
+ // registers, and nothing materializes at all. Verified in a real session.
75
+ //
76
+ // So the setup run is the FLOOR — a fresh environment has its secrets even
77
+ // where the hook cannot fire — and the hook is the REFRESH, picking up
78
+ // rotation on resumed sessions that skip setup. A stale secret is
79
+ // recoverable; an absent one means the environment does not work.
80
+ materializeAt: "both",
60
81
  },
61
82
  };
62
83
 
@@ -736,7 +736,7 @@ export function selectPhases(requested, materializeAt) {
736
736
  `unknown --phase "${requested}". Known: ${PHASES.join(", ")}.`
737
737
  );
738
738
  }
739
- if (requested === "secrets" && materializeAt !== "session-start") {
739
+ if (requested === "secrets" && !materializesAtSessionStart(materializeAt)) {
740
740
  // Not an error: the hook is committed to the repository and runs on every
741
741
  // surface the project is ever checked out on. Refusing loudly would make
742
742
  // a correct local session look broken every time it started.
@@ -745,10 +745,34 @@ export function selectPhases(requested, materializeAt) {
745
745
  return [requested];
746
746
  }
747
747
  return PHASES.filter(
748
- phase => phase !== "secrets" || materializeAt === "setup"
748
+ phase => phase !== "secrets" || materializesAtSetup(materializeAt)
749
749
  );
750
750
  }
751
751
 
752
+ /**
753
+ * Whether the setup run itself should materialize.
754
+ *
755
+ * `"both"` means the surface materializes in the setup run AND from the
756
+ * session-start hook, because on that surface either one alone has a hole: the
757
+ * setup run is skipped when a cached environment is reused, and the hook cannot
758
+ * fire when Claude Code's project directory is not the repository — which is
759
+ * every cloud environment configured with more than one repo.
760
+ * @param {string|null} materializeAt Surface capability.
761
+ * @returns {boolean} Whether to materialize during setup.
762
+ */
763
+ export function materializesAtSetup(materializeAt) {
764
+ return materializeAt === "setup" || materializeAt === "both";
765
+ }
766
+
767
+ /**
768
+ * Whether the committed session-start hook should materialize.
769
+ * @param {string|null} materializeAt Surface capability.
770
+ * @returns {boolean} Whether the hook does the work.
771
+ */
772
+ export function materializesAtSessionStart(materializeAt) {
773
+ return materializeAt === "session-start" || materializeAt === "both";
774
+ }
775
+
752
776
  /**
753
777
  * Read the surface the resolver detects, without duplicating its rules.
754
778
  *
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.331.1",
3
+ "version": "2.331.3",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.331.1",
3
+ "version": "2.331.3",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.331.1",
3
+ "version": "2.331.3",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.331.1",
3
+ "version": "2.331.3",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.331.1",
3
+ "version": "2.331.3",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.331.1",
3
+ "version": "2.331.3",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Turn the AWS bootstrap bundle into environment variables the AWS SDKs obey.
3
+ *
4
+ * `LISA_AWS_BOOTSTRAP_JSON` carries the agent's AWS identity as one JSON blob.
5
+ * Materializing it verbatim is not enough, because nothing reads that name:
6
+ * every AWS SDK and the CLI look for `AWS_ACCESS_KEY_ID` and
7
+ * `AWS_SECRET_ACCESS_KEY`, or for a profile.
8
+ *
9
+ * Writing profiles alone is also not enough, and that is the failure this
10
+ * module exists for. A Claude cloud container ships its own
11
+ * `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` in the ambient environment, and
12
+ * **environment variables outrank profile files in the credential chain**. So a
13
+ * session with correctly written `~/.aws/credentials` still failed:
14
+ *
15
+ * aws sts get-caller-identity
16
+ * InvalidClientTokenId: The security token included in the request is invalid.
17
+ *
18
+ * Deriving the pair into the materialized env file fixes it, because that file
19
+ * is sourced into the session and the last export wins. Exporting over a
20
+ * variable this project did not set is a real intrusion, so it is deliberate,
21
+ * narrow, and never silent:
22
+ *
23
+ * - only when the bundle actually parses and carries both halves
24
+ * - never over a value the secret store itself supplies under those names,
25
+ * which is a project stating an explicit intent
26
+ * - recorded in the notes file, so the override is discoverable by the same
27
+ * read-the-note path as any other credential
28
+ * @module aws-bootstrap
29
+ */
30
+
31
+ /** The names every AWS SDK and the CLI actually read. */
32
+ const ACCESS_KEY = "AWS_ACCESS_KEY_ID";
33
+ const SECRET_KEY = "AWS_SECRET_ACCESS_KEY";
34
+ const REGION = "AWS_DEFAULT_REGION";
35
+
36
+ /** The bundle that carries the agent's identity. */
37
+ export const BOOTSTRAP_KEY = "LISA_AWS_BOOTSTRAP_JSON";
38
+
39
+ /**
40
+ * Read the bootstrap bundle, treating anything malformed as absent.
41
+ *
42
+ * A parse failure must not take materialization down with it: every other
43
+ * secret in the run is still valid and still needed, and a session with most of
44
+ * its credentials beats a session with none.
45
+ * @param {string} raw Bundle contents.
46
+ * @returns {object|null} Parsed bundle, or null.
47
+ */
48
+ export function parseBootstrap(raw) {
49
+ if (typeof raw !== "string" || raw.trim() === "") return null;
50
+ try {
51
+ const parsed = JSON.parse(raw);
52
+ return parsed && typeof parsed === "object" ? parsed : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Derive the AWS variables implied by an already-selected secret set.
60
+ *
61
+ * Returns only additions; the caller merges. Nothing here mutates its input,
62
+ * so a caller can decide what to do with the result — including reporting it.
63
+ * @param {Map<string, {value: string}>} selected Secrets by exact name.
64
+ * @returns {Map<string, {value: string, note: string}>} Derived variables.
65
+ */
66
+ export function deriveAwsEnvironment(selected) {
67
+ const derived = new Map();
68
+ const bundle = parseBootstrap(selected.get(BOOTSTRAP_KEY)?.value);
69
+ if (!bundle) return derived;
70
+
71
+ const accessKeyId = bundle.accessKeyId ?? bundle.aws_access_key_id;
72
+ const secretAccessKey =
73
+ bundle.secretAccessKey ?? bundle.aws_secret_access_key;
74
+ // Both halves or neither. Half a credential is not a weaker credential, it is
75
+ // a confusing failure — the SDK reports a signature error rather than a
76
+ // missing one.
77
+ if (!accessKeyId || !secretAccessKey) return derived;
78
+
79
+ // A project that stores these under their own names has said something
80
+ // explicit. Overriding that would be this module guessing against an operator.
81
+ if (selected.has(ACCESS_KEY) || selected.has(SECRET_KEY)) return derived;
82
+
83
+ const note =
84
+ `Derived from ${BOOTSTRAP_KEY} by lisa-secrets-access. Exported ` +
85
+ `deliberately so it overrides any ambient ${ACCESS_KEY} the host injects — ` +
86
+ `environment variables outrank profile files in the AWS credential chain, ` +
87
+ `so without this a stale host value wins and every call fails with ` +
88
+ `InvalidClientTokenId. This is the assume-only bootstrap identity; real ` +
89
+ `work assumes a role from it.`;
90
+
91
+ derived.set(ACCESS_KEY, { value: String(accessKeyId), note });
92
+ derived.set(SECRET_KEY, { value: String(secretAccessKey), note });
93
+
94
+ const region = bundle.region ?? bundle.defaultRegion;
95
+ if (region && !selected.has(REGION)) {
96
+ derived.set(REGION, {
97
+ value: String(region),
98
+ note: `Derived from ${BOOTSTRAP_KEY} by lisa-secrets-access.`,
99
+ });
100
+ }
101
+ return derived;
102
+ }
@@ -27,6 +27,7 @@ import {
27
27
  writeFileSync,
28
28
  } from "node:fs";
29
29
 
30
+ import { deriveAwsEnvironment } from "./aws-bootstrap.mjs";
30
31
  import { renderEnv, renderNotes } from "./envfile.mjs";
31
32
  import { fetchAll } from "./providers.mjs";
32
33
  import { materializedPaths, readConfig } from "./surfaces.mjs";
@@ -74,25 +75,47 @@ export function materialize(cfg = readConfig()) {
74
75
  );
75
76
  }
76
77
 
78
+ // The AWS bundle is stored as one JSON blob under a name no SDK reads, so
79
+ // the variables it implies are derived here. Without them a host-injected
80
+ // AWS_ACCESS_KEY_ID wins — environment variables outrank profile files in the
81
+ // credential chain — and every AWS call fails with InvalidClientTokenId even
82
+ // though the real credential materialized correctly.
83
+ const derived = deriveAwsEnvironment(selected);
84
+ for (const [name, entry] of derived) selected.set(name, entry);
85
+
77
86
  const { dir, valuesFile, notesFile } = materializedPaths(cfg.namespace);
78
87
  mkdirSync(dir, { recursive: true, mode: 0o700 });
79
88
  chmodSync(dir, 0o700);
80
89
  writeAtomic(valuesFile, renderEnv(selected));
81
90
  writeAtomic(notesFile, renderNotes(selected));
82
- return { count: selected.size, dir };
91
+ return { count: selected.size, derived: derived.size, dir };
83
92
  }
84
93
 
85
94
  function main() {
86
95
  const cfg = readConfig();
87
96
  if (process.argv.includes("--dry-run")) {
88
97
  const selected = fetchAll(cfg);
98
+ // Derive here too, or the preview lies in the one place it matters most:
99
+ // the derived names are exactly the ones that override ambient host
100
+ // credentials, so omitting them hides the most surprising effect of the run
101
+ // and reports a smaller count than the real write produces.
102
+ for (const [name, entry] of deriveAwsEnvironment(selected)) {
103
+ selected.set(name, entry);
104
+ }
89
105
  const { dir } = materializedPaths(cfg.namespace);
90
106
  console.log(`would write ${selected.size} secret(s) to ${dir}`);
91
107
  console.log([...selected.keys()].sort().join("\n"));
92
108
  return;
93
109
  }
94
- const { count, dir } = materialize(cfg);
110
+ const { count, derived, dir } = materialize(cfg);
95
111
  console.log(`materialized ${count} secret(s) and their notes into ${dir}`);
112
+ if (derived > 0) {
113
+ // Said out loud: this run exported variables the host may also have set.
114
+ console.log(
115
+ ` ${derived} AWS variable(s) derived from the bootstrap bundle, ` +
116
+ `overriding any ambient value`
117
+ );
118
+ }
96
119
  }
97
120
 
98
121
  if (import.meta.url === `file://${process.argv[1]}`) {
@@ -56,7 +56,28 @@ export const SURFACES = {
56
56
  "claude-web": {
57
57
  materialized: true,
58
58
  mayWriteValues: true,
59
- materializeAt: "session-start",
59
+ // BOTH, and the two cover disjoint failures rather than duplicating work.
60
+ //
61
+ // The hook alone was the original design, for a real reason: this surface
62
+ // skips its setup script whenever a cached environment exists, so
63
+ // materializing only there would write values once and never refresh them.
64
+ //
65
+ // What that missed is that the hook is PROJECT-scoped — it lives in a
66
+ // repository's `.claude/settings.json` and only loads when Claude Code's
67
+ // project directory is that repository. A cloud environment configured with
68
+ // more than one repository starts in their parent:
69
+ //
70
+ // HOME=/root PWD=/home/user
71
+ // /home/user/backend /home/user/infrastructure
72
+ //
73
+ // `/home/user` is not a project root, so no settings load, no hook
74
+ // registers, and nothing materializes at all. Verified in a real session.
75
+ //
76
+ // So the setup run is the FLOOR — a fresh environment has its secrets even
77
+ // where the hook cannot fire — and the hook is the REFRESH, picking up
78
+ // rotation on resumed sessions that skip setup. A stale secret is
79
+ // recoverable; an absent one means the environment does not work.
80
+ materializeAt: "both",
60
81
  },
61
82
  };
62
83
 
@@ -736,7 +736,7 @@ export function selectPhases(requested, materializeAt) {
736
736
  `unknown --phase "${requested}". Known: ${PHASES.join(", ")}.`
737
737
  );
738
738
  }
739
- if (requested === "secrets" && materializeAt !== "session-start") {
739
+ if (requested === "secrets" && !materializesAtSessionStart(materializeAt)) {
740
740
  // Not an error: the hook is committed to the repository and runs on every
741
741
  // surface the project is ever checked out on. Refusing loudly would make
742
742
  // a correct local session look broken every time it started.
@@ -745,10 +745,34 @@ export function selectPhases(requested, materializeAt) {
745
745
  return [requested];
746
746
  }
747
747
  return PHASES.filter(
748
- phase => phase !== "secrets" || materializeAt === "setup"
748
+ phase => phase !== "secrets" || materializesAtSetup(materializeAt)
749
749
  );
750
750
  }
751
751
 
752
+ /**
753
+ * Whether the setup run itself should materialize.
754
+ *
755
+ * `"both"` means the surface materializes in the setup run AND from the
756
+ * session-start hook, because on that surface either one alone has a hole: the
757
+ * setup run is skipped when a cached environment is reused, and the hook cannot
758
+ * fire when Claude Code's project directory is not the repository — which is
759
+ * every cloud environment configured with more than one repo.
760
+ * @param {string|null} materializeAt Surface capability.
761
+ * @returns {boolean} Whether to materialize during setup.
762
+ */
763
+ export function materializesAtSetup(materializeAt) {
764
+ return materializeAt === "setup" || materializeAt === "both";
765
+ }
766
+
767
+ /**
768
+ * Whether the committed session-start hook should materialize.
769
+ * @param {string|null} materializeAt Surface capability.
770
+ * @returns {boolean} Whether the hook does the work.
771
+ */
772
+ export function materializesAtSessionStart(materializeAt) {
773
+ return materializeAt === "session-start" || materializeAt === "both";
774
+ }
775
+
752
776
  /**
753
777
  * Read the surface the resolver detects, without duplicating its rules.
754
778
  *
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.331.1",
3
+ "version": "2.331.3",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Turn the AWS bootstrap bundle into environment variables the AWS SDKs obey.
3
+ *
4
+ * `LISA_AWS_BOOTSTRAP_JSON` carries the agent's AWS identity as one JSON blob.
5
+ * Materializing it verbatim is not enough, because nothing reads that name:
6
+ * every AWS SDK and the CLI look for `AWS_ACCESS_KEY_ID` and
7
+ * `AWS_SECRET_ACCESS_KEY`, or for a profile.
8
+ *
9
+ * Writing profiles alone is also not enough, and that is the failure this
10
+ * module exists for. A Claude cloud container ships its own
11
+ * `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` in the ambient environment, and
12
+ * **environment variables outrank profile files in the credential chain**. So a
13
+ * session with correctly written `~/.aws/credentials` still failed:
14
+ *
15
+ * aws sts get-caller-identity
16
+ * InvalidClientTokenId: The security token included in the request is invalid.
17
+ *
18
+ * Deriving the pair into the materialized env file fixes it, because that file
19
+ * is sourced into the session and the last export wins. Exporting over a
20
+ * variable this project did not set is a real intrusion, so it is deliberate,
21
+ * narrow, and never silent:
22
+ *
23
+ * - only when the bundle actually parses and carries both halves
24
+ * - never over a value the secret store itself supplies under those names,
25
+ * which is a project stating an explicit intent
26
+ * - recorded in the notes file, so the override is discoverable by the same
27
+ * read-the-note path as any other credential
28
+ * @module aws-bootstrap
29
+ */
30
+
31
+ /** The names every AWS SDK and the CLI actually read. */
32
+ const ACCESS_KEY = "AWS_ACCESS_KEY_ID";
33
+ const SECRET_KEY = "AWS_SECRET_ACCESS_KEY";
34
+ const REGION = "AWS_DEFAULT_REGION";
35
+
36
+ /** The bundle that carries the agent's identity. */
37
+ export const BOOTSTRAP_KEY = "LISA_AWS_BOOTSTRAP_JSON";
38
+
39
+ /**
40
+ * Read the bootstrap bundle, treating anything malformed as absent.
41
+ *
42
+ * A parse failure must not take materialization down with it: every other
43
+ * secret in the run is still valid and still needed, and a session with most of
44
+ * its credentials beats a session with none.
45
+ * @param {string} raw Bundle contents.
46
+ * @returns {object|null} Parsed bundle, or null.
47
+ */
48
+ export function parseBootstrap(raw) {
49
+ if (typeof raw !== "string" || raw.trim() === "") return null;
50
+ try {
51
+ const parsed = JSON.parse(raw);
52
+ return parsed && typeof parsed === "object" ? parsed : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Derive the AWS variables implied by an already-selected secret set.
60
+ *
61
+ * Returns only additions; the caller merges. Nothing here mutates its input,
62
+ * so a caller can decide what to do with the result — including reporting it.
63
+ * @param {Map<string, {value: string}>} selected Secrets by exact name.
64
+ * @returns {Map<string, {value: string, note: string}>} Derived variables.
65
+ */
66
+ export function deriveAwsEnvironment(selected) {
67
+ const derived = new Map();
68
+ const bundle = parseBootstrap(selected.get(BOOTSTRAP_KEY)?.value);
69
+ if (!bundle) return derived;
70
+
71
+ const accessKeyId = bundle.accessKeyId ?? bundle.aws_access_key_id;
72
+ const secretAccessKey =
73
+ bundle.secretAccessKey ?? bundle.aws_secret_access_key;
74
+ // Both halves or neither. Half a credential is not a weaker credential, it is
75
+ // a confusing failure — the SDK reports a signature error rather than a
76
+ // missing one.
77
+ if (!accessKeyId || !secretAccessKey) return derived;
78
+
79
+ // A project that stores these under their own names has said something
80
+ // explicit. Overriding that would be this module guessing against an operator.
81
+ if (selected.has(ACCESS_KEY) || selected.has(SECRET_KEY)) return derived;
82
+
83
+ const note =
84
+ `Derived from ${BOOTSTRAP_KEY} by lisa-secrets-access. Exported ` +
85
+ `deliberately so it overrides any ambient ${ACCESS_KEY} the host injects — ` +
86
+ `environment variables outrank profile files in the AWS credential chain, ` +
87
+ `so without this a stale host value wins and every call fails with ` +
88
+ `InvalidClientTokenId. This is the assume-only bootstrap identity; real ` +
89
+ `work assumes a role from it.`;
90
+
91
+ derived.set(ACCESS_KEY, { value: String(accessKeyId), note });
92
+ derived.set(SECRET_KEY, { value: String(secretAccessKey), note });
93
+
94
+ const region = bundle.region ?? bundle.defaultRegion;
95
+ if (region && !selected.has(REGION)) {
96
+ derived.set(REGION, {
97
+ value: String(region),
98
+ note: `Derived from ${BOOTSTRAP_KEY} by lisa-secrets-access.`,
99
+ });
100
+ }
101
+ return derived;
102
+ }
@@ -27,6 +27,7 @@ import {
27
27
  writeFileSync,
28
28
  } from "node:fs";
29
29
 
30
+ import { deriveAwsEnvironment } from "./aws-bootstrap.mjs";
30
31
  import { renderEnv, renderNotes } from "./envfile.mjs";
31
32
  import { fetchAll } from "./providers.mjs";
32
33
  import { materializedPaths, readConfig } from "./surfaces.mjs";
@@ -74,25 +75,47 @@ export function materialize(cfg = readConfig()) {
74
75
  );
75
76
  }
76
77
 
78
+ // The AWS bundle is stored as one JSON blob under a name no SDK reads, so
79
+ // the variables it implies are derived here. Without them a host-injected
80
+ // AWS_ACCESS_KEY_ID wins — environment variables outrank profile files in the
81
+ // credential chain — and every AWS call fails with InvalidClientTokenId even
82
+ // though the real credential materialized correctly.
83
+ const derived = deriveAwsEnvironment(selected);
84
+ for (const [name, entry] of derived) selected.set(name, entry);
85
+
77
86
  const { dir, valuesFile, notesFile } = materializedPaths(cfg.namespace);
78
87
  mkdirSync(dir, { recursive: true, mode: 0o700 });
79
88
  chmodSync(dir, 0o700);
80
89
  writeAtomic(valuesFile, renderEnv(selected));
81
90
  writeAtomic(notesFile, renderNotes(selected));
82
- return { count: selected.size, dir };
91
+ return { count: selected.size, derived: derived.size, dir };
83
92
  }
84
93
 
85
94
  function main() {
86
95
  const cfg = readConfig();
87
96
  if (process.argv.includes("--dry-run")) {
88
97
  const selected = fetchAll(cfg);
98
+ // Derive here too, or the preview lies in the one place it matters most:
99
+ // the derived names are exactly the ones that override ambient host
100
+ // credentials, so omitting them hides the most surprising effect of the run
101
+ // and reports a smaller count than the real write produces.
102
+ for (const [name, entry] of deriveAwsEnvironment(selected)) {
103
+ selected.set(name, entry);
104
+ }
89
105
  const { dir } = materializedPaths(cfg.namespace);
90
106
  console.log(`would write ${selected.size} secret(s) to ${dir}`);
91
107
  console.log([...selected.keys()].sort().join("\n"));
92
108
  return;
93
109
  }
94
- const { count, dir } = materialize(cfg);
110
+ const { count, derived, dir } = materialize(cfg);
95
111
  console.log(`materialized ${count} secret(s) and their notes into ${dir}`);
112
+ if (derived > 0) {
113
+ // Said out loud: this run exported variables the host may also have set.
114
+ console.log(
115
+ ` ${derived} AWS variable(s) derived from the bootstrap bundle, ` +
116
+ `overriding any ambient value`
117
+ );
118
+ }
96
119
  }
97
120
 
98
121
  if (import.meta.url === `file://${process.argv[1]}`) {
@@ -56,7 +56,28 @@ export const SURFACES = {
56
56
  "claude-web": {
57
57
  materialized: true,
58
58
  mayWriteValues: true,
59
- materializeAt: "session-start",
59
+ // BOTH, and the two cover disjoint failures rather than duplicating work.
60
+ //
61
+ // The hook alone was the original design, for a real reason: this surface
62
+ // skips its setup script whenever a cached environment exists, so
63
+ // materializing only there would write values once and never refresh them.
64
+ //
65
+ // What that missed is that the hook is PROJECT-scoped — it lives in a
66
+ // repository's `.claude/settings.json` and only loads when Claude Code's
67
+ // project directory is that repository. A cloud environment configured with
68
+ // more than one repository starts in their parent:
69
+ //
70
+ // HOME=/root PWD=/home/user
71
+ // /home/user/backend /home/user/infrastructure
72
+ //
73
+ // `/home/user` is not a project root, so no settings load, no hook
74
+ // registers, and nothing materializes at all. Verified in a real session.
75
+ //
76
+ // So the setup run is the FLOOR — a fresh environment has its secrets even
77
+ // where the hook cannot fire — and the hook is the REFRESH, picking up
78
+ // rotation on resumed sessions that skip setup. A stale secret is
79
+ // recoverable; an absent one means the environment does not work.
80
+ materializeAt: "both",
60
81
  },
61
82
  };
62
83
 
@@ -736,7 +736,7 @@ export function selectPhases(requested, materializeAt) {
736
736
  `unknown --phase "${requested}". Known: ${PHASES.join(", ")}.`
737
737
  );
738
738
  }
739
- if (requested === "secrets" && materializeAt !== "session-start") {
739
+ if (requested === "secrets" && !materializesAtSessionStart(materializeAt)) {
740
740
  // Not an error: the hook is committed to the repository and runs on every
741
741
  // surface the project is ever checked out on. Refusing loudly would make
742
742
  // a correct local session look broken every time it started.
@@ -745,10 +745,34 @@ export function selectPhases(requested, materializeAt) {
745
745
  return [requested];
746
746
  }
747
747
  return PHASES.filter(
748
- phase => phase !== "secrets" || materializeAt === "setup"
748
+ phase => phase !== "secrets" || materializesAtSetup(materializeAt)
749
749
  );
750
750
  }
751
751
 
752
+ /**
753
+ * Whether the setup run itself should materialize.
754
+ *
755
+ * `"both"` means the surface materializes in the setup run AND from the
756
+ * session-start hook, because on that surface either one alone has a hole: the
757
+ * setup run is skipped when a cached environment is reused, and the hook cannot
758
+ * fire when Claude Code's project directory is not the repository — which is
759
+ * every cloud environment configured with more than one repo.
760
+ * @param {string|null} materializeAt Surface capability.
761
+ * @returns {boolean} Whether to materialize during setup.
762
+ */
763
+ export function materializesAtSetup(materializeAt) {
764
+ return materializeAt === "setup" || materializeAt === "both";
765
+ }
766
+
767
+ /**
768
+ * Whether the committed session-start hook should materialize.
769
+ * @param {string|null} materializeAt Surface capability.
770
+ * @returns {boolean} Whether the hook does the work.
771
+ */
772
+ export function materializesAtSessionStart(materializeAt) {
773
+ return materializeAt === "session-start" || materializeAt === "both";
774
+ }
775
+
752
776
  /**
753
777
  * Read the surface the resolver detects, without duplicating its rules.
754
778
  *
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.331.1",
3
+ "version": "2.331.3",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.331.1",
3
+ "version": "2.331.3",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.331.1",
3
+ "version": "2.331.3",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.331.1",
3
+ "version": "2.331.3",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.331.1",
3
+ "version": "2.331.3",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"