@wirecat/cli-base 0.65.3 → 0.65.4

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,29 @@ 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.4 — 31.10.2026
12
+
13
+ ### Fixed
14
+
15
+ - **A run with moved folders uses the app's own keyring entry again** ([WC-SECR-24](docs/standards/security.md#wc-secr-24--a-run-with-moved-folders-shares-the-owners-keyring-entry-and-never-deletes-it),
16
+ rewritten). `Credentials` with `isolated` reads and writes service `<app>`, not `<app>:<config folder>`, and no
17
+ longer saves to the moved folder's file first (the 0.65.1 redirect is gone). `keyringService` returns the app's
18
+ service whatever its other arguments say, so cli-core's sign-in key names the shared entry. Per-folder entries made
19
+ before are no longer read; they were copies made by throwaway runs. A secret 0.65.1 saved in a moved folder's file
20
+ is still read when the keyring has none.
21
+ - **The handbook's statements about gcal match gcal-cli 0.2.0:** it has `session list`, its `api` checks
22
+ `events.notify` and `events.delete.series`, its `api` answers drop the passcode lines of a description and a
23
+ location, it serves MCP as `mcp serve`, it sets `requestMs` to 1.5 s, and it packs 144 files.
24
+
25
+ ### Security
26
+
27
+ - **A moved run never deletes the owner's keyring entry.** A security review found that
28
+ `HOME=/tmp/x gcal personal session end` deleted the owner's real Google sign-in. With the system keyring,
29
+ `Credentials.remove` from a moved run keeps the entry, warns on stderr and leaves `"keyring"` out of what it
30
+ returns. A run counts as moved with `isolated`, or when the `env` it is given moves `HOME` or an XDG folder away from
31
+ the account's own home. The new `isMovedRun({ appName, prefix, env })` says the same for a CLI: pass it as
32
+ `isolated`, and check it before revoking a sign-in at the provider. A test with `memoryKeyring()` is unchanged.
33
+
11
34
  ## 0.65.3 — 30.10.2026
12
35
 
13
36
  ### Added
package/README.md CHANGED
@@ -150,8 +150,8 @@ on macOS and Windows through `node-ci.yml` with `platforms: true`; here, Full va
150
150
  spawning `npm` and `tsc`). In every test the keyring is a fake, so no test reaches a real Keychain or Credential
151
151
  Manager.
152
152
 
153
- Two traps worth designing for: the keyring is global, so a throwaway config directory overwrites the real secret
154
- unless you pass `isolated: true`; and a locked Linux keyring can block on a prompt rather than fail.
153
+ Two traps worth designing for: the keyring is global, so a throwaway config directory shares the owner's entry (pass
154
+ `isolated: isMovedRun(…)` so it never deletes it); and a locked Linux keyring can block on a prompt rather than fail.
155
155
 
156
156
  ### Time, retries and deadlines
157
157
 
