@codyswann/lisa 2.338.5 → 2.340.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.
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 +20 -2
  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/bootstrap-store.mjs +199 -0
  8. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
  9. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/providers.mjs +21 -3
  10. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +61 -3
  11. package/plugins/lisa/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +199 -0
  12. package/plugins/lisa/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
  13. package/plugins/lisa/skills/lisa-secrets-access/scripts/providers.mjs +21 -3
  14. package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +61 -3
  15. package/plugins/lisa-agy/plugin.json +1 -1
  16. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +199 -0
  17. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
  18. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/providers.mjs +21 -3
  19. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +61 -3
  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/bootstrap-store.mjs +199 -0
  27. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
  28. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/providers.mjs +21 -3
  29. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +61 -3
  30. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +199 -0
  32. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
  33. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/providers.mjs +21 -3
  34. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +61 -3
  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/bootstrap-store.mjs +199 -0
  76. package/plugins/src/base/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
  77. package/plugins/src/base/skills/lisa-secrets-access/scripts/providers.mjs +21 -3
  78. package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +61 -3
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.338.5",
3
+ "version": "2.340.0",
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.338.5",
3
+ "version": "2.340.0",
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,199 @@
1
+ /**
2
+ * Store the one credential that unlocks the provider, on the machine that needs
3
+ * it.
4
+ *
5
+ * Reading it already existed; writing it did not, so every operator ran a
6
+ * platform-specific `security add-generic-password` copied from documentation.
7
+ * That is a command with a credential in it, typed by hand, on the one value
8
+ * whose compromise costs every other secret.
9
+ *
10
+ * **Two stores, because there is no third option.** macOS has a keychain that
11
+ * is encrypted at rest and unlocked with the login session. Linux has no
12
+ * equivalent that can be assumed present — libsecret needs a running daemon and
13
+ * a desktop session, which a server or a container does not have. So the
14
+ * fallback is a `0600` file under `$XDG_CONFIG_HOME`, which is the same
15
+ * protection the materialized secrets file already relies on. It is weaker than
16
+ * a keychain and it is stated as such, rather than pretending the two are equal.
17
+ * @module bootstrap-store
18
+ */
19
+
20
+ import { execFileSync } from "node:child_process";
21
+ import {
22
+ chmodSync,
23
+ existsSync,
24
+ mkdirSync,
25
+ readFileSync,
26
+ renameSync,
27
+ rmSync,
28
+ writeFileSync,
29
+ } from "node:fs";
30
+ import { homedir } from "node:os";
31
+ import { dirname, join } from "node:path";
32
+
33
+ /**
34
+ * Where a file-backed bootstrap lives, for platforms with no keychain.
35
+ * @param {string} key Bootstrap variable name.
36
+ * @param {Record<string, string|undefined>} [env] Environment to read.
37
+ * @returns {string} Absolute path.
38
+ */
39
+ export function bootstrapFile(key, env = process.env) {
40
+ const root = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
41
+ return join(root, "lisa", "bootstrap", assertKey(key));
42
+ }
43
+
44
+ /**
45
+ * Reject a key that is not exactly one safe path segment.
46
+ *
47
+ * The key is joined onto a directory, and every entry point — store, clear and
48
+ * read — routes through that join. A `/` or a `..` in it escapes the bootstrap
49
+ * directory, so an unvalidated key can write, delete, or read an arbitrary file
50
+ * as the operator. Validating in `bootstrapFile` covers all three at once
51
+ * rather than trusting each caller to remember.
52
+ *
53
+ * Same guard `assertNamespace` applies to the namespace, and for the same
54
+ * reason. The character set is what a variable name can hold anyway, so nothing
55
+ * legitimate is refused.
56
+ * @param {string} key Candidate bootstrap variable name.
57
+ * @returns {string} The key, unchanged, when valid.
58
+ */
59
+ export function assertKey(key) {
60
+ if (
61
+ typeof key !== "string" ||
62
+ !/^[A-Za-z0-9._-]+$/.test(key) ||
63
+ key === ".."
64
+ ) {
65
+ throw new Error(
66
+ `bootstrap key must be one safe path segment, got ${JSON.stringify(key)}`
67
+ );
68
+ }
69
+ return key;
70
+ }
71
+
72
+ /**
73
+ * Quote a word for `security -i`, which tokenizes its stdin like a shell.
74
+ *
75
+ * A token is usually base64-ish and needs no quoting, but "usually" is not a
76
+ * property to rely on for a credential: an unquoted value containing a space
77
+ * would be parsed as two arguments and silently store only the first part.
78
+ * A newline is refused outright rather than escaped, because it terminates the
79
+ * command line and there is no quoting that survives it.
80
+ * @param {string} word Value to quote.
81
+ * @returns {string} A single quoted token.
82
+ */
83
+ function quote(word) {
84
+ if (/[\n\r]/.test(word)) {
85
+ throw new Error("bootstrap value must not contain a newline");
86
+ }
87
+ return `"${String(word).replace(/(["\\])/g, "\\$1")}"`;
88
+ }
89
+
90
+ /**
91
+ * Which store this platform uses.
92
+ * @param {string} [platform] Platform name, injectable for tests.
93
+ * @returns {"keychain"|"file"} The store.
94
+ */
95
+ export function storeKind(platform = process.platform) {
96
+ return platform === "darwin" ? "keychain" : "file";
97
+ }
98
+
99
+ /**
100
+ * Write the bootstrap where this machine's sessions will look for it.
101
+ *
102
+ * The value never reaches a command line on either path. The keychain path
103
+ * feeds `security -i` a command stream on **stdin**, because an argument is
104
+ * visible in `ps` to every process running as this user for as long as the call
105
+ * lasts. The file path writes and chmods a temporary before renaming, so the
106
+ * credential is never briefly world-readable on a filesystem where the default
107
+ * umask would have made it so.
108
+ * @param {string} key Bootstrap variable name.
109
+ * @param {string} value The token.
110
+ * @param {object} [deps] Injected seams, for tests.
111
+ * @returns {{kind: string, where: string}} Where it went.
112
+ */
113
+ export function storeBootstrap(key, value, deps = {}) {
114
+ const kind = deps.kind ?? storeKind();
115
+ const env = deps.env ?? process.env;
116
+
117
+ if (kind === "keychain") {
118
+ const run = deps.run ?? execFileSync;
119
+ assertKey(key);
120
+ // `security -i` reads a COMMAND STREAM on stdin, which is what keeps the
121
+ // value out of `argv` — where `ps` would show it to every process running
122
+ // as this user for as long as the call lasts.
123
+ //
124
+ // The obvious `-w` with no argument does NOT read the password from stdin.
125
+ // It opens an interactive prompt on the terminal, and with stdin piped it
126
+ // stores an EMPTY value and exits 0 — verified against the real binary,
127
+ // which is how this shipped broken the first time: unit tests injected the
128
+ // runner and never executed it.
129
+ //
130
+ // `-U` updates in place; without it a second run fails with "already
131
+ // exists", making rotation — the common case — the broken one.
132
+ run("security", ["-i"], {
133
+ input: `add-generic-password -U -s ${quote(key)} -a ${quote(
134
+ env.USER ?? ""
135
+ )} -w ${quote(value)}\n`,
136
+ stdio: ["pipe", "ignore", "ignore"],
137
+ });
138
+ return { kind, where: `keychain (service ${key})` };
139
+ }
140
+
141
+ const path = deps.path ?? bootstrapFile(key, env);
142
+ const write = deps.write ?? writeFileSync;
143
+ const chmod = deps.chmod ?? chmodSync;
144
+ const rename = deps.rename ?? renameSync;
145
+ const makeDir = deps.mkdir ?? mkdirSync;
146
+
147
+ makeDir(dirname(path), { recursive: true, mode: 0o700 });
148
+ const temporary = `${path}.tmp`;
149
+ // mode on write applies only when the file is CREATED, so an existing
150
+ // temporary from a crashed run would keep its old permissions. chmod after
151
+ // writing is what makes 0600 true rather than usually true.
152
+ write(temporary, `${value}\n`, { mode: 0o600 });
153
+ chmod(temporary, 0o600);
154
+ rename(temporary, path);
155
+ return { kind, where: path };
156
+ }
157
+
158
+ /**
159
+ * Remove a stored bootstrap, so a rotation can be proven rather than assumed.
160
+ * @param {string} key Bootstrap variable name.
161
+ * @param {object} [deps] Injected seams, for tests.
162
+ */
163
+ export function clearBootstrap(key, deps = {}) {
164
+ const kind = deps.kind ?? storeKind();
165
+ const env = deps.env ?? process.env;
166
+
167
+ if (kind === "keychain") {
168
+ const run = deps.run ?? execFileSync;
169
+ try {
170
+ run(
171
+ "security",
172
+ ["delete-generic-password", "-s", key, "-a", env.USER ?? ""],
173
+ {
174
+ stdio: "ignore",
175
+ }
176
+ );
177
+ } catch {
178
+ // Absent is the desired state, and `security` exits non-zero for it.
179
+ }
180
+ return;
181
+ }
182
+ const remove = deps.remove ?? rmSync;
183
+ remove(deps.path ?? bootstrapFile(key, env), { force: true });
184
+ }
185
+
186
+ /**
187
+ * Read a file-backed bootstrap, treating absence as empty.
188
+ *
189
+ * The keychain reader lives beside the other provider concerns in
190
+ * `providers.mjs`; this is its counterpart for platforms without one.
191
+ * @param {string} key Bootstrap variable name.
192
+ * @param {Record<string, string|undefined>} [env] Environment to read.
193
+ * @returns {string} The value, or an empty string.
194
+ */
195
+ export function readBootstrapFile(key, env = process.env) {
196
+ const path = bootstrapFile(key, env);
197
+ if (!existsSync(path)) return "";
198
+ return readFileSync(path, "utf8").trim();
199
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Ask an operator for a credential, without it appearing anywhere.
3
+ *
4
+ * The obvious alternatives all leave a copy behind:
5
+ *
6
+ * - **A command-line argument** is visible in `ps` to every process on the
7
+ * machine for as long as the call runs, and a shell records it in history.
8
+ * - **The clipboard** is worse than it looks. Raycast, Alfred, Maccy and every
9
+ * other clipboard manager persist history in plaintext, searchable, long
10
+ * after the paste — so a bootstrap routed through it outlives the operation
11
+ * in a database nobody thinks about. Reading it silently also takes whatever
12
+ * happens to be there, which is the wrong thing whenever the operator copied
13
+ * something else in between.
14
+ * - **Echoed input** puts it in the terminal scrollback, which is frequently
15
+ * logged.
16
+ *
17
+ * So: read from the tty with echo off, and never touch anything else.
18
+ * @module prompt-secret
19
+ */
20
+
21
+ import { execFileSync } from "node:child_process";
22
+ import { closeSync, openSync, readSync, writeSync } from "node:fs";
23
+
24
+ /**
25
+ * Whether a human can actually answer.
26
+ *
27
+ * Checked so an automated caller SKIPS the prompt rather than hanging on a
28
+ * read that will never return. A setup script blocked forever is worse than one
29
+ * that says what it could not do — the first looks like a slow install.
30
+ * @param {object} [io] Injected seams, for tests.
31
+ * @returns {boolean} True when stdin is a terminal.
32
+ */
33
+ export function canPrompt(io = {}) {
34
+ return Boolean(io.isTTY ?? process.stdin.isTTY);
35
+ }
36
+
37
+ /**
38
+ * Prompt for a secret with terminal echo disabled.
39
+ *
40
+ * Echo is turned off with `stty` and restored in a `finally`, because a caller
41
+ * that throws mid-read would otherwise leave the operator's terminal silently
42
+ * not echoing anything they type afterwards.
43
+ * @param {string} message Prompt to display.
44
+ * @param {object} [io] Injected seams, for tests.
45
+ * @returns {string} What was typed, trimmed.
46
+ */
47
+ export function promptSecret(message, io = {}) {
48
+ const run = io.run ?? execFileSync;
49
+ const open = io.open ?? openSync;
50
+ const read = io.read ?? readSync;
51
+ const close = io.close ?? closeSync;
52
+ const write = io.write ?? writeSync;
53
+
54
+ const tty = open("/dev/tty", "r+");
55
+ try {
56
+ write(tty, message);
57
+ // `stty` must be pointed at the descriptor the credential is READ from, not
58
+ // at this process's stdin. With "inherit" it configures whatever stdin
59
+ // happens to be: a pipe, where stty exits non-zero and this throws; or a
60
+ // different terminal, where echo stays on for the device the operator is
61
+ // typing into and the credential lands in their scrollback.
62
+ run("stty", ["-echo"], { stdio: [tty, "ignore", "ignore"] });
63
+ const buffer = Buffer.alloc(4096);
64
+ try {
65
+ const bytes = read(tty, buffer, 0, buffer.length, null);
66
+ return buffer.toString("utf8", 0, bytes).trim();
67
+ } finally {
68
+ // Zeroed rather than left for the collector: a 4 KiB buffer holding a
69
+ // bootstrap credential is exactly the page that ends up in a core dump.
70
+ buffer.fill(0);
71
+ run("stty", ["echo"], { stdio: [tty, "ignore", "ignore"] });
72
+ // The newline the operator typed was not echoed, so without this the next
73
+ // line of output starts halfway across the prompt.
74
+ write(tty, "\n");
75
+ }
76
+ } finally {
77
+ close(tty);
78
+ }
79
+ }
@@ -11,6 +11,8 @@
11
11
 
12
12
  import { execFileSync } from "node:child_process";
13
13
 
14
+ import { readBootstrapFile } from "./bootstrap-store.mjs";
15
+
14
16
  /**
15
17
  * A provider key becomes a shell variable name on materializing surfaces, so
16
18
  * only names valid in every POSIX-like shell are accepted. Anything else stays
@@ -129,13 +131,29 @@ export function bootstrapToken(bootstrap) {
129
131
  }
130
132
 
131
133
  /**
132
- * Read one value from the macOS keychain, treating absence as empty.
133
- * @param {string} key Keychain service name.
134
+ * Read one value from this machine's credential store, treating absence as
135
+ * empty.
136
+ *
137
+ * The `keychain` source names a role, not a macOS API: "wherever this machine
138
+ * keeps its bootstrap". On macOS that is the keychain. Elsewhere there is no
139
+ * store that can be assumed present — libsecret needs a daemon and a desktop
140
+ * session, which a server or container does not have — so it is a `0600` file,
141
+ * the same protection the materialized secrets file already relies on.
142
+ *
143
+ * Returning empty on the other platform, as this did before, meant a Linux
144
+ * machine could store a bootstrap and never read it back.
145
+ * @param {string} key Bootstrap variable name.
134
146
  * @returns {string} The value, or an empty string when unavailable.
135
147
  */
136
148
  function fromKeychain(key) {
137
- if (process.platform !== "darwin") return "";
138
149
  try {
150
+ // Inside the `try`, not before it. `readBootstrapFile` uses `readFileSync`,
151
+ // which throws on a permission error, on EISDIR, and when the file is
152
+ // removed between the existence check and the read. This function's
153
+ // contract is to return empty when the value is unavailable, and
154
+ // `bootstrapToken` has no handler — so a raw filesystem error would
155
+ // replace the curated "not found in: ..." message with a stack trace.
156
+ if (process.platform !== "darwin") return readBootstrapFile(key);
139
157
  return execFileSync(
140
158
  "security",
141
159
  ["find-generic-password", "-s", key, "-a", process.env.USER ?? "", "-w"],
@@ -81,6 +81,28 @@ export const SURFACES = {
81
81
  // recoverable; an absent one means the environment does not work.
82
82
  materializeAt: "both",
83
83
  },
84
+ // A container an operator builds and runs themselves — locally, or anywhere
85
+ // Lisa is not the one provisioning the host.
86
+ //
87
+ // It exists because `local` was standing in for it, and `local` is
88
+ // `materialized: false`. That default is right for a human at a keyboard: the
89
+ // provider CLI is authenticated, secrets resolve live, and writing them to
90
+ // disk would be a second copy to keep honest. A container has no keychain and
91
+ // dies with its filesystem, so the same default leaves it with the CLIs
92
+ // installed and nothing to authenticate them.
93
+ //
94
+ // The workaround was to claim `claude-web`, which materializes correctly and
95
+ // is a lie — it made a local container indistinguishable from a cloud session
96
+ // in every diagnostic and log.
97
+ //
98
+ // `setup` rather than `both`: a container has no session-start hook to run,
99
+ // and it is started fresh rather than resumed from a vendor's cache, so the
100
+ // start of the container IS the refresh.
101
+ container: {
102
+ materialized: true,
103
+ mayWriteValues: true,
104
+ materializeAt: "setup",
105
+ },
84
106
  };
85
107
 
86
108
  /** Config defaults when `.lisa.config.json` carries no `secrets` block. */
@@ -187,17 +209,49 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
187
209
  throw new Error(`.lisa.config.json is not readable: ${err.message}`);
188
210
  }
189
211
  if (!cfg) return withSurface(DEFAULTS);
212
+ const provider = cfg.provider ?? DEFAULTS.provider;
213
+ const namespace = assertNamespace(cfg.namespace ?? DEFAULTS.namespace);
190
214
  return withSurface({
191
- provider: cfg.provider ?? DEFAULTS.provider,
192
- bootstrap: { ...DEFAULTS.bootstrap, ...(cfg.bootstrap ?? {}) },
215
+ provider,
216
+ bootstrap: resolveBootstrap(cfg.bootstrap, provider, namespace),
193
217
  require: cfg.require ?? null,
194
218
  rotating: cfg.rotating ?? [],
195
- namespace: assertNamespace(cfg.namespace ?? DEFAULTS.namespace),
219
+ namespace,
196
220
  narrow: { ...DEFAULTS.narrow, ...(cfg.narrow ?? {}) },
197
221
  surface: cfg.surface ?? null,
198
222
  });
199
223
  }
200
224
 
225
+ /**
226
+ * Fill in the bootstrap block a project did not spell out.
227
+ *
228
+ * Both defaults exist because a token stored on the machine was going unread.
229
+ *
230
+ * **The key** follows the same convention the repo-less path uses,
231
+ * `<PROVIDER_VAR>_<namespace>`, so a machine provisioned once serves every
232
+ * project of that tenant without each restating the name. A project that sets
233
+ * one keeps it: a name that is not derivable is a legitimate choice, and
234
+ * overriding it would point sessions at a variable nobody set.
235
+ *
236
+ * **The sources** gain `keychain` because `["env"]` alone meant a token in the
237
+ * OS keychain was never consulted — so the machine setup appeared to work and
238
+ * every project still failed until someone hand-edited its config. Env stays
239
+ * first, so a CI runner injecting the bootstrap never reaches for a local store.
240
+ * On a platform with no keychain the extra source reads as empty and costs
241
+ * nothing.
242
+ * @param {object|undefined} bootstrap The project's own block, if any.
243
+ * @param {string} provider Resolved provider name.
244
+ * @param {string} namespace Resolved namespace.
245
+ * @returns {{sources: string[], key: string|null}} The resolved block.
246
+ */
247
+ function resolveBootstrap(bootstrap, provider, namespace) {
248
+ const given = bootstrap ?? {};
249
+ return {
250
+ sources: given.sources ?? ["env", "keychain"],
251
+ key: given.key ?? bootstrapKeyFor(provider, namespace),
252
+ };
253
+ }
254
+
201
255
  /**
202
256
  * Build a configuration from the environment, for sessions with no checkout.
203
257
  *
@@ -243,6 +297,10 @@ function fromEnvironment(env) {
243
297
  // name. Hardcoding it told a Doppler tenant to set BWS_ACCESS_TOKEN_<ns>,
244
298
  // which its CLI has never heard of — on the one surface that has no
245
299
  // config file to override the guess.
300
+ // Same two defaults as a project config gets, for the same reasons —
301
+ // see `resolveBootstrap`. Stated here too because this path never reads
302
+ // a file, so it cannot inherit them.
303
+ sources: ["env", "keychain"],
246
304
  key:
247
305
  env.LISA_BOOTSTRAP_KEY ??
248
306
  env.LISA_SECRETS_BOOTSTRAP_KEY ??
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.338.5",
3
+ "version": "2.340.0",
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,199 @@
1
+ /**
2
+ * Store the one credential that unlocks the provider, on the machine that needs
3
+ * it.
4
+ *
5
+ * Reading it already existed; writing it did not, so every operator ran a
6
+ * platform-specific `security add-generic-password` copied from documentation.
7
+ * That is a command with a credential in it, typed by hand, on the one value
8
+ * whose compromise costs every other secret.
9
+ *
10
+ * **Two stores, because there is no third option.** macOS has a keychain that
11
+ * is encrypted at rest and unlocked with the login session. Linux has no
12
+ * equivalent that can be assumed present — libsecret needs a running daemon and
13
+ * a desktop session, which a server or a container does not have. So the
14
+ * fallback is a `0600` file under `$XDG_CONFIG_HOME`, which is the same
15
+ * protection the materialized secrets file already relies on. It is weaker than
16
+ * a keychain and it is stated as such, rather than pretending the two are equal.
17
+ * @module bootstrap-store
18
+ */
19
+
20
+ import { execFileSync } from "node:child_process";
21
+ import {
22
+ chmodSync,
23
+ existsSync,
24
+ mkdirSync,
25
+ readFileSync,
26
+ renameSync,
27
+ rmSync,
28
+ writeFileSync,
29
+ } from "node:fs";
30
+ import { homedir } from "node:os";
31
+ import { dirname, join } from "node:path";
32
+
33
+ /**
34
+ * Where a file-backed bootstrap lives, for platforms with no keychain.
35
+ * @param {string} key Bootstrap variable name.
36
+ * @param {Record<string, string|undefined>} [env] Environment to read.
37
+ * @returns {string} Absolute path.
38
+ */
39
+ export function bootstrapFile(key, env = process.env) {
40
+ const root = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
41
+ return join(root, "lisa", "bootstrap", assertKey(key));
42
+ }
43
+
44
+ /**
45
+ * Reject a key that is not exactly one safe path segment.
46
+ *
47
+ * The key is joined onto a directory, and every entry point — store, clear and
48
+ * read — routes through that join. A `/` or a `..` in it escapes the bootstrap
49
+ * directory, so an unvalidated key can write, delete, or read an arbitrary file
50
+ * as the operator. Validating in `bootstrapFile` covers all three at once
51
+ * rather than trusting each caller to remember.
52
+ *
53
+ * Same guard `assertNamespace` applies to the namespace, and for the same
54
+ * reason. The character set is what a variable name can hold anyway, so nothing
55
+ * legitimate is refused.
56
+ * @param {string} key Candidate bootstrap variable name.
57
+ * @returns {string} The key, unchanged, when valid.
58
+ */
59
+ export function assertKey(key) {
60
+ if (
61
+ typeof key !== "string" ||
62
+ !/^[A-Za-z0-9._-]+$/.test(key) ||
63
+ key === ".."
64
+ ) {
65
+ throw new Error(
66
+ `bootstrap key must be one safe path segment, got ${JSON.stringify(key)}`
67
+ );
68
+ }
69
+ return key;
70
+ }
71
+
72
+ /**
73
+ * Quote a word for `security -i`, which tokenizes its stdin like a shell.
74
+ *
75
+ * A token is usually base64-ish and needs no quoting, but "usually" is not a
76
+ * property to rely on for a credential: an unquoted value containing a space
77
+ * would be parsed as two arguments and silently store only the first part.
78
+ * A newline is refused outright rather than escaped, because it terminates the
79
+ * command line and there is no quoting that survives it.
80
+ * @param {string} word Value to quote.
81
+ * @returns {string} A single quoted token.
82
+ */
83
+ function quote(word) {
84
+ if (/[\n\r]/.test(word)) {
85
+ throw new Error("bootstrap value must not contain a newline");
86
+ }
87
+ return `"${String(word).replace(/(["\\])/g, "\\$1")}"`;
88
+ }
89
+
90
+ /**
91
+ * Which store this platform uses.
92
+ * @param {string} [platform] Platform name, injectable for tests.
93
+ * @returns {"keychain"|"file"} The store.
94
+ */
95
+ export function storeKind(platform = process.platform) {
96
+ return platform === "darwin" ? "keychain" : "file";
97
+ }
98
+
99
+ /**
100
+ * Write the bootstrap where this machine's sessions will look for it.
101
+ *
102
+ * The value never reaches a command line on either path. The keychain path
103
+ * feeds `security -i` a command stream on **stdin**, because an argument is
104
+ * visible in `ps` to every process running as this user for as long as the call
105
+ * lasts. The file path writes and chmods a temporary before renaming, so the
106
+ * credential is never briefly world-readable on a filesystem where the default
107
+ * umask would have made it so.
108
+ * @param {string} key Bootstrap variable name.
109
+ * @param {string} value The token.
110
+ * @param {object} [deps] Injected seams, for tests.
111
+ * @returns {{kind: string, where: string}} Where it went.
112
+ */
113
+ export function storeBootstrap(key, value, deps = {}) {
114
+ const kind = deps.kind ?? storeKind();
115
+ const env = deps.env ?? process.env;
116
+
117
+ if (kind === "keychain") {
118
+ const run = deps.run ?? execFileSync;
119
+ assertKey(key);
120
+ // `security -i` reads a COMMAND STREAM on stdin, which is what keeps the
121
+ // value out of `argv` — where `ps` would show it to every process running
122
+ // as this user for as long as the call lasts.
123
+ //
124
+ // The obvious `-w` with no argument does NOT read the password from stdin.
125
+ // It opens an interactive prompt on the terminal, and with stdin piped it
126
+ // stores an EMPTY value and exits 0 — verified against the real binary,
127
+ // which is how this shipped broken the first time: unit tests injected the
128
+ // runner and never executed it.
129
+ //
130
+ // `-U` updates in place; without it a second run fails with "already
131
+ // exists", making rotation — the common case — the broken one.
132
+ run("security", ["-i"], {
133
+ input: `add-generic-password -U -s ${quote(key)} -a ${quote(
134
+ env.USER ?? ""
135
+ )} -w ${quote(value)}\n`,
136
+ stdio: ["pipe", "ignore", "ignore"],
137
+ });
138
+ return { kind, where: `keychain (service ${key})` };
139
+ }
140
+
141
+ const path = deps.path ?? bootstrapFile(key, env);
142
+ const write = deps.write ?? writeFileSync;
143
+ const chmod = deps.chmod ?? chmodSync;
144
+ const rename = deps.rename ?? renameSync;
145
+ const makeDir = deps.mkdir ?? mkdirSync;
146
+
147
+ makeDir(dirname(path), { recursive: true, mode: 0o700 });
148
+ const temporary = `${path}.tmp`;
149
+ // mode on write applies only when the file is CREATED, so an existing
150
+ // temporary from a crashed run would keep its old permissions. chmod after
151
+ // writing is what makes 0600 true rather than usually true.
152
+ write(temporary, `${value}\n`, { mode: 0o600 });
153
+ chmod(temporary, 0o600);
154
+ rename(temporary, path);
155
+ return { kind, where: path };
156
+ }
157
+
158
+ /**
159
+ * Remove a stored bootstrap, so a rotation can be proven rather than assumed.
160
+ * @param {string} key Bootstrap variable name.
161
+ * @param {object} [deps] Injected seams, for tests.
162
+ */
163
+ export function clearBootstrap(key, deps = {}) {
164
+ const kind = deps.kind ?? storeKind();
165
+ const env = deps.env ?? process.env;
166
+
167
+ if (kind === "keychain") {
168
+ const run = deps.run ?? execFileSync;
169
+ try {
170
+ run(
171
+ "security",
172
+ ["delete-generic-password", "-s", key, "-a", env.USER ?? ""],
173
+ {
174
+ stdio: "ignore",
175
+ }
176
+ );
177
+ } catch {
178
+ // Absent is the desired state, and `security` exits non-zero for it.
179
+ }
180
+ return;
181
+ }
182
+ const remove = deps.remove ?? rmSync;
183
+ remove(deps.path ?? bootstrapFile(key, env), { force: true });
184
+ }
185
+
186
+ /**
187
+ * Read a file-backed bootstrap, treating absence as empty.
188
+ *
189
+ * The keychain reader lives beside the other provider concerns in
190
+ * `providers.mjs`; this is its counterpart for platforms without one.
191
+ * @param {string} key Bootstrap variable name.
192
+ * @param {Record<string, string|undefined>} [env] Environment to read.
193
+ * @returns {string} The value, or an empty string.
194
+ */
195
+ export function readBootstrapFile(key, env = process.env) {
196
+ const path = bootstrapFile(key, env);
197
+ if (!existsSync(path)) return "";
198
+ return readFileSync(path, "utf8").trim();
199
+ }