@codyswann/lisa 2.311.13 → 2.312.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/doctor-readiness-promoted-artifact.d.ts +17 -0
- package/dist/cli/doctor-readiness-promoted-artifact.d.ts.map +1 -1
- package/dist/cli/doctor-readiness-promoted-artifact.js +50 -0
- package/dist/cli/doctor-readiness-promoted-artifact.js.map +1 -1
- package/dist/cli/doctor-readiness-release-path.d.ts.map +1 -1
- package/dist/cli/doctor-readiness-release-path.js +5 -1
- package/dist/cli/doctor-readiness-release-path.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +16 -0
- 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/SKILL.md +122 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/resolve-secret.mjs +261 -0
- package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +122 -0
- package/plugins/lisa/skills/lisa-secrets-access/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/resolve-secret.mjs +261 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +122 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/resolve-secret.mjs +261 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +122 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/resolve-secret.mjs +261 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +122 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/resolve-secret.mjs +261 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +122 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/resolve-secret.mjs +261 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lisa-secrets-access
|
|
3
|
+
description: "Vendor-neutral access layer for secrets. Every skill and script that needs an API key MUST resolve it through this skill rather than reading a keychain, an .env file, or a provider CLI directly. Resolves the environment first so CI injection wins, then the configured provider — Bitwarden Secrets Manager, 1Password, AWS Secrets Manager, Doppler or Vault — looked up by key name. Enforces one store per secret, reads usage metadata from the provider's own note field, and never writes."
|
|
4
|
+
allowed-tools: ["Bash", "Read", "Skill"]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Secrets Access: $ARGUMENTS
|
|
8
|
+
|
|
9
|
+
Single chokepoint for reading credentials. Caller skills MUST go through this — they MUST NOT read the OS keychain, parse a `.env`, or invoke a provider CLI themselves.
|
|
10
|
+
|
|
11
|
+
The rule this exists to enforce: **a secret lives in exactly one store.** Every local cache is a copy that will eventually drift from its source, and a drifted copy is indistinguishable from a valid one until something fails in production.
|
|
12
|
+
|
|
13
|
+
## Invocation contract
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
operation: get name: ATTIO_API_KEY
|
|
17
|
+
operation: list # names only, never values
|
|
18
|
+
operation: describe name: ATTIO_API_KEY # the usage note, not the value
|
|
19
|
+
operation: verify # every declared secret resolves
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`get` returns the value on stdout and nothing else. `list` and `describe` never emit a secret value.
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
In `.lisa.config.json`:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"secrets": {
|
|
31
|
+
"provider": "bitwarden",
|
|
32
|
+
"bootstrap": { "sources": ["env", "keychain"], "key": "BWS_ACCESS_TOKEN" },
|
|
33
|
+
"require": ["ATTIO_API_KEY", "SLACK_WEBHOOK_URL"]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**`provider`** — `bitwarden` | `1password` | `aws` | `doppler` | `vault` | `env`.
|
|
39
|
+
|
|
40
|
+
**`bootstrap`** — how to obtain the one credential that unlocks the rest. `sources` is ordered: environment first, so CI injection wins over any local copy. This is the **only** credential permitted in a keychain; it is a bootstrap, not a cache.
|
|
41
|
+
|
|
42
|
+
**`require`** — optional. Omit it and every secret the provider grants is available, which is correct when the provider already scopes access per project. Present, it narrows to exactly those names **and asserts them**: a listed name that does not resolve is a startup error, not a late surprise. Use it when a repo needs a subset of a deliberately broader project.
|
|
43
|
+
|
|
44
|
+
There is no map of secret IDs, deliberately. Copying an ID per secret is the same duplication in a smaller costume, and lookup is by name.
|
|
45
|
+
|
|
46
|
+
## The naming convention this rests on
|
|
47
|
+
|
|
48
|
+
**A secret's key is the exact environment-variable name.** No inference, no fuzzy matching, no case folding. A secret named `attio-prod` will not resolve for `ATTIO_API_KEY`, and the error says so rather than silently returning nothing.
|
|
49
|
+
|
|
50
|
+
Enforce it: `doctor` should warn on any key that is not a valid `UPPER_SNAKE_CASE` identifier.
|
|
51
|
+
|
|
52
|
+
## Workflow
|
|
53
|
+
|
|
54
|
+
### Step 1 — Environment first
|
|
55
|
+
|
|
56
|
+
If `$<NAME>` is set and non-empty, return it. This is how CI injects secrets, and it means a scheduled run never touches the provider or a local store.
|
|
57
|
+
|
|
58
|
+
### Step 2 — Bootstrap
|
|
59
|
+
|
|
60
|
+
Resolve the bootstrap credential by walking `bootstrap.sources` in order. Fail with an actionable message naming where it was looked for.
|
|
61
|
+
|
|
62
|
+
### Step 3 — Provider lookup by name
|
|
63
|
+
|
|
64
|
+
| Provider | Read |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `bitwarden` | `bws secret list` → index by `key` |
|
|
67
|
+
| `1password` | `op read "op://<vault>/<name>/credential"` |
|
|
68
|
+
| `aws` | `aws secretsmanager get-secret-value --secret-id <name>` |
|
|
69
|
+
| `doppler` | `doppler secrets get <name> --plain` |
|
|
70
|
+
| `vault` | `vault kv get -field=<name> <path>` |
|
|
71
|
+
| `env` | environment only; the provider is the environment |
|
|
72
|
+
|
|
73
|
+
Cache **in-process only**. Never write a resolved value to disk — that recreates the problem this skill exists to remove.
|
|
74
|
+
|
|
75
|
+
### Step 4 — Fail loudly and usefully
|
|
76
|
+
|
|
77
|
+
A missing name reports what *is* visible, so the caller can see immediately whether the secret is absent or merely misnamed:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
GOOGLE_SERVICE_ACCOUNT_JSON is not available to this account.
|
|
81
|
+
Visible: APOLLO_API_KEY, ATTIO_API_KEY, SLACK_WEBHOOK_URL
|
|
82
|
+
A secret's key must be the exact environment variable name.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Usage metadata lives on the secret
|
|
86
|
+
|
|
87
|
+
Every provider has a description field — Bitwarden `note`, 1Password notes, AWS `Description`, Doppler notes, Vault custom metadata. **That is where a secret's usage documentation belongs**, not in a config file: a note travels with the secret and therefore cannot drift from it, which is precisely the property config lacks.
|
|
88
|
+
|
|
89
|
+
Format — first line prose, then `key: value` lines:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
Attio CRM - system of record for the sales funnel.
|
|
93
|
+
scope: object_configuration, record, list_entry - read-write
|
|
94
|
+
owner: <name>
|
|
95
|
+
ci: yes - injected by <mechanism>
|
|
96
|
+
docs: <path>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`describe` returns this. When a note is empty, **infer purpose from the name, mark it inferred, and report the gap** — infer *and* warn, never instead of. A silent fallback that works well enough guarantees the notes stay empty forever.
|
|
100
|
+
|
|
101
|
+
**An inferred mapping must never authorise a write.** It orients a reader; it does not pick which credential calls a production API. `ATTIO_API_KEY` versus `ATTIO_API_KEY_STAGING` is exactly the guess that silently writes to the wrong system.
|
|
102
|
+
|
|
103
|
+
## This skill never writes
|
|
104
|
+
|
|
105
|
+
No create, no update, no rotate. Writing secrets or their notes requires an authority a CI credential should not hold, and a read-only path cannot be turned against the vault if it leaks.
|
|
106
|
+
|
|
107
|
+
Where a rotating credential must persist a new value — an OAuth refresh token that the issuer replaces on every use — that is a **separate, deliberately-scoped writer**, and exactly one process may hold that loop. Two refreshers race, and each invalidates the other's token silently.
|
|
108
|
+
|
|
109
|
+
## Doctor checks worth wiring
|
|
110
|
+
|
|
111
|
+
- Every name in `require` resolves.
|
|
112
|
+
- Every key matches `^[A-Z][A-Z0-9_]*$`.
|
|
113
|
+
- No secret has an empty note.
|
|
114
|
+
- **No secret is readable from two stores.** A value present in both the provider and a local cache is not a duplicate — it is **two live credentials**, one of which is untracked. This is the check most worth having: it catches drift before a deletion turns the forgotten copy into an orphan nobody can revoke.
|
|
115
|
+
|
|
116
|
+
## Rules
|
|
117
|
+
|
|
118
|
+
1. **Never read a keychain, `.env`, or provider CLI outside this skill.** One chokepoint is what makes the single-store rule enforceable.
|
|
119
|
+
2. **Never log a secret value.** Print a length or a hash prefix when proving identity.
|
|
120
|
+
3. **Never write a resolved value to disk**, including "temporary" files.
|
|
121
|
+
4. **Verify a credential when it is stored, not when it is first used.** An unverified credential is indistinguishable from a broken one, and the gap between the two is measured in weeks.
|
|
122
|
+
5. **Treat a mismatch as stop-and-ask.** If a value differs between two places, they are two live credentials — not a stale copy to be discarded. Deleting the one you cannot verify leaves a working credential that no record accounts for.
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a secret through the configured provider.
|
|
4
|
+
*
|
|
5
|
+
* The executable half of `lisa-secrets-access`. It exists as a CLI rather than
|
|
6
|
+
* a library because the callers are polyglot — Python scripts, shell, and
|
|
7
|
+
* TypeScript all need the same answer, and a subprocess is the only interface
|
|
8
|
+
* all three share without duplicating the logic three times.
|
|
9
|
+
*
|
|
10
|
+
* Resolution order is environment, then provider. The environment comes first
|
|
11
|
+
* so a CI run — where secrets are injected by the pipeline — never reaches for
|
|
12
|
+
* a provider or a local store at all.
|
|
13
|
+
*
|
|
14
|
+
* Values are written to stdout with no trailing newline and nothing else, so
|
|
15
|
+
* `$(resolve-secret.mjs get NAME)` is safe. Diagnostics go to stderr.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* resolve-secret.mjs get NAME
|
|
19
|
+
* resolve-secret.mjs list
|
|
20
|
+
* resolve-secret.mjs describe NAME
|
|
21
|
+
* resolve-secret.mjs verify
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
|
|
28
|
+
/** Config defaults when `.lisa.config.json` carries no `secrets` block. */
|
|
29
|
+
const DEFAULTS = {
|
|
30
|
+
provider: "env",
|
|
31
|
+
bootstrap: { sources: ["env"], key: null },
|
|
32
|
+
require: null,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read the `secrets` block from `.lisa.config.json`.
|
|
37
|
+
* @param {string} cwd Directory to look in.
|
|
38
|
+
* @returns {{provider: string, bootstrap: {sources: string[], key: string|null}, require: string[]|null}}
|
|
39
|
+
*/
|
|
40
|
+
export function readConfig(cwd = process.cwd()) {
|
|
41
|
+
const path = join(cwd, ".lisa.config.json");
|
|
42
|
+
if (!existsSync(path)) return DEFAULTS;
|
|
43
|
+
try {
|
|
44
|
+
const cfg = JSON.parse(readFileSync(path, "utf8")).secrets;
|
|
45
|
+
if (!cfg) return DEFAULTS;
|
|
46
|
+
return {
|
|
47
|
+
provider: cfg.provider ?? DEFAULTS.provider,
|
|
48
|
+
bootstrap: { ...DEFAULTS.bootstrap, ...(cfg.bootstrap ?? {}) },
|
|
49
|
+
require: cfg.require ?? null,
|
|
50
|
+
};
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw new Error(`.lisa.config.json is not readable: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Obtain the one credential that unlocks the provider.
|
|
58
|
+
*
|
|
59
|
+
* Walks `sources` in order. This is the only credential permitted in an OS
|
|
60
|
+
* keychain — it is a bootstrap, not a cached copy of anything.
|
|
61
|
+
* @param {{sources: string[], key: string|null}} bootstrap Bootstrap config.
|
|
62
|
+
* @returns {string|null} The token, or null when the provider needs none.
|
|
63
|
+
*/
|
|
64
|
+
function bootstrapToken(bootstrap) {
|
|
65
|
+
if (!bootstrap.key) return null;
|
|
66
|
+
for (const source of bootstrap.sources) {
|
|
67
|
+
if (source === "env") {
|
|
68
|
+
const v = (process.env[bootstrap.key] ?? "").trim();
|
|
69
|
+
if (v) return v;
|
|
70
|
+
}
|
|
71
|
+
if (source === "keychain" && process.platform === "darwin") {
|
|
72
|
+
try {
|
|
73
|
+
const v = execFileSync(
|
|
74
|
+
"security",
|
|
75
|
+
[
|
|
76
|
+
"find-generic-password",
|
|
77
|
+
"-s",
|
|
78
|
+
bootstrap.key,
|
|
79
|
+
"-a",
|
|
80
|
+
process.env.USER ?? "",
|
|
81
|
+
"-w",
|
|
82
|
+
],
|
|
83
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
|
84
|
+
).trim();
|
|
85
|
+
if (v) return v;
|
|
86
|
+
} catch {
|
|
87
|
+
/* not present in the keychain; try the next source */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw new Error(
|
|
92
|
+
`${bootstrap.key} not found in: ${bootstrap.sources.join(", ")}.\n` +
|
|
93
|
+
`It is the bootstrap credential — without it no other secret can be read.`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Fetch every secret the provider grants, keyed by name.
|
|
99
|
+
*
|
|
100
|
+
* Deliberately fetches all rather than one: the provider already scopes what
|
|
101
|
+
* this caller may see, so the set it returns *is* the permitted set, and
|
|
102
|
+
* re-stating that as a list in config would duplicate a boundary the provider
|
|
103
|
+
* already enforces.
|
|
104
|
+
* @param {ReturnType<typeof readConfig>} cfg Resolved config.
|
|
105
|
+
* @returns {Map<string, {value: string, note: string}>} Secrets by name.
|
|
106
|
+
*/
|
|
107
|
+
function fetchAll(cfg) {
|
|
108
|
+
const out = new Map();
|
|
109
|
+
const token = bootstrapToken(cfg.bootstrap);
|
|
110
|
+
const env = { ...process.env };
|
|
111
|
+
if (cfg.bootstrap.key && token) env[cfg.bootstrap.key] = token;
|
|
112
|
+
|
|
113
|
+
switch (cfg.provider) {
|
|
114
|
+
case "env":
|
|
115
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
116
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(k))
|
|
117
|
+
out.set(k, { value: v ?? "", note: "" });
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
|
|
121
|
+
case "bitwarden": {
|
|
122
|
+
const raw = execFileSync("bws", ["secret", "list", "--output", "json"], {
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
env,
|
|
125
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
126
|
+
});
|
|
127
|
+
for (const s of JSON.parse(raw || "[]")) {
|
|
128
|
+
out.set(s.key, { value: s.value, note: s.note ?? "" });
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case "doppler": {
|
|
134
|
+
const raw = execFileSync(
|
|
135
|
+
"doppler",
|
|
136
|
+
["secrets", "download", "--no-file", "--format", "json"],
|
|
137
|
+
{
|
|
138
|
+
encoding: "utf8",
|
|
139
|
+
env,
|
|
140
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
for (const [k, v] of Object.entries(JSON.parse(raw || "{}"))) {
|
|
144
|
+
out.set(k, { value: String(v), note: "" });
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
default:
|
|
150
|
+
throw new Error(
|
|
151
|
+
`provider "${cfg.provider}" has no bulk read implemented yet.\n` +
|
|
152
|
+
`Add one in resolve-secret.mjs — see the dispatch table in SKILL.md.`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Resolve one secret: environment first, then the provider.
|
|
159
|
+
* @param {string} name Environment-variable-style key.
|
|
160
|
+
* @param {ReturnType<typeof readConfig>} [cfg] Resolved config.
|
|
161
|
+
* @returns {string} The secret value.
|
|
162
|
+
*/
|
|
163
|
+
export function get(name, cfg = readConfig()) {
|
|
164
|
+
const fromEnv = (process.env[name] ?? "").trim();
|
|
165
|
+
if (fromEnv) return fromEnv;
|
|
166
|
+
|
|
167
|
+
// A `require` list is an assertion, not just a filter: naming a secret
|
|
168
|
+
// declares that this project needs it, so asking for one outside the list is
|
|
169
|
+
// a configuration error rather than a lookup miss.
|
|
170
|
+
if (cfg.require && !cfg.require.includes(name)) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`${name} is not declared in secrets.require.\n` +
|
|
173
|
+
`Declared: ${cfg.require.join(", ") || "(none)"}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const all = fetchAll(cfg);
|
|
178
|
+
const hit = all.get(name);
|
|
179
|
+
if (!hit || !hit.value) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`${name} is not available to this account.\n` +
|
|
182
|
+
`Visible: ${[...all.keys()].sort().join(", ") || "(none)"}\n` +
|
|
183
|
+
`A secret's key must be the exact environment variable name.`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return hit.value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function main() {
|
|
190
|
+
const [op, name] = process.argv.slice(2);
|
|
191
|
+
const cfg = readConfig();
|
|
192
|
+
|
|
193
|
+
if (op === "get") {
|
|
194
|
+
if (!name) throw new Error("usage: resolve-secret.mjs get NAME");
|
|
195
|
+
process.stdout.write(get(name, cfg));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (op === "list") {
|
|
199
|
+
// Names only. This command must never be able to leak a value.
|
|
200
|
+
console.log([...fetchAll(cfg).keys()].sort().join("\n"));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (op === "describe") {
|
|
204
|
+
if (!name) throw new Error("usage: resolve-secret.mjs describe NAME");
|
|
205
|
+
const hit = fetchAll(cfg).get(name);
|
|
206
|
+
if (hit) {
|
|
207
|
+
console.log(
|
|
208
|
+
hit.note || `(no note — purpose inferred from the name: ${name})`
|
|
209
|
+
);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// Environment-only is a legitimate state, not an error: CI injects secrets
|
|
213
|
+
// that never appear in the provider listing. Say so rather than claiming
|
|
214
|
+
// the secret does not exist, and never print the value.
|
|
215
|
+
if ((process.env[name] ?? "").trim()) {
|
|
216
|
+
console.log(
|
|
217
|
+
`(set in the environment; no provider entry, so no note is available)`
|
|
218
|
+
);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
throw new Error(`${name} not found in the environment or the provider`);
|
|
222
|
+
}
|
|
223
|
+
if (op === "verify") {
|
|
224
|
+
const all = fetchAll(cfg);
|
|
225
|
+
const names = cfg.require ?? [...all.keys()];
|
|
226
|
+
let bad = 0;
|
|
227
|
+
for (const n of names) {
|
|
228
|
+
// Mirror get()'s environment-first order. Checking only the provider
|
|
229
|
+
// reports MISSING for every secret in CI, where the pipeline injects
|
|
230
|
+
// them as environment variables — a false negative precisely where
|
|
231
|
+
// verification matters most.
|
|
232
|
+
const fromEnv = Boolean((process.env[n] ?? "").trim());
|
|
233
|
+
const entry = all.get(n);
|
|
234
|
+
const ok = fromEnv || Boolean(entry?.value);
|
|
235
|
+
const named = /^[A-Z][A-Z0-9_]*$/.test(n);
|
|
236
|
+
const noted = Boolean(entry?.note);
|
|
237
|
+
if (!ok || !named) bad += 1;
|
|
238
|
+
const source = fromEnv
|
|
239
|
+
? "env "
|
|
240
|
+
: entry?.value
|
|
241
|
+
? "provider"
|
|
242
|
+
: " ";
|
|
243
|
+
console.log(
|
|
244
|
+
` ${n.padEnd(30)} ${ok ? "resolves" : "MISSING "} ${source} ` +
|
|
245
|
+
`${named ? "name ok" : "NAME NOT UPPER_SNAKE"} ${noted ? "noted" : "no note"}`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (bad) throw new Error(`${bad} secret(s) failed verification`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
throw new Error("usage: resolve-secret.mjs get|list|describe|verify [NAME]");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
255
|
+
try {
|
|
256
|
+
main();
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.error(err.message);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lisa-secrets-access
|
|
3
|
+
description: "Vendor-neutral access layer for secrets. Every skill and script that needs an API key MUST resolve it through this skill rather than reading a keychain, an .env file, or a provider CLI directly. Resolves the environment first so CI injection wins, then the configured provider — Bitwarden Secrets Manager, 1Password, AWS Secrets Manager, Doppler or Vault — looked up by key name. Enforces one store per secret, reads usage metadata from the provider's own note field, and never writes."
|
|
4
|
+
allowed-tools: ["Bash", "Read", "Skill"]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Secrets Access: $ARGUMENTS
|
|
8
|
+
|
|
9
|
+
Single chokepoint for reading credentials. Caller skills MUST go through this — they MUST NOT read the OS keychain, parse a `.env`, or invoke a provider CLI themselves.
|
|
10
|
+
|
|
11
|
+
The rule this exists to enforce: **a secret lives in exactly one store.** Every local cache is a copy that will eventually drift from its source, and a drifted copy is indistinguishable from a valid one until something fails in production.
|
|
12
|
+
|
|
13
|
+
## Invocation contract
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
operation: get name: ATTIO_API_KEY
|
|
17
|
+
operation: list # names only, never values
|
|
18
|
+
operation: describe name: ATTIO_API_KEY # the usage note, not the value
|
|
19
|
+
operation: verify # every declared secret resolves
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`get` returns the value on stdout and nothing else. `list` and `describe` never emit a secret value.
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
In `.lisa.config.json`:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"secrets": {
|
|
31
|
+
"provider": "bitwarden",
|
|
32
|
+
"bootstrap": { "sources": ["env", "keychain"], "key": "BWS_ACCESS_TOKEN" },
|
|
33
|
+
"require": ["ATTIO_API_KEY", "SLACK_WEBHOOK_URL"]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**`provider`** — `bitwarden` | `1password` | `aws` | `doppler` | `vault` | `env`.
|
|
39
|
+
|
|
40
|
+
**`bootstrap`** — how to obtain the one credential that unlocks the rest. `sources` is ordered: environment first, so CI injection wins over any local copy. This is the **only** credential permitted in a keychain; it is a bootstrap, not a cache.
|
|
41
|
+
|
|
42
|
+
**`require`** — optional. Omit it and every secret the provider grants is available, which is correct when the provider already scopes access per project. Present, it narrows to exactly those names **and asserts them**: a listed name that does not resolve is a startup error, not a late surprise. Use it when a repo needs a subset of a deliberately broader project.
|
|
43
|
+
|
|
44
|
+
There is no map of secret IDs, deliberately. Copying an ID per secret is the same duplication in a smaller costume, and lookup is by name.
|
|
45
|
+
|
|
46
|
+
## The naming convention this rests on
|
|
47
|
+
|
|
48
|
+
**A secret's key is the exact environment-variable name.** No inference, no fuzzy matching, no case folding. A secret named `attio-prod` will not resolve for `ATTIO_API_KEY`, and the error says so rather than silently returning nothing.
|
|
49
|
+
|
|
50
|
+
Enforce it: `doctor` should warn on any key that is not a valid `UPPER_SNAKE_CASE` identifier.
|
|
51
|
+
|
|
52
|
+
## Workflow
|
|
53
|
+
|
|
54
|
+
### Step 1 — Environment first
|
|
55
|
+
|
|
56
|
+
If `$<NAME>` is set and non-empty, return it. This is how CI injects secrets, and it means a scheduled run never touches the provider or a local store.
|
|
57
|
+
|
|
58
|
+
### Step 2 — Bootstrap
|
|
59
|
+
|
|
60
|
+
Resolve the bootstrap credential by walking `bootstrap.sources` in order. Fail with an actionable message naming where it was looked for.
|
|
61
|
+
|
|
62
|
+
### Step 3 — Provider lookup by name
|
|
63
|
+
|
|
64
|
+
| Provider | Read |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `bitwarden` | `bws secret list` → index by `key` |
|
|
67
|
+
| `1password` | `op read "op://<vault>/<name>/credential"` |
|
|
68
|
+
| `aws` | `aws secretsmanager get-secret-value --secret-id <name>` |
|
|
69
|
+
| `doppler` | `doppler secrets get <name> --plain` |
|
|
70
|
+
| `vault` | `vault kv get -field=<name> <path>` |
|
|
71
|
+
| `env` | environment only; the provider is the environment |
|
|
72
|
+
|
|
73
|
+
Cache **in-process only**. Never write a resolved value to disk — that recreates the problem this skill exists to remove.
|
|
74
|
+
|
|
75
|
+
### Step 4 — Fail loudly and usefully
|
|
76
|
+
|
|
77
|
+
A missing name reports what *is* visible, so the caller can see immediately whether the secret is absent or merely misnamed:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
GOOGLE_SERVICE_ACCOUNT_JSON is not available to this account.
|
|
81
|
+
Visible: APOLLO_API_KEY, ATTIO_API_KEY, SLACK_WEBHOOK_URL
|
|
82
|
+
A secret's key must be the exact environment variable name.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Usage metadata lives on the secret
|
|
86
|
+
|
|
87
|
+
Every provider has a description field — Bitwarden `note`, 1Password notes, AWS `Description`, Doppler notes, Vault custom metadata. **That is where a secret's usage documentation belongs**, not in a config file: a note travels with the secret and therefore cannot drift from it, which is precisely the property config lacks.
|
|
88
|
+
|
|
89
|
+
Format — first line prose, then `key: value` lines:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
Attio CRM - system of record for the sales funnel.
|
|
93
|
+
scope: object_configuration, record, list_entry - read-write
|
|
94
|
+
owner: <name>
|
|
95
|
+
ci: yes - injected by <mechanism>
|
|
96
|
+
docs: <path>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`describe` returns this. When a note is empty, **infer purpose from the name, mark it inferred, and report the gap** — infer *and* warn, never instead of. A silent fallback that works well enough guarantees the notes stay empty forever.
|
|
100
|
+
|
|
101
|
+
**An inferred mapping must never authorise a write.** It orients a reader; it does not pick which credential calls a production API. `ATTIO_API_KEY` versus `ATTIO_API_KEY_STAGING` is exactly the guess that silently writes to the wrong system.
|
|
102
|
+
|
|
103
|
+
## This skill never writes
|
|
104
|
+
|
|
105
|
+
No create, no update, no rotate. Writing secrets or their notes requires an authority a CI credential should not hold, and a read-only path cannot be turned against the vault if it leaks.
|
|
106
|
+
|
|
107
|
+
Where a rotating credential must persist a new value — an OAuth refresh token that the issuer replaces on every use — that is a **separate, deliberately-scoped writer**, and exactly one process may hold that loop. Two refreshers race, and each invalidates the other's token silently.
|
|
108
|
+
|
|
109
|
+
## Doctor checks worth wiring
|
|
110
|
+
|
|
111
|
+
- Every name in `require` resolves.
|
|
112
|
+
- Every key matches `^[A-Z][A-Z0-9_]*$`.
|
|
113
|
+
- No secret has an empty note.
|
|
114
|
+
- **No secret is readable from two stores.** A value present in both the provider and a local cache is not a duplicate — it is **two live credentials**, one of which is untracked. This is the check most worth having: it catches drift before a deletion turns the forgotten copy into an orphan nobody can revoke.
|
|
115
|
+
|
|
116
|
+
## Rules
|
|
117
|
+
|
|
118
|
+
1. **Never read a keychain, `.env`, or provider CLI outside this skill.** One chokepoint is what makes the single-store rule enforceable.
|
|
119
|
+
2. **Never log a secret value.** Print a length or a hash prefix when proving identity.
|
|
120
|
+
3. **Never write a resolved value to disk**, including "temporary" files.
|
|
121
|
+
4. **Verify a credential when it is stored, not when it is first used.** An unverified credential is indistinguishable from a broken one, and the gap between the two is measured in weeks.
|
|
122
|
+
5. **Treat a mismatch as stop-and-ask.** If a value differs between two places, they are two live credentials — not a stale copy to be discarded. Deleting the one you cannot verify leaves a working credential that no record accounts for.
|