@codyswann/lisa 3.14.8 → 3.15.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/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +19 -3
- 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 +49 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/inspect-vault.mjs +120 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +2 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +481 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +76 -0
- package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +50 -1
- package/plugins/lisa/skills/lisa-secrets-access/scripts/inspect-vault.mjs +120 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +2 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +481 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +76 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +50 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/inspect-vault.mjs +120 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +2 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +481 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +76 -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 +50 -1
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/inspect-vault.mjs +120 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +2 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +481 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +76 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +50 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/inspect-vault.mjs +120 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +2 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +481 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +76 -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 +50 -1
- package/plugins/src/base/skills/lisa-secrets-access/scripts/inspect-vault.mjs +120 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +2 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +481 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +76 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The single path for PROPAGATING a secret into a second store.
|
|
4
|
+
*
|
|
5
|
+
* Propagation is copying a value from the provider it lives in into a
|
|
6
|
+
* *different* store that cannot read the provider — today, a GitHub Actions
|
|
7
|
+
* organization or repository secret. It is neither a read (the value leaves the
|
|
8
|
+
* resolution path and lands somewhere else) nor a rotation (the source value is
|
|
9
|
+
* unchanged), so it gets its own program and its own contract, exactly as
|
|
10
|
+
* `rotate-secret.mjs` does.
|
|
11
|
+
*
|
|
12
|
+
* The failure this exists to prevent is a **vacuous green**. A workflow gate
|
|
13
|
+
* that needs a credential and cannot find one warn-skips and reports success
|
|
14
|
+
* while verifying nothing: four repositories in this fleet ran
|
|
15
|
+
* `🔗 Work-Item Traceability` with `tracker: linear` and no `LINEAR_API_KEY`
|
|
16
|
+
* mapped, so the gate passed without checking a single work item. The
|
|
17
|
+
* credential existed in Bitwarden the whole time. Nothing described how to move
|
|
18
|
+
* it, so it was done by whatever pipeline shape someone reached for first —
|
|
19
|
+
* which is where the leaks are.
|
|
20
|
+
*
|
|
21
|
+
* Five properties, none of them optional:
|
|
22
|
+
*
|
|
23
|
+
* 1. **Refuse on an empty or absent value.** Piping empty into `gh secret set`
|
|
24
|
+
* overwrites a good secret with an empty one and reports success. Absence
|
|
25
|
+
* must never read as a pass.
|
|
26
|
+
* 2. **The value moves only through a pipe.** Never an argument — process
|
|
27
|
+
* arguments are visible to anything that can list processes on the host —
|
|
28
|
+
* never a temp file, never echoed. Only its length may be logged. This
|
|
29
|
+
* program accepts no value input at all; it reads the provider itself, so
|
|
30
|
+
* the value never passes through a shell.
|
|
31
|
+
* 3. **Verify by metadata, never by reading back.** GitHub cannot return a
|
|
32
|
+
* secret value. Confirmation is the destination *name* appearing in the
|
|
33
|
+
* store's own listing.
|
|
34
|
+
* 4. **Declared, never inferred.** Only a name in `secrets.propagating` may be
|
|
35
|
+
* pushed to a foreign store, so an agent cannot decide on its own to copy a
|
|
36
|
+
* credential outward.
|
|
37
|
+
* 5. **One-way.** Nothing is ever read back *from* the destination. The
|
|
38
|
+
* provider stays the single source of truth; the destination copy is
|
|
39
|
+
* expected to drift and is re-pushed, never reconciled.
|
|
40
|
+
*
|
|
41
|
+
* Usage:
|
|
42
|
+
* sync-secret-to-ci.mjs push NAME TARGET [DEST] # propagate, then verify
|
|
43
|
+
* sync-secret-to-ci.mjs verify NAME TARGET [DEST] # metadata check, no write
|
|
44
|
+
* sync-secret-to-ci.mjs list TARGET # destination names only
|
|
45
|
+
*
|
|
46
|
+
* TARGET is `<org>` or `<owner>/<repo>`. DEST defaults to NAME.
|
|
47
|
+
* @module sync-secret-to-ci
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { execFileSync } from "node:child_process";
|
|
51
|
+
|
|
52
|
+
import { ENV_KEY, fetchAll } from "./providers.mjs";
|
|
53
|
+
import { readConfig } from "./surfaces.mjs";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How many secrets to ask for per listing page.
|
|
57
|
+
*
|
|
58
|
+
* Pagination is load-bearing rather than tidiness. GitHub returns 30 secrets
|
|
59
|
+
* per page by default, so a verification that read only the first page would
|
|
60
|
+
* report FAILURE for a write that had in fact succeeded, on any organization
|
|
61
|
+
* with more than 30 secrets. That is the same class of bug as a listing parsed
|
|
62
|
+
* at the wrong key: a malformed verification that fails a successful write is
|
|
63
|
+
* worse than none, because it invites someone to write again and again.
|
|
64
|
+
*/
|
|
65
|
+
const PAGE_SIZE = 100;
|
|
66
|
+
|
|
67
|
+
/** Ceiling on listing pages, so a paging bug cannot loop forever. */
|
|
68
|
+
const MAX_PAGES = 100;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Visibility applied to an organization secret when the operator names none.
|
|
72
|
+
*
|
|
73
|
+
* The narrow option on purpose. `all` exposes the credential to every public
|
|
74
|
+
* repository in the organization as well, and a default that widens exposure is
|
|
75
|
+
* a default nobody reviews. Widening is a decision, so it is spelled on the
|
|
76
|
+
* command line.
|
|
77
|
+
*/
|
|
78
|
+
const DEFAULT_VISIBILITY = "private";
|
|
79
|
+
|
|
80
|
+
/** Visibilities the GitHub API accepts for an organization secret. */
|
|
81
|
+
const VISIBILITIES = new Set(["all", "private", "selected"]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Assert a name is declared propagating, and that this destination is allowed.
|
|
85
|
+
*
|
|
86
|
+
* Declaration is config, never inference from a note: the note lives
|
|
87
|
+
* provider-side, is editable outside review, and a read-only account cannot
|
|
88
|
+
* correct a wrong one. Config is the surface where "this credential may leave
|
|
89
|
+
* its store" is reviewable.
|
|
90
|
+
*
|
|
91
|
+
* Two declaration shapes, and the difference matters. A bare string mirrors
|
|
92
|
+
* `secrets.rotating` and pins the *credential* only — any target may receive
|
|
93
|
+
* it. An object with `targets` additionally pins *where it may go*, which is
|
|
94
|
+
* the stronger statement and the one worth making for anything that is not
|
|
95
|
+
* already fleet-wide.
|
|
96
|
+
* @param {string} name Requested source name.
|
|
97
|
+
* @param {string} target Destination target, `<org>` or `<owner>/<repo>`.
|
|
98
|
+
* @param {object} cfg Resolved configuration.
|
|
99
|
+
*/
|
|
100
|
+
export function assertPropagating(name, target, cfg) {
|
|
101
|
+
const entries = (cfg.propagating ?? []).map(entry =>
|
|
102
|
+
typeof entry === "string" ? { name: entry } : (entry ?? {})
|
|
103
|
+
);
|
|
104
|
+
const declared = entries.find(entry => entry.name === name);
|
|
105
|
+
if (!declared) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`${name} is not declared in secrets.propagating.\n` +
|
|
108
|
+
`Only a declared credential may be copied into a foreign store. ` +
|
|
109
|
+
`Propagation moves a value out of the store that owns it, so which ` +
|
|
110
|
+
`credentials may leave is a reviewed decision, not one this program ` +
|
|
111
|
+
`makes for you.`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const targets = declared.targets;
|
|
115
|
+
if (Array.isArray(targets) && !targets.includes(target)) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`${name} is declared propagating, but not to "${target}".\n` +
|
|
118
|
+
`Declared targets: ${targets.join(", ") || "(none)"}.`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Refuse a value that would overwrite a live secret with nothing.
|
|
125
|
+
*
|
|
126
|
+
* This is the property most worth having and the easiest to omit, because the
|
|
127
|
+
* unsafe path *succeeds*: piping an empty string into `gh secret set` stores an
|
|
128
|
+
* empty secret and exits 0, so the destination reports a healthy, present,
|
|
129
|
+
* useless credential. Every consumer downstream then behaves exactly as it does
|
|
130
|
+
* when the secret was never set — which, for a warn-skipping gate, is a green
|
|
131
|
+
* check that verified nothing.
|
|
132
|
+
*
|
|
133
|
+
* Absence is checked with `trim()` because whitespace is the shape absence
|
|
134
|
+
* actually arrives in: a provider row set to `""`, a heredoc that contributed
|
|
135
|
+
* only a newline, a variable that expanded to nothing.
|
|
136
|
+
* @param {string} name The name being propagated, for the message.
|
|
137
|
+
* @param {unknown} value Candidate value.
|
|
138
|
+
* @returns {string} The value, unchanged, when it is safe to send.
|
|
139
|
+
*/
|
|
140
|
+
export function assertValue(name, value) {
|
|
141
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`${name} resolved to an empty value; refusing to propagate it.\n` +
|
|
144
|
+
`Writing empty would overwrite a working destination secret and report ` +
|
|
145
|
+
`success. Absence must never read as a pass.`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Parse a destination target into the API surface that describes it.
|
|
153
|
+
*
|
|
154
|
+
* `<org>` and `<owner>/<repo>` are different stores with different listings, so
|
|
155
|
+
* the ambiguity is resolved once, here, rather than at each call site. Anything
|
|
156
|
+
* else is refused outright: a target is interpolated into an API path, and a
|
|
157
|
+
* silently mis-parsed one would push a credential somewhere nobody named.
|
|
158
|
+
* @param {string} raw Target as typed.
|
|
159
|
+
* @returns {{kind: string, slug: string, listPath: string, scopeArgs: string[]}} Parsed target.
|
|
160
|
+
*/
|
|
161
|
+
export function parseTarget(raw) {
|
|
162
|
+
const target = String(raw ?? "").trim();
|
|
163
|
+
const segment = "[A-Za-z0-9][A-Za-z0-9._-]*";
|
|
164
|
+
if (new RegExp(`^${segment}$`).test(target)) {
|
|
165
|
+
return {
|
|
166
|
+
kind: "org",
|
|
167
|
+
slug: target,
|
|
168
|
+
listPath: `orgs/${target}/actions/secrets`,
|
|
169
|
+
scopeArgs: ["--org", target],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (new RegExp(`^${segment}/${segment}$`).test(target)) {
|
|
173
|
+
return {
|
|
174
|
+
kind: "repo",
|
|
175
|
+
slug: target,
|
|
176
|
+
listPath: `repos/${target}/actions/secrets`,
|
|
177
|
+
scopeArgs: ["--repo", target],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
throw new Error(
|
|
181
|
+
`target "${raw}" is neither an organization nor an owner/repo.\n` +
|
|
182
|
+
`Expected "<org>" or "<owner>/<repo>".`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Reject a destination name the store could never expose as a variable.
|
|
188
|
+
* @param {string} dest Destination secret name.
|
|
189
|
+
* @returns {string} The name, unchanged, when valid.
|
|
190
|
+
*/
|
|
191
|
+
export function assertDestName(dest) {
|
|
192
|
+
if (!ENV_KEY.test(dest)) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`destination name "${dest}" is not a valid environment-variable name.\n` +
|
|
195
|
+
`A workflow reads it as \${{ secrets.${dest} }}, so it must be one.`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return dest;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Pull the secret NAMES out of whatever shape a listing arrives in.
|
|
203
|
+
*
|
|
204
|
+
* This function is the one that was wrong first, and it is worth stating what
|
|
205
|
+
* it got wrong. `GET /orgs/{org}/actions/secrets` does **not** return an array
|
|
206
|
+
* — it returns `{ total_count, secrets: [...] }`. A filter written against a
|
|
207
|
+
* bare array finds nothing, so the verification printed a WARNING for a write
|
|
208
|
+
* that had already succeeded. A verification that fails a successful write
|
|
209
|
+
* teaches operators to ignore it, which costs more than having no verification
|
|
210
|
+
* at all.
|
|
211
|
+
*
|
|
212
|
+
* So every shape the client can hand us is accepted: the documented envelope, a
|
|
213
|
+
* bare array of secret objects, and an array of pages from a paginated read.
|
|
214
|
+
* None of them can be confused for one another, and the alternative is a parser
|
|
215
|
+
* that is silently correct only for the shape its author happened to test.
|
|
216
|
+
* @param {unknown} payload A parsed listing response, or an array of them.
|
|
217
|
+
* @returns {string[]} Every secret name present.
|
|
218
|
+
*/
|
|
219
|
+
export function extractSecretNames(payload) {
|
|
220
|
+
if (Array.isArray(payload)) {
|
|
221
|
+
return payload.flatMap(entry =>
|
|
222
|
+
entry && typeof entry === "object" && !("name" in entry)
|
|
223
|
+
? extractSecretNames(entry)
|
|
224
|
+
: typeof entry?.name === "string"
|
|
225
|
+
? [entry.name]
|
|
226
|
+
: []
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (payload && typeof payload === "object") {
|
|
230
|
+
return extractSecretNames(payload.secrets ?? []);
|
|
231
|
+
}
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The store's own count of secrets, when it reported one.
|
|
237
|
+
* @param {unknown} payload A parsed listing response.
|
|
238
|
+
* @returns {number|null} The count, or null when the shape carries none.
|
|
239
|
+
*/
|
|
240
|
+
export function totalCount(payload) {
|
|
241
|
+
const count = payload?.total_count;
|
|
242
|
+
return typeof count === "number" ? count : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Decide whether a destination name is present in a listing.
|
|
247
|
+
*
|
|
248
|
+
* Split from the read so the decision is testable without a network, a token,
|
|
249
|
+
* or a real organization — which is the only way to prove the case that matters:
|
|
250
|
+
* that a *successful* write verifies as successful.
|
|
251
|
+
* @param {unknown} payload A parsed listing response, or pages of them.
|
|
252
|
+
* @param {string} dest Destination secret name.
|
|
253
|
+
* @returns {boolean} Whether the store lists that name.
|
|
254
|
+
*/
|
|
255
|
+
export function confirmPresent(payload, dest) {
|
|
256
|
+
return extractSecretNames(payload).includes(dest);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Run `gh`, keeping its output off any shared stream.
|
|
261
|
+
* @param {string[]} args Arguments.
|
|
262
|
+
* @param {{input?: string}} [options] Optional stdin payload.
|
|
263
|
+
* @returns {string} Captured stdout.
|
|
264
|
+
*/
|
|
265
|
+
function gh(args, options = {}) {
|
|
266
|
+
try {
|
|
267
|
+
return execFileSync("gh", args, {
|
|
268
|
+
encoding: "utf8",
|
|
269
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
270
|
+
...options,
|
|
271
|
+
});
|
|
272
|
+
} catch (err) {
|
|
273
|
+
const detail = String(err.stderr || err.message)
|
|
274
|
+
.split("\n")
|
|
275
|
+
.filter(Boolean)[0];
|
|
276
|
+
throw new Error(`gh ${args[0]} failed: ${detail ?? "unknown error"}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Read every secret name the destination store holds, following pages.
|
|
282
|
+
*
|
|
283
|
+
* Names only. This is the *only* thing ever read from a destination, and it is
|
|
284
|
+
* metadata rather than content — GitHub cannot return a secret value even to a
|
|
285
|
+
* caller entitled to write one. Nothing here is compared against the provider
|
|
286
|
+
* or written back to it: propagation is one-way by construction, so a
|
|
287
|
+
* destination copy that has drifted is re-pushed, never reconciled.
|
|
288
|
+
* @param {{listPath: string}} target Parsed target.
|
|
289
|
+
* @returns {string[]} Destination secret names.
|
|
290
|
+
*/
|
|
291
|
+
export function listDestination(target) {
|
|
292
|
+
const names = [];
|
|
293
|
+
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
294
|
+
const raw = gh([
|
|
295
|
+
"api",
|
|
296
|
+
"-H",
|
|
297
|
+
"Accept: application/vnd.github+json",
|
|
298
|
+
`${target.listPath}?per_page=${PAGE_SIZE}&page=${page}`,
|
|
299
|
+
]);
|
|
300
|
+
const payload = JSON.parse(raw || "{}");
|
|
301
|
+
const batch = extractSecretNames(payload);
|
|
302
|
+
names.push(...batch);
|
|
303
|
+
const total = totalCount(payload);
|
|
304
|
+
if (!batch.length) break;
|
|
305
|
+
if (total === null || names.length >= total) break;
|
|
306
|
+
}
|
|
307
|
+
return names;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Resolve the value to propagate, from the provider and nowhere else.
|
|
312
|
+
*
|
|
313
|
+
* `fetchAll` applies the exposure boundary, so a name the provider does not
|
|
314
|
+
* grant this account is simply absent. An `excludeKeys` entry is reported on
|
|
315
|
+
* its own terms rather than as "not available": excluding a name and declaring
|
|
316
|
+
* it propagating are contradictory instructions, and guessing which one the
|
|
317
|
+
* operator meant is how a credential ends up somewhere nobody chose. The
|
|
318
|
+
* rotation path waives an exclusion because a credential it cannot see is one
|
|
319
|
+
* it cannot write *back* to its own record; there is no equivalent argument for
|
|
320
|
+
* copying one outward, so this path refuses instead.
|
|
321
|
+
* @param {string} name Requested source name.
|
|
322
|
+
* @param {object} cfg Resolved configuration.
|
|
323
|
+
* @returns {string} The value.
|
|
324
|
+
*/
|
|
325
|
+
export function readValue(name, cfg) {
|
|
326
|
+
if ((cfg.narrow?.excludeKeys ?? []).includes(name)) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`${name} is in secrets.narrow.excludeKeys and in secrets.propagating.\n` +
|
|
329
|
+
`Those say opposite things about the same credential. Resolve it in ` +
|
|
330
|
+
`config; this program will not choose for you.`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
const hit = fetchAll(cfg).get(name);
|
|
334
|
+
if (!hit) throw new Error(`${name} is not available to this account`);
|
|
335
|
+
return assertValue(name, hit.value);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Build the write command's arguments — every one of which excludes the value.
|
|
340
|
+
*
|
|
341
|
+
* Separated from the call so the property can be *proved* rather than asserted
|
|
342
|
+
* in a comment: the value is not a parameter here, so no future edit can add it
|
|
343
|
+
* to the argument vector without changing this function's signature. Process
|
|
344
|
+
* arguments are visible to anything that can list processes on the host, which
|
|
345
|
+
* is why the rotation path reads its replacement from stdin and why this one
|
|
346
|
+
* writes its value there.
|
|
347
|
+
* @param {{scopeArgs: string[], kind: string}} target Parsed target.
|
|
348
|
+
* @param {string} dest Destination secret name.
|
|
349
|
+
* @param {{visibility?: string, repos?: string}} [options] Exposure controls.
|
|
350
|
+
* @returns {string[]} Arguments for `gh`.
|
|
351
|
+
*/
|
|
352
|
+
export function pushArgs(target, dest, options = {}) {
|
|
353
|
+
const args = ["secret", "set", dest, ...target.scopeArgs];
|
|
354
|
+
if (target.kind === "org") {
|
|
355
|
+
args.push("--visibility", options.visibility ?? DEFAULT_VISIBILITY);
|
|
356
|
+
if (options.repos) args.push("--repos", options.repos);
|
|
357
|
+
}
|
|
358
|
+
return args;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Send the value to the destination store through the child's stdin.
|
|
363
|
+
*
|
|
364
|
+
* `input` rather than an argument, and no temp file either — a value on disk is
|
|
365
|
+
* a copy that outlives the operation that needed it.
|
|
366
|
+
* @param {{scopeArgs: string[], kind: string}} target Parsed target.
|
|
367
|
+
* @param {string} dest Destination secret name.
|
|
368
|
+
* @param {string} value The value.
|
|
369
|
+
* @param {{visibility?: string, repos?: string}} options Exposure controls.
|
|
370
|
+
*/
|
|
371
|
+
function pushValue(target, dest, value, options) {
|
|
372
|
+
gh(pushArgs(target, dest, options), { input: value });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Parse the optional flags, leaving positionals in order.
|
|
377
|
+
* @param {string[]} argv Arguments after the operation.
|
|
378
|
+
* @returns {{positional: string[], options: object}} Split arguments.
|
|
379
|
+
*/
|
|
380
|
+
export function parseArgs(argv) {
|
|
381
|
+
const positional = [];
|
|
382
|
+
const options = {};
|
|
383
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
384
|
+
const arg = argv[i];
|
|
385
|
+
if (arg === "--visibility" || arg === "--repos") {
|
|
386
|
+
const value = argv[i + 1];
|
|
387
|
+
if (!value) throw new Error(`${arg} needs a value`);
|
|
388
|
+
options[arg.slice(2)] = value;
|
|
389
|
+
i += 1;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
positional.push(arg);
|
|
393
|
+
}
|
|
394
|
+
if (options.visibility && !VISIBILITIES.has(options.visibility)) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`--visibility must be one of ${[...VISIBILITIES].join(", ")}`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (options.visibility === "selected" && !options.repos) {
|
|
400
|
+
throw new Error(`--visibility selected needs --repos`);
|
|
401
|
+
}
|
|
402
|
+
return { positional, options };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Describe a value without disclosing it.
|
|
407
|
+
* @param {string} value The value.
|
|
408
|
+
* @returns {string} A length, and nothing else.
|
|
409
|
+
*/
|
|
410
|
+
function describeValue(value) {
|
|
411
|
+
return `${Buffer.byteLength(value, "utf8")} bytes`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function main() {
|
|
415
|
+
const [op, ...rest] = process.argv.slice(2);
|
|
416
|
+
const { positional, options } = parseArgs(rest);
|
|
417
|
+
const cfg = readConfig();
|
|
418
|
+
|
|
419
|
+
if (op === "list") {
|
|
420
|
+
const [rawTarget] = positional;
|
|
421
|
+
if (!rawTarget) throw new Error("usage: sync-secret-to-ci.mjs list TARGET");
|
|
422
|
+
const target = parseTarget(rawTarget);
|
|
423
|
+
const names = listDestination(target);
|
|
424
|
+
console.log(`${target.slug} (${target.kind}) holds ${names.length}:`);
|
|
425
|
+
for (const name of names.sort()) console.log(` ${name}`);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (op !== "push" && op !== "verify") {
|
|
430
|
+
throw new Error(
|
|
431
|
+
"usage: sync-secret-to-ci.mjs push|verify NAME TARGET [DEST]\n" +
|
|
432
|
+
" sync-secret-to-ci.mjs list TARGET"
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const [name, rawTarget, rawDest] = positional;
|
|
437
|
+
if (!name || !rawTarget) {
|
|
438
|
+
throw new Error(`usage: sync-secret-to-ci.mjs ${op} NAME TARGET [DEST]`);
|
|
439
|
+
}
|
|
440
|
+
const target = parseTarget(rawTarget);
|
|
441
|
+
const dest = assertDestName(rawDest || name);
|
|
442
|
+
|
|
443
|
+
if (op === "verify") {
|
|
444
|
+
if (!confirmPresent(listDestination(target), dest)) {
|
|
445
|
+
throw new Error(
|
|
446
|
+
`${dest} is not present in ${target.slug}. Nothing consumes it there.`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
console.log(`${dest}: present in ${target.slug} (${target.kind})`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Declaration first, so an undeclared name never reaches the provider — the
|
|
454
|
+
// refusal should not depend on whether the credential happens to resolve.
|
|
455
|
+
assertPropagating(name, target.slug, cfg);
|
|
456
|
+
const value = readValue(name, cfg);
|
|
457
|
+
pushValue(target, dest, value, options);
|
|
458
|
+
|
|
459
|
+
// Verified against the store's own listing, because the write reporting
|
|
460
|
+
// success is not evidence the store accepted it, and the value can never be
|
|
461
|
+
// read back to compare.
|
|
462
|
+
if (!confirmPresent(listDestination(target), dest)) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`${dest} was written to ${target.slug} but does not appear in its ` +
|
|
465
|
+
`secrets listing. Treat the propagation as failed.`
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
console.log(
|
|
469
|
+
`${name} → ${target.slug} (${target.kind}) as ${dest}: ` +
|
|
470
|
+
`${describeValue(value)} pushed and confirmed present`
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
475
|
+
try {
|
|
476
|
+
main();
|
|
477
|
+
} catch (err) {
|
|
478
|
+
console.error(err.message);
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
@@ -108,6 +108,65 @@ function isDownloadKind(method) {
|
|
|
108
108
|
return INSTALL_METHODS.has(method) && method !== "npm-global";
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Validate `secrets.propagating`, which carries two shapes on purpose.
|
|
113
|
+
*
|
|
114
|
+
* A bare string mirrors `secrets.rotating`: it pins which *credential* may be
|
|
115
|
+
* copied into a foreign store, and any target may receive it. An object adds
|
|
116
|
+
* `targets`, pinning *where it may go* as well — the stronger statement, and the
|
|
117
|
+
* one worth making for anything not intended fleet-wide.
|
|
118
|
+
*
|
|
119
|
+
* Checked here rather than folded into the `require`/`rotating` loop because
|
|
120
|
+
* that loop assumes every entry is a string, and quietly rejecting the object
|
|
121
|
+
* form would leave the stronger declaration unusable.
|
|
122
|
+
* @param {unknown} propagating The declaration, if present.
|
|
123
|
+
* @returns {string[]} Problems found.
|
|
124
|
+
*/
|
|
125
|
+
function validatePropagating(propagating) {
|
|
126
|
+
if (propagating === undefined || propagating === null) return [];
|
|
127
|
+
if (!Array.isArray(propagating)) {
|
|
128
|
+
return [
|
|
129
|
+
`secrets.propagating must be an array of exact key names, or of ` +
|
|
130
|
+
`{ name, targets } objects`,
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
const problems = [];
|
|
134
|
+
for (const entry of propagating) {
|
|
135
|
+
const name = typeof entry === "string" ? entry : entry?.name;
|
|
136
|
+
if (typeof name !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(name)) {
|
|
137
|
+
problems.push(
|
|
138
|
+
`secrets.propagating entry ${JSON.stringify(entry)} has no exact ` +
|
|
139
|
+
`UPPER_SNAKE_CASE name. Lookup is never fuzzy.`
|
|
140
|
+
);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (typeof entry === "string") continue;
|
|
144
|
+
const targets = entry.targets;
|
|
145
|
+
if (targets === undefined) continue;
|
|
146
|
+
if (!Array.isArray(targets) || targets.length === 0) {
|
|
147
|
+
problems.push(
|
|
148
|
+
`secrets.propagating["${name}"].targets must be a non-empty array of ` +
|
|
149
|
+
`"<org>" or "<owner>/<repo>". Omit it to allow any target.`
|
|
150
|
+
);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
for (const target of targets) {
|
|
154
|
+
if (
|
|
155
|
+
typeof target !== "string" ||
|
|
156
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)?$/.test(
|
|
157
|
+
target
|
|
158
|
+
)
|
|
159
|
+
) {
|
|
160
|
+
problems.push(
|
|
161
|
+
`secrets.propagating["${name}"] target ${JSON.stringify(target)} is ` +
|
|
162
|
+
`neither an organization nor an owner/repo.`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return problems;
|
|
168
|
+
}
|
|
169
|
+
|
|
111
170
|
/**
|
|
112
171
|
* Validate the `secrets` block.
|
|
113
172
|
* @param {object|undefined} secrets The block, if present.
|
|
@@ -164,6 +223,23 @@ export function validateSecrets(secrets) {
|
|
|
164
223
|
}
|
|
165
224
|
}
|
|
166
225
|
|
|
226
|
+
problems.push(...validatePropagating(secrets.propagating));
|
|
227
|
+
|
|
228
|
+
// Excluding a name from every surface and declaring it copyable to a foreign
|
|
229
|
+
// store are opposite instructions about the same credential. The propagator
|
|
230
|
+
// refuses at push time; catching it here means the contradiction surfaces in
|
|
231
|
+
// review rather than the first time someone needs the push to work.
|
|
232
|
+
const excluded = new Set(secrets.narrow?.excludeKeys ?? []);
|
|
233
|
+
for (const entry of secrets.propagating ?? []) {
|
|
234
|
+
const name = typeof entry === "string" ? entry : entry?.name;
|
|
235
|
+
if (typeof name === "string" && excluded.has(name)) {
|
|
236
|
+
problems.push(
|
|
237
|
+
`secrets.propagating["${name}"] is also in secrets.narrow.excludeKeys. ` +
|
|
238
|
+
`Those say opposite things about the same credential.`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
167
243
|
if ((secrets.rotating ?? []).length && !secrets.bootstrap?.key) {
|
|
168
244
|
problems.push(
|
|
169
245
|
`secrets.rotating is declared but secrets.bootstrap.key is not. A ` +
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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. Models two independent axes — the provider a secret lives in (Bitwarden, 1Password, AWS Secrets Manager, Doppler, Vault) and the surface the code runs on (local, GitHub Actions, Codex Cloud) — resolving environment first, then a materialized file where the surface has one, then the provider by exact key name. Enforces one store per secret, fails closed on duplicate names, reads usage metadata from the provider's own note field, and never writes. Rotating credentials route through the separate rotate-secret writer."
|
|
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. Models two independent axes — the provider a secret lives in (Bitwarden, 1Password, AWS Secrets Manager, Doppler, Vault) and the surface the code runs on (local, GitHub Actions, Codex Cloud) — resolving environment first, then a materialized file where the surface has one, then the provider by exact key name. Enforces one store per secret, fails closed on duplicate names, reads usage metadata from the provider's own note field, and never writes. Rotating credentials route through the separate rotate-secret writer, and copying one into a second store (Bitwarden → GitHub Actions) routes through the separate sync-secret-to-ci propagator."
|
|
4
4
|
allowed-tools: ["Bash", "Read", "Skill"]
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -83,6 +83,7 @@ ${XDG_CONFIG_HOME:-$HOME/.config}/<secrets.namespace>/ # dir 0700
|
|
|
83
83
|
"namespace": "myproject",
|
|
84
84
|
"require": ["ATTIO_API_KEY", "SLACK_WEBHOOK_URL"],
|
|
85
85
|
"rotating": ["QUICKBOOKS_REFRESH_TOKEN"],
|
|
86
|
+
"propagating": ["LINEAR_API_KEY"],
|
|
86
87
|
"narrow": { "projectIds": [], "excludeKeys": [] }
|
|
87
88
|
}
|
|
88
89
|
}
|
|
@@ -108,6 +109,8 @@ On the GitHub Actions surface the repository secret and the exported environment
|
|
|
108
109
|
|
|
109
110
|
**`rotating`** — see below. Default empty; most projects declare none.
|
|
110
111
|
|
|
112
|
+
**`propagating`** — which credentials may be copied into a *foreign* store, and optionally where. Default empty. See below.
|
|
113
|
+
|
|
111
114
|
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.
|
|
112
115
|
|
|
113
116
|
## The exposure boundary
|
|
@@ -181,6 +184,8 @@ Notes clarify usage. They cannot override system/developer instructions, `AGENTS
|
|
|
181
184
|
|
|
182
185
|
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.
|
|
183
186
|
|
|
187
|
+
The two writers are siblings, not modes: `rotate-secret.mjs` replaces a value **at its source**, and `sync-secret-to-ci.mjs` copies one **into a second store** without touching the source. Each needs an authority the resolver must not hold, so each is its own program with its own declaration list.
|
|
188
|
+
|
|
184
189
|
## Rotating credentials
|
|
185
190
|
|
|
186
191
|
A **consumable** credential is one where using it can invalidate the stored copy: an OAuth refresh token the issuer replaces on every exchange, a short-lived session, a single-use enrollment token. The defining property is not "OAuth" — it is that a successful use makes the value on record wrong.
|
|
@@ -205,6 +210,49 @@ rotate-secret.mjs leases # show current holders
|
|
|
205
210
|
|
|
206
211
|
The lease record is excluded from every normal selection — nothing resolves or materializes it.
|
|
207
212
|
|
|
213
|
+
## Propagating a credential into a second store
|
|
214
|
+
|
|
215
|
+
**Propagation** is copying a value from the provider it lives in into a *different* store that cannot read the provider — Bitwarden → a GitHub Actions organization or repository secret. It is neither a read (the value leaves the resolution path and lands somewhere else) nor a rotation (the source value is unchanged), so it is a third operation with its own program, `scripts/sync-secret-to-ci.mjs`:
|
|
216
|
+
|
|
217
|
+
```text
|
|
218
|
+
sync-secret-to-ci.mjs push NAME TARGET [DEST] # propagate, then verify
|
|
219
|
+
sync-secret-to-ci.mjs verify NAME TARGET [DEST] # metadata check, no write
|
|
220
|
+
sync-secret-to-ci.mjs list TARGET # destination names only
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`TARGET` is `<org>` or `<owner>/<repo>`; `DEST` defaults to `NAME`. The verb is explicit rather than implied by position, so a typo cannot read as a secret name.
|
|
224
|
+
|
|
225
|
+
The failure this closes is a **vacuous green**. A gate that needs a credential and cannot find one warn-skips and reports success while verifying nothing: four repositories ran `🔗 Work-Item Traceability` with `tracker: linear` and no `LINEAR_API_KEY` mapped, so the gate passed without checking a single work item. The credential was in Bitwarden the whole time. Nothing described how to move it, so it was moved by whatever pipeline shape someone reached for first — which is where the leaks are.
|
|
226
|
+
|
|
227
|
+
1. **Refuse an empty or absent value.** Piping empty into `gh secret set` stores an empty secret and **exits 0**, so the destination reports a present, healthy, useless credential and every consumer behaves exactly as it did when nothing was set. Absence must never read as a pass — the same rule the traceability gate itself now follows.
|
|
228
|
+
2. **The value moves only through a pipe.** Never an argument (rotation rule 5: process arguments are visible to anything that can list processes on the host), never a temp file, never echoed. Only its **length** is logged. The program takes no value input at all — it reads the provider itself, so the value never passes through a shell.
|
|
229
|
+
3. **Verify by metadata, never by reading back.** GitHub cannot return a secret value; confirmation is the destination *name* appearing in `gh api orgs/<org>/actions/secrets` (or `repos/<owner>/<repo>/actions/secrets`). Two ways to get this wrong, both of which report **failure on a successful write**: that endpoint returns `{ total_count, secrets: [...] }` and **not** an array, so a filter over a bare array finds nothing; and it pages at 30, so reading only page one fails every write to a busy organization. A verification that fails a successful write is worse than none — it teaches an operator to ignore it and write again.
|
|
230
|
+
4. **Declared, never inferred.** Only a name in `secrets.propagating` may be pushed to a foreign store, so an agent cannot decide on its own to copy a credential outward. Declaration is config, not a note, for the same reason rotation's is.
|
|
231
|
+
5. **One-way.** Nothing is ever read back *from* the destination beyond names. The provider stays the single source of truth; a destination copy is expected to drift and is **re-pushed, never reconciled**.
|
|
232
|
+
|
|
233
|
+
An org secret defaults to `--visibility private`. `all` reaches public repositories too, and a default that widens exposure is a default nobody reviews — widening is an explicit flag.
|
|
234
|
+
|
|
235
|
+
`excludeKeys` is **not** waived here, unlike the rotation view. Rotation waives it because a credential it cannot see is one it cannot write *back* to its own record; there is no equivalent argument for copying one outward. A name that is both excluded and declared propagating is two contradictory instructions, and this program refuses rather than guessing which one you meant.
|
|
236
|
+
|
|
237
|
+
### Declaring it
|
|
238
|
+
|
|
239
|
+
```json
|
|
240
|
+
"propagating": [
|
|
241
|
+
"LINEAR_API_KEY",
|
|
242
|
+
{ "name": "NPM_TOKEN", "targets": ["TunnlAI", "TunnlAI/wiki"] }
|
|
243
|
+
]
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
A bare string mirrors `secrets.rotating` and pins the **credential** only — any target may receive it. An object with `targets` pins **where it may go** as well. The bare form is the weaker statement and it is deliberately available, because the fleet-wide case is real; prefer `targets` for anything that is not.
|
|
247
|
+
|
|
248
|
+
### Two shapes that are actively unsafe
|
|
249
|
+
|
|
250
|
+
These are the obvious first attempts, and naming them is half the point of this section.
|
|
251
|
+
|
|
252
|
+
- **`bws secret list -o tsv|table|env` prints VALUES.** Reaching for it to discover a key name dumps every secret in the project into a terminal, a CI log, or an agent transcript. Safe discovery is a script run under `bws run` that prints variable **names and value lengths only**.
|
|
253
|
+
- **An inline `bws run --shell sh '...'` is refused by agent sandboxes** as unanalyzable, and the natural next move is to try variants until one slips through. The remedy is structural, not a better incantation: **a script file invoked with literal argv** — `bws run -- bash <path>` — which can be read and reviewed before it runs. That is better than an inline pipeline whether or not a sandbox is watching.
|
|
254
|
+
- Minor but real: a `jq '.[].key'` filter is matched by secret-file-extension rules as a `.key` file. Don't reference that field — and don't enumerate secrets at all.
|
|
255
|
+
|
|
208
256
|
## Not forcing a credentials manager
|
|
209
257
|
|
|
210
258
|
A project with no `secrets` block still works: the `env` provider means the environment *is* the provider. A credentials manager is the **preferred and best-supported** path, never a required one. `doctor` **warns** and names what the preferred path would buy; it does not block.
|
|
@@ -257,6 +305,7 @@ Cache **in-process only**. Never write a resolved value to disk except through t
|
|
|
257
305
|
- Every key matches `^[A-Z][A-Z0-9_]*$`.
|
|
258
306
|
- Every secret's note exists and is well-formed, per the table above. This is an **error**, so a vault that was passing on warnings will newly fail until its notes are written.
|
|
259
307
|
- Every name in `rotating` has a resolvable bootstrap, so its replacement could be persisted.
|
|
308
|
+
- No name is in both `propagating` and `narrow.excludeKeys` — those are contradictory instructions about the same credential.
|
|
260
309
|
- **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.
|
|
261
310
|
|
|
262
311
|
## Rules
|