@wirecat/cli-base 0.65.3 → 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,46 @@ 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
+
28
+ ## 0.65.4 — 31.10.2026
29
+
30
+ ### Fixed
31
+
32
+ - **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),
33
+ rewritten). `Credentials` with `isolated` reads and writes service `<app>`, not `<app>:<config folder>`, and no
34
+ longer saves to the moved folder's file first (the 0.65.1 redirect is gone). `keyringService` returns the app's
35
+ service whatever its other arguments say, so cli-core's sign-in key names the shared entry. Per-folder entries made
36
+ before are no longer read; they were copies made by throwaway runs. A secret 0.65.1 saved in a moved folder's file
37
+ is still read when the keyring has none.
38
+ - **The handbook's statements about gcal match gcal-cli 0.2.0:** it has `session list`, its `api` checks
39
+ `events.notify` and `events.delete.series`, its `api` answers drop the passcode lines of a description and a
40
+ location, it serves MCP as `mcp serve`, it sets `requestMs` to 1.5 s, and it packs 144 files.
41
+
42
+ ### Security
43
+
44
+ - **A moved run never deletes the owner's keyring entry.** A security review found that
45
+ `HOME=/tmp/x gcal personal session end` deleted the owner's real Google sign-in. With the system keyring,
46
+ `Credentials.remove` from a moved run keeps the entry, warns on stderr and leaves `"keyring"` out of what it
47
+ returns. A run counts as moved with `isolated`, or when the `env` it is given moves `HOME` or an XDG folder away from
48
+ the account's own home. The new `isMovedRun({ appName, prefix, env })` says the same for a CLI: pass it as
49
+ `isolated`, and check it before revoking a sign-in at the provider. A test with `memoryKeyring()` is unchanged.
50
+
11
51
  ## 0.65.3 — 30.10.2026
12
52
 
13
53
  ### 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,11 @@ 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
+ * 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.
25
20
  */
26
21
  isolated?: boolean;
27
22
  /** Read before the keyring, for CI. */
@@ -33,7 +28,16 @@ export interface CredentialsOptions {
33
28
  /** Where the one-line warning goes when the keyring is unavailable. Never stdout. */
34
29
  warn?: (message: string) => void;
35
30
  }
36
- export declare const keyringService: (service: string, configDir: string, isolated?: boolean) => string;
31
+ /**
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.
34
+ */
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,10 +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
+ 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 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
+ */
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)}`;
8
18
  /**
9
19
  * Environment first, then the OS keyring, then a file.
10
20
  *
@@ -21,7 +31,8 @@ export class Credentials {
21
31
  #envVar;
22
32
  #fileName;
23
33
  #warn;
24
- #fileFirst;
34
+ /** Set only for a moved run with the system keyring. */
35
+ #temporaryService;
25
36
  #warned = false;
26
37
  constructor(options) {
27
38
  this.#configDir = options.configDir;
@@ -31,33 +42,27 @@ export class Credentials {
31
42
  this.#envVar = options.envVar;
32
43
  this.#fileName = options.fileName ?? "credentials.json";
33
44
  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;
45
+ this.#service = options.service;
46
+ const moved = this.#keyring === systemKeyring && (options.isolated === true || homeIsMoved({ env: this.#env }));
47
+ this.#temporaryService = moved ? movedRunService(options.service, options.configDir) : undefined;
36
48
  }
37
49
  read(account) {
38
50
  const fromEnv = this.#envVar ? this.#env[this.#envVar]?.trim() : undefined;
39
51
  if (fromEnv)
40
52
  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
53
  if (this.#storage !== "file") {
51
- 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));
52
56
  if (fromKeyring)
53
57
  return { secret: fromKeyring, source: "keyring" };
54
58
  }
55
- return this.#fileFirst ? undefined : fromFile();
59
+ const secret = this.#readFile()?.[account]?.secret;
60
+ return secret ? { secret, source: "file" } : undefined;
56
61
  }
57
62
  write(account, secret) {
58
- if (this.#storage !== "file" && !this.#fileFirst) {
63
+ if (this.#storage !== "file") {
59
64
  const stored = this.#tryKeyring(() => {
60
- this.#keyring.set(this.#service, account, secret);
65
+ this.#keyring.set(this.#temporaryService ?? this.#service, account, secret);
61
66
  return true;
62
67
  });
63
68
  if (stored)
@@ -74,8 +79,14 @@ export class Credentials {
74
79
  }
75
80
  remove(account) {
76
81
  const removed = [];
77
- if (this.#storage !== "file" && this.#tryKeyring(() => this.#keyring.delete(this.#service, account))) {
78
- 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
+ }
79
90
  }
80
91
  const store = this.#readFile();
81
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";
@@ -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
@@ -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";
@@ -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,11 @@ 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 reads the same entry, writes a temporary one,
72
+ and never replaces or deletes the owner's
73
+ ([WC-SECR-24](#wc-secr-24--a-run-with-moved-folders-shares-the-owners-keyring-entry-and-never-deletes-it)).
73
74
 
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.
75
+ - **Why:** the keyring is the safest store on the computer.
76
76
  - **Enforced by:** `src/credentials.test.ts`.
77
77
  - **Example:** a bot token is read from `MAX_BOT_TOKEN`, then the keyring entry `bot:<profile>`, then the file.
78
78
  - **Exceptions:** `auto` storage tries the keyring and, when it fails, warns once on stderr and uses the file. A
@@ -322,28 +322,42 @@ store and runs no search. A CLI declares its provider and may replace any step.
322
322
  the provider and never touched the store: a generic API command removes the provider's passcode fields (Google's
323
323
  `conferenceData.entryPoints[].passcode`, `pin`, `accessCode`, `password`), the query of every join link and the
324
324
  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.
325
+ - **Exceptions:** a title that names a PIN or a password is kept: it is the meeting's name.
328
326
  - **Applies to:** cli-core and every CLI that stores or shows meetings or calendar entries. **Since:** cli-base 0.57.1
329
327
  (output and agent answers: cli-base 0.65.0). **Rulings:** none (Wave 3 requirements 3.4).
330
328
 
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.
329
+ ### WC-SECR-24 · A run with moved folders shares the owner's keyring entry and never deletes it
330
+
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.
336
+ - **Enforced by:** `src/credentials.test.ts` · `src/paths.test.ts`.
337
+ - **Example:** a run is moved when `isMovedRun({ appName, prefix, env })` is true: a `<PREFIX>_CONFIG_DIR`,
338
+ `_STATE_DIR` or `_CACHE_DIR` override, or a `HOME` or XDG folder that puts the default folders anywhere but under
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`.
344
356
  - **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).
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.3",
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",