@wirecat/cli-base 0.65.4 → 0.65.5

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 CHANGED
@@ -8,6 +8,23 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
8
8
  Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
9
9
  [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).
10
10
 
11
+ ## 0.65.5 — 31.10.2026
12
+
13
+ ### Added
14
+
15
+ - **`movedRunService(app, configDir)` names a moved run's temporary keyring entry**, so a test or an agent can delete
16
+ it by name.
17
+
18
+ ### Security
19
+
20
+ - **A moved run no longer replaces the owner's keyring entry.** `session start` in a worktree or with
21
+ `HOME=/tmp/x` overwrote the owner's real sign-in. With the system keyring, `Credentials` in a moved run (`isolated`,
22
+ or a moved `HOME` or XDG folder) now writes a temporary entry, `<app>:<absolute config folder>`; reads it first and
23
+ the owner's `<app>` entry when it has none; and `remove` deletes only the temporary entry, so `session end` cleans up.
24
+ A token refreshed in a moved run is saved in the temporary entry too, so a run that never ends its session leaves
25
+ one behind ([WC-SECR-24](docs/standards/security.md#wc-secr-24--a-run-with-moved-folders-shares-the-owners-keyring-entry-and-never-deletes-it)).
26
+ A run with the default folders, and a test with `memoryKeyring()`, are unchanged.
27
+
11
28
  ## 0.65.4 — 31.10.2026
12
29
 
13
30
  ### Fixed
@@ -14,10 +14,9 @@ export interface CredentialsOptions {
14
14
  /**
15
15
  * **True when the run's folders are not the owner's own** — pass `isMovedRun(…)`.
16
16
  *
17
- * The run still uses the owner's keyring entry (`service`, the same account names): one sign-in per computer, not
18
- * one per temp folder. With the system keyring, `remove` then keeps that entry instead of deleting it, so a test or
19
- * worktree run cannot end the owner's real sign-in (WC-SECR-24). A moved `HOME` or XDG folder in `env` counts as
20
- * moved even when this is false.
17
+ * With the system keyring the run reads the owner's entry (`service`) but never replaces or deletes it: `write`
18
+ * goes to a temporary entry, `movedRunService(service, configDir)`, which `read` prefers and `remove` deletes
19
+ * (WC-SECR-24). A moved `HOME` or XDG folder in `env` counts as moved even when this is false.
21
20
  */
22
21
  isolated?: boolean;
23
22
  /** Read before the keyring, for CI. */
@@ -30,10 +29,15 @@ export interface CredentialsOptions {
30
29
  warn?: (message: string) => void;
31
30
  }
32
31
  /**
33
- * The keyring service a `Credentials` uses: the app's own, for a moved run too. The other two arguments are ignored
34
- * and stay only so that callers written against 0.65.0 still compile.
32
+ * The app's shared keyring service, which a moved run reads too. The other two arguments are ignored and stay only so
33
+ * that callers written against 0.65.0 still compile.
35
34
  */
36
35
  export declare const keyringService: (service: string, _configDir?: string, _isolated?: boolean) => string;
36
+ /**
37
+ * The temporary keyring service a moved run writes instead of the owner's entry: `<app>:<absolute config folder>`, the
38
+ * shape `bin/keyring-dedupe` already finds and removes.
39
+ */
40
+ export declare const movedRunService: (service: string, configDir: string) => string;
37
41
  /**
38
42
  * Environment first, then the OS keyring, then a file.
39
43
  *
@@ -1,15 +1,20 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { writeSecurely } from "./config.js";
4
4
  import { CliError } from "./errors.js";
5
5
  import { systemKeyring } from "./keyring.js";
6
6
  import { homeIsMoved } from "./paths.js";
7
7
  export const CREDENTIAL_STORAGE = ["auto", "keyring", "file"];
8
8
  /**
9
- * The keyring service a `Credentials` uses: the app's own, for a moved run too. The other two arguments are ignored
10
- * and stay only so that callers written against 0.65.0 still compile.
9
+ * The app's shared keyring service, which a moved run reads too. The other two arguments are ignored and stay only so
10
+ * that callers written against 0.65.0 still compile.
11
11
  */
12
12
  export const keyringService = (service, _configDir, _isolated) => service;
13
+ /**
14
+ * The temporary keyring service a moved run writes instead of the owner's entry: `<app>:<absolute config folder>`, the
15
+ * shape `bin/keyring-dedupe` already finds and removes.
16
+ */
17
+ export const movedRunService = (service, configDir) => `${service}:${resolve(configDir)}`;
13
18
  /**
14
19
  * Environment first, then the OS keyring, then a file.
15
20
  *
@@ -26,7 +31,8 @@ export class Credentials {
26
31
  #envVar;
27
32
  #fileName;
28
33
  #warn;
29
- #keepsSharedEntry;
34
+ /** Set only for a moved run with the system keyring. */
35
+ #temporaryService;
30
36
  #warned = false;
31
37
  constructor(options) {
32
38
  this.#configDir = options.configDir;
@@ -37,15 +43,16 @@ export class Credentials {
37
43
  this.#fileName = options.fileName ?? "credentials.json";
38
44
  this.#warn = options.warn ?? ((message) => process.stderr.write(`${message}\n`));
39
45
  this.#service = options.service;
40
- this.#keepsSharedEntry =
41
- this.#keyring === systemKeyring && (options.isolated === true || homeIsMoved({ env: this.#env }));
46
+ const moved = this.#keyring === systemKeyring && (options.isolated === true || homeIsMoved({ env: this.#env }));
47
+ this.#temporaryService = moved ? movedRunService(options.service, options.configDir) : undefined;
42
48
  }
43
49
  read(account) {
44
50
  const fromEnv = this.#envVar ? this.#env[this.#envVar]?.trim() : undefined;
45
51
  if (fromEnv)
46
52
  return { secret: fromEnv, source: "environment" };
47
53
  if (this.#storage !== "file") {
48
- const fromKeyring = this.#tryKeyring(() => this.#keyring.get(this.#service, account));
54
+ const temporary = this.#temporaryService;
55
+ const fromKeyring = this.#tryKeyring(() => (temporary && this.#keyring.get(temporary, account)) || this.#keyring.get(this.#service, account));
49
56
  if (fromKeyring)
50
57
  return { secret: fromKeyring, source: "keyring" };
51
58
  }
@@ -55,7 +62,7 @@ export class Credentials {
55
62
  write(account, secret) {
56
63
  if (this.#storage !== "file") {
57
64
  const stored = this.#tryKeyring(() => {
58
- this.#keyring.set(this.#service, account, secret);
65
+ this.#keyring.set(this.#temporaryService ?? this.#service, account, secret);
59
66
  return true;
60
67
  });
61
68
  if (stored)
@@ -72,12 +79,14 @@ export class Credentials {
72
79
  }
73
80
  remove(account) {
74
81
  const removed = [];
75
- if (this.#storage !== "file" && this.#keepsSharedEntry) {
76
- this.#warn(`kept ${account} in the OS keyring: it is the owner's sign-in, shared with every run, and a run with moved ` +
77
- "folders never removes it");
78
- }
79
- else if (this.#storage !== "file" && this.#tryKeyring(() => this.#keyring.delete(this.#service, account))) {
80
- removed.push("keyring");
82
+ if (this.#storage !== "file") {
83
+ if (this.#tryKeyring(() => this.#keyring.delete(this.#temporaryService ?? this.#service, account))) {
84
+ removed.push("keyring");
85
+ }
86
+ if (this.#temporaryService) {
87
+ this.#warn(`kept ${account} in the OS keyring: it is the owner's sign-in, shared with every run, and a run with moved ` +
88
+ "folders never removes it");
89
+ }
81
90
  }
82
91
  const store = this.#readFile();
83
92
  if (store?.[account] !== undefined) {
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { BATCH_LIMITS, type BatchContext, type BatchHandler, type BatchOperation, type BatchReport, type BatchResult, type PreparedBatchRequest, prepareBatch, projectBatchAnswers, readBatchInput, runBatch, } from "./batch.js";
2
2
  export { loadConfigFile, saveConfigFile, writeSecurely } from "./config.js";
3
3
  export type { CredentialSource, CredentialStorage, CredentialsOptions, StoredCredential } from "./credentials.js";
4
- export { CREDENTIAL_STORAGE, Credentials, keyringService } from "./credentials.js";
4
+ export { CREDENTIAL_STORAGE, Credentials, keyringService, movedRunService } from "./credentials.js";
5
5
  export { createDeadline, type Deadline, type DeadlineOptions } from "./deadline.js";
6
6
  export { CliError, type CliErrorDetails, type ErrorCode, errorCodes, isCliError } from "./errors.js";
7
7
  export { EXIT_CODES, exitCodeFor, GENERIC_FAILURE } from "./exit-codes.js";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { BATCH_LIMITS, prepareBatch, projectBatchAnswers, readBatchInput, runBatch, } from "./batch.js";
2
2
  export { loadConfigFile, saveConfigFile, writeSecurely } from "./config.js";
3
- export { CREDENTIAL_STORAGE, Credentials, keyringService } from "./credentials.js";
3
+ export { CREDENTIAL_STORAGE, Credentials, keyringService, movedRunService } from "./credentials.js";
4
4
  export { createDeadline } from "./deadline.js";
5
5
  export { CliError, errorCodes, isCliError } from "./errors.js";
6
6
  export { EXIT_CODES, exitCodeFor, GENERIC_FAILURE } from "./exit-codes.js";
@@ -68,7 +68,8 @@ name. Any other top-level key is written as `[not allowed]`. A key that names co
68
68
 
69
69
  cli-base's `Credentials` reads the environment variable first (for CI), then the OS keyring, then a file with mode
70
70
  `0600`. The keyring service is the CLI's app name, `appName` in its identity (`max-cli`, `gcal-cli`, `cli-memo`), not
71
- the command word people type (`max`, `gcal`). A run with moved folders uses the same entry and never deletes it
71
+ the command word people type (`max`, `gcal`). A run with moved folders reads the same entry, writes a temporary one,
72
+ and never replaces or deletes the owner's
72
73
  ([WC-SECR-24](#wc-secr-24--a-run-with-moved-folders-shares-the-owners-keyring-entry-and-never-deletes-it)).
73
74
 
74
75
  - **Why:** the keyring is the safest store on the computer.
@@ -327,23 +328,36 @@ store and runs no search. A CLI declares its provider and may replace any step.
327
328
 
328
329
  ### WC-SECR-24 · A run with moved folders shares the owner's keyring entry and never deletes it
329
330
 
330
- - **Why:** a per-folder keyring entry (`<app>:<config folder>`) left one copy of a sign-in for every test run, agent
331
- check or worktree, about 1,500 of them in the owner's keyring, and nothing removed them. One entry per computer is
332
- enough. But a moved run then reaches the owner's real sign-in: `HOME=/tmp/x gcal personal session end` deleted the
333
- owner's Google sign-in, because only `<PREFIX>_*_DIR` counted as moved (a security review, 2026-10-30).
331
+ - **Why:** a per-folder keyring entry (`<app>:<config folder>`) for every run left one copy of a sign-in for every test
332
+ run, agent check or worktree, about 1,500 of them in the owner's keyring, and nothing removed them. One entry per
333
+ computer is enough. But a moved run then reaches the owner's real sign-in: `HOME=/tmp/x gcal personal session end`
334
+ deleted the owner's Google sign-in, because only `<PREFIX>_*_DIR` counted as moved (a security review, 2026-10-30),
335
+ and `session start` in a worktree replaced it.
334
336
  - **Enforced by:** `src/credentials.test.ts` · `src/paths.test.ts`.
335
337
  - **Example:** a run is moved when `isMovedRun({ appName, prefix, env })` is true: a `<PREFIX>_CONFIG_DIR`,
336
338
  `_STATE_DIR` or `_CACHE_DIR` override, or a `HOME` or XDG folder that puts the default folders anywhere but under
337
- the account's own home. A CLI passes that as `isolated`. `Credentials` then reads and writes the app's own entry
338
- (`<app>` / `oauth:<provider>:<profile>`), as a run with the default folders does. With the system keyring, its
339
- `remove` keeps that entry, warns on stderr and leaves `"keyring"` out of what it returns; a moved `HOME` or XDG
340
- folder in its `env` counts even when `isolated` is false. A CLI checks `isMovedRun` before it revokes a sign-in at
341
- the provider, and says so when `session end` removed nothing. A test passes `memoryKeyring()`; a run that must not
342
- see the keyring at all also runs with `DBUS_SESSION_BUS_ADDRESS` removed, since the keyring is reached over D-Bus.
343
- - **Exceptions:** a write from a moved run, such as `session start` in a worktree, replaces the owner's entry.
344
- - **Applies to:** every CLI, its tests and every agent that runs a CLI. **Since:** cli-base 0.65.4.
345
- **Rulings:** owner, 2026-10-29 (sign-ins copied per work folder should have reused the existing entry); owner,
346
- 2026-10-30 (share the keyring entry, do not multiply it).
339
+ the account's own home. A CLI passes that as `isolated`; a moved `HOME` or XDG folder in `env` counts even when
340
+ `isolated` is false. With the system keyring, `Credentials` in a moved run:
341
+ - **writes** only a temporary entry, `movedRunService(app, configDir)` = `<app>:<absolute config folder>`, never the
342
+ owner's `<app>` entry;
343
+ - **reads** that temporary entry first and the owner's `<app>` entry when it has none, so a moved run still uses
344
+ the owner's sign-in;
345
+ - **removes** only the temporary entry, warns on stderr that the owner's was kept, and returns `"keyring"` only when
346
+ a temporary entry was there. `session end` in a moved run therefore cleans up after `session start`.
347
+
348
+ A CLI never revokes a sign-in at the provider in a moved run, even one read from the temporary entry: a refreshed
349
+ token is still the owner's grant. A test passes `memoryKeyring()`; a run that must not see the keyring at all also
350
+ runs with `DBUS_SESSION_BUS_ADDRESS` removed, since the keyring is reached over D-Bus.
351
+ - **Cleanup:** a moved run that never reaches `session end` leaves its temporary entry, and so does one that refreshes
352
+ a token, since the refreshed token is written there. `bin/keyring-dedupe --delete` in the projects folder removes
353
+ temporary entries that sit beside the owner's real one; a test or agent can also delete one by name with
354
+ `movedRunService`. When only `<PREFIX>_STATE_DIR` or `_CACHE_DIR` is moved, the temporary entry is named after the
355
+ owner's own config folder, which `keyring-dedupe` treats as a real entry: remove it with `session end`.
356
+ - **Exceptions:** none.
357
+ - **Applies to:** every CLI, its tests and every agent that runs a CLI. **Since:** cli-base 0.65.4 (temporary entry:
358
+ 0.65.5). **Rulings:** owner, 2026-10-29 (sign-ins copied per work folder should have reused the existing entry);
359
+ owner, 2026-10-30 (share the keyring entry, do not multiply it); owner, 2026-10-31 (do not replace it: a test of a
360
+ new session writes a temporary entry and cleans it up).
347
361
 
348
362
  ## Sources
349
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.65.4",
3
+ "version": "0.65.5",
4
4
  "description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",