keyshroud 0.5.3 → 0.6.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/CHANGELOG.md +12 -0
- package/README.md +30 -2
- package/dist/cli.js +151 -0
- package/dist/core.d.ts +16 -3
- package/dist/core.js +148 -7
- package/package.json +1 -1
- package/skills/keyshroud/SKILL.md +3 -0
- package/src/cli.ts +105 -1
- package/src/core.ts +134 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ All notable changes are documented here. This project follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.6.0] - 2026-10-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `setup --email`: keyshroud gets its own `bw` login on a dedicated agent account
|
|
12
|
+
(`~/.config/keyshroud/bw`, keychain entry `keyshroud-master-password`), so agents
|
|
13
|
+
can no longer unlock your personal vault. `--confirm-unlock` (macOS) makes the
|
|
14
|
+
keychain ask before every unlock. Run from an agent's shell, it generates the
|
|
15
|
+
agent account's password, stores it over stdin, and hands it to you on the
|
|
16
|
+
clipboard for the signup page.
|
|
17
|
+
- `migrate`: copies the items your wired names reference from the personal vault
|
|
18
|
+
into the agent account (dry run unless `--yes`, names only, originals kept).
|
|
19
|
+
|
|
8
20
|
## [0.5.3] - 2026-10-15
|
|
9
21
|
|
|
10
22
|
### Security
|
package/README.md
CHANGED
|
@@ -135,6 +135,32 @@ Optional: put `vaultwarden-api-client-id` / `vaultwarden-api-client-secret` (fro
|
|
|
135
135
|
also re-login automatically when the `bw` session expires. Linux keyring commands
|
|
136
136
|
and a headless-server option are in the [vault setup guide](examples/vaultwarden#4-let-keyshroud-unlock-without-prompting).
|
|
137
137
|
|
|
138
|
+
### Least privilege: a separate agent account
|
|
139
|
+
|
|
140
|
+
With the steps above, keyshroud holds your master password, so an agent that runs
|
|
141
|
+
`bw` as you can open your whole vault. `setup` gives keyshroud its own `bw` login
|
|
142
|
+
on a dedicated account instead, which holds only the secrets agents need:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
keyshroud setup --email you+agent@example.com # opens the signup page, then stores the new
|
|
146
|
+
# master password in the keychain and logs in
|
|
147
|
+
keyshroud migrate # dry run: which wired items would be copied
|
|
148
|
+
keyshroud migrate --yes # copy them; references keep working
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- The agent login lives in `~/.config/keyshroud/bw` (`BITWARDENCLI_APPDATA_DIR`), its master
|
|
152
|
+
password in the keychain entry `keyshroud-master-password`. Your own `bw` login is untouched.
|
|
153
|
+
- `migrate` copies the items that wired names (and any `--env-file` / `--item`) reference, with
|
|
154
|
+
the same names, then locks your personal vault again. Originals stay where they are. It needs
|
|
155
|
+
your personal master password in the keychain once; delete it afterwards (the command prints how).
|
|
156
|
+
- An agent can run `setup` too. Without a terminal, keyshroud generates the agent account's
|
|
157
|
+
master password, writes it to the keychain over stdin and puts it on the clipboard; you paste
|
|
158
|
+
it into the signup page, then the agent runs `setup --registered`, which clears the clipboard.
|
|
159
|
+
- `--confirm-unlock` (macOS, off by default) creates the keychain entry so macOS asks you before
|
|
160
|
+
every read, i.e. every vault unlock.
|
|
161
|
+
- Self-hosted Vaultwarden with `SIGNUPS_ALLOWED=false`: invite the address from the admin page
|
|
162
|
+
(`/admin`), or allow signups for a minute, then pass `--registered` once the account exists.
|
|
163
|
+
|
|
138
164
|
## How it works
|
|
139
165
|
|
|
140
166
|
```
|
|
@@ -274,6 +300,8 @@ keyshroud import [--from F|-] [--out F] [--prefix P] [--only A,B] [--skip A,B] [
|
|
|
274
300
|
keyshroud rotate --item ITEM [--stdin|--dialog]
|
|
275
301
|
keyshroud wire --env NAME --item ITEM [--target pi|omp|both] [--force]
|
|
276
302
|
keyshroud unwire --env NAME [--target pi|omp|both]
|
|
303
|
+
keyshroud setup --email EMAIL [--server URL] [--confirm-unlock] [--registered]
|
|
304
|
+
keyshroud migrate [--item NAME]... [--env-file F]... [--yes]
|
|
277
305
|
keyshroud status | unlock | lock | doctor
|
|
278
306
|
keyshroud mcp
|
|
279
307
|
keyshroud hook claude <session-start|post-tool-use>
|
|
@@ -397,8 +425,8 @@ as a literal; check its plan before committing `.env.refs`. The Claude Code
|
|
|
397
425
|
rewrite output) or anything a client sends as telemetry, and it only knows wired
|
|
398
426
|
values, so `keyshroud get bw://…` or `run --no-mask` print in the clear; run
|
|
399
427
|
secret-using commands through `keyshroud run` or `secrets_run`. An agent with shell access as your user can read
|
|
400
|
-
`~/.config/keyshroud/session` and use `bw` directly —
|
|
401
|
-
container if that matters. Redaction is exact-match, so a *partial* or transformed value
|
|
428
|
+
`~/.config/keyshroud/session` and use `bw` directly — after `setup` that reaches
|
|
429
|
+
only the agent account; otherwise run agents in a sandbox or container if that matters. Redaction is exact-match, so a *partial* or transformed value
|
|
402
430
|
(`${TOKEN:0:10}`, base64, a line wrapped by a tool) is not masked; values
|
|
403
431
|
shorter than 6 characters are not redacted (to avoid noise), so don't wire short
|
|
404
432
|
PINs.
|
package/dist/cli.js
CHANGED
|
@@ -31,6 +31,11 @@ Secrets
|
|
|
31
31
|
unwire --env NAME [--target pi|omp|both]
|
|
32
32
|
|
|
33
33
|
Vault
|
|
34
|
+
setup --email EMAIL [--server URL] [--confirm-unlock] [--registered]
|
|
35
|
+
Give ${BRAND.name} its own bw login on a dedicated agent account
|
|
36
|
+
migrate [--item NAME]... [--env-file F]... [--yes]
|
|
37
|
+
Copy the wired items from your personal vault to that account
|
|
38
|
+
(dry run unless --yes; prints names only)
|
|
34
39
|
status | unlock | lock | doctor
|
|
35
40
|
|
|
36
41
|
Agents
|
|
@@ -241,6 +246,143 @@ async function scan(paths) {
|
|
|
241
246
|
if (leaks)
|
|
242
247
|
process.exitCode = 1;
|
|
243
248
|
}
|
|
249
|
+
// setup: keyshroud gets its own bw login (CONFIG_DIR/bw) on a separate agent
|
|
250
|
+
// account, so agents never get a session for the user's personal vault.
|
|
251
|
+
async function setupCommand(args) {
|
|
252
|
+
requireKnownFlags(args, ["--email", "--server"], ["--confirm-unlock", "--registered"]);
|
|
253
|
+
const email = valueAfter(args, "--email");
|
|
254
|
+
if (!email || !/^[^\s@]+@[^\s@]+$/.test(email))
|
|
255
|
+
fail("setup needs --email for a separate agent account (not your personal one)", 2);
|
|
256
|
+
let server = valueAfter(args, "--server");
|
|
257
|
+
if (!server) {
|
|
258
|
+
try {
|
|
259
|
+
server = JSON.parse(await core.bw(["status"])).serverUrl ?? undefined;
|
|
260
|
+
}
|
|
261
|
+
catch { }
|
|
262
|
+
}
|
|
263
|
+
if (!server || !/^https:\/\//.test(server))
|
|
264
|
+
fail("setup needs --server https://… (no bw server is configured)", 2);
|
|
265
|
+
const confirm = args.includes("--confirm-unlock");
|
|
266
|
+
if (confirm && process.platform !== "darwin")
|
|
267
|
+
fail("--confirm-unlock needs the macOS keychain; Linux secret-tool cannot ask before each read", 2);
|
|
268
|
+
core.useOwnBwLogin();
|
|
269
|
+
await mkdir(core.OWN_BW_DIR, { recursive: true, mode: 0o700 });
|
|
270
|
+
const status = JSON.parse(await core.bw(["status"]).catch(() => "{}"));
|
|
271
|
+
if (status.userEmail && status.userEmail.toLowerCase() !== email.toLowerCase())
|
|
272
|
+
fail(`${BRAND.name}'s bw login already belongs to ${status.userEmail}; remove ${core.OWN_BW_DIR} to start over`, 5);
|
|
273
|
+
const loggedOut = !status.status || status.status === "unauthenticated";
|
|
274
|
+
if (loggedOut)
|
|
275
|
+
await core.bw(["config", "server", server]);
|
|
276
|
+
await unlink(core.SESSION_FILE).catch(() => { });
|
|
277
|
+
console.log(`${BRAND.name} now keeps its own bw login in ${core.OWN_BW_DIR}; your personal bw login is untouched.`);
|
|
278
|
+
const signupUrl = `${server.replace(/\/$/, "")}/#/signup?email=${encodeURIComponent(email)}`;
|
|
279
|
+
if (!process.stdin.isTTY) {
|
|
280
|
+
// An agent's shell: nothing can be typed, so keyshroud picks the password.
|
|
281
|
+
const hasEntry = Boolean(await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE));
|
|
282
|
+
if (!loggedOut && (confirm || !hasEntry))
|
|
283
|
+
fail(`The agent account is already logged in; run \`${BRAND.bin} setup\` in a terminal to store its password`, 2);
|
|
284
|
+
if (loggedOut && !args.includes("--registered")) {
|
|
285
|
+
if (!hasEntry && !(await core.generateKeychainSecret(core.KEYCHAIN_MASTER_SERVICE, `${BRAND.name} agent account`, confirm)))
|
|
286
|
+
fail("Could not store a generated password (needs macOS security or Linux secret-tool)", 3);
|
|
287
|
+
const master = await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE);
|
|
288
|
+
if (!master || !(await core.copyToClipboard(master)))
|
|
289
|
+
fail(`No clipboard tool; run \`${BRAND.bin} setup\` in a terminal instead`, 3);
|
|
290
|
+
spawn(process.platform === "darwin" ? "open" : "xdg-open", [signupUrl], { stdio: "ignore" }).on("error", () => { });
|
|
291
|
+
console.log(`A random master password for ${email} is in the keychain (${core.KEYCHAIN_MASTER_SERVICE}) and on the clipboard.`);
|
|
292
|
+
console.log(`Create the account at ${signupUrl} and paste it into both password fields.`);
|
|
293
|
+
console.log(`Then: ${BRAND.bin} setup --email ${email} --registered`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (loggedOut && !args.includes("--registered")) {
|
|
298
|
+
const url = signupUrl;
|
|
299
|
+
console.log(`1. Create the agent account ${email}: ${url}`);
|
|
300
|
+
console.log(" Pick a new master password, not your personal one.");
|
|
301
|
+
spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore" }).on("error", () => { });
|
|
302
|
+
process.stdout.write(" Press Enter once the account exists. ");
|
|
303
|
+
await new Promise((resolve) => process.stdin.once("data", resolve));
|
|
304
|
+
process.stdin.pause();
|
|
305
|
+
}
|
|
306
|
+
if (confirm || !(await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE))) {
|
|
307
|
+
console.log(`2. Type the agent account's master password for the keychain entry ${core.KEYCHAIN_MASTER_SERVICE}${confirm ? " (macOS will ask before every unlock)" : ""}:`);
|
|
308
|
+
if (!(await core.storeKeychainInteractive(core.KEYCHAIN_MASTER_SERVICE, `${BRAND.name} agent account`, confirm)))
|
|
309
|
+
fail("Could not store it (needs macOS security or Linux secret-tool)", 3);
|
|
310
|
+
}
|
|
311
|
+
const master = await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE);
|
|
312
|
+
if (!master)
|
|
313
|
+
fail(`The keychain entry ${core.KEYCHAIN_MASTER_SERVICE} is missing or access was denied`, 3);
|
|
314
|
+
if (loggedOut)
|
|
315
|
+
await core.loginOwnAccount(email, master);
|
|
316
|
+
else
|
|
317
|
+
await core.ensureSession();
|
|
318
|
+
await core.clearClipboardIf(master);
|
|
319
|
+
await core.audit("setup", [], "cli");
|
|
320
|
+
console.log(`ready: bw login=${email} (own). Next: \`${BRAND.bin} migrate\` copies the wired secrets from your personal vault.`);
|
|
321
|
+
}
|
|
322
|
+
// migrate: copy the items that wired names (and env files) reference from the
|
|
323
|
+
// personal vault into the agent account. Same names, so references keep working.
|
|
324
|
+
async function migrateCommand(args) {
|
|
325
|
+
requireKnownFlags(args, ["--item", "--env-file"], ["--yes"]);
|
|
326
|
+
if (!core.OWN_BW_LOGIN)
|
|
327
|
+
fail(`Run \`${BRAND.bin} setup\` first: migrate copies items into ${BRAND.name}'s own agent account`, 2);
|
|
328
|
+
const entries = [...await core.authEnvEntries()];
|
|
329
|
+
for (const file of valuesAfter(args, "--env-file"))
|
|
330
|
+
entries.push(...await core.envFileEntries(file));
|
|
331
|
+
const names = new Map();
|
|
332
|
+
for (const entry of entries) {
|
|
333
|
+
try {
|
|
334
|
+
const ref = parseRef(entry.raw);
|
|
335
|
+
if (ref)
|
|
336
|
+
names.set(ref.item.toLowerCase(), ref.item);
|
|
337
|
+
}
|
|
338
|
+
catch { }
|
|
339
|
+
}
|
|
340
|
+
for (const name of valuesAfter(args, "--item"))
|
|
341
|
+
names.set(name.toLowerCase(), name);
|
|
342
|
+
await core.ensureSession();
|
|
343
|
+
const own = await core.findItems("");
|
|
344
|
+
const personal = await core.readPersonalItems();
|
|
345
|
+
const rows = [...names.values()].sort().map((name) => {
|
|
346
|
+
if (own.some((item) => item.name.toLowerCase() === name.toLowerCase() || item.id === name))
|
|
347
|
+
return { name, state: "present" };
|
|
348
|
+
const matches = personal.filter((item) => String(item.name ?? "").toLowerCase() === name.toLowerCase() || item.id === name);
|
|
349
|
+
return matches.length === 1 ? { name, state: "copy", item: matches[0] } : { name, state: matches.length ? "ambiguous" : "missing" };
|
|
350
|
+
});
|
|
351
|
+
const width = Math.max(4, ...rows.map((row) => row.name.length));
|
|
352
|
+
console.log("migrate plan (personal vault -> agent account):");
|
|
353
|
+
for (const row of rows)
|
|
354
|
+
console.log(` ${row.name.padEnd(width)} ${row.state === "present" ? "already in the agent account" : row.state}`);
|
|
355
|
+
const count = (state) => rows.filter((row) => row.state === state).length;
|
|
356
|
+
console.log(`${count("copy")} to copy, ${count("present")} already there, ${count("missing")} missing, ${count("ambiguous")} ambiguous`);
|
|
357
|
+
if (!args.includes("--yes")) {
|
|
358
|
+
console.log("Dry run: nothing copied. Re-run with --yes.");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const copied = [];
|
|
362
|
+
for (const row of rows.filter((entry) => entry.state === "copy")) {
|
|
363
|
+
try {
|
|
364
|
+
await core.copyItemToOwnVault(row.item);
|
|
365
|
+
copied.push(row.name);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
if (copied.length)
|
|
369
|
+
console.error(`Copied before the failure: ${copied.join(", ")}`);
|
|
370
|
+
fail(`Failed to copy ${row.name}`, 3);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
await core.syncOwnVault();
|
|
374
|
+
const after = await core.findItems("");
|
|
375
|
+
const unverified = copied.filter((name) => !after.some((item) => item.name.toLowerCase() === name.toLowerCase()));
|
|
376
|
+
await core.audit("migrate", copied, "cli");
|
|
377
|
+
await core.refreshRedactIndex();
|
|
378
|
+
console.log(`copied ${copied.length} item(s) into the agent account; references are unchanged.`);
|
|
379
|
+
const cut = process.platform === "darwin" ? `security delete-generic-password -s ${core.PERSONAL_KEYCHAIN_MASTER_SERVICE}` : `secret-tool clear service ${core.PERSONAL_KEYCHAIN_MASTER_SERVICE} account "$USER"`;
|
|
380
|
+
console.log(`Your personal master password is still in the keychain, where any program running as you can read it. Once you no longer need it there: ${cut}`);
|
|
381
|
+
if (unverified.length)
|
|
382
|
+
fail(`Not found after copying: ${unverified.join(", ")}`, 4);
|
|
383
|
+
if (count("missing") || count("ambiguous"))
|
|
384
|
+
process.exitCode = 4;
|
|
385
|
+
}
|
|
244
386
|
async function importCommand(args) {
|
|
245
387
|
requireKnownFlags(args, ["--from", "--out", "--prefix", "--only", "--skip"], ["--yes", "--replace", "--force"]);
|
|
246
388
|
const from = valueAfter(args, "--from") ?? ".env";
|
|
@@ -501,6 +643,14 @@ async function main() {
|
|
|
501
643
|
await importCommand(args);
|
|
502
644
|
return;
|
|
503
645
|
}
|
|
646
|
+
if (command === "setup") {
|
|
647
|
+
await setupCommand(args);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (command === "migrate") {
|
|
651
|
+
await migrateCommand(args);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
504
654
|
if (command === "rotate") {
|
|
505
655
|
requireKnownFlags(args, ["--item"], ["--stdin", "--dialog"]);
|
|
506
656
|
const itemName = valueAfter(args, "--item");
|
|
@@ -598,6 +748,7 @@ async function main() {
|
|
|
598
748
|
}
|
|
599
749
|
catch { }
|
|
600
750
|
const s = await core.getStatus();
|
|
751
|
+
console.log(`bw login=${core.OWN_BW_LOGIN ? `own agent account (${core.OWN_BW_DIR})` : `shared with your personal bw (run: ${BRAND.bin} setup)`}`);
|
|
601
752
|
console.log(`bw version=${s.version ?? "missing (install @bitwarden/cli)"}`);
|
|
602
753
|
console.log(`bw server=${s.serverUrl ?? "unconfigured (run: bw config server <url>)"}`);
|
|
603
754
|
console.log(`session=${s.status}`);
|
package/dist/core.d.ts
CHANGED
|
@@ -5,9 +5,13 @@ export declare const SESSION_FILE: string;
|
|
|
5
5
|
export declare const MASTER_PASSWORD_FILE: string;
|
|
6
6
|
export declare const REDACT_INDEX_FILE: string;
|
|
7
7
|
export declare const AUDIT_FILE: string;
|
|
8
|
-
export declare const
|
|
9
|
-
export declare const
|
|
10
|
-
export declare
|
|
8
|
+
export declare const OWN_BW_DIR: string;
|
|
9
|
+
export declare const PERSONAL_KEYCHAIN_MASTER_SERVICE: string;
|
|
10
|
+
export declare let OWN_BW_LOGIN: boolean;
|
|
11
|
+
export declare let KEYCHAIN_MASTER_SERVICE: string;
|
|
12
|
+
export declare let KEYCHAIN_CLIENT_ID_SERVICE: string;
|
|
13
|
+
export declare let KEYCHAIN_CLIENT_SECRET_SERVICE: string;
|
|
14
|
+
export declare function useOwnBwLogin(): void;
|
|
11
15
|
export declare const PROVIDER_KEYS: ReadonlySet<string>;
|
|
12
16
|
export interface VaultStatus {
|
|
13
17
|
status: string;
|
|
@@ -39,8 +43,17 @@ export interface VaultItem {
|
|
|
39
43
|
}
|
|
40
44
|
export declare function invalidateSecretCache(): void;
|
|
41
45
|
export declare function resolveRef(ref: SecretRef): Promise<string | null>;
|
|
46
|
+
export declare function readKeychain(service: string): Promise<string | null>;
|
|
42
47
|
export declare function keychainPresence(): Promise<Record<string, boolean>>;
|
|
43
48
|
export declare function ensureSession(): Promise<string>;
|
|
49
|
+
export declare function storeKeychainInteractive(service: string, label: string, confirm: boolean): Promise<boolean>;
|
|
50
|
+
export declare function generateKeychainSecret(service: string, label: string, confirm: boolean): Promise<boolean>;
|
|
51
|
+
export declare function copyToClipboard(text: string): Promise<boolean>;
|
|
52
|
+
export declare function clearClipboardIf(text: string): Promise<void>;
|
|
53
|
+
export declare function loginOwnAccount(email: string, master: string): Promise<string>;
|
|
54
|
+
export declare function readPersonalItems(): Promise<Array<Record<string, unknown>>>;
|
|
55
|
+
export declare function copyItemToOwnVault(item: Record<string, unknown>): Promise<void>;
|
|
56
|
+
export declare function syncOwnVault(): Promise<void>;
|
|
44
57
|
export declare function lock(): Promise<void>;
|
|
45
58
|
export declare function audit(action: string, names: string[], consumer: string): Promise<void>;
|
|
46
59
|
export declare function getStatus(): Promise<VaultStatus>;
|
package/dist/core.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Runtime-neutral core: no pi/omp/Claude Code imports. Every caller (agent
|
|
2
2
|
// extensions, CLI, MCP server, hooks) goes through these functions.
|
|
3
|
-
import { execFile } from "node:child_process";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
5
6
|
import { appendFile, chmod, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import { join, dirname } from "node:path";
|
|
@@ -19,9 +20,24 @@ export const SESSION_FILE = join(CONFIG_DIR, "session");
|
|
|
19
20
|
export const MASTER_PASSWORD_FILE = join(CONFIG_DIR, "master-password");
|
|
20
21
|
export const REDACT_INDEX_FILE = join(CONFIG_DIR, "redact-index.json");
|
|
21
22
|
export const AUDIT_FILE = join(CONFIG_DIR, "audit.jsonl");
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// After `setup`, keyshroud keeps its own bw login (a dedicated agent account) in
|
|
24
|
+
// CONFIG_DIR/bw, with its own keychain entries, so the user's personal bw login
|
|
25
|
+
// and vault are never unlocked. Without it, keyshroud shares the default bw login.
|
|
26
|
+
export const OWN_BW_DIR = join(CONFIG_DIR, "bw");
|
|
27
|
+
export const PERSONAL_KEYCHAIN_MASTER_SERVICE = setting("PERSONAL_KEYCHAIN_MASTER") ?? "vaultwarden-master-password";
|
|
28
|
+
export let OWN_BW_LOGIN = false;
|
|
29
|
+
export let KEYCHAIN_MASTER_SERVICE = setting("KEYCHAIN_MASTER") ?? PERSONAL_KEYCHAIN_MASTER_SERVICE;
|
|
30
|
+
export let KEYCHAIN_CLIENT_ID_SERVICE = setting("KEYCHAIN_CLIENT_ID") ?? "vaultwarden-api-client-id";
|
|
31
|
+
export let KEYCHAIN_CLIENT_SECRET_SERVICE = setting("KEYCHAIN_CLIENT_SECRET") ?? "vaultwarden-api-client-secret";
|
|
32
|
+
export function useOwnBwLogin() {
|
|
33
|
+
OWN_BW_LOGIN = true;
|
|
34
|
+
process.env.BITWARDENCLI_APPDATA_DIR = OWN_BW_DIR;
|
|
35
|
+
KEYCHAIN_MASTER_SERVICE = setting("KEYCHAIN_MASTER") ?? `${BRAND.bin}-master-password`;
|
|
36
|
+
KEYCHAIN_CLIENT_ID_SERVICE = setting("KEYCHAIN_CLIENT_ID") ?? `${BRAND.bin}-api-client-id`;
|
|
37
|
+
KEYCHAIN_CLIENT_SECRET_SERVICE = setting("KEYCHAIN_CLIENT_SECRET") ?? `${BRAND.bin}-api-client-secret`;
|
|
38
|
+
}
|
|
39
|
+
if (!process.env.BITWARDENCLI_APPDATA_DIR && existsSync(join(OWN_BW_DIR, "data.json")))
|
|
40
|
+
useOwnBwLogin();
|
|
25
41
|
export const PROVIDER_KEYS = new Set([
|
|
26
42
|
"anthropic", "openai", "azure-openai-responses", "deepseek", "google", "mistral",
|
|
27
43
|
"groq", "cerebras", "cloudflare-ai-gateway", "cloudflare-workers-ai", "xai",
|
|
@@ -55,7 +71,7 @@ export function getAuthTargets() {
|
|
|
55
71
|
export const referenceFor = legacyRefFor;
|
|
56
72
|
// Vault credentials never reach commands run on the agent's behalf: with the
|
|
57
73
|
// session token a child could dump the whole vault, defeating `--only`.
|
|
58
|
-
export const VAULT_ENV_KEYS = ["BW_SESSION", "BW_PASSWORD", "BW_CLIENTID", "BW_CLIENTSECRET"];
|
|
74
|
+
export const VAULT_ENV_KEYS = ["BW_SESSION", "BW_PASSWORD", "BW_CLIENTID", "BW_CLIENTSECRET", "BITWARDENCLI_APPDATA_DIR"];
|
|
59
75
|
export function childEnv(extra = {}) {
|
|
60
76
|
const env = {};
|
|
61
77
|
for (const [name, value] of Object.entries(process.env))
|
|
@@ -189,10 +205,12 @@ export async function resolveRef(ref) {
|
|
|
189
205
|
case "totp": return (await bw(["get", "totp", item.id])).trim() || null;
|
|
190
206
|
}
|
|
191
207
|
}
|
|
192
|
-
|
|
208
|
+
// The timeout leaves room for a macOS "allow access" prompt when the entry was
|
|
209
|
+
// stored with `setup --confirm-unlock`; a missing entry still fails at once.
|
|
210
|
+
export async function readKeychain(service) {
|
|
193
211
|
const user = process.env.USER || process.env.LOGNAME || "";
|
|
194
212
|
try {
|
|
195
|
-
const result = await runFile("security", ["find-generic-password", "-a", user, "-s", service, "-w"], { timeout:
|
|
213
|
+
const result = await runFile("security", ["find-generic-password", "-a", user, "-s", service, "-w"], { timeout: 120000, maxBuffer: 1024 * 1024 });
|
|
196
214
|
return result.stdout.trim() || null;
|
|
197
215
|
}
|
|
198
216
|
catch { }
|
|
@@ -285,6 +303,129 @@ async function ensureSessionInner(existing) {
|
|
|
285
303
|
invalidateSecretCache();
|
|
286
304
|
return token;
|
|
287
305
|
}
|
|
306
|
+
// Store a keychain entry by letting the OS tool prompt for the value on the
|
|
307
|
+
// user's terminal, so keyshroud never holds it in argv. `confirm` (macOS) trusts
|
|
308
|
+
// no app, so every later read asks the user first.
|
|
309
|
+
export async function storeKeychainInteractive(service, label, confirm) {
|
|
310
|
+
const user = process.env.USER || process.env.LOGNAME || "";
|
|
311
|
+
const attempts = process.platform === "darwin"
|
|
312
|
+
? [["security", ["add-generic-password", "-U", "-a", user, "-s", service, "-l", label, ...(confirm ? ["-T", ""] : []), "-w"]]]
|
|
313
|
+
: [["secret-tool", ["store", `--label=${label}`, "service", service, "account", user]]];
|
|
314
|
+
// -U keeps an existing entry's access list, so an ask-every-time entry is recreated.
|
|
315
|
+
if (process.platform === "darwin" && confirm)
|
|
316
|
+
await runFile("security", ["delete-generic-password", "-a", user, "-s", service], { timeout: 10000, maxBuffer: 1024 * 1024 }).catch(() => { });
|
|
317
|
+
for (const [command, args] of attempts) {
|
|
318
|
+
const code = await new Promise((resolve) => {
|
|
319
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
320
|
+
child.on("error", () => resolve(null));
|
|
321
|
+
child.on("exit", (exit) => resolve(exit));
|
|
322
|
+
});
|
|
323
|
+
if (code === 0)
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
// `setup` from an agent's shell (no terminal): a random master password for the
|
|
329
|
+
// new agent account, written to the keychain over stdin. Nobody types it and it
|
|
330
|
+
// never reaches argv or output; the user gets it via the clipboard for signup.
|
|
331
|
+
export async function generateKeychainSecret(service, label, confirm) {
|
|
332
|
+
const secret = randomBytes(24).toString("base64url");
|
|
333
|
+
const user = process.env.USER || process.env.LOGNAME || "";
|
|
334
|
+
const opts = { timeout: 30000, maxBuffer: 1024 * 1024 };
|
|
335
|
+
try {
|
|
336
|
+
if (process.platform === "darwin") {
|
|
337
|
+
// `security -i` reads the command from stdin, so the value is not in argv.
|
|
338
|
+
await runFile("security", ["-i"], opts, `add-generic-password -U -a "${user}" -s "${service}" -l "${label}"${confirm ? ' -T ""' : ""} -w "${secret}"\n`);
|
|
339
|
+
await runFile("security", ["find-generic-password", "-a", user, "-s", service], opts);
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
await runFile("secret-tool", ["store", `--label=${label}`, "service", service, "account", user], opts, secret);
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const CLIPBOARD_TOOLS = process.platform === "darwin"
|
|
351
|
+
? [["pbcopy", [], "pbpaste", []]]
|
|
352
|
+
: [["wl-copy", [], "wl-paste", ["--no-newline"]], ["xclip", ["-selection", "clipboard"], "xclip", ["-selection", "clipboard", "-o"]]];
|
|
353
|
+
export async function copyToClipboard(text) {
|
|
354
|
+
for (const [copy, args] of CLIPBOARD_TOOLS) {
|
|
355
|
+
try {
|
|
356
|
+
await runFile(copy, args, { timeout: 10000, maxBuffer: 1024 * 1024 }, text);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
catch { }
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
// Empty the clipboard only if it still holds `text`.
|
|
364
|
+
export async function clearClipboardIf(text) {
|
|
365
|
+
for (const [copy, copyArgs, paste, pasteArgs] of CLIPBOARD_TOOLS) {
|
|
366
|
+
try {
|
|
367
|
+
if ((await runFile(paste, pasteArgs, { timeout: 10000, maxBuffer: 1024 * 1024 })).stdout.trim() === text)
|
|
368
|
+
await runFile(copy, copyArgs, { timeout: 10000, maxBuffer: 1024 * 1024 }, "");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
catch { }
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// `setup`: log keyshroud's own bw login in to the agent account. stdin and
|
|
375
|
+
// stderr stay on the terminal so bw can ask for a two-step login code.
|
|
376
|
+
export async function loginOwnAccount(email, master) {
|
|
377
|
+
const token = await new Promise((resolve, reject) => {
|
|
378
|
+
const child = spawn("bw", ["login", email, "--passwordenv", "BW_PASSWORD", "--raw"], { env: { ...process.env, BW_PASSWORD: master }, stdio: ["inherit", "pipe", "inherit"] });
|
|
379
|
+
let out = "";
|
|
380
|
+
child.stdout.on("data", (chunk) => { out += chunk; });
|
|
381
|
+
child.on("error", reject);
|
|
382
|
+
child.on("exit", (exit) => (exit === 0 && out.trim() ? resolve(out.trim()) : reject(new Error("bw login failed"))));
|
|
383
|
+
});
|
|
384
|
+
await writePrivateFile(SESSION_FILE, `${token}\n`);
|
|
385
|
+
process.env.BW_SESSION = token;
|
|
386
|
+
try {
|
|
387
|
+
await bw(["sync", "--quiet"], { session: token });
|
|
388
|
+
}
|
|
389
|
+
catch { }
|
|
390
|
+
invalidateSecretCache();
|
|
391
|
+
return token;
|
|
392
|
+
}
|
|
393
|
+
// `migrate`: read the items of the bw login the user had before setup (the
|
|
394
|
+
// default bw data dir), then lock it again. The values stay in memory.
|
|
395
|
+
export async function readPersonalItems() {
|
|
396
|
+
const master = await readKeychain(PERSONAL_KEYCHAIN_MASTER_SERVICE);
|
|
397
|
+
if (!master)
|
|
398
|
+
throw new Error(`Personal master password not in the keychain (${PERSONAL_KEYCHAIN_MASTER_SERVICE})`);
|
|
399
|
+
const env = (extra) => {
|
|
400
|
+
const out = {};
|
|
401
|
+
for (const [name, value] of Object.entries(process.env))
|
|
402
|
+
if (value !== undefined && !VAULT_ENV_KEYS.includes(name))
|
|
403
|
+
out[name] = value;
|
|
404
|
+
return { ...out, ...extra };
|
|
405
|
+
};
|
|
406
|
+
const token = (await runFile("bw", ["unlock", "--passwordenv", "BW_PASSWORD", "--raw"], { env: env({ BW_PASSWORD: master }), timeout: 30000, maxBuffer: 4 * 1024 * 1024 })).stdout.trim();
|
|
407
|
+
if (!token)
|
|
408
|
+
throw new Error("Personal vault unlock returned no session");
|
|
409
|
+
try {
|
|
410
|
+
const parsed = JSON.parse((await runFile("bw", ["list", "items"], { env: env({ BW_SESSION: token }), timeout: 120000, maxBuffer: 256 * 1024 * 1024 })).stdout);
|
|
411
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
try {
|
|
415
|
+
await runFile("bw", ["lock"], { env: env({}), timeout: 20000, maxBuffer: 1024 * 1024 });
|
|
416
|
+
}
|
|
417
|
+
catch { }
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// A personal item as a new item for the agent account: no ids, folders,
|
|
421
|
+
// organizations, history, attachments or per-item key (encrypted to the personal account).
|
|
422
|
+
export async function copyItemToOwnVault(item) {
|
|
423
|
+
const copy = { ...item, favorite: false };
|
|
424
|
+
for (const key of ["id", "key", "object", "organizationId", "folderId", "collectionIds", "revisionDate", "creationDate", "deletedDate", "passwordHistory", "attachments"])
|
|
425
|
+
delete copy[key];
|
|
426
|
+
await bw(["create", "item"], { input: Buffer.from(JSON.stringify(copy), "utf8").toString("base64") });
|
|
427
|
+
}
|
|
428
|
+
export async function syncOwnVault() { await bw(["sync", "--quiet"]); invalidateSecretCache(); }
|
|
288
429
|
// Forget the cached session and lock the bw CLI. The next resolve unlocks
|
|
289
430
|
// again from the keychain.
|
|
290
431
|
export async function lock() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keyshroud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"mcpName": "io.github.dinhkarate/keyshroud",
|
|
5
5
|
"description": "Let AI coding agents use your secrets without ever seeing them. Self-hosted Vaultwarden/Bitwarden, masked output, MCP server, Claude Code + pi plugins.",
|
|
6
6
|
"keywords": [
|
|
@@ -78,3 +78,6 @@ keyshroud scan . # exits 1 and lists file:line NAME for any leaked valu
|
|
|
78
78
|
`0` ok · `2` usage · `3` vault locked/unavailable (ask the user to run
|
|
79
79
|
`keyshroud unlock`) · `4` secret missing or item name ambiguous · `5` would
|
|
80
80
|
overwrite existing state.
|
|
81
|
+
|
|
82
|
+
`setup` and `migrate` are for the user: they touch the personal vault. If an item
|
|
83
|
+
is missing after `setup`, ask the user to run `keyshroud migrate --item "NAME"`.
|
package/src/cli.ts
CHANGED
|
@@ -32,6 +32,11 @@ Secrets
|
|
|
32
32
|
unwire --env NAME [--target pi|omp|both]
|
|
33
33
|
|
|
34
34
|
Vault
|
|
35
|
+
setup --email EMAIL [--server URL] [--confirm-unlock] [--registered]
|
|
36
|
+
Give ${BRAND.name} its own bw login on a dedicated agent account
|
|
37
|
+
migrate [--item NAME]... [--env-file F]... [--yes]
|
|
38
|
+
Copy the wired items from your personal vault to that account
|
|
39
|
+
(dry run unless --yes; prints names only)
|
|
35
40
|
status | unlock | lock | doctor
|
|
36
41
|
|
|
37
42
|
Agents
|
|
@@ -196,6 +201,103 @@ async function scan(paths: string[]): Promise<void> {
|
|
|
196
201
|
|
|
197
202
|
interface ImportRow { entry: DotenvEntry; kind: "new" | "reuse" | "conflict" | "literal" | "ref"; item?: string; note?: string }
|
|
198
203
|
|
|
204
|
+
// setup: keyshroud gets its own bw login (CONFIG_DIR/bw) on a separate agent
|
|
205
|
+
// account, so agents never get a session for the user's personal vault.
|
|
206
|
+
async function setupCommand(args: string[]): Promise<void> {
|
|
207
|
+
requireKnownFlags(args, ["--email", "--server"], ["--confirm-unlock", "--registered"]);
|
|
208
|
+
const email = valueAfter(args, "--email");
|
|
209
|
+
if (!email || !/^[^\s@]+@[^\s@]+$/.test(email)) fail("setup needs --email for a separate agent account (not your personal one)", 2);
|
|
210
|
+
let server = valueAfter(args, "--server");
|
|
211
|
+
if (!server) { try { server = (JSON.parse(await core.bw(["status"])) as { serverUrl?: string }).serverUrl ?? undefined; } catch {} }
|
|
212
|
+
if (!server || !/^https:\/\//.test(server)) fail("setup needs --server https://… (no bw server is configured)", 2);
|
|
213
|
+
const confirm = args.includes("--confirm-unlock");
|
|
214
|
+
if (confirm && process.platform !== "darwin") fail("--confirm-unlock needs the macOS keychain; Linux secret-tool cannot ask before each read", 2);
|
|
215
|
+
core.useOwnBwLogin();
|
|
216
|
+
await mkdir(core.OWN_BW_DIR, { recursive: true, mode: 0o700 });
|
|
217
|
+
const status = JSON.parse(await core.bw(["status"]).catch(() => "{}")) as { status?: string; userEmail?: string };
|
|
218
|
+
if (status.userEmail && status.userEmail.toLowerCase() !== email.toLowerCase()) fail(`${BRAND.name}'s bw login already belongs to ${status.userEmail}; remove ${core.OWN_BW_DIR} to start over`, 5);
|
|
219
|
+
const loggedOut = !status.status || status.status === "unauthenticated";
|
|
220
|
+
if (loggedOut) await core.bw(["config", "server", server]);
|
|
221
|
+
await unlink(core.SESSION_FILE).catch(() => {});
|
|
222
|
+
console.log(`${BRAND.name} now keeps its own bw login in ${core.OWN_BW_DIR}; your personal bw login is untouched.`);
|
|
223
|
+
const signupUrl = `${server.replace(/\/$/, "")}/#/signup?email=${encodeURIComponent(email)}`;
|
|
224
|
+
if (!process.stdin.isTTY) {
|
|
225
|
+
// An agent's shell: nothing can be typed, so keyshroud picks the password.
|
|
226
|
+
const hasEntry = Boolean(await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE));
|
|
227
|
+
if (!loggedOut && (confirm || !hasEntry)) fail(`The agent account is already logged in; run \`${BRAND.bin} setup\` in a terminal to store its password`, 2);
|
|
228
|
+
if (loggedOut && !args.includes("--registered")) {
|
|
229
|
+
if (!hasEntry && !(await core.generateKeychainSecret(core.KEYCHAIN_MASTER_SERVICE, `${BRAND.name} agent account`, confirm))) fail("Could not store a generated password (needs macOS security or Linux secret-tool)", 3);
|
|
230
|
+
const master = await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE);
|
|
231
|
+
if (!master || !(await core.copyToClipboard(master))) fail(`No clipboard tool; run \`${BRAND.bin} setup\` in a terminal instead`, 3);
|
|
232
|
+
spawn(process.platform === "darwin" ? "open" : "xdg-open", [signupUrl], { stdio: "ignore" }).on("error", () => {});
|
|
233
|
+
console.log(`A random master password for ${email} is in the keychain (${core.KEYCHAIN_MASTER_SERVICE}) and on the clipboard.`);
|
|
234
|
+
console.log(`Create the account at ${signupUrl} and paste it into both password fields.`);
|
|
235
|
+
console.log(`Then: ${BRAND.bin} setup --email ${email} --registered`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (loggedOut && !args.includes("--registered")) {
|
|
240
|
+
const url = signupUrl;
|
|
241
|
+
console.log(`1. Create the agent account ${email}: ${url}`);
|
|
242
|
+
console.log(" Pick a new master password, not your personal one.");
|
|
243
|
+
spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore" }).on("error", () => {});
|
|
244
|
+
process.stdout.write(" Press Enter once the account exists. ");
|
|
245
|
+
await new Promise((resolve) => process.stdin.once("data", resolve)); process.stdin.pause();
|
|
246
|
+
}
|
|
247
|
+
if (confirm || !(await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE))) {
|
|
248
|
+
console.log(`2. Type the agent account's master password for the keychain entry ${core.KEYCHAIN_MASTER_SERVICE}${confirm ? " (macOS will ask before every unlock)" : ""}:`);
|
|
249
|
+
if (!(await core.storeKeychainInteractive(core.KEYCHAIN_MASTER_SERVICE, `${BRAND.name} agent account`, confirm))) fail("Could not store it (needs macOS security or Linux secret-tool)", 3);
|
|
250
|
+
}
|
|
251
|
+
const master = await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE);
|
|
252
|
+
if (!master) fail(`The keychain entry ${core.KEYCHAIN_MASTER_SERVICE} is missing or access was denied`, 3);
|
|
253
|
+
if (loggedOut) await core.loginOwnAccount(email, master); else await core.ensureSession();
|
|
254
|
+
await core.clearClipboardIf(master);
|
|
255
|
+
await core.audit("setup", [], "cli");
|
|
256
|
+
console.log(`ready: bw login=${email} (own). Next: \`${BRAND.bin} migrate\` copies the wired secrets from your personal vault.`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// migrate: copy the items that wired names (and env files) reference from the
|
|
260
|
+
// personal vault into the agent account. Same names, so references keep working.
|
|
261
|
+
async function migrateCommand(args: string[]): Promise<void> {
|
|
262
|
+
requireKnownFlags(args, ["--item", "--env-file"], ["--yes"]);
|
|
263
|
+
if (!core.OWN_BW_LOGIN) fail(`Run \`${BRAND.bin} setup\` first: migrate copies items into ${BRAND.name}'s own agent account`, 2);
|
|
264
|
+
const entries = [...await core.authEnvEntries()];
|
|
265
|
+
for (const file of valuesAfter(args, "--env-file")) entries.push(...await core.envFileEntries(file));
|
|
266
|
+
const names = new Map<string, string>();
|
|
267
|
+
for (const entry of entries) { try { const ref = parseRef(entry.raw); if (ref) names.set(ref.item.toLowerCase(), ref.item); } catch {} }
|
|
268
|
+
for (const name of valuesAfter(args, "--item")) names.set(name.toLowerCase(), name);
|
|
269
|
+
await core.ensureSession();
|
|
270
|
+
const own = await core.findItems("");
|
|
271
|
+
const personal = await core.readPersonalItems();
|
|
272
|
+
type Row = { name: string; state: "copy" | "present" | "missing" | "ambiguous"; item?: Record<string, unknown> };
|
|
273
|
+
const rows: Row[] = [...names.values()].sort().map((name) => {
|
|
274
|
+
if (own.some((item) => item.name.toLowerCase() === name.toLowerCase() || item.id === name)) return { name, state: "present" };
|
|
275
|
+
const matches = personal.filter((item) => String(item.name ?? "").toLowerCase() === name.toLowerCase() || item.id === name);
|
|
276
|
+
return matches.length === 1 ? { name, state: "copy", item: matches[0] } : { name, state: matches.length ? "ambiguous" : "missing" };
|
|
277
|
+
});
|
|
278
|
+
const width = Math.max(4, ...rows.map((row) => row.name.length));
|
|
279
|
+
console.log("migrate plan (personal vault -> agent account):");
|
|
280
|
+
for (const row of rows) console.log(` ${row.name.padEnd(width)} ${row.state === "present" ? "already in the agent account" : row.state}`);
|
|
281
|
+
const count = (state: Row["state"]) => rows.filter((row) => row.state === state).length;
|
|
282
|
+
console.log(`${count("copy")} to copy, ${count("present")} already there, ${count("missing")} missing, ${count("ambiguous")} ambiguous`);
|
|
283
|
+
if (!args.includes("--yes")) { console.log("Dry run: nothing copied. Re-run with --yes."); return; }
|
|
284
|
+
const copied: string[] = [];
|
|
285
|
+
for (const row of rows.filter((entry) => entry.state === "copy")) {
|
|
286
|
+
try { await core.copyItemToOwnVault(row.item!); copied.push(row.name); }
|
|
287
|
+
catch { if (copied.length) console.error(`Copied before the failure: ${copied.join(", ")}`); fail(`Failed to copy ${row.name}`, 3); }
|
|
288
|
+
}
|
|
289
|
+
await core.syncOwnVault();
|
|
290
|
+
const after = await core.findItems("");
|
|
291
|
+
const unverified = copied.filter((name) => !after.some((item) => item.name.toLowerCase() === name.toLowerCase()));
|
|
292
|
+
await core.audit("migrate", copied, "cli");
|
|
293
|
+
await core.refreshRedactIndex();
|
|
294
|
+
console.log(`copied ${copied.length} item(s) into the agent account; references are unchanged.`);
|
|
295
|
+
const cut = process.platform === "darwin" ? `security delete-generic-password -s ${core.PERSONAL_KEYCHAIN_MASTER_SERVICE}` : `secret-tool clear service ${core.PERSONAL_KEYCHAIN_MASTER_SERVICE} account "$USER"`;
|
|
296
|
+
console.log(`Your personal master password is still in the keychain, where any program running as you can read it. Once you no longer need it there: ${cut}`);
|
|
297
|
+
if (unverified.length) fail(`Not found after copying: ${unverified.join(", ")}`, 4);
|
|
298
|
+
if (count("missing") || count("ambiguous")) process.exitCode = 4;
|
|
299
|
+
}
|
|
300
|
+
|
|
199
301
|
async function importCommand(args: string[]): Promise<void> {
|
|
200
302
|
requireKnownFlags(args, ["--from", "--out", "--prefix", "--only", "--skip"], ["--yes", "--replace", "--force"]);
|
|
201
303
|
const from = valueAfter(args, "--from") ?? ".env";
|
|
@@ -353,6 +455,8 @@ async function main(): Promise<void> {
|
|
|
353
455
|
return;
|
|
354
456
|
}
|
|
355
457
|
if (command === "import") { await importCommand(args); return; }
|
|
458
|
+
if (command === "setup") { await setupCommand(args); return; }
|
|
459
|
+
if (command === "migrate") { await migrateCommand(args); return; }
|
|
356
460
|
if (command === "rotate") {
|
|
357
461
|
requireKnownFlags(args, ["--item"], ["--stdin", "--dialog"]);
|
|
358
462
|
const itemName = valueAfter(args, "--item"); if (!itemName) fail(usage, 2);
|
|
@@ -402,7 +506,7 @@ async function main(): Promise<void> {
|
|
|
402
506
|
if (command === "doctor") {
|
|
403
507
|
requireKnownFlags(args, []);
|
|
404
508
|
try { await core.ensureSession(); } catch {}
|
|
405
|
-
const s = await core.getStatus(); console.log(`bw version=${s.version ?? "missing (install @bitwarden/cli)"}`); console.log(`bw server=${s.serverUrl ?? "unconfigured (run: bw config server <url>)"}`); console.log(`session=${s.status}`);
|
|
509
|
+
const s = await core.getStatus(); console.log(`bw login=${core.OWN_BW_LOGIN ? `own agent account (${core.OWN_BW_DIR})` : `shared with your personal bw (run: ${BRAND.bin} setup)`}`); console.log(`bw version=${s.version ?? "missing (install @bitwarden/cli)"}`); console.log(`bw server=${s.serverUrl ?? "unconfigured (run: bw config server <url>)"}`); console.log(`session=${s.status}`);
|
|
406
510
|
for (const [service, present] of Object.entries(await core.keychainPresence())) console.log(`keychain ${service}=${present ? "present" : "missing"}`);
|
|
407
511
|
for (const path of [targetPaths.omp, targetPaths.pi]) for (const name of Object.keys(await core.readAuthEntries(path))) console.log(`${path} ${name} resolved=${(await authValue(name)) ? "yes" : "no"}`);
|
|
408
512
|
console.log(`redact index=${(await core.readRedactIndex()) ? "present" : "missing"}`);
|
package/src/core.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Runtime-neutral core: no pi/omp/Claude Code imports. Every caller (agent
|
|
2
2
|
// extensions, CLI, MCP server, hooks) goes through these functions.
|
|
3
|
-
import { execFile } from "node:child_process";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
5
6
|
import { appendFile, chmod, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import { join, dirname } from "node:path";
|
|
@@ -21,9 +22,25 @@ export const SESSION_FILE = join(CONFIG_DIR, "session");
|
|
|
21
22
|
export const MASTER_PASSWORD_FILE = join(CONFIG_DIR, "master-password");
|
|
22
23
|
export const REDACT_INDEX_FILE = join(CONFIG_DIR, "redact-index.json");
|
|
23
24
|
export const AUDIT_FILE = join(CONFIG_DIR, "audit.jsonl");
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
|
|
26
|
+
// After `setup`, keyshroud keeps its own bw login (a dedicated agent account) in
|
|
27
|
+
// CONFIG_DIR/bw, with its own keychain entries, so the user's personal bw login
|
|
28
|
+
// and vault are never unlocked. Without it, keyshroud shares the default bw login.
|
|
29
|
+
export const OWN_BW_DIR = join(CONFIG_DIR, "bw");
|
|
30
|
+
export const PERSONAL_KEYCHAIN_MASTER_SERVICE = setting("PERSONAL_KEYCHAIN_MASTER") ?? "vaultwarden-master-password";
|
|
31
|
+
export let OWN_BW_LOGIN = false;
|
|
32
|
+
export let KEYCHAIN_MASTER_SERVICE = setting("KEYCHAIN_MASTER") ?? PERSONAL_KEYCHAIN_MASTER_SERVICE;
|
|
33
|
+
export let KEYCHAIN_CLIENT_ID_SERVICE = setting("KEYCHAIN_CLIENT_ID") ?? "vaultwarden-api-client-id";
|
|
34
|
+
export let KEYCHAIN_CLIENT_SECRET_SERVICE = setting("KEYCHAIN_CLIENT_SECRET") ?? "vaultwarden-api-client-secret";
|
|
35
|
+
|
|
36
|
+
export function useOwnBwLogin(): void {
|
|
37
|
+
OWN_BW_LOGIN = true;
|
|
38
|
+
process.env.BITWARDENCLI_APPDATA_DIR = OWN_BW_DIR;
|
|
39
|
+
KEYCHAIN_MASTER_SERVICE = setting("KEYCHAIN_MASTER") ?? `${BRAND.bin}-master-password`;
|
|
40
|
+
KEYCHAIN_CLIENT_ID_SERVICE = setting("KEYCHAIN_CLIENT_ID") ?? `${BRAND.bin}-api-client-id`;
|
|
41
|
+
KEYCHAIN_CLIENT_SECRET_SERVICE = setting("KEYCHAIN_CLIENT_SECRET") ?? `${BRAND.bin}-api-client-secret`;
|
|
42
|
+
}
|
|
43
|
+
if (!process.env.BITWARDENCLI_APPDATA_DIR && existsSync(join(OWN_BW_DIR, "data.json"))) useOwnBwLogin();
|
|
27
44
|
|
|
28
45
|
export const PROVIDER_KEYS: ReadonlySet<string> = new Set([
|
|
29
46
|
"anthropic", "openai", "azure-openai-responses", "deepseek", "google", "mistral",
|
|
@@ -67,7 +84,7 @@ export const referenceFor = legacyRefFor;
|
|
|
67
84
|
|
|
68
85
|
// Vault credentials never reach commands run on the agent's behalf: with the
|
|
69
86
|
// session token a child could dump the whole vault, defeating `--only`.
|
|
70
|
-
export const VAULT_ENV_KEYS: readonly string[] = ["BW_SESSION", "BW_PASSWORD", "BW_CLIENTID", "BW_CLIENTSECRET"];
|
|
87
|
+
export const VAULT_ENV_KEYS: readonly string[] = ["BW_SESSION", "BW_PASSWORD", "BW_CLIENTID", "BW_CLIENTSECRET", "BITWARDENCLI_APPDATA_DIR"];
|
|
71
88
|
|
|
72
89
|
export function childEnv(extra: Record<string, string> = {}): Record<string, string> {
|
|
73
90
|
const env: Record<string, string> = {};
|
|
@@ -205,10 +222,12 @@ export async function resolveRef(ref: SecretRef): Promise<string | null> {
|
|
|
205
222
|
}
|
|
206
223
|
}
|
|
207
224
|
|
|
208
|
-
|
|
225
|
+
// The timeout leaves room for a macOS "allow access" prompt when the entry was
|
|
226
|
+
// stored with `setup --confirm-unlock`; a missing entry still fails at once.
|
|
227
|
+
export async function readKeychain(service: string): Promise<string | null> {
|
|
209
228
|
const user = process.env.USER || process.env.LOGNAME || "";
|
|
210
229
|
try {
|
|
211
|
-
const result = await runFile("security", ["find-generic-password", "-a", user, "-s", service, "-w"], { timeout:
|
|
230
|
+
const result = await runFile("security", ["find-generic-password", "-a", user, "-s", service, "-w"], { timeout: 120000, maxBuffer: 1024 * 1024 });
|
|
212
231
|
return result.stdout.trim() || null;
|
|
213
232
|
} catch {}
|
|
214
233
|
try {
|
|
@@ -294,6 +313,114 @@ async function ensureSessionInner(existing: string | undefined): Promise<string>
|
|
|
294
313
|
return token;
|
|
295
314
|
}
|
|
296
315
|
|
|
316
|
+
// Store a keychain entry by letting the OS tool prompt for the value on the
|
|
317
|
+
// user's terminal, so keyshroud never holds it in argv. `confirm` (macOS) trusts
|
|
318
|
+
// no app, so every later read asks the user first.
|
|
319
|
+
export async function storeKeychainInteractive(service: string, label: string, confirm: boolean): Promise<boolean> {
|
|
320
|
+
const user = process.env.USER || process.env.LOGNAME || "";
|
|
321
|
+
const attempts: Array<[string, string[]]> = process.platform === "darwin"
|
|
322
|
+
? [["security", ["add-generic-password", "-U", "-a", user, "-s", service, "-l", label, ...(confirm ? ["-T", ""] : []), "-w"]]]
|
|
323
|
+
: [["secret-tool", ["store", `--label=${label}`, "service", service, "account", user]]];
|
|
324
|
+
// -U keeps an existing entry's access list, so an ask-every-time entry is recreated.
|
|
325
|
+
if (process.platform === "darwin" && confirm) await runFile("security", ["delete-generic-password", "-a", user, "-s", service], { timeout: 10000, maxBuffer: 1024 * 1024 }).catch(() => {});
|
|
326
|
+
for (const [command, args] of attempts) {
|
|
327
|
+
const code = await new Promise<number | null>((resolve) => {
|
|
328
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
329
|
+
child.on("error", () => resolve(null));
|
|
330
|
+
child.on("exit", (exit) => resolve(exit));
|
|
331
|
+
});
|
|
332
|
+
if (code === 0) return true;
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// `setup` from an agent's shell (no terminal): a random master password for the
|
|
338
|
+
// new agent account, written to the keychain over stdin. Nobody types it and it
|
|
339
|
+
// never reaches argv or output; the user gets it via the clipboard for signup.
|
|
340
|
+
export async function generateKeychainSecret(service: string, label: string, confirm: boolean): Promise<boolean> {
|
|
341
|
+
const secret = randomBytes(24).toString("base64url");
|
|
342
|
+
const user = process.env.USER || process.env.LOGNAME || "";
|
|
343
|
+
const opts = { timeout: 30000, maxBuffer: 1024 * 1024 };
|
|
344
|
+
try {
|
|
345
|
+
if (process.platform === "darwin") {
|
|
346
|
+
// `security -i` reads the command from stdin, so the value is not in argv.
|
|
347
|
+
await runFile("security", ["-i"], opts, `add-generic-password -U -a "${user}" -s "${service}" -l "${label}"${confirm ? ' -T ""' : ""} -w "${secret}"\n`);
|
|
348
|
+
await runFile("security", ["find-generic-password", "-a", user, "-s", service], opts);
|
|
349
|
+
} else {
|
|
350
|
+
await runFile("secret-tool", ["store", `--label=${label}`, "service", service, "account", user], opts, secret);
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
} catch { return false; }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const CLIPBOARD_TOOLS: Array<[string, string[], string, string[]]> = process.platform === "darwin"
|
|
357
|
+
? [["pbcopy", [], "pbpaste", []]]
|
|
358
|
+
: [["wl-copy", [], "wl-paste", ["--no-newline"]], ["xclip", ["-selection", "clipboard"], "xclip", ["-selection", "clipboard", "-o"]]];
|
|
359
|
+
|
|
360
|
+
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
361
|
+
for (const [copy, args] of CLIPBOARD_TOOLS) {
|
|
362
|
+
try { await runFile(copy, args, { timeout: 10000, maxBuffer: 1024 * 1024 }, text); return true; } catch {}
|
|
363
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Empty the clipboard only if it still holds `text`.
|
|
368
|
+
export async function clearClipboardIf(text: string): Promise<void> {
|
|
369
|
+
for (const [copy, copyArgs, paste, pasteArgs] of CLIPBOARD_TOOLS) {
|
|
370
|
+
try {
|
|
371
|
+
if ((await runFile(paste, pasteArgs, { timeout: 10000, maxBuffer: 1024 * 1024 })).stdout.trim() === text) await runFile(copy, copyArgs, { timeout: 10000, maxBuffer: 1024 * 1024 }, "");
|
|
372
|
+
return;
|
|
373
|
+
} catch {}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// `setup`: log keyshroud's own bw login in to the agent account. stdin and
|
|
378
|
+
// stderr stay on the terminal so bw can ask for a two-step login code.
|
|
379
|
+
export async function loginOwnAccount(email: string, master: string): Promise<string> {
|
|
380
|
+
const token = await new Promise<string>((resolve, reject) => {
|
|
381
|
+
const child = spawn("bw", ["login", email, "--passwordenv", "BW_PASSWORD", "--raw"], { env: { ...process.env, BW_PASSWORD: master }, stdio: ["inherit", "pipe", "inherit"] });
|
|
382
|
+
let out = "";
|
|
383
|
+
child.stdout.on("data", (chunk: Buffer) => { out += chunk; });
|
|
384
|
+
child.on("error", reject);
|
|
385
|
+
child.on("exit", (exit) => (exit === 0 && out.trim() ? resolve(out.trim()) : reject(new Error("bw login failed"))));
|
|
386
|
+
});
|
|
387
|
+
await writePrivateFile(SESSION_FILE, `${token}\n`);
|
|
388
|
+
process.env.BW_SESSION = token;
|
|
389
|
+
try { await bw(["sync", "--quiet"], { session: token }); } catch {}
|
|
390
|
+
invalidateSecretCache();
|
|
391
|
+
return token;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// `migrate`: read the items of the bw login the user had before setup (the
|
|
395
|
+
// default bw data dir), then lock it again. The values stay in memory.
|
|
396
|
+
export async function readPersonalItems(): Promise<Array<Record<string, unknown>>> {
|
|
397
|
+
const master = await readKeychain(PERSONAL_KEYCHAIN_MASTER_SERVICE);
|
|
398
|
+
if (!master) throw new Error(`Personal master password not in the keychain (${PERSONAL_KEYCHAIN_MASTER_SERVICE})`);
|
|
399
|
+
const env = (extra: Record<string, string>) => {
|
|
400
|
+
const out: Record<string, string> = {};
|
|
401
|
+
for (const [name, value] of Object.entries(process.env)) if (value !== undefined && !VAULT_ENV_KEYS.includes(name)) out[name] = value;
|
|
402
|
+
return { ...out, ...extra };
|
|
403
|
+
};
|
|
404
|
+
const token = (await runFile("bw", ["unlock", "--passwordenv", "BW_PASSWORD", "--raw"], { env: env({ BW_PASSWORD: master }), timeout: 30000, maxBuffer: 4 * 1024 * 1024 })).stdout.trim();
|
|
405
|
+
if (!token) throw new Error("Personal vault unlock returned no session");
|
|
406
|
+
try {
|
|
407
|
+
const parsed = JSON.parse((await runFile("bw", ["list", "items"], { env: env({ BW_SESSION: token }), timeout: 120000, maxBuffer: 256 * 1024 * 1024 })).stdout) as unknown;
|
|
408
|
+
return Array.isArray(parsed) ? parsed as Array<Record<string, unknown>> : [];
|
|
409
|
+
} finally {
|
|
410
|
+
try { await runFile("bw", ["lock"], { env: env({}), timeout: 20000, maxBuffer: 1024 * 1024 }); } catch {}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// A personal item as a new item for the agent account: no ids, folders,
|
|
415
|
+
// organizations, history, attachments or per-item key (encrypted to the personal account).
|
|
416
|
+
export async function copyItemToOwnVault(item: Record<string, unknown>): Promise<void> {
|
|
417
|
+
const copy: Record<string, unknown> = { ...item, favorite: false };
|
|
418
|
+
for (const key of ["id", "key", "object", "organizationId", "folderId", "collectionIds", "revisionDate", "creationDate", "deletedDate", "passwordHistory", "attachments"]) delete copy[key];
|
|
419
|
+
await bw(["create", "item"], { input: Buffer.from(JSON.stringify(copy), "utf8").toString("base64") });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function syncOwnVault(): Promise<void> { await bw(["sync", "--quiet"]); invalidateSecretCache(); }
|
|
423
|
+
|
|
297
424
|
// Forget the cached session and lock the bw CLI. The next resolve unlocks
|
|
298
425
|
// again from the keychain.
|
|
299
426
|
export async function lock(): Promise<void> {
|