bwx 0.3.0 → 0.4.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/README.md CHANGED
@@ -17,9 +17,11 @@ Bitwarden CLI wrapper for AI agents (`bwx` = `bw extended`). Auto-unlock, sessio
17
17
  The native `bw` CLI requires interactive prompts and manual session management. `bwx` wraps it with:
18
18
 
19
19
  - **Auto-unlock** — first vault command authenticates automatically via macOS Keychain
20
- - **Session caching** — token persisted to `~/.config/bwx/session` (mode 600), reused across calls
20
+ - **Session caching** — token written atomically as mode 600 to `~/.config/bwx/session`, passed to `bw` through `BW_SESSION` so it never appears in a process command line
21
21
  - **Structured output** — `--json` and `--plain` flags for machine-parseable output
22
+ - **Leak-resistant** — listings never print secrets, `bwx run` hands them to a child process instead of stdout
22
23
  - **No interaction** — credentials from config + Keychain, never prompts
24
+ - **Bounded** — every `bw` call has a deadline, so a wedged vault operation cannot hang a caller
23
25
 
24
26
  ## Install
25
27
 
@@ -64,10 +66,47 @@ bwx doctor
64
66
  |---------|-------------|
65
67
  | `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
66
68
  | `bwx field <item> <name>` | Get custom field by name |
67
- | `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`) |
69
+ | `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`, `--full-items`) |
70
+ | `bwx list [type]` | List items (`--folder`, `--limit`, `--full-items`) |
71
+ | `bwx run --env NAME=<field>:<item> -- <cmd>` | Run a command with secrets in its environment |
68
72
  | `bwx attach list <item>` | List attachments for an item |
69
73
  | `bwx attach get <item> <attachment>` | Download attachment by ID or filename |
70
74
 
75
+ `search` and `list` print the same reduced projection — id, type, name, username — in
76
+ human, `--plain`, and `--json` form, so asking for machine-readable output never widens
77
+ what is exposed. `--full-items` is the explicit opt-in to complete item objects, which
78
+ carry passwords, notes, and custom fields.
79
+
80
+ ### Secrets into a child process
81
+
82
+ `bwx run` resolves secrets and places them only in the child's environment: nothing is
83
+ printed to stdout, and nothing lands in a command line where other processes could read it.
84
+
85
+ ```bash
86
+ bwx run --env KEENETIC_PASS=password:'http://my.keenetic.net' -- expect ./recovery.exp
87
+ bwx run --env DB_PASS=password:'Prod DB' --env TOKEN='API Key:Acme' -- ./deploy.sh
88
+ ```
89
+
90
+ - `--env NAME=<field>:<item>` is repeatable; the field resolves exactly as in `bwx get`
91
+ (built-ins first, otherwise a custom field name).
92
+ - The item may contain colons — only the first one separates field from item.
93
+ - Use `--` before the command whenever it takes its own flags.
94
+ - The child inherits stdio and bwx exits with the child's status (`128 + signal` when killed).
95
+ - `-v` logs the injected variable *names*; values are never logged.
96
+
97
+ ### Vault freshness
98
+
99
+ `bw` answers reads from the local vault copy, so a vault that has not synced in a while
100
+ returns stale secrets. Reads warn on stderr when the last sync is over 24h old, and
101
+ `--sync-if-older-than <duration>` syncs first instead of warning:
102
+
103
+ ```bash
104
+ bwx get password "Router" # warns if the vault is stale
105
+ bwx --sync-if-older-than 15m get password "Router" # syncs first when needed
106
+ ```
107
+
108
+ The check is cached in `~/.config/bwx/state.json`, so a fresh vault costs no extra `bw` call.
109
+
71
110
  ### Write
72
111
 
73
112
  | Command | Description |
@@ -119,8 +158,15 @@ Supported source flags:
119
158
  |------|--------|
120
159
  | `--json` | JSON output: `{ "data": ... }` / `{ "error": ... }` |
121
160
  | `--plain` | Tab-separated, one item per line |
122
- | `-q, --quiet` | Suppress logs |
161
+ | `-q, --quiet` | Suppress logs and warnings |
123
162
  | `-v, --verbose` | Include stack traces |
163
+ | `--timeout <duration>` | Deadline for each `bw` call (default `1m`, `0` disables) |
164
+ | `--sync-if-older-than <duration>` | Sync before vault access when the last sync is older |
165
+
166
+ Durations accept `250ms`, `30s`, `15m`, `2h`, `1d`; a bare number means seconds.
167
+
168
+ Warnings ("vault is stale", "ignoring cached session") go to stderr even under `--json`,
169
+ because they say the returned data may be wrong. `-q` silences them.
124
170
 
125
171
  ## Custom fields
126
172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bwx",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "bitwarden",
@@ -0,0 +1,133 @@
1
+ import pc from "picocolors";
2
+ import { CliError, ExitCode } from "../cli/errors.ts";
3
+ import type { GlobalOptions } from "../cli/types.ts";
4
+ import { summarizeItem, type ItemSummary } from "./items.ts";
5
+ import { runBwOrThrow } from "./runner.ts";
6
+ import { withSession } from "./session.ts";
7
+ import { BwItemSchema } from "./types.ts";
8
+
9
+ /** Fields `bw get` resolves natively; anything else is a custom field name. */
10
+ const BUILTIN_FIELDS = [
11
+ "password",
12
+ "username",
13
+ "totp",
14
+ "notes",
15
+ "uri",
16
+ "item",
17
+ ] as const;
18
+
19
+ function isBuiltinField(field: string): boolean {
20
+ return (BUILTIN_FIELDS as readonly string[]).includes(field);
21
+ }
22
+
23
+ export interface ReadFieldOptions {
24
+ /**
25
+ * Always resolve `field` as a custom field name, so an item whose custom
26
+ * field is called e.g. `password` is still reachable.
27
+ */
28
+ customOnly?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Reads one field from a vault item and returns it verbatim (`item` yields the
33
+ * item JSON), leaving the caller to decide whether the value is printed at all.
34
+ * Ambiguous item lookups fail with the candidate list instead of a raw bw error.
35
+ */
36
+ export async function readItemField(
37
+ field: string,
38
+ item: string,
39
+ opts: GlobalOptions,
40
+ options?: ReadFieldOptions,
41
+ ): Promise<string> {
42
+ try {
43
+ return isBuiltinField(field) && !options?.customOnly
44
+ ? await readBuiltinField(field, item, opts)
45
+ : await readCustomField(field, item, opts);
46
+ } catch (err) {
47
+ if (err instanceof CliError && /more than one result/i.test(err.message)) {
48
+ const matches = await findMatches(item);
49
+ throw new CliError(
50
+ formatAmbiguousError(item, matches, opts),
51
+ ExitCode.BadArgs,
52
+ );
53
+ }
54
+ throw err;
55
+ }
56
+ }
57
+
58
+ async function readBuiltinField(
59
+ field: string,
60
+ item: string,
61
+ opts: GlobalOptions,
62
+ ): Promise<string> {
63
+ const result = await withSession(opts, () =>
64
+ runBwOrThrow(["get", field, item]),
65
+ );
66
+
67
+ if (!result) {
68
+ throw new CliError(
69
+ `No ${field} found for item: ${item}`,
70
+ ExitCode.NotFound,
71
+ );
72
+ }
73
+
74
+ return result;
75
+ }
76
+
77
+ async function readCustomField(
78
+ fieldName: string,
79
+ item: string,
80
+ opts: GlobalOptions,
81
+ ): Promise<string> {
82
+ const json = await withSession(opts, () =>
83
+ runBwOrThrow(["get", "item", item]),
84
+ );
85
+
86
+ const parsed = BwItemSchema.parse(JSON.parse(json));
87
+ const fields = parsed.fields ?? [];
88
+ const match = fields.find((f) => f.name === fieldName);
89
+
90
+ if (!match) {
91
+ const available = fields.map((f) => f.name).join(", ");
92
+ const msg = available
93
+ ? `Field "${fieldName}" not found. Available: ${available}`
94
+ : `Field "${fieldName}" not found (item has no custom fields)`;
95
+ throw new CliError(msg, ExitCode.NotFound);
96
+ }
97
+
98
+ return match.value ?? "";
99
+ }
100
+
101
+ async function findMatches(query: string): Promise<ItemSummary[]> {
102
+ try {
103
+ const json = await runBwOrThrow(["list", "items", "--search", query]);
104
+ const items: Array<Record<string, unknown>> = JSON.parse(json);
105
+ return items.map(summarizeItem);
106
+ } catch {
107
+ return [];
108
+ }
109
+ }
110
+
111
+ function formatAmbiguousError(
112
+ query: string,
113
+ matches: ItemSummary[],
114
+ opts: GlobalOptions,
115
+ ): string {
116
+ if (matches.length === 0) {
117
+ return `Multiple items match "${query}". Use a specific ID instead.`;
118
+ }
119
+
120
+ if (opts.json) {
121
+ return `Multiple items match "${query}": ${JSON.stringify(matches)}`;
122
+ }
123
+
124
+ const lines = [`Multiple items match "${query}":\n`];
125
+ for (const m of matches) {
126
+ const user = m.username ? ` (${m.username})` : "";
127
+ lines.push(
128
+ ` ${pc.dim(m.id)} ${pc.cyan(m.type)} ${m.name}${pc.dim(user)}`,
129
+ );
130
+ }
131
+ lines.push(`\nUse a specific ID: ${pc.dim("bwx get <field> <id>")}`);
132
+ return lines.join("\n");
133
+ }
@@ -0,0 +1,161 @@
1
+ import { formatDuration } from "../cli/duration.ts";
2
+ import { emitLog, emitWarn } from "../cli/io.ts";
3
+ import type { GlobalOptions } from "../cli/types.ts";
4
+ import { STATE_FILE } from "../config/paths.ts";
5
+ import { readPrivateFile, writePrivateFile } from "../config/secure-file.ts";
6
+ import { getStatus } from "./status.ts";
7
+ import type { BwStatus } from "./types.ts";
8
+
9
+ /**
10
+ * `bw` serves reads from the local vault copy, so a vault that has not synced in
11
+ * days silently returns rotated-away secrets. Reads therefore check freshness —
12
+ * but `bw status` costs a full `bw` spawn (~2s), far too much to pay on every
13
+ * read, so the answer is cached here and only re-polled when it might matter.
14
+ */
15
+
16
+ /** Warn when the vault has not synced within this window. */
17
+ export const STALE_AFTER_MS = 24 * 60 * 60 * 1_000;
18
+
19
+ /** While the vault looks stale, ask `bw status` again at most this often. */
20
+ const RECHECK_INTERVAL_MS = 5 * 60 * 1_000;
21
+
22
+ export interface FreshnessSnapshot {
23
+ /** `lastSync` as reported by `bw status`; null when the vault never synced. */
24
+ lastSync: string | null;
25
+ /** When bwx last asked `bw status` for that value. */
26
+ checkedAt: string;
27
+ }
28
+
29
+ export type FreshnessPlan =
30
+ /** Known fresh — no `bw` call, no warning. */
31
+ | "skip"
32
+ /** Known stale recently enough to trust the cached answer. */
33
+ | "stale"
34
+ /** Cached answer is missing, unusable, or too old to trust. */
35
+ | "poll";
36
+
37
+ /**
38
+ * Decides what can be concluded from the cached snapshot alone. Pure so the
39
+ * caching policy stays testable without a vault.
40
+ */
41
+ export function planFreshnessCheck(
42
+ snapshot: FreshnessSnapshot | null,
43
+ thresholdMs: number,
44
+ now: number,
45
+ ): FreshnessPlan {
46
+ if (!snapshot) return "poll";
47
+
48
+ if (isFresh(snapshot, thresholdMs, now)) return "skip";
49
+
50
+ const checkedAgeMs = now - Date.parse(snapshot.checkedAt);
51
+ const checkedRecently =
52
+ Number.isFinite(checkedAgeMs) &&
53
+ checkedAgeMs >= 0 &&
54
+ checkedAgeMs < RECHECK_INTERVAL_MS;
55
+
56
+ // lastSync only ever moves forward, so a stale reading stays stale unless
57
+ // another client synced — worth re-checking, just not on every read.
58
+ return checkedRecently ? "stale" : "poll";
59
+ }
60
+
61
+ /**
62
+ * Warns (or syncs, with `--sync-if-older-than`) when the local vault is stale.
63
+ * Advisory only: any failure to determine freshness is ignored rather than
64
+ * failing the operation the caller actually asked for.
65
+ */
66
+ export async function checkVaultFreshness(
67
+ opts: GlobalOptions,
68
+ sync: () => Promise<unknown>,
69
+ ): Promise<void> {
70
+ const syncOnStale = opts.syncIfOlderThanMs !== undefined;
71
+ const thresholdMs = opts.syncIfOlderThanMs ?? STALE_AFTER_MS;
72
+
73
+ let snapshot = loadSnapshot();
74
+ const plan = planFreshnessCheck(snapshot, thresholdMs, Date.now());
75
+ if (plan === "skip") return;
76
+
77
+ if (plan === "poll") {
78
+ try {
79
+ snapshot = recordStatus(await getStatus());
80
+ } catch {
81
+ return; // Freshness is advisory — never block the real operation.
82
+ }
83
+ if (isFresh(snapshot, thresholdMs, Date.now())) return;
84
+ }
85
+
86
+ const ageMs = snapshotAgeMs(snapshot, Date.now());
87
+ const age = ageMs === null ? "never" : `${formatDuration(ageMs)} ago`;
88
+
89
+ if (!syncOnStale) {
90
+ emitWarn(
91
+ `Vault last synced ${age} — reads may be stale. Run 'bwx sync' or pass --sync-if-older-than <duration>.`,
92
+ opts,
93
+ );
94
+ return;
95
+ }
96
+
97
+ emitLog(`Vault last synced ${age} — syncing...`, opts);
98
+ await sync();
99
+ recordSyncNow();
100
+ }
101
+
102
+ /** Caches what `bw status` just reported. */
103
+ export function recordStatus(status: BwStatus): FreshnessSnapshot {
104
+ return saveSnapshot({
105
+ lastSync: status.lastSync,
106
+ checkedAt: new Date().toISOString(),
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Records a sync that just completed. The stored `lastSync` is our own clock
112
+ * rather than the server's, which is close enough to keep later reads quiet.
113
+ */
114
+ export function recordSyncNow(): void {
115
+ const now = new Date().toISOString();
116
+ saveSnapshot({ lastSync: now, checkedAt: now });
117
+ }
118
+
119
+ function isFresh(
120
+ snapshot: FreshnessSnapshot | null,
121
+ thresholdMs: number,
122
+ now: number,
123
+ ): boolean {
124
+ const ageMs = snapshotAgeMs(snapshot, now);
125
+ return ageMs !== null && ageMs < thresholdMs;
126
+ }
127
+
128
+ function snapshotAgeMs(
129
+ snapshot: FreshnessSnapshot | null,
130
+ now: number,
131
+ ): number | null {
132
+ if (!snapshot?.lastSync) return null;
133
+ const syncedAt = Date.parse(snapshot.lastSync);
134
+ if (!Number.isFinite(syncedAt)) return null;
135
+ return Math.max(0, now - syncedAt);
136
+ }
137
+
138
+ function loadSnapshot(): FreshnessSnapshot | null {
139
+ const read = readPrivateFile(STATE_FILE);
140
+ if (read.kind !== "ok") return null;
141
+
142
+ try {
143
+ const parsed = JSON.parse(read.text) as Partial<FreshnessSnapshot>;
144
+ if (typeof parsed.checkedAt !== "string") return null;
145
+ return {
146
+ lastSync: typeof parsed.lastSync === "string" ? parsed.lastSync : null,
147
+ checkedAt: parsed.checkedAt,
148
+ };
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ function saveSnapshot(snapshot: FreshnessSnapshot): FreshnessSnapshot {
155
+ try {
156
+ writePrivateFile(STATE_FILE, JSON.stringify(snapshot) + "\n");
157
+ } catch {
158
+ // A read-only config dir must not break vault access.
159
+ }
160
+ return snapshot;
161
+ }
@@ -0,0 +1,25 @@
1
+ import { BW_TYPE_LABELS } from "./types.ts";
2
+
3
+ /**
4
+ * The only shape of a vault item bwx prints when the caller did not ask for a
5
+ * specific secret. Enough to identify an item and follow up with a narrow `get`,
6
+ * with no room for passwords, notes, custom fields, or URIs to leak into logs.
7
+ */
8
+ export interface ItemSummary {
9
+ id: string;
10
+ type: string;
11
+ name: string;
12
+ username: string | null;
13
+ }
14
+
15
+ export function summarizeItem(item: Record<string, unknown>): ItemSummary {
16
+ const login = item.login as Record<string, unknown> | null | undefined;
17
+ const username = login?.username;
18
+
19
+ return {
20
+ id: String(item.id ?? ""),
21
+ type: BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`,
22
+ name: String(item.name ?? ""),
23
+ username: typeof username === "string" && username ? username : null,
24
+ };
25
+ }
package/src/bw/runner.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CliError, ExitCode, classifyBwError } from "../cli/errors.ts";
2
+ import { formatDuration } from "../cli/duration.ts";
2
3
 
