@phnx-labs/agents-cli 1.20.18 → 1.20.19
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 +4 -0
- package/dist/commands/secrets.js +104 -23
- package/dist/lib/secrets/agent.d.ts +15 -2
- package/dist/lib/secrets/agent.js +51 -52
- package/dist/lib/secrets/bundles.d.ts +37 -7
- package/dist/lib/secrets/bundles.js +226 -80
- package/dist/lib/secrets/filestore.d.ts +82 -0
- package/dist/lib/secrets/filestore.js +295 -0
- package/dist/lib/secrets/linux.d.ts +6 -24
- package/dist/lib/secrets/linux.js +22 -265
- package/package.json +1 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passphrase-encrypted file store for secrets — platform-neutral.
|
|
3
|
+
*
|
|
4
|
+
* An AES-256-GCM encrypted-file store under `~/.agents/.cache/secrets/`. The
|
|
5
|
+
* encryption key is scrypt-derived from a passphrase read from
|
|
6
|
+
* `AGENTS_SECRETS_PASSPHRASE` (preferred), a machine-local provisioned key, or
|
|
7
|
+
* a TTY prompt. One `<item>.enc` JSON file per item, mode 0600.
|
|
8
|
+
*
|
|
9
|
+
* Two callers:
|
|
10
|
+
* - Linux (src/lib/secrets/linux.ts): the headless fallback when the default
|
|
11
|
+
* Secret Service collection is locked. Auto-provisions a machine-local
|
|
12
|
+
* passphrase so `agents secrets` works out of the box on a server.
|
|
13
|
+
* - macOS file-backed bundles (src/lib/secrets/bundles.ts): an explicit,
|
|
14
|
+
* opt-in non-biometry backend for headless/remote release runs. The bundle
|
|
15
|
+
* layer guards this path so it only activates with an explicit
|
|
16
|
+
* AGENTS_SECRETS_PASSPHRASE (or TTY) — never the silent machine-local
|
|
17
|
+
* auto-provision — so a remote box holds ciphertext only.
|
|
18
|
+
*
|
|
19
|
+
* The item-name scheme is shared with the keychain backend so a file-backed
|
|
20
|
+
* item and its keychain twin carry identical names:
|
|
21
|
+
* `agents-cli.bundles.<name>` and `agents-cli.secrets.<bundle>.<key>`.
|
|
22
|
+
*/
|
|
23
|
+
import { execSync } from 'child_process';
|
|
24
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
|
25
|
+
import * as fs from 'fs';
|
|
26
|
+
import * as os from 'os';
|
|
27
|
+
import * as path from 'path';
|
|
28
|
+
// ---------- file store location ----------
|
|
29
|
+
let fileDirOverride = null;
|
|
30
|
+
let cachedPassphrase = null;
|
|
31
|
+
let warnedAutoPassphrase = false;
|
|
32
|
+
export function fileDir() {
|
|
33
|
+
return fileDirOverride ?? path.join(os.homedir(), '.agents', '.cache', 'secrets');
|
|
34
|
+
}
|
|
35
|
+
function ensureFileDir() {
|
|
36
|
+
fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
|
|
37
|
+
}
|
|
38
|
+
// ---------- passphrase ----------
|
|
39
|
+
function readPassphraseFromTty() {
|
|
40
|
+
const fd = fs.openSync('/dev/tty', 'r+');
|
|
41
|
+
let echoDisabled = false;
|
|
42
|
+
try {
|
|
43
|
+
fs.writeSync(fd, 'Enter AGENTS_SECRETS_PASSPHRASE: ');
|
|
44
|
+
try {
|
|
45
|
+
execSync('stty -echo < /dev/tty', { stdio: 'ignore' });
|
|
46
|
+
echoDisabled = true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// stty not available — fall through; passphrase will echo. Better
|
|
50
|
+
// than refusing to function.
|
|
51
|
+
}
|
|
52
|
+
let pass = '';
|
|
53
|
+
const buf = Buffer.alloc(1);
|
|
54
|
+
while (true) {
|
|
55
|
+
const n = fs.readSync(fd, buf, 0, 1, null);
|
|
56
|
+
if (n === 0)
|
|
57
|
+
break;
|
|
58
|
+
const ch = buf.toString('utf8', 0, n);
|
|
59
|
+
if (ch === '\n' || ch === '\r')
|
|
60
|
+
break;
|
|
61
|
+
pass += ch;
|
|
62
|
+
}
|
|
63
|
+
return pass;
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
if (echoDisabled) {
|
|
67
|
+
try {
|
|
68
|
+
execSync('stty echo < /dev/tty', { stdio: 'ignore' });
|
|
69
|
+
}
|
|
70
|
+
catch { /* best effort */ }
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
fs.writeSync(fd, '\n');
|
|
74
|
+
}
|
|
75
|
+
catch { /* best effort */ }
|
|
76
|
+
fs.closeSync(fd);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Path of the auto-provisioned machine-local passphrase. Lives alongside the
|
|
80
|
+
* encrypted items but is never itself an item (no `.enc` suffix, so it's
|
|
81
|
+
* excluded from list/has/get and from fileFallbackPreviouslyActivated). */
|
|
82
|
+
function passphraseFilePath() {
|
|
83
|
+
return path.join(fileDir(), '.passphrase');
|
|
84
|
+
}
|
|
85
|
+
/** True if a machine-local passphrase has already been provisioned. */
|
|
86
|
+
export function machinePassphraseExists() {
|
|
87
|
+
try {
|
|
88
|
+
return fs.readFileSync(passphraseFilePath(), 'utf8').trim().length > 0;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function readMachinePassphrase() {
|
|
95
|
+
try {
|
|
96
|
+
const p = fs.readFileSync(passphraseFilePath(), 'utf8').trim();
|
|
97
|
+
return p.length > 0 ? p : null;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Provision (or read back) a stable machine-local passphrase for the encrypted
|
|
105
|
+
* file store, so `agents secrets` works out of the box on a headless box where
|
|
106
|
+
* the keyring is locked and no AGENTS_SECRETS_PASSPHRASE is set.
|
|
107
|
+
*
|
|
108
|
+
* Security model: this is encryption-at-rest with the key held in a 0600 file —
|
|
109
|
+
* the same posture as an SSH private key, and identical to the common
|
|
110
|
+
* "export AGENTS_SECRETS_PASSPHRASE=… in ~/.zshenv (chmod 600)" workaround. The
|
|
111
|
+
* keyring (key in a daemon's locked memory) is stronger but is unavailable
|
|
112
|
+
* without a graphical/unlocked session. For an off-disk key, set
|
|
113
|
+
* AGENTS_SECRETS_PASSPHRASE (it always takes precedence) or unlock the keyring.
|
|
114
|
+
*/
|
|
115
|
+
function provisionMachinePassphrase() {
|
|
116
|
+
const existing = readMachinePassphrase();
|
|
117
|
+
if (existing)
|
|
118
|
+
return existing;
|
|
119
|
+
ensureFileDir();
|
|
120
|
+
const generated = randomBytes(32).toString('base64');
|
|
121
|
+
const fp = passphraseFilePath();
|
|
122
|
+
try {
|
|
123
|
+
// wx: fail if a concurrent process created it first (then we read theirs).
|
|
124
|
+
fs.writeFileSync(fp, generated, { mode: 0o600, flag: 'wx' });
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
const raced = readMachinePassphrase();
|
|
128
|
+
if (raced)
|
|
129
|
+
return raced;
|
|
130
|
+
throw new Error(`Failed to provision machine-local passphrase at ${fp}.`);
|
|
131
|
+
}
|
|
132
|
+
if (!warnedAutoPassphrase) {
|
|
133
|
+
warnedAutoPassphrase = true;
|
|
134
|
+
process.stderr.write(`[agents] keyring locked and no AGENTS_SECRETS_PASSPHRASE set; provisioned a ` +
|
|
135
|
+
`machine-local passphrase at ${fp} (mode 0600). Set AGENTS_SECRETS_PASSPHRASE ` +
|
|
136
|
+
`for a key held off disk.\n`);
|
|
137
|
+
}
|
|
138
|
+
return generated;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Resolve the passphrase for the encrypted file store.
|
|
142
|
+
*
|
|
143
|
+
* Order: AGENTS_SECRETS_PASSPHRASE > previously-provisioned machine-local key >
|
|
144
|
+
* (interactive) TTY prompt > (headless) auto-provisioned machine-local key.
|
|
145
|
+
*
|
|
146
|
+
* `allowAutoProvision` (default true, used by the Linux fallback) controls the
|
|
147
|
+
* last two steps. macOS file-backed bundles pass `false` so a missing
|
|
148
|
+
* passphrase is a hard, explicit error instead of a silently provisioned
|
|
149
|
+
* on-disk key — the caller (bundles.ts) guards this before we get here.
|
|
150
|
+
*/
|
|
151
|
+
export function getPassphrase(opts = {}) {
|
|
152
|
+
const allowAutoProvision = opts.allowAutoProvision ?? true;
|
|
153
|
+
if (cachedPassphrase !== null)
|
|
154
|
+
return cachedPassphrase;
|
|
155
|
+
const env = process.env.AGENTS_SECRETS_PASSPHRASE;
|
|
156
|
+
if (env && env.length > 0) {
|
|
157
|
+
cachedPassphrase = env;
|
|
158
|
+
return env;
|
|
159
|
+
}
|
|
160
|
+
// A previously-provisioned machine-local passphrase is this machine's stable
|
|
161
|
+
// file-store key — prefer it for both interactive and headless runs so they
|
|
162
|
+
// always agree (a TTY run won't re-prompt once the file exists).
|
|
163
|
+
const onDisk = readMachinePassphrase();
|
|
164
|
+
if (onDisk) {
|
|
165
|
+
cachedPassphrase = onDisk;
|
|
166
|
+
return onDisk;
|
|
167
|
+
}
|
|
168
|
+
if (!allowAutoProvision) {
|
|
169
|
+
throw new Error('AGENTS_SECRETS_PASSPHRASE is not set. A passphrase is required to decrypt ' +
|
|
170
|
+
'this file-backed secret store.');
|
|
171
|
+
}
|
|
172
|
+
// First run, no env, no provisioned key: prompt when interactive, otherwise
|
|
173
|
+
// (headless — the reported bug) auto-provision instead of hard-failing.
|
|
174
|
+
if (process.stdin.isTTY) {
|
|
175
|
+
const p = readPassphraseFromTty();
|
|
176
|
+
if (!p)
|
|
177
|
+
throw new Error('No passphrase entered.');
|
|
178
|
+
cachedPassphrase = p;
|
|
179
|
+
return p;
|
|
180
|
+
}
|
|
181
|
+
cachedPassphrase = provisionMachinePassphrase();
|
|
182
|
+
return cachedPassphrase;
|
|
183
|
+
}
|
|
184
|
+
function deriveKey(passphrase, salt) {
|
|
185
|
+
return scryptSync(passphrase, salt, 32);
|
|
186
|
+
}
|
|
187
|
+
/** Encrypt plaintext under a passphrase using AES-256-GCM with a random
|
|
188
|
+
* scrypt salt and a random 96-bit IV. Exported for tests. */
|
|
189
|
+
export function encryptForFallback(plaintext, passphrase) {
|
|
190
|
+
const salt = randomBytes(16);
|
|
191
|
+
const iv = randomBytes(12);
|
|
192
|
+
const key = deriveKey(passphrase, salt);
|
|
193
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
194
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
195
|
+
return {
|
|
196
|
+
salt: salt.toString('hex'),
|
|
197
|
+
iv: iv.toString('hex'),
|
|
198
|
+
authTag: cipher.getAuthTag().toString('hex'),
|
|
199
|
+
ciphertext: ciphertext.toString('hex'),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
|
|
203
|
+
* ciphertext (auth-tag mismatch). Exported for tests. */
|
|
204
|
+
export function decryptForFallback(enc, passphrase) {
|
|
205
|
+
const salt = Buffer.from(enc.salt, 'hex');
|
|
206
|
+
const iv = Buffer.from(enc.iv, 'hex');
|
|
207
|
+
const authTag = Buffer.from(enc.authTag, 'hex');
|
|
208
|
+
const ciphertext = Buffer.from(enc.ciphertext, 'hex');
|
|
209
|
+
const key = deriveKey(passphrase, salt);
|
|
210
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
211
|
+
decipher.setAuthTag(authTag);
|
|
212
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
213
|
+
return plaintext.toString('utf8');
|
|
214
|
+
}
|
|
215
|
+
// ---------- file backend ----------
|
|
216
|
+
function fileFor(item) {
|
|
217
|
+
return path.join(fileDir(), `${item}.enc`);
|
|
218
|
+
}
|
|
219
|
+
function fileHas(item) {
|
|
220
|
+
return fs.existsSync(fileFor(item));
|
|
221
|
+
}
|
|
222
|
+
function fileGet(item, opts = {}) {
|
|
223
|
+
const fp = fileFor(item);
|
|
224
|
+
if (!fs.existsSync(fp)) {
|
|
225
|
+
throw new Error(`Secret '${item}' not found in encrypted store.`);
|
|
226
|
+
}
|
|
227
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
228
|
+
let parsed;
|
|
229
|
+
try {
|
|
230
|
+
parsed = JSON.parse(raw);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
throw new Error(`Encrypted secret file ${fp} is corrupt (not valid JSON).`);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
return decryptForFallback(parsed, getPassphrase(opts));
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function fileSet(item, value, opts = {}) {
|
|
243
|
+
ensureFileDir();
|
|
244
|
+
const enc = encryptForFallback(value, getPassphrase(opts));
|
|
245
|
+
fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
|
|
246
|
+
}
|
|
247
|
+
function fileDelete(item) {
|
|
248
|
+
const fp = fileFor(item);
|
|
249
|
+
if (!fs.existsSync(fp))
|
|
250
|
+
return true; // idempotent, matches secret-tool clear
|
|
251
|
+
fs.unlinkSync(fp);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
function fileList(prefix) {
|
|
255
|
+
const dir = fileDir();
|
|
256
|
+
if (!fs.existsSync(dir))
|
|
257
|
+
return [];
|
|
258
|
+
return fs.readdirSync(dir)
|
|
259
|
+
.filter((f) => f.endsWith('.enc'))
|
|
260
|
+
.map((f) => f.slice(0, -'.enc'.length))
|
|
261
|
+
.filter((name) => name.startsWith(prefix));
|
|
262
|
+
}
|
|
263
|
+
/** True if the fallback dir has any committed encrypted items. */
|
|
264
|
+
export function fileStoreHasItems() {
|
|
265
|
+
try {
|
|
266
|
+
return fs.readdirSync(fileDir()).some((e) => e.endsWith('.enc'));
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** Low-level file-store ops, exported so callers (linux fallback, macOS
|
|
273
|
+
* file-backed bundles) can opt into or out of passphrase auto-provision. */
|
|
274
|
+
export const fileStore = {
|
|
275
|
+
has: fileHas,
|
|
276
|
+
get: fileGet,
|
|
277
|
+
set: fileSet,
|
|
278
|
+
delete: fileDelete,
|
|
279
|
+
list: fileList,
|
|
280
|
+
};
|
|
281
|
+
/** File-only KeychainBackend (exported for tests; the Linux backend uses these
|
|
282
|
+
* ops with auto-provision allowed). */
|
|
283
|
+
export const fileBackend = {
|
|
284
|
+
has: fileHas,
|
|
285
|
+
get: (item) => fileGet(item),
|
|
286
|
+
set: (item, value) => fileSet(item, value),
|
|
287
|
+
delete: fileDelete,
|
|
288
|
+
list: fileList,
|
|
289
|
+
};
|
|
290
|
+
/** Test-only: reset module state (file dir + cached passphrase). */
|
|
291
|
+
export function _resetFileStoreForTest(opts = {}) {
|
|
292
|
+
fileDirOverride = opts.fileDir ?? null;
|
|
293
|
+
cachedPassphrase = opts.passphrase ?? null;
|
|
294
|
+
warnedAutoPassphrase = false;
|
|
295
|
+
}
|
|
@@ -7,36 +7,17 @@
|
|
|
7
7
|
* (common on server-class Linux — no graphical login means the keyring
|
|
8
8
|
* passphrase never enters the daemon, so `secret-tool store` fails with
|
|
9
9
|
* "Cannot create an item in a locked collection"), we transparently switch
|
|
10
|
-
* to
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* prompt. The decision is cached per process; one stderr line is emitted
|
|
14
|
-
* the first time the fallback activates.
|
|
10
|
+
* to the AES-256-GCM encrypted-file store in ./filestore.ts. The decision is
|
|
11
|
+
* cached per process; one stderr line is emitted the first time the fallback
|
|
12
|
+
* activates.
|
|
15
13
|
*
|
|
16
14
|
* Secrets stored via secret-tool use:
|
|
17
15
|
* service = "agents-cli"
|
|
18
16
|
* account = username
|
|
19
17
|
* item = the secret identifier
|
|
20
|
-
*
|
|
21
|
-
* File-fallback layout: one `<item>.enc` JSON file per item, mode 0600.
|
|
22
18
|
*/
|
|
23
19
|
import type { KeychainBackend } from './index.js';
|
|
24
|
-
|
|
25
|
-
export interface EncFile {
|
|
26
|
-
salt: string;
|
|
27
|
-
iv: string;
|
|
28
|
-
authTag: string;
|
|
29
|
-
ciphertext: string;
|
|
30
|
-
}
|
|
31
|
-
/** Encrypt plaintext under a passphrase using AES-256-GCM with a random
|
|
32
|
-
* scrypt salt and a random 96-bit IV. Exported for tests. */
|
|
33
|
-
export declare function encryptForFallback(plaintext: string, passphrase: string): EncFile;
|
|
34
|
-
/** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
|
|
35
|
-
* ciphertext (auth-tag mismatch). Exported for tests. */
|
|
36
|
-
export declare function decryptForFallback(enc: EncFile, passphrase: string): string;
|
|
37
|
-
/** File-only KeychainBackend (exported for tests; the public surface uses
|
|
38
|
-
* the secret-tool-with-fallback `linuxBackend` below). */
|
|
39
|
-
export declare const fileBackend: KeychainBackend;
|
|
20
|
+
export { encryptForFallback, decryptForFallback, fileBackend, type EncFile, } from './filestore.js';
|
|
40
21
|
/** secret-tool lookup attributes:
|
|
41
22
|
* service=agents-cli account=<user> item=<itemName> */
|
|
42
23
|
export declare function hasSecretToolToken(item: string): boolean;
|
|
@@ -66,7 +47,8 @@ export declare function listSecretToolItems(prefix: string): string[];
|
|
|
66
47
|
* AGENTS_SECRETS_PASSPHRASE is set). */
|
|
67
48
|
export declare const linuxBackend: KeychainBackend;
|
|
68
49
|
/** Test-only: reset module state so independent test cases don't bleed
|
|
69
|
-
* passphrase / fallback decisions across each other.
|
|
50
|
+
* passphrase / fallback decisions across each other. File-store state (file
|
|
51
|
+
* dir + cached passphrase) lives in ./filestore.ts and is reset there. */
|
|
70
52
|
export declare function _resetForTest(opts?: {
|
|
71
53
|
fileDir?: string | null;
|
|
72
54
|
forceFileFallback?: boolean;
|