@codyswann/lisa 2.339.0 → 2.341.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/environment-cmd.d.ts +40 -0
- package/dist/cli/environment-cmd.d.ts.map +1 -0
- package/dist/cli/environment-cmd.js +84 -0
- package/dist/cli/environment-cmd.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +30 -4
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +232 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +19 -3
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/providers.mjs +21 -3
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +27 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/environment.mjs +227 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +136 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +232 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +19 -3
- package/plugins/lisa/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/providers.mjs +21 -3
- package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +27 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/environment.mjs +227 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +136 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +232 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +19 -3
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/providers.mjs +21 -3
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +27 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/environment.mjs +227 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +136 -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/scripts/bootstrap-store.mjs +232 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +19 -3
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/providers.mjs +21 -3
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +27 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/environment.mjs +227 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +136 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +232 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +19 -3
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/providers.mjs +21 -3
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +27 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/environment.mjs +227 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +136 -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/scripts/bootstrap-store.mjs +232 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +19 -3
- package/plugins/src/base/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/providers.mjs +21 -3
- package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +27 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/environment.mjs +227 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +136 -0
package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs
CHANGED
|
@@ -1030,6 +1030,142 @@ export function emitClaudeWeb({
|
|
|
1030
1030
|
].join("\n");
|
|
1031
1031
|
}
|
|
1032
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Emit the configuration for a surface Lisa cannot reach.
|
|
1035
|
+
*
|
|
1036
|
+
* Dispatch rather than four call sites, so `environment` names a surface and
|
|
1037
|
+
* nothing downstream has to know which of them has an API and which does not.
|
|
1038
|
+
* @param {string} target Surface name.
|
|
1039
|
+
* @param {object} identity Resolved tenant, provider and bootstrap key.
|
|
1040
|
+
* @returns {string} Text to show the operator.
|
|
1041
|
+
*/
|
|
1042
|
+
export function emitFor(target, identity) {
|
|
1043
|
+
if (target === "claude-web") return emitClaudeWeb(identity);
|
|
1044
|
+
if (target === "codex-cloud") return emitCodexCloud(identity);
|
|
1045
|
+
if (target === "container") return emitContainer(identity);
|
|
1046
|
+
throw new Error(`no emit template for surface "${target}".`);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Produce the configuration a human pastes into a Codex Cloud environment.
|
|
1051
|
+
*
|
|
1052
|
+
* Differs from Claude's in three ways that are properties of the vendor rather
|
|
1053
|
+
* than of Lisa, and each has cost someone a debugging session.
|
|
1054
|
+
* @param {{bootstrapKey: string|null, tenant?: string|null, provider?: string}} options Project details.
|
|
1055
|
+
* @returns {string} Text to show the operator.
|
|
1056
|
+
*/
|
|
1057
|
+
export function emitCodexCloud({
|
|
1058
|
+
bootstrapKey,
|
|
1059
|
+
tenant = null,
|
|
1060
|
+
provider = null,
|
|
1061
|
+
}) {
|
|
1062
|
+
const key = bootstrapKey ?? "<not resolved>";
|
|
1063
|
+
const namespace = tenant ?? "<your namespace>";
|
|
1064
|
+
return [
|
|
1065
|
+
"Provisioning tier: EMIT — Codex Cloud exposes no environment API.",
|
|
1066
|
+
" `codex cloud` offers exec, status and list; nothing that writes an",
|
|
1067
|
+
" environment. So this is text for the settings page.",
|
|
1068
|
+
"",
|
|
1069
|
+
"Paste into the environment settings",
|
|
1070
|
+
"-----------------------------------",
|
|
1071
|
+
" Repository: the DEFAULT checkout for this environment",
|
|
1072
|
+
"",
|
|
1073
|
+
" Environment variables — NOT task secrets:",
|
|
1074
|
+
// Written as name=value, not as `export name='value'`. This is a settings
|
|
1075
|
+
// form, not a shell: the quotes would be stored as part of the value, and
|
|
1076
|
+
// the surface named must be this one rather than the shell block's.
|
|
1077
|
+
` LISA_TENANT=${namespace}`,
|
|
1078
|
+
...(bootstrapKey
|
|
1079
|
+
? [` ${key}=<read this from your credential manager>`]
|
|
1080
|
+
: [` (${provider} has no environment-variable bootstrap)`]),
|
|
1081
|
+
...(provider ? [` LISA_PROVIDER=${provider}`] : []),
|
|
1082
|
+
" LISA_SECRETS_SURFACE=codex-cloud",
|
|
1083
|
+
"",
|
|
1084
|
+
" Setup and cache-resume maintenance both run BEFORE task secrets",
|
|
1085
|
+
" exist, so a bootstrap placed in the secrets box is invisible to the",
|
|
1086
|
+
" only two steps that need it.",
|
|
1087
|
+
"",
|
|
1088
|
+
" Setup script:",
|
|
1089
|
+
` ${SETUP_FIELD}`,
|
|
1090
|
+
"",
|
|
1091
|
+
" Maintenance script:",
|
|
1092
|
+
" The SAME line. It runs when a container resumes from cache, and every",
|
|
1093
|
+
" step is idempotent and version-aware — so running it again is how a",
|
|
1094
|
+
" rotated value, an edited note, or a changed pin gets picked up.",
|
|
1095
|
+
"",
|
|
1096
|
+
"Watch for",
|
|
1097
|
+
"---------",
|
|
1098
|
+
" CODEX_ENV_NODE_VERSION overrides the repository's own .nvmrc and",
|
|
1099
|
+
" engines. An environment left on an older major breaks setup for a repo",
|
|
1100
|
+
" that requires a newer one, and the error blames the package manager.",
|
|
1101
|
+
"",
|
|
1102
|
+
" Codex clones `main`, not the repository's default branch. If those",
|
|
1103
|
+
" differ here, that is where the confusion starts.",
|
|
1104
|
+
"",
|
|
1105
|
+
" Setup logs are visible only in the Codex UI; no CLI retrieves them.",
|
|
1106
|
+
].join("\n");
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Produce an image definition and a run command for a container.
|
|
1111
|
+
*
|
|
1112
|
+
* Split across build and run on purpose: the binaries are baked once, and the
|
|
1113
|
+
* credential arrives at `docker run`. Baking it would put it in an image layer,
|
|
1114
|
+
* readable by anyone who can pull the image and surviving every
|
|
1115
|
+
* `docker history` — so the Dockerfile deliberately contains no secret.
|
|
1116
|
+
* @param {{bootstrapKey: string|null, tenant?: string|null, provider?: string}} options Project details.
|
|
1117
|
+
* @returns {string} Text to show the operator.
|
|
1118
|
+
*/
|
|
1119
|
+
export function emitContainer({
|
|
1120
|
+
bootstrapKey,
|
|
1121
|
+
tenant = null,
|
|
1122
|
+
provider = null,
|
|
1123
|
+
}) {
|
|
1124
|
+
const key = bootstrapKey ?? "<not resolved>";
|
|
1125
|
+
const namespace = tenant ?? "<your namespace>";
|
|
1126
|
+
return [
|
|
1127
|
+
"Provisioning tier: EMIT — an image is built from a file you own.",
|
|
1128
|
+
"",
|
|
1129
|
+
"Dockerfile",
|
|
1130
|
+
"----------",
|
|
1131
|
+
" Generate it with the bootstrap's own emitter, so the image and this",
|
|
1132
|
+
" machine install the identical set:",
|
|
1133
|
+
"",
|
|
1134
|
+
" npx -y @codyswann/lisa@latest workstation --print-dockerfile \\",
|
|
1135
|
+
` --provider=${provider ?? "bitwarden"} > Dockerfile.lisa`,
|
|
1136
|
+
" docker build -f Dockerfile.lisa -t lisa-workstation .",
|
|
1137
|
+
"",
|
|
1138
|
+
" Nothing secret belongs in it. An image layer is readable by anyone who",
|
|
1139
|
+
" can pull the image and survives every `docker history`, so the",
|
|
1140
|
+
" credential arrives at run time instead.",
|
|
1141
|
+
"",
|
|
1142
|
+
"docker run",
|
|
1143
|
+
"----------",
|
|
1144
|
+
" docker run --rm -it \\",
|
|
1145
|
+
` -e LISA_TENANT=${namespace} \\`,
|
|
1146
|
+
` -e ${key}="<this image's own access token>" \\`,
|
|
1147
|
+
...(provider ? [` -e LISA_PROVIDER=${provider} \\`] : []),
|
|
1148
|
+
" -e LISA_SECRETS_SURFACE=container \\",
|
|
1149
|
+
' -v "$PWD:/work" -w /work \\',
|
|
1150
|
+
" lisa-workstation",
|
|
1151
|
+
"",
|
|
1152
|
+
" LISA_SECRETS_SURFACE=container is what makes credentials materialize.",
|
|
1153
|
+
" Left to detection a container reads as `local`, which is",
|
|
1154
|
+
" materialized:false — right for a human at a keyboard whose provider CLI",
|
|
1155
|
+
" is authenticated, wrong for a container that has no keychain and dies",
|
|
1156
|
+
" with its filesystem.",
|
|
1157
|
+
"",
|
|
1158
|
+
" Give the image its own access token rather than reusing this machine's.",
|
|
1159
|
+
" If the image is shared, so is anything its token can read.",
|
|
1160
|
+
"",
|
|
1161
|
+
"Mounted repositories need nothing further",
|
|
1162
|
+
"-----------------------------------------",
|
|
1163
|
+
" A checkout you have already run `apply` and `sync` on carries its",
|
|
1164
|
+
" .lisa.config.json with it. Cloning INSIDE the container instead means",
|
|
1165
|
+
" running those there.",
|
|
1166
|
+
].join("\n");
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1033
1169
|
/**
|
|
1034
1170
|
* Report tooling the project appears to need but has not declared.
|
|
1035
1171
|
*
|
|
@@ -0,0 +1,232 @@
|
|
|
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
|
+
* Whether this machine already holds a bootstrap under that name.
|
|
188
|
+
*
|
|
189
|
+
* Asked so `environment local` can re-materialize after a rotation in the vault
|
|
190
|
+
* without demanding the token again — that is the common case, and prompting
|
|
191
|
+
* every time would make it the tedious one.
|
|
192
|
+
*
|
|
193
|
+
* Presence only. The value is never read here, because nothing about deciding
|
|
194
|
+
* whether to prompt needs it.
|
|
195
|
+
* @param {string} key Bootstrap variable name.
|
|
196
|
+
* @param {object} [deps] Injected seams, for tests.
|
|
197
|
+
* @returns {boolean} True when a value is stored.
|
|
198
|
+
*/
|
|
199
|
+
export function hasBootstrap(key, deps = {}) {
|
|
200
|
+
const kind = deps.kind ?? storeKind();
|
|
201
|
+
const env = deps.env ?? process.env;
|
|
202
|
+
|
|
203
|
+
if (kind === "keychain") {
|
|
204
|
+
const run = deps.run ?? execFileSync;
|
|
205
|
+
try {
|
|
206
|
+
run(
|
|
207
|
+
"security",
|
|
208
|
+
["find-generic-password", "-s", assertKey(key), "-a", env.USER ?? ""],
|
|
209
|
+
{ stdio: "ignore" }
|
|
210
|
+
);
|
|
211
|
+
return true;
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return (deps.exists ?? existsSync)(deps.path ?? bootstrapFile(key, env));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Read a file-backed bootstrap, treating absence as empty.
|
|
221
|
+
*
|
|
222
|
+
* The keychain reader lives beside the other provider concerns in
|
|
223
|
+
* `providers.mjs`; this is its counterpart for platforms without one.
|
|
224
|
+
* @param {string} key Bootstrap variable name.
|
|
225
|
+
* @param {Record<string, string|undefined>} [env] Environment to read.
|
|
226
|
+
* @returns {string} The value, or an empty string.
|
|
227
|
+
*/
|
|
228
|
+
export function readBootstrapFile(key, env = process.env) {
|
|
229
|
+
const path = bootstrapFile(key, env);
|
|
230
|
+
if (!existsSync(path)) return "";
|
|
231
|
+
return readFileSync(path, "utf8").trim();
|
|
232
|
+
}
|
|
@@ -299,12 +299,28 @@ export function installAwsProfiles(bundle, options = {}) {
|
|
|
299
299
|
return rendered.profiles;
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
-
export function materialize(cfg = readConfig()) {
|
|
303
|
-
|
|
302
|
+
export function materialize(cfg = readConfig(), options = {}) {
|
|
303
|
+
// `requested` distinguishes an OPERATOR asking from a flow deciding.
|
|
304
|
+
//
|
|
305
|
+
// The capability governs automated behaviour: a session-start hook on a
|
|
306
|
+
// laptop must not write credentials to disk, because the provider CLI is
|
|
307
|
+
// authenticated there and a copy would add drift and exposure without adding
|
|
308
|
+
// capability. That reasoning holds, and the guard below still enforces it.
|
|
309
|
+
//
|
|
310
|
+
// It does not hold for `lisa environment local`, where the operator typed the
|
|
311
|
+
// command whose entire purpose is to put credentials on this machine — the
|
|
312
|
+
// AWS `-static` profiles reference variables that exist only once the env
|
|
313
|
+
// file is materialized, so refusing there means the profiles never work.
|
|
314
|
+
//
|
|
315
|
+
// Two different questions, so two different answers, rather than flipping
|
|
316
|
+
// `local` to `materialized: true` and silently starting to write on every
|
|
317
|
+
// machine that upgrades.
|
|
318
|
+
if (!cfg.capabilities.mayWriteValues && !options.requested) {
|
|
304
319
|
throw new Error(
|
|
305
320
|
`surface "${cfg.surface}" may not write secret values to disk.\n` +
|
|
306
321
|
`It can read through to the provider, so a copy on disk would add drift ` +
|
|
307
|
-
`and exposure without adding capability
|
|
322
|
+
`and exposure without adding capability.\n` +
|
|
323
|
+
`Run 'lisa environment local --tenant=<name>' to ask for it explicitly.`
|
|
308
324
|
);
|
|
309
325
|
}
|
|
310
326
|
|
|
@@ -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
|
|
133
|
-
*
|
|
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"],
|
|
@@ -252,6 +252,33 @@ function resolveBootstrap(bootstrap, provider, namespace) {
|
|
|
252
252
|
};
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Build a configuration for a tenant the operator named outright.
|
|
257
|
+
*
|
|
258
|
+
* Deliberately ignores any `.lisa.config.json` in the working directory. The
|
|
259
|
+
* flag is the more specific statement — someone who typed `--tenant=acme` means
|
|
260
|
+
* acme, whichever repository they happen to be standing in — and silently
|
|
261
|
+
* preferring the file would materialize a different tenant than the one on the
|
|
262
|
+
* command line.
|
|
263
|
+
*
|
|
264
|
+
* Getting this wrong is not a cosmetic error. Every namespace is a directory
|
|
265
|
+
* under `$XDG_CONFIG_HOME`, so resolving to the wrong one writes one tenant's
|
|
266
|
+
* credentials where another tenant's sessions read, and on a machine serving
|
|
267
|
+
* several the two would share a store.
|
|
268
|
+
* @param {{tenant: string, provider?: string}} options Named identity.
|
|
269
|
+
* @param {Record<string, string|undefined>} [env] Environment to inherit.
|
|
270
|
+
* @returns {object} A resolved configuration.
|
|
271
|
+
*/
|
|
272
|
+
export function configForTenant({ tenant, provider }, env = process.env) {
|
|
273
|
+
return withSurface(
|
|
274
|
+
fromEnvironment({
|
|
275
|
+
...env,
|
|
276
|
+
LISA_TENANT: tenant,
|
|
277
|
+
...(provider ? { LISA_PROVIDER: provider } : {}),
|
|
278
|
+
})
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
255
282
|
/**
|
|
256
283
|
* Build a configuration from the environment, for sessions with no checkout.
|
|
257
284
|
*
|