@@ -12,16 +12,12 @@ export interface CredentialsOptions {
12
12
  /** The keyring service name — the CLI's app name, e.g. `max-cli`, not its command word. */
13
13
  service: string;
14
14
  /**
15
- * **True when the config directory came from the environment rather than the OS convention.**
15
+ * **True when the run's folders are not the owner's own** — pass `isMovedRun(…)`.
16
16
  *
17
- * The OS keyring is global: an entry is addressed by service and account and knows nothing about
18
- * which config directory asked for it. So a throwaway config directory looks isolated and is
19
- * not — it overwrites the real secret for that account. In `brazecli` that destroyed two working
20
- * keys on 2026-09-14, and a keyring entry cannot be read back out. Passing `true` here scopes the
21
- * service name to the directory, making the isolation people already assume they have real.
22
- *
23
- * With the system keyring it also keeps new secrets out of it: they go to the file in the moved directory, so a test
24
- * or worktree run leaves nothing in the owner's keyring (WC-SECR-24). A secret saved there before is still read.
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.
25
21
  */
26
22
  isolated?: boolean;
27
23
  /** Read before the keyring, for CI. */
@@ -33,7 +29,11 @@ export interface CredentialsOptions {
33
29
  /** Where the one-line warning goes when the keyring is unavailable. Never stdout. */
34
30
  warn?: (message: string) => void;
35
31
  }
36
- export declare const keyringService: (service: string, configDir: string, isolated?: boolean) => string;
32
+ /**
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.
35
+ */
36
+ export declare const keyringService: (service: string, _configDir?: string, _isolated?: boolean) => string;
37
37
  /**
38
38
  * Environment first, then the OS keyring, then a file.
39
39
  *
@@ -3,8 +3,13 @@ import { join } 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
+ import { homeIsMoved } from "./paths.js";
6
7
  export const CREDENTIAL_STORAGE = ["auto", "keyring", "file"];
7
- export const keyringService = (service, configDir, isolated = false) => isolated ? `${service}:${configDir}` : service;
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.
11
+ */
12
+ export const keyringService = (service, _configDir, _isolated) => service;
8
13
  /**
9
14
  * Environment first, then the OS keyring, then a file.
10
15
  *
@@ -21,7 +26,7 @@ export class Credentials {
21
26
  #envVar;
22
27
  #fileName;
23
28
  #warn;
24
- #fileFirst;
29
+ #keepsSharedEntry;
25
30
  #warned = false;
26
31
  constructor(options) {
27
32
  this.#configDir = options.configDir;
@@ -31,31 +36,24 @@ export class Credentials {
31
36
  this.#envVar = options.envVar;
32
37
  this.#fileName = options.fileName ?? "credentials.json";
33
38
  this.#warn = options.warn ?? ((message) => process.stderr.write(`${message}\n`));
34
- this.#service = keyringService(options.service, options.configDir, options.isolated);
35
- this.#fileFirst = options.isolated === true && this.#keyring === systemKeyring;
39
+ this.#service = options.service;
40
+ this.#keepsSharedEntry =
41
+ this.#keyring === systemKeyring && (options.isolated === true || homeIsMoved({ env: this.#env }));
36
42
  }
37
43
  read(account) {
38
44
  const fromEnv = this.#envVar ? this.#env[this.#envVar]?.trim() : undefined;
39
45
  if (fromEnv)
40
46
  return { secret: fromEnv, source: "environment" };
41
- const fromFile = () => {
42
- const secret = this.#readFile()?.[account]?.secret;
43
- return secret ? { secret, source: "file" } : undefined;
44
- };
45
- if (this.#fileFirst) {
46
- const found = fromFile();
47
- if (found)
48
- return found;
49
- }
50
47
  if (this.#storage !== "file") {
51
48
  const fromKeyring = this.#tryKeyring(() => this.#keyring.get(this.#service, account));
52
49
  if (fromKeyring)
53
50
  return { secret: fromKeyring, source: "keyring" };
54
51
  }
55
- return this.#fileFirst ? undefined : fromFile();
52
+ const secret = this.#readFile()?.[account]?.secret;
53
+ return secret ? { secret, source: "file" } : undefined;
56
54
  }
57
55
  write(account, secret) {
58
- if (this.#storage !== "file" && !this.#fileFirst) {
56
+ if (this.#storage !== "file") {
59
57
  const stored = this.#tryKeyring(() => {
60
58
  this.#keyring.set(this.#service, account, secret);
61
59
  return true;
@@ -74,7 +72,11 @@ export class Credentials {
74
72
  }
75
73
  remove(account) {
76
74
  const removed = [];
77
- if (this.#storage !== "file" && this.#tryKeyring(() => this.#keyring.delete(this.#service, account))) {
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))) {
78
80
  removed.push("keyring");
79
81
  }
80
82
  const store = this.#readFile();
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export { brokenKeyring, type KeyringStore, memoryKeyring, systemKeyring } from "
10
10
  export type { Logger } from "./logger.js";
11
11
  export { createRecordLog, logRecordOf, NOT_ALLOWED, REDACTED_FIELDS, type RecordInput, type RecordLogOptions, } from "./logging.js";
12
12
  export { createOutputBuffer, type OutputBuffer, type OutputBufferOptions } from "./output-buffer.js";
13
- export { configFilePath, type Paths, type PathsOptions, pathsAreOverridden, resolvePaths } from "./paths.js";
13
+ export { configFilePath, isMovedRun, type Paths, type PathsOptions, pathsAreOverridden, resolvePaths } from "./paths.js";
14
14
  export { indent, type PrettyOptions, renderPretty } from "./pretty.js";
15
15
  export { createRenderer, RENDER_FORMATS, type Renderer, type RendererOptions, type RenderFormat, } from "./renderer.js";
16
16
  export { fieldsOf, projectFields } from "./result-fields.js";
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ export { catchCrashes, errorObjectOf, errorRecordOf, exitCodeOfAnswer, exitCodeO
8
8
  export { brokenKeyring, memoryKeyring, systemKeyring } from "./keyring.js";
9
9
  export { createRecordLog, logRecordOf, NOT_ALLOWED, REDACTED_FIELDS, } from "./logging.js";
10
10
  export { createOutputBuffer } from "./output-buffer.js";
11
- export { configFilePath, pathsAreOverridden, resolvePaths } from "./paths.js";
11
+ export { configFilePath, isMovedRun, pathsAreOverridden, resolvePaths } from "./paths.js";
12
12
  export { indent, renderPretty } from "./pretty.js";
13
13
  export { createRenderer, RENDER_FORMATS, } from "./renderer.js";
14
14
  export { fieldsOf, projectFields } from "./result-fields.js";
package/dist/paths.d.ts CHANGED
@@ -31,4 +31,21 @@ export declare const defaultPaths: ({ appName, env, platform }: PathsOptions) =>
31
31
  export declare const resolvePaths: (options: PathsOptions) => Paths;
32
32
  /** Whether any of the three directories came from the environment rather than the OS convention. */
33
33
  export declare const pathsAreOverridden: ({ appName, prefix, env }: PathsOptions) => boolean;
34
+ /**
35
+ * Whether `HOME` or an XDG (or Windows) folder in `env` puts the default folders anywhere but under the account's own
36
+ * home, which comes from the user database: `os.homedir()` returns `$HOME` and would miss exactly this. An account with
37
+ * no home entry counts as moved.
38
+ */
39
+ export declare const homeIsMoved: ({ env, platform, home, }?: {
40
+ env?: NodeJS.ProcessEnv;
41
+ platform?: NodeJS.Platform;
42
+ home?: string | undefined;
43
+ }) => boolean;
44
+ /**
45
+ * **A run whose folders are not the owner's own**: a `<PREFIX>_*_DIR` override, or a moved `HOME` or XDG folder.
46
+ * It still shares the owner's keyring entry, so it must not delete or revoke it (WC-SECR-24).
47
+ */
48
+ export declare const isMovedRun: (options: PathsOptions & {
49
+ home?: string | undefined;
50
+ }) => boolean;
34
51
  export declare const configFilePath: (configDir: string, fileName?: string) => string;
package/dist/paths.js CHANGED
@@ -1,4 +1,4 @@
1
- import { homedir } from "node:os";
1
+ import { homedir, userInfo } from "node:os";
2
2
  import { join, posix, win32 } from "node:path";
3
3
  export const PATH_KINDS = ["config", "state", "cache"];
4
4
  const OVERRIDES = { config: "CONFIG_DIR", state: "STATE_DIR", cache: "CACHE_DIR" };
@@ -41,4 +41,30 @@ export const resolvePaths = (options) => {
41
41
  };
42
42
  /** Whether any of the three directories came from the environment rather than the OS convention. */
43
43
  export const pathsAreOverridden = ({ appName, prefix, env = process.env }) => PATH_KINDS.some((kind) => env[overrideName(appName, prefix, kind)] !== undefined);
44
+ const accountHome = () => {
45
+ try {
46
+ return userInfo().homedir;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ };
52
+ /**
53
+ * Whether `HOME` or an XDG (or Windows) folder in `env` puts the default folders anywhere but under the account's own
54
+ * home, which comes from the user database: `os.homedir()` returns `$HOME` and would miss exactly this. An account with
55
+ * no home entry counts as moved.
56
+ */
57
+ export const homeIsMoved = ({ env = process.env, platform = process.platform, home = accountHome(), } = {}) => {
58
+ if (home === undefined)
59
+ return true;
60
+ const appName = "app";
61
+ const actual = defaultPaths({ appName, env, platform });
62
+ const owners = defaultPaths({ appName, env: { HOME: home, USERPROFILE: home }, platform });
63
+ return PATH_KINDS.some((kind) => actual[kind] !== owners[kind]);
64
+ };
65
+ /**
66
+ * **A run whose folders are not the owner's own**: a `<PREFIX>_*_DIR` override, or a moved `HOME` or XDG folder.
67
+ * It still shares the owner's keyring entry, so it must not delete or revoke it (WC-SECR-24).
68
+ */
69
+ export const isMovedRun = (options) => pathsAreOverridden(options) || homeIsMoved(options);
44
70
  export const configFilePath = (configDir, fileName = "config.json") => join(configDir, fileName);
@@ -180,7 +180,6 @@ Every CLI that signs in has four commands under `session`:
180
180
  - tg, max and wa have `session start` and `session end`, no `session status`, and list their profiles with
181
181
  `account list` instead of `session list`.
182
182
  - drive has `session start`, `session list` and `session end`, and no `session status`.
183
- - gcal has `session start`, `session status` and `session end`, and no `session list`.
184
183
  - zm has `session start` and `session status`, and no `session end` or `session list`.
185
184
  - memo signs in per mail account with `mail add` and has no `session` group.
186
185
 
@@ -23,10 +23,10 @@ and [WC-SECR-2](security.md#wc-secr-2--a-log-keeps-only-known-attribute-keys-and
23
23
  - **WC-LIFE-15:** tg, max, wa, gcal and the template. drive, memo and zm do not keep runs yet; each is adding them on a
24
24
  `feat/runs` branch (2026-10-25). zm's `resolveSettings` also returns `keepFailedRuns: false`, which turns off the
25
25
  failure log cli-core's `run` would keep.
26
- - **WC-LIFE-16:** cli-core's shared calendar `sync`, from cli-core 0.464.0 (cli-core #2118); gcal gets it when it
27
- moves up from cli-core 0.459.0. The deadlines of WC-LIFE-5 come with the same release. The other syncs
28
- (`messages sync` in tg, max and wa, drive's `sync`, memo's mail import, zm's `recordings sync`) are cooperative but
29
- spend no request budget yet, and gcal sets no `requestMs` of its own, so it uses the 2 s default.
26
+ - **WC-LIFE-16:** cli-core's shared calendar `sync`, from cli-core 0.464.0 (cli-core #2118); gcal has it. The
27
+ deadlines of WC-LIFE-5 come with the same release. The other syncs (`messages sync` in tg, max and wa, drive's
28
+ `sync`, memo's mail import, zm's `recordings sync`) are cooperative but spend no request budget yet. gcal sets
29
+ `requestMs` to 1.5 s.
30
30
 
31
31
  ## One-shot commands
32
32
 
@@ -271,7 +271,7 @@ status command answers how far it is
271
271
  and the run loses its round; with one, every run ends with an answer, saves its work and says how to go on. One
272
272
  `requestMs` per CLI, in one place, keeps the arithmetic the same in every sync of that CLI.
273
273
  - **Enforced by:** `cli-core: src/background/budget.test.ts` · `cli-core: src/calendar/testing/sync-contract.ts`.
274
- - **Example:** `gcal personal sync` over MCP has 45 s: margin 6.75 s, so with the 2 s default about 19 requests. It
274
+ - **Example:** `gcal personal sync` over MCP has 45 s: margin 6.75 s, so with gcal's 1.5 s about 25 requests. It
275
275
  answers `state: "partial"`, `stopped: "requests"`, `progress: { calendars, calendarsDone, requests, saved }`, and
276
276
  `next` with `gcal personal sync`; `gcal personal status` shows each calendar's progress. cli-core builds the budget
277
277
  with `runBudgetOf(command, maxPages)` from `@wirecat/cli-core/cli` over `runBudget` in
@@ -19,9 +19,8 @@ tg, max and wa serve cli-core's personal server (`<cli> <profile> mcp serve`) an
19
19
  `mcp serve --profiles` ([WC-MCP-9](#wc-mcp-9--a-server-serves-the-profiles-it-is-told-with-one-connection-for-each)) is not
20
20
  built yet: cli-core serves one named profile.
21
21
  zoom serves the three shared tools over its own commands from zoom-cli 0.35.0 (`zm <profile> mcp serve`, cli-core's
22
- `toolsFromCommands` and `runCommandForAgent`), and gcal does the same (`gcal <profile> mcp`; it moves to `mcp serve`
23
- when it takes cli-core 0.463.0). memo and drive have their own servers: memo has hand-written tools, and
24
- drive builds one tool per command from its command tree and skips commands through its own list. Those two are known
22
+ `toolsFromCommands` and `runCommandForAgent`), and gcal does the same (`gcal <profile> mcp serve`). memo and drive have their own servers: memo has hand-written
23
+ tools, and drive builds one tool per command from its command tree and skips commands through its own list. Those two are known
25
24
  gaps against these rules.
26
25
 
27
26
  WC-MCP-2 and WC-MCP-8 need cli-core 0.321.0, which tg, max and wa pin.
@@ -346,8 +346,7 @@ A messenger's keys come from its resources (`messages`, `chats`). Any other CLI
346
346
  - **Exceptions:** known gaps, as of 2026-10-27:
347
347
  - cli-core's `CALENDAR_WRITE_DEFAULTS` (`src/calendar/writer.ts`) still has `events create`, `update` and
348
348
  `respond` at `ask`.
349
- - gcal's generated `api` commands default every write to `ask`, ordinary changes included, and check neither
350
- `events.notify` nor `events.delete.series`.
349
+ - gcal's generated `api` commands default every write to `ask`, ordinary changes included.
351
350
  - drive keeps its own policy and `--read-only` flag, zoom reads its own `remote-meetings` key with `levelFor`, and
352
351
  memo uses cli-core's permissions for the shared commands only. None of them declares `commandPermissions` yet.
353
352
  - **Applies to:** every CLI that writes and is not a messenger. **Since:** cli-base 0.64.0 (the levels and the shared
@@ -196,8 +196,9 @@ fixtures, `scripts/`, `.github/`, `bin/release`, `config/`, `lint/`, `contracts/
196
196
  `@wirecat/cli-base/release` fails on any path in `npm pack --dry-run` outside `allowed`. It checks the pack against
197
197
  the list it is given, not against this rule: a CLI that passes its own `files` list as `allowed`, as the template's
198
198
  `scripts/smoke.ts` does, checks only that npm packs what `files` says, and cannot fail on a wrong `files` list.
199
- `npm pack --dry-run` in gcal-cli (2026-10-27) packs 256 files, `src/`, `scripts/`, `.github/`, `config/`,
200
- `contracts/`, `lint/` and `template.json` among them, because its `files` list names them; its check cannot see it. The check this rule needs
199
+ `npm pack --dry-run` in gcal-cli packed 256 files on 2026-10-27, `src/`, `scripts/`, `.github/`, `config/`,
200
+ `contracts/`, `lint/` and `template.json` among them, because its `files` list named them; its check could not see
201
+ it. On 2026-10-31 (gcal-cli 0.2.0) it packs 144. The check this rule needs
201
202
  refuses the forbidden paths above whatever `files` says; it belongs in cli-template, so every CLI gets it by
202
203
  `upgrade` ([cli-template#77](https://github.com/WireCatLabs/cli-template/issues/77)).
203
204
  - **Exceptions:** cli-template's own package ships the skeleton's sources and configs, since `new` copies them; the
@@ -68,11 +68,10 @@ 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`). When the config directory comes from the environment, the service name
72
- is scoped to that directory (`isolated: true`).
71
+ the command word people type (`max`, `gcal`). A run with moved folders uses the same entry and never deletes it
72
+ ([WC-SECR-24](#wc-secr-24--a-run-with-moved-folders-shares-the-owners-keyring-entry-and-never-deletes-it)).
73
73
 
74
- - **Why:** the keyring is the safest store on the computer, and a throwaway config directory must not overwrite the
75
- owner's real keyring entry.
74
+ - **Why:** the keyring is the safest store on the computer.
76
75
  - **Enforced by:** `src/credentials.test.ts`.
77
76
  - **Example:** a bot token is read from `MAX_BOT_TOKEN`, then the keyring entry `bot:<profile>`, then the file.
78
77
  - **Exceptions:** `auto` storage tries the keyring and, when it fails, warns once on stderr and uses the file. A
@@ -322,28 +321,29 @@ store and runs no search. A CLI declares its provider and may replace any step.
322
321
  the provider and never touched the store: a generic API command removes the provider's passcode fields (Google's
323
322
  `conferenceData.entryPoints[].passcode`, `pin`, `accessCode`, `password`), the query of every join link and the
324
323
  dial sequence after a phone number, and the passcode lines of a description or a location.
325
- - **Exceptions:** a title that names a PIN or a password is kept: it is the meeting's name. Known gap, as of
326
- 2026-10-27: gcal's `api` answers drop the conference entry points' secrets (`src/conference.ts`) but keep the
327
- description and location as Google sent them.
324
+ - **Exceptions:** a title that names a PIN or a password is kept: it is the meeting's name.
328
325
  - **Applies to:** cli-core and every CLI that stores or shows meetings or calendar entries. **Since:** cli-base 0.57.1
329
326
  (output and agent answers: cli-base 0.65.0). **Rulings:** none (Wave 3 requirements 3.4).
330
327
 
331
- ### WC-SECR-24 · A run with moved folders never writes the owner's system keyring
332
-
333
- - **Why:** with its folders moved (`<PREFIX>_CONFIG_DIR`, `<PREFIX>_STATE_DIR`, a throwaway `HOME` or `XDG_*`),
334
- `Credentials` keys the keyring by `<app>:<config folder>` (`keyringService`, `isolated`). Each test run, agent check
335
- or worktree that signs in then leaves its own copy of a sign-in in the owner's keyring, and nothing removes it.
336
- - **Enforced by:** `src/credentials.test.ts`.
337
- - **Example:** a test passes `memoryKeyring()`. An agent checking a built CLI signs in with file storage inside its
338
- temp folder (`session start --credential-storage file`), or does not sign in. A run against the owner's real data
339
- keeps the default folders, so it reads the owner's existing entry (`<app>` / `oauth:<provider>:<profile>`) instead
340
- of making a new one. A throwaway `HOME` does not hide the keyring, which is reached over D-Bus: a run that must not
341
- see it also runs with `DBUS_SESSION_BUS_ADDRESS` removed. With `isolated` and the system keyring, `Credentials`
342
- saves new secrets to the private file in the moved config folder and only reads the keyring, for a secret saved
343
- there before.
344
- - **Exceptions:** none.
345
- - **Applies to:** every CLI, its tests and every agent that runs a CLI. **Since:** the next cli-base release. **Rulings:** owner,
346
- 2026-10-29 (sign-ins copied per work folder should have reused the existing entry).
328
+ ### WC-SECR-24 · A run with moved folders shares the owner's keyring entry and never deletes it
329
+
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).
334
+ - **Enforced by:** `src/credentials.test.ts` · `src/paths.test.ts`.
335
+ - **Example:** a run is moved when `isMovedRun({ appName, prefix, env })` is true: a `<PREFIX>_CONFIG_DIR`,
336
+ `_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).
347
347
 
348
348
  ## Sources
349
349
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.65.3",
3
+ "version": "0.65.4",
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",