@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 +17 -0
- package/dist/credentials.d.ts +10 -6
- package/dist/credentials.js +23 -14
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/docs/standards/security.md +29 -15
- package/package.json +1 -1
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
|
package/dist/credentials.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
34
|
-
*
|
|
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
|
*
|
package/dist/credentials.js
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
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
|
-
|
|
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.#
|
|
41
|
-
|
|
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
|
|
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"
|
|
76
|
-
this.#
|
|
77
|
-
"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
|
331
|
-
check or worktree, about 1,500 of them in the owner's keyring, and nothing removed them. One entry per
|
|
332
|
-
enough. But a moved run then reaches the owner's real sign-in: `HOME=/tmp/x gcal personal session end`
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
- **
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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.
|
|
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",
|