3
4
  export interface BwResult {
4
5
  stdout: string;
@@ -6,7 +7,20 @@ export interface BwResult {
6
7
  exitCode: number;
7
8
  }
8
9
 
10
+ export interface RunBwOptions {
11
+ /** Explicit session, or `null` to run without one (login/unlock/status). */
12
+ session?: string | null;
13
+ env?: Record<string, string>;
14
+ }
15
+
16
+ /** Deadline for a single `bw` call; overridable with `--timeout`. */
17
+ export const DEFAULT_TIMEOUT_MS = 60_000;
18
+
19
+ /** Grace period between SIGTERM and SIGKILL for a `bw` process past its deadline. */
20
+ const KILL_GRACE_MS = 2_000;
21
+
9
22
  let sessionToken: string | null = null;
23
+ let timeoutMs: number = DEFAULT_TIMEOUT_MS;
10
24
 
11
25
  export function setSession(token: string | null): void {
12
26
  sessionToken = token;
@@ -16,33 +30,51 @@ export function getSession(): string | null {
16
30
  return sessionToken;
17
31
  }
18
32
 
33
+ /** Applies the `--timeout` value for the rest of the process. */
34
+ export function setBwTimeout(ms: number): void {
35
+ timeoutMs = ms;
36
+ }
37
+
19
38
  export async function runBw(
20
39
  args: string[],
21
- options?: { session?: string | null; env?: Record<string, string> },
40
+ options?: RunBwOptions,
22
41
  ): Promise<BwResult> {
23
42
  const session = options?.session ?? sessionToken;
24
- const fullArgs = session ? [...args, "--session", session] : args;
25
43
 
26
- const proc = Bun.spawn(["bw", ...fullArgs], {
44
+ const proc = Bun.spawn(["bw", ...args], {
27
45
  stdout: "pipe",
28
46
  stderr: "pipe",
29
47
  stdin: "ignore",
30
- env: options?.env ? { ...process.env, ...options.env } : undefined,
48
+ // The session goes in the environment, never in argv: command lines are
49
+ // readable by any same-user process. Nothing here is ever logged.
50
+ env: buildEnv(session, options?.env),
31
51
  });
32
52
 
33
- const [stdout, stderr] = await Promise.all([
34
- new Response(proc.stdout).text(),
35
- new Response(proc.stderr).text(),
36
- ]);
53
+ const guard = timeoutMs > 0 ? armDeadline(proc, timeoutMs) : null;
54
+
55
+ try {
56
+ const [stdout, stderr] = await Promise.all([
57
+ new Response(proc.stdout).text(),
58
+ new Response(proc.stderr).text(),
59
+ ]);
60
+ const exitCode = await proc.exited;
37
61
 
38
- const exitCode = await proc.exited;
62
+ if (guard?.expired) {
63
+ throw new CliError(
64
+ `bw ${args[0] ?? ""} timed out after ${formatDuration(timeoutMs)}`.trim(),
65
+ ExitCode.Timeout,
66
+ );
67
+ }
39
68
 
40
- return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
69
+ return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
70
+ } finally {
71
+ guard?.disarm();
72
+ }
41
73
  }
42
74
 
43
75
  export async function runBwOrThrow(
44
76
  args: string[],
45
- options?: { session?: string | null; env?: Record<string, string> },
77
+ options?: RunBwOptions,
46
78
  ): Promise<string> {
47
79
  const result = await runBw(args, options);
48
80
  if (result.exitCode !== 0) {
@@ -55,6 +87,70 @@ export async function runBwOrThrow(
55
87
  return result.stdout;
56
88
  }
57
89
 
58
- export function isAuthError(result: BwResult): boolean {
59
- return /not logged in|vault is locked|unauthenticated/i.test(result.stderr);
90
+ /**
91
+ * Child environment for `bw`. `BW_SESSION` is set from our session, or removed
92
+ * outright so an ambient one from the caller's shell cannot silently substitute
93
+ * for it (`status`, `login`, and `unlock` rely on seeing the real vault state).
94
+ *
95
+ * `BW_NOINTERACTION` is mandatory: bwx runs `bw` with stdin ignored, so a prompt
96
+ * can only ever produce a hang or garbled output — a locked vault must fail
97
+ * loudly instead, which is what lets callers auto-unlock and retry.
98
+ */
99
+ function buildEnv(
100
+ session: string | null,
101
+ extra?: Record<string, string>,
102
+ ): Record<string, string | undefined> {
103
+ const env: Record<string, string | undefined> = {
104
+ ...process.env,
105
+ BW_NOINTERACTION: "true",
106
+ ...extra,
107
+ };
108
+ if (session) {
109
+ env.BW_SESSION = session;
110
+ } else {
111
+ delete env.BW_SESSION;
112
+ }
113
+ return env;
114
+ }
115
+
116
+ interface Deadline {
117
+ expired: boolean;
118
+ disarm(): void;
119
+ }
120
+
121
+ /**
122
+ * Kills `bw` once the deadline passes so a wedged vault operation (stuck Keychain
123
+ * prompt, unreachable self-hosted server) cannot block the caller forever.
124
+ */
125
+ function armDeadline(proc: Bun.Subprocess, ms: number): Deadline {
126
+ const timers: ReturnType<typeof setTimeout>[] = [];
127
+ const deadline: Deadline = {
128
+ expired: false,
129
+ disarm: () => timers.forEach(clearTimeout),
130
+ };
131
+
132
+ const arm = (delay: number, fn: () => void) => {
133
+ const timer = setTimeout(fn, delay);
134
+ timer.unref?.();
135
+ timers.push(timer);
136
+ };
137
+
138
+ // A timer can still fire in the moment between exit and disarm, where the
139
+ // signal has nowhere to go.
140
+ const kill = (signal: NodeJS.Signals) => {
141
+ try {
142
+ proc.kill(signal);
143
+ } catch {
144
+ // Already gone.
145
+ }
146
+ };
147
+
148
+ arm(ms, () => {
149
+ deadline.expired = true;
150
+ kill("SIGTERM");
151
+ // Escalate if bw ignores SIGTERM.
152
+ arm(KILL_GRACE_MS, () => kill("SIGKILL"));
153
+ });
154
+
155
+ return deadline;
60
156
  }