@mandujs/core 0.39.3 → 0.40.1
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/package.json +1 -1
- package/src/brain/__tests__/redactor.test.ts +94 -0
- package/src/brain/adapters/__tests__/_helpers.ts +64 -0
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -0
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +202 -0
- package/src/brain/adapters/__tests__/resolver.test.ts +122 -0
- package/src/brain/adapters/anthropic-oauth.ts +420 -0
- package/src/brain/adapters/index.ts +290 -2
- package/src/brain/adapters/oauth-flow.ts +439 -0
- package/src/brain/adapters/openai-oauth.ts +463 -0
- package/src/brain/consent.ts +240 -0
- package/src/brain/credentials.ts +396 -0
- package/src/brain/index.ts +39 -1
- package/src/brain/redactor.ts +196 -0
- package/src/config/mandu.ts +39 -0
- package/src/config/validate.ts +49 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain — OS keychain adapter for OAuth tokens (Issue #235).
|
|
3
|
+
*
|
|
4
|
+
* Zero new dependencies — we shell out to the native secret-store CLI
|
|
5
|
+
* on each platform, with a filesystem fallback at
|
|
6
|
+
* `~/.mandu/credentials.json` (mode 0600) when no CLI is available.
|
|
7
|
+
*
|
|
8
|
+
* - macOS → `security find-generic-password` / `add-generic-password`
|
|
9
|
+
* - Windows → `cmdkey` (read) + PowerShell `ConvertFrom-SecureString`
|
|
10
|
+
* is too heavyweight; we use a narrow `cmdkey /add` +
|
|
11
|
+
* `cmdkey /list` shell dance instead. Windows does NOT let
|
|
12
|
+
* `cmdkey` read back the password, so we fall through to
|
|
13
|
+
* the filesystem store on Windows and mark it 0600 — the
|
|
14
|
+
* file lives under the user profile which is already
|
|
15
|
+
* ACL'd to the current user. This matches the behavior of
|
|
16
|
+
* `gh auth`, `npm`, and `docker-credential-wincred`
|
|
17
|
+
* when run without their companion helper installed.
|
|
18
|
+
* - Linux → `secret-tool store` / `secret-tool lookup` (libsecret).
|
|
19
|
+
*
|
|
20
|
+
* API surface is intentionally minimal:
|
|
21
|
+
*
|
|
22
|
+
* - `saveToken(provider, token)` → persist
|
|
23
|
+
* - `loadToken(provider)` → returns token or null
|
|
24
|
+
* - `deleteToken(provider)` → idempotent delete
|
|
25
|
+
* - `listProviders()` → for `mandu brain status`
|
|
26
|
+
*
|
|
27
|
+
* The token type is arbitrary JSON (access_token + refresh_token +
|
|
28
|
+
* expires_at) — `CredentialStore` serializes it as a JSON string.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { promises as fs, constants as fsConstants } from "node:fs";
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
|
|
35
|
+
const SERVICE = "mandu-brain";
|
|
36
|
+
const FALLBACK_DIR = path.join(os.homedir(), ".mandu");
|
|
37
|
+
const FALLBACK_FILE = path.join(FALLBACK_DIR, "credentials.json");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stored token shape. `access_token` is required; the refresh fields
|
|
41
|
+
* are optional because template-only adapters never write them.
|
|
42
|
+
*/
|
|
43
|
+
export interface StoredToken {
|
|
44
|
+
access_token: string;
|
|
45
|
+
refresh_token?: string;
|
|
46
|
+
/** Unix-epoch seconds when the access_token expires. */
|
|
47
|
+
expires_at?: number;
|
|
48
|
+
/** ISO timestamp of last successful use — for `mandu brain status`. */
|
|
49
|
+
last_used_at?: string;
|
|
50
|
+
/** Cached default model chosen at login time (override via config). */
|
|
51
|
+
default_model?: string;
|
|
52
|
+
/** Scope string returned by the OAuth provider. */
|
|
53
|
+
scope?: string;
|
|
54
|
+
/** Provider identifier — for audit. */
|
|
55
|
+
provider?: "openai" | "anthropic";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pluggable backend for the credential store. Tests inject an
|
|
60
|
+
* in-memory backend; production auto-selects by OS.
|
|
61
|
+
*/
|
|
62
|
+
export interface CredentialBackend {
|
|
63
|
+
readonly name: string;
|
|
64
|
+
save(provider: string, token: StoredToken): Promise<void>;
|
|
65
|
+
load(provider: string): Promise<StoredToken | null>;
|
|
66
|
+
delete(provider: string): Promise<void>;
|
|
67
|
+
list(): Promise<string[]>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* -------------------------------------------------------------------- */
|
|
71
|
+
/* Filesystem fallback — always available, always the safety net. */
|
|
72
|
+
/* -------------------------------------------------------------------- */
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the fallback credentials file. Missing file → empty object.
|
|
76
|
+
* Corrupt file → empty object + a warning to stderr (so a bad edit
|
|
77
|
+
* does not brick `mandu brain status`).
|
|
78
|
+
*/
|
|
79
|
+
async function readFallback(): Promise<Record<string, StoredToken>> {
|
|
80
|
+
try {
|
|
81
|
+
const raw = await fs.readFile(FALLBACK_FILE, "utf8");
|
|
82
|
+
const parsed = JSON.parse(raw);
|
|
83
|
+
if (parsed && typeof parsed === "object") {
|
|
84
|
+
return parsed as Record<string, StoredToken>;
|
|
85
|
+
}
|
|
86
|
+
return {};
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (
|
|
89
|
+
err instanceof Error &&
|
|
90
|
+
(err as NodeJS.ErrnoException).code === "ENOENT"
|
|
91
|
+
) {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
// Corrupt JSON — treat as empty, do not throw (Brain never blocks).
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function writeFallback(all: Record<string, StoredToken>): Promise<void> {
|
|
100
|
+
await fs.mkdir(FALLBACK_DIR, { recursive: true, mode: 0o700 });
|
|
101
|
+
const payload = JSON.stringify(all, null, 2);
|
|
102
|
+
// Write atomically: write to tmp then rename, setting 0600 on the
|
|
103
|
+
// final file. mode on `writeFile` is advisory on Windows, enforced
|
|
104
|
+
// on POSIX.
|
|
105
|
+
const tmp = `${FALLBACK_FILE}.${process.pid}.tmp`;
|
|
106
|
+
await fs.writeFile(tmp, payload, { mode: 0o600 });
|
|
107
|
+
await fs.rename(tmp, FALLBACK_FILE);
|
|
108
|
+
try {
|
|
109
|
+
await fs.chmod(FALLBACK_FILE, 0o600);
|
|
110
|
+
} catch {
|
|
111
|
+
// chmod is best-effort on Windows.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const filesystemBackend: CredentialBackend = {
|
|
116
|
+
name: "filesystem",
|
|
117
|
+
async save(provider, token) {
|
|
118
|
+
const all = await readFallback();
|
|
119
|
+
all[provider] = token;
|
|
120
|
+
await writeFallback(all);
|
|
121
|
+
},
|
|
122
|
+
async load(provider) {
|
|
123
|
+
const all = await readFallback();
|
|
124
|
+
return all[provider] ?? null;
|
|
125
|
+
},
|
|
126
|
+
async delete(provider) {
|
|
127
|
+
const all = await readFallback();
|
|
128
|
+
if (provider in all) {
|
|
129
|
+
delete all[provider];
|
|
130
|
+
await writeFallback(all);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
async list() {
|
|
134
|
+
const all = await readFallback();
|
|
135
|
+
return Object.keys(all);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/* -------------------------------------------------------------------- */
|
|
140
|
+
/* Native backends — best-effort; fall through to filesystem on error. */
|
|
141
|
+
/* -------------------------------------------------------------------- */
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Shell out to a binary with a short stdin payload and return stdout.
|
|
145
|
+
* Never throws — returns `null` on any non-zero exit. We never log the
|
|
146
|
+
* stderr verbatim because some utilities echo the payload back on
|
|
147
|
+
* failure.
|
|
148
|
+
*/
|
|
149
|
+
async function runWithStdin(
|
|
150
|
+
cmd: string,
|
|
151
|
+
args: string[],
|
|
152
|
+
stdin: string,
|
|
153
|
+
): Promise<{ ok: boolean; stdout: string } | null> {
|
|
154
|
+
try {
|
|
155
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
156
|
+
stdin: "pipe",
|
|
157
|
+
stdout: "pipe",
|
|
158
|
+
stderr: "pipe",
|
|
159
|
+
});
|
|
160
|
+
proc.stdin.write(stdin);
|
|
161
|
+
await proc.stdin.end();
|
|
162
|
+
const [stdout, exitCode] = await Promise.all([
|
|
163
|
+
new Response(proc.stdout).text(),
|
|
164
|
+
proc.exited,
|
|
165
|
+
]);
|
|
166
|
+
return { ok: exitCode === 0, stdout };
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function runCapture(
|
|
173
|
+
cmd: string,
|
|
174
|
+
args: string[],
|
|
175
|
+
): Promise<{ ok: boolean; stdout: string } | null> {
|
|
176
|
+
try {
|
|
177
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
178
|
+
stdout: "pipe",
|
|
179
|
+
stderr: "pipe",
|
|
180
|
+
});
|
|
181
|
+
const [stdout, exitCode] = await Promise.all([
|
|
182
|
+
new Response(proc.stdout).text(),
|
|
183
|
+
proc.exited,
|
|
184
|
+
]);
|
|
185
|
+
return { ok: exitCode === 0, stdout };
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* macOS Keychain backend — uses the `security` CLI.
|
|
193
|
+
*
|
|
194
|
+
* `security add-generic-password -U -a <provider> -s mandu-brain -w <json>`
|
|
195
|
+
* to store; `find-generic-password -a <provider> -s mandu-brain -w`
|
|
196
|
+
* to read the password back. Deletion uses `delete-generic-password`.
|
|
197
|
+
*/
|
|
198
|
+
export const macosBackend: CredentialBackend = {
|
|
199
|
+
name: "macos-keychain",
|
|
200
|
+
async save(provider, token) {
|
|
201
|
+
const payload = JSON.stringify(token);
|
|
202
|
+
const res = await runCapture("security", [
|
|
203
|
+
"add-generic-password",
|
|
204
|
+
"-U", // update if exists
|
|
205
|
+
"-a",
|
|
206
|
+
provider,
|
|
207
|
+
"-s",
|
|
208
|
+
SERVICE,
|
|
209
|
+
"-w",
|
|
210
|
+
payload,
|
|
211
|
+
]);
|
|
212
|
+
if (!res || !res.ok) {
|
|
213
|
+
// Fall back to filesystem — never let a keychain hiccup block login.
|
|
214
|
+
await filesystemBackend.save(provider, token);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
async load(provider) {
|
|
218
|
+
const res = await runCapture("security", [
|
|
219
|
+
"find-generic-password",
|
|
220
|
+
"-a",
|
|
221
|
+
provider,
|
|
222
|
+
"-s",
|
|
223
|
+
SERVICE,
|
|
224
|
+
"-w",
|
|
225
|
+
]);
|
|
226
|
+
if (!res || !res.ok) {
|
|
227
|
+
return filesystemBackend.load(provider);
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
return JSON.parse(res.stdout.trim()) as StoredToken;
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
async delete(provider) {
|
|
236
|
+
await runCapture("security", [
|
|
237
|
+
"delete-generic-password",
|
|
238
|
+
"-a",
|
|
239
|
+
provider,
|
|
240
|
+
"-s",
|
|
241
|
+
SERVICE,
|
|
242
|
+
]);
|
|
243
|
+
// Also scrub filesystem fallback, in case a previous save hit it.
|
|
244
|
+
await filesystemBackend.delete(provider);
|
|
245
|
+
},
|
|
246
|
+
async list() {
|
|
247
|
+
// `security` has no "list by service" flag that returns accounts
|
|
248
|
+
// without the full dump; use filesystem fallback as the source of
|
|
249
|
+
// truth for the list, since saves always also go through keychain
|
|
250
|
+
// (or fell back to the file).
|
|
251
|
+
return filesystemBackend.list();
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Linux libsecret backend — `secret-tool`.
|
|
257
|
+
*/
|
|
258
|
+
export const linuxBackend: CredentialBackend = {
|
|
259
|
+
name: "linux-secret-tool",
|
|
260
|
+
async save(provider, token) {
|
|
261
|
+
const payload = JSON.stringify(token);
|
|
262
|
+
const res = await runWithStdin(
|
|
263
|
+
"secret-tool",
|
|
264
|
+
["store", "--label=mandu-brain", "service", SERVICE, "account", provider],
|
|
265
|
+
payload,
|
|
266
|
+
);
|
|
267
|
+
if (!res || !res.ok) {
|
|
268
|
+
await filesystemBackend.save(provider, token);
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
async load(provider) {
|
|
272
|
+
const res = await runCapture("secret-tool", [
|
|
273
|
+
"lookup",
|
|
274
|
+
"service",
|
|
275
|
+
SERVICE,
|
|
276
|
+
"account",
|
|
277
|
+
provider,
|
|
278
|
+
]);
|
|
279
|
+
if (!res || !res.ok || res.stdout.trim().length === 0) {
|
|
280
|
+
return filesystemBackend.load(provider);
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
return JSON.parse(res.stdout.trim()) as StoredToken;
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
async delete(provider) {
|
|
289
|
+
await runCapture("secret-tool", [
|
|
290
|
+
"clear",
|
|
291
|
+
"service",
|
|
292
|
+
SERVICE,
|
|
293
|
+
"account",
|
|
294
|
+
provider,
|
|
295
|
+
]);
|
|
296
|
+
await filesystemBackend.delete(provider);
|
|
297
|
+
},
|
|
298
|
+
async list() {
|
|
299
|
+
return filesystemBackend.list();
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Windows — `cmdkey` can store but cannot read back the password
|
|
305
|
+
* portion without the Credential Manager API (which we would need a
|
|
306
|
+
* native binding for). Use filesystem fallback directly; the file
|
|
307
|
+
* lives under `%USERPROFILE%\.mandu\credentials.json` which is ACL'd
|
|
308
|
+
* to the current user by default. This matches the behavior of
|
|
309
|
+
* `gh auth login` when the Git Credential Manager is not installed.
|
|
310
|
+
*/
|
|
311
|
+
export const windowsBackend: CredentialBackend = {
|
|
312
|
+
name: "windows-filesystem",
|
|
313
|
+
save: filesystemBackend.save,
|
|
314
|
+
load: filesystemBackend.load,
|
|
315
|
+
delete: filesystemBackend.delete,
|
|
316
|
+
list: filesystemBackend.list,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Pick the best backend for the current platform. Tests override this
|
|
321
|
+
* by constructing a `CredentialStore` with an explicit backend.
|
|
322
|
+
*/
|
|
323
|
+
export function pickPlatformBackend(): CredentialBackend {
|
|
324
|
+
if (process.platform === "darwin") return macosBackend;
|
|
325
|
+
if (process.platform === "win32") return windowsBackend;
|
|
326
|
+
if (process.platform === "linux") return linuxBackend;
|
|
327
|
+
return filesystemBackend;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* -------------------------------------------------------------------- */
|
|
331
|
+
/* Public store */
|
|
332
|
+
/* -------------------------------------------------------------------- */
|
|
333
|
+
|
|
334
|
+
export class CredentialStore {
|
|
335
|
+
constructor(private backend: CredentialBackend = pickPlatformBackend()) {}
|
|
336
|
+
|
|
337
|
+
/** Which backend is active — surfaced in `mandu brain status`. */
|
|
338
|
+
get backendName(): string {
|
|
339
|
+
return this.backend.name;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async save(provider: string, token: StoredToken): Promise<void> {
|
|
343
|
+
await this.backend.save(provider, token);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async load(provider: string): Promise<StoredToken | null> {
|
|
347
|
+
return this.backend.load(provider);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async delete(provider: string): Promise<void> {
|
|
351
|
+
await this.backend.delete(provider);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async list(): Promise<string[]> {
|
|
355
|
+
return this.backend.list();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Touch the last_used_at timestamp on an existing token without
|
|
360
|
+
* rotating the secret. Best-effort — swallow errors (telemetry).
|
|
361
|
+
*/
|
|
362
|
+
async touch(provider: string): Promise<void> {
|
|
363
|
+
try {
|
|
364
|
+
const tok = await this.load(provider);
|
|
365
|
+
if (tok) {
|
|
366
|
+
tok.last_used_at = new Date().toISOString();
|
|
367
|
+
await this.save(provider, tok);
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
/* ignore */
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* File path of the filesystem fallback — used by tests + the CLI
|
|
376
|
+
* `mandu brain status` to point users at the 0600 file. Always
|
|
377
|
+
* returns the same path regardless of backend.
|
|
378
|
+
*/
|
|
379
|
+
static fallbackPath(): string {
|
|
380
|
+
return FALLBACK_FILE;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Default singleton — production path. */
|
|
385
|
+
let defaultStore: CredentialStore | null = null;
|
|
386
|
+
export function getCredentialStore(): CredentialStore {
|
|
387
|
+
if (!defaultStore) {
|
|
388
|
+
defaultStore = new CredentialStore();
|
|
389
|
+
}
|
|
390
|
+
return defaultStore;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Override the singleton (test affordance). */
|
|
394
|
+
export function setCredentialStore(store: CredentialStore): void {
|
|
395
|
+
defaultStore = store;
|
|
396
|
+
}
|
package/src/brain/index.ts
CHANGED
|
@@ -10,9 +10,47 @@
|
|
|
10
10
|
// Types
|
|
11
11
|
export * from "./types";
|
|
12
12
|
|
|
13
|
-
// Adapters
|
|
13
|
+
// Adapters (includes Ollama, OpenAI OAuth, Anthropic OAuth, and the
|
|
14
|
+
// `createBrainAdapter` / `resolveBrainAdapter` resolver — Issue #235).
|
|
14
15
|
export * from "./adapters";
|
|
15
16
|
|
|
17
|
+
// Credential store (OS keychain + filesystem fallback).
|
|
18
|
+
export {
|
|
19
|
+
CredentialStore,
|
|
20
|
+
getCredentialStore,
|
|
21
|
+
setCredentialStore,
|
|
22
|
+
filesystemBackend,
|
|
23
|
+
macosBackend,
|
|
24
|
+
linuxBackend,
|
|
25
|
+
windowsBackend,
|
|
26
|
+
pickPlatformBackend,
|
|
27
|
+
type CredentialBackend,
|
|
28
|
+
type StoredToken,
|
|
29
|
+
} from "./credentials";
|
|
30
|
+
|
|
31
|
+
// Consent prompt + cache (Issue #235).
|
|
32
|
+
export {
|
|
33
|
+
ensureConsent,
|
|
34
|
+
hasConsent,
|
|
35
|
+
grantConsent,
|
|
36
|
+
revokeConsent,
|
|
37
|
+
fingerprintProject,
|
|
38
|
+
consentFilePath,
|
|
39
|
+
type ConsentContext,
|
|
40
|
+
type ConsentEntry,
|
|
41
|
+
type ConsentProvider,
|
|
42
|
+
type ConsentPromptDeps,
|
|
43
|
+
} from "./consent";
|
|
44
|
+
|
|
45
|
+
// Redactor (pre-transmission secret scrubbing — Issue #235).
|
|
46
|
+
export {
|
|
47
|
+
redact,
|
|
48
|
+
redactSecrets,
|
|
49
|
+
type RedactionHit,
|
|
50
|
+
type RedactionKind,
|
|
51
|
+
type RedactionResult,
|
|
52
|
+
} from "./redactor";
|
|
53
|
+
|
|
16
54
|
// Permissions
|
|
17
55
|
export {
|
|
18
56
|
detectEnvironment,
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain — pre-transmission secret redactor (Issue #235)
|
|
3
|
+
*
|
|
4
|
+
* Every payload destined for a cloud adapter is passed through
|
|
5
|
+
* `redactSecrets()` FIRST. The function returns:
|
|
6
|
+
*
|
|
7
|
+
* - `{ redacted, hits }` — redacted payload + list of what matched, so
|
|
8
|
+
* the CLI / adapter can print an audit line and append to
|
|
9
|
+
* `.mandu/brain-redactions.jsonl` for user inspection.
|
|
10
|
+
*
|
|
11
|
+
* Design principles (user feedback — Mandu as connector, not owner):
|
|
12
|
+
* - Mandu NEVER transmits a prompt without passing it through here.
|
|
13
|
+
* - Patterns are conservative (false positives preferred over leaks).
|
|
14
|
+
* - No regex is anchored; we scan the full text so embedded secrets
|
|
15
|
+
* inside a diff block are still caught.
|
|
16
|
+
* - All replacements collapse to a single token `[[REDACTED:<kind>]]`
|
|
17
|
+
* so the cloud model sees a stable shape rather than a mangled
|
|
18
|
+
* partial key that could still be reconstructed.
|
|
19
|
+
*
|
|
20
|
+
* @see docs/brain/oauth-adapters.md (if that ever ships — for now this
|
|
21
|
+
* file + its tests are the source of truth).
|
|
22
|
+
*/
|
|
23
|
+
export type RedactionKind =
|
|
24
|
+
| "openai-key"
|
|
25
|
+
| "stripe-key"
|
|
26
|
+
| "github-token"
|
|
27
|
+
| "slack-token"
|
|
28
|
+
| "aws-key"
|
|
29
|
+
| "bearer-token"
|
|
30
|
+
| "env-ref"
|
|
31
|
+
| "api-key-assignment"
|
|
32
|
+
| "jwt"
|
|
33
|
+
| "long-base64";
|
|
34
|
+
|
|
35
|
+
export interface RedactionHit {
|
|
36
|
+
/** The kind of secret matched (for audit logging). */
|
|
37
|
+
kind: RedactionKind;
|
|
38
|
+
/** Byte offset into the original input where the match started. */
|
|
39
|
+
start: number;
|
|
40
|
+
/** Byte offset into the original input where the match ended. */
|
|
41
|
+
end: number;
|
|
42
|
+
/**
|
|
43
|
+
* Short sample of the redacted material — always prefix + ellipsis,
|
|
44
|
+
* never the full secret. Safe to log.
|
|
45
|
+
*/
|
|
46
|
+
sample: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RedactionResult {
|
|
50
|
+
/** Input with every match replaced by `[[REDACTED:<kind>]]`. */
|
|
51
|
+
redacted: string;
|
|
52
|
+
/** One entry per match, in scan order. */
|
|
53
|
+
hits: RedactionHit[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ordered pattern list. Earlier patterns win — specific formats (e.g.
|
|
58
|
+
* `sk-...`) are checked before the generic "long base64" fallback so
|
|
59
|
+
* their `kind` tag is accurate.
|
|
60
|
+
*
|
|
61
|
+
* Every pattern is intentionally over-conservative — we would rather
|
|
62
|
+
* redact a false-positive hash than leak a real key. Users can inspect
|
|
63
|
+
* `.mandu/brain-redactions.jsonl` to spot over-redaction.
|
|
64
|
+
*/
|
|
65
|
+
const PATTERNS: Array<{ kind: RedactionKind; regex: RegExp }> = [
|
|
66
|
+
// OpenAI-style keys: sk-XXXX, sk-proj-XXXX, pk-XXXX.
|
|
67
|
+
{ kind: "openai-key", regex: /\b(?:sk|pk)-(?:proj-)?[A-Za-z0-9_\-]{16,}\b/g },
|
|
68
|
+
// Stripe live/test keys.
|
|
69
|
+
{ kind: "stripe-key", regex: /\brk_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
|
|
70
|
+
// GitHub PATs — ghp_, gho_, ghu_, ghs_, ghr_.
|
|
71
|
+
{ kind: "github-token", regex: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
|
|
72
|
+
// Slack bot / user tokens.
|
|
73
|
+
{ kind: "slack-token", regex: /\bxox[abpors]-[A-Za-z0-9-]{10,}\b/g },
|
|
74
|
+
// AWS access key id (fixed-width 20 char uppercase starting w/ AKIA / ASIA).
|
|
75
|
+
{ kind: "aws-key", regex: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
|
|
76
|
+
// Authorization: Bearer ...
|
|
77
|
+
{
|
|
78
|
+
kind: "bearer-token",
|
|
79
|
+
regex: /(?:Authorization:\s*)?\bBearer\s+[A-Za-z0-9._\-]{16,}/gi,
|
|
80
|
+
},
|
|
81
|
+
// `.env` path references — we redact the path itself, not the
|
|
82
|
+
// surrounding sentence. "see .env" → "see [[REDACTED:env-ref]]".
|
|
83
|
+
// Trailing lookahead matches sentence punctuation / whitespace /
|
|
84
|
+
// end-of-string so "see .env." / ".env.production." both redact.
|
|
85
|
+
{ kind: "env-ref", regex: /\.env(?:\.[a-z]+)?(?=[\s,.;:!?'")`]|$)/g },
|
|
86
|
+
// KEY="value" / KEY=value assignments, where KEY contains
|
|
87
|
+
// API_KEY/SECRET/TOKEN/PASSWORD. Matches the whole assignment.
|
|
88
|
+
{
|
|
89
|
+
kind: "api-key-assignment",
|
|
90
|
+
regex:
|
|
91
|
+
/\b[A-Z][A-Z0-9_]*(?:API_KEY|SECRET|TOKEN|PASSWORD|PASSWD)[A-Z0-9_]*\s*=\s*["']?[^\s"'\n]+["']?/g,
|
|
92
|
+
},
|
|
93
|
+
// JWTs (three base64url segments separated by dots).
|
|
94
|
+
{
|
|
95
|
+
kind: "jwt",
|
|
96
|
+
regex: /\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b/g,
|
|
97
|
+
},
|
|
98
|
+
// Generic long base64/hex blob (20+ chars, mixed case or digit-heavy).
|
|
99
|
+
// Runs last so more specific kinds claim first.
|
|
100
|
+
{
|
|
101
|
+
kind: "long-base64",
|
|
102
|
+
regex: /\b[A-Za-z0-9+/=_\-]{24,}\b/g,
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Sample a match for audit logging — never return the full secret.
|
|
108
|
+
*
|
|
109
|
+
* Returns `"<first 4 chars>...<last 2 chars>"` for entries longer
|
|
110
|
+
* than 8 characters, and a hard-coded stub otherwise. The sample is
|
|
111
|
+
* deliberately short so it cannot be used to reconstruct the key.
|
|
112
|
+
*/
|
|
113
|
+
function sampleOf(match: string): string {
|
|
114
|
+
if (match.length <= 8) return "***";
|
|
115
|
+
return `${match.slice(0, 4)}...${match.slice(-2)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Scan `input` for secrets and return a redacted copy + hit list.
|
|
120
|
+
*
|
|
121
|
+
* The scan is non-overlapping: once a region is claimed by an earlier
|
|
122
|
+
* pattern, later patterns cannot match inside it. This is enforced by
|
|
123
|
+
* walking the hit set and rebuilding the string in one pass rather than
|
|
124
|
+
* running `.replace()` per-pattern (which would allow the generic
|
|
125
|
+
* "long-base64" to fire on the already-inserted `[[REDACTED:...]]`
|
|
126
|
+
* marker).
|
|
127
|
+
*
|
|
128
|
+
* @param input Raw text about to be transmitted to a cloud adapter.
|
|
129
|
+
* @returns Redacted text plus audit-safe hit list.
|
|
130
|
+
*/
|
|
131
|
+
export function redactSecrets(input: string): RedactionResult {
|
|
132
|
+
if (!input || input.length === 0) {
|
|
133
|
+
return { redacted: input ?? "", hits: [] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 1. Collect every candidate hit across every pattern.
|
|
137
|
+
const raw: RedactionHit[] = [];
|
|
138
|
+
for (const { kind, regex } of PATTERNS) {
|
|
139
|
+
regex.lastIndex = 0;
|
|
140
|
+
let m: RegExpExecArray | null;
|
|
141
|
+
while ((m = regex.exec(input)) !== null) {
|
|
142
|
+
if (m[0].length === 0) {
|
|
143
|
+
regex.lastIndex += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
raw.push({
|
|
147
|
+
kind,
|
|
148
|
+
start: m.index,
|
|
149
|
+
end: m.index + m[0].length,
|
|
150
|
+
sample: sampleOf(m[0]),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (raw.length === 0) {
|
|
156
|
+
return { redacted: input, hits: [] };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2. Sort by start offset; on tie prefer the earlier pattern (which
|
|
160
|
+
// means the more specific kind since PATTERNS is ordered).
|
|
161
|
+
raw.sort((a, b) => (a.start - b.start) || (a.end - b.end));
|
|
162
|
+
|
|
163
|
+
// 3. Greedy non-overlap: keep the first match; skip anything that
|
|
164
|
+
// starts before the last accepted match ended.
|
|
165
|
+
const accepted: RedactionHit[] = [];
|
|
166
|
+
let cursor = -1;
|
|
167
|
+
for (const h of raw) {
|
|
168
|
+
if (h.start < cursor) continue;
|
|
169
|
+
accepted.push(h);
|
|
170
|
+
cursor = h.end;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 4. Rebuild the output in one pass.
|
|
174
|
+
let out = "";
|
|
175
|
+
let i = 0;
|
|
176
|
+
for (const h of accepted) {
|
|
177
|
+
out += input.slice(i, h.start);
|
|
178
|
+
out += `[[REDACTED:${h.kind}]]`;
|
|
179
|
+
i = h.end;
|
|
180
|
+
}
|
|
181
|
+
out += input.slice(i);
|
|
182
|
+
|
|
183
|
+
return { redacted: out, hits: accepted };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Convenience — returns only the redacted string.
|
|
188
|
+
*
|
|
189
|
+
* Use `redactSecrets()` directly when you also need the hit list (for
|
|
190
|
+
* audit logging). Use this when you just want "scrub and forward".
|
|
191
|
+
*/
|
|
192
|
+
export function redact(input: string): string {
|
|
193
|
+
return redactSecrets(input).hits.length === 0
|
|
194
|
+
? input
|
|
195
|
+
: redactSecrets(input).redacted;
|
|
196
|
+
}
|
package/src/config/mandu.ts
CHANGED
|
@@ -618,6 +618,45 @@ export interface ManduConfig {
|
|
|
618
618
|
/** Domain → locale map; required when strategy === "domain". */
|
|
619
619
|
domains?: Record<string, LocaleCode>;
|
|
620
620
|
};
|
|
621
|
+
/**
|
|
622
|
+
* Issue #235 — Brain LLM adapter selection.
|
|
623
|
+
*
|
|
624
|
+
* Mandu is a CONNECTOR for third-party LLMs, not an LLM owner. Cloud
|
|
625
|
+
* adapters forward requests using the user's own OAuth credentials,
|
|
626
|
+
* obtained via `mandu brain login --provider=<name>` and stored in
|
|
627
|
+
* the OS keychain. No API keys ever live in Mandu's process memory;
|
|
628
|
+
* Mandu-controlled billing is not a thing.
|
|
629
|
+
*
|
|
630
|
+
* Fields:
|
|
631
|
+
* - `adapter` — Which connector to use. Default `"auto"`.
|
|
632
|
+
* Auto resolves in priority order:
|
|
633
|
+
* openai → anthropic → ollama → template.
|
|
634
|
+
* Explicit values pin the choice but still
|
|
635
|
+
* degrade to template when the dependency is
|
|
636
|
+
* unreachable (no hard failures).
|
|
637
|
+
* - `openai.model` — Override the OpenAI model (default
|
|
638
|
+
* `"gpt-4o-mini"`).
|
|
639
|
+
* - `anthropic.model` — Override the Anthropic model (default
|
|
640
|
+
* `"claude-haiku-4-5-20251001"`).
|
|
641
|
+
* - `ollama.model` — Override the local Ollama model (default
|
|
642
|
+
* `"ministral-3:3b"`).
|
|
643
|
+
* - `telemetryOptOut` — When `true`, cloud adapters are disabled
|
|
644
|
+
* entirely regardless of stored tokens. The
|
|
645
|
+
* resolver falls to ollama/template. Use for
|
|
646
|
+
* privacy-strict environments.
|
|
647
|
+
*
|
|
648
|
+
* Omitting this block is equivalent to `{ adapter: "auto" }`.
|
|
649
|
+
*
|
|
650
|
+
* @see `@mandujs/core/brain/adapters` for the resolver implementation.
|
|
651
|
+
* @see `docs/brain/oauth-adapters.md` (when authored).
|
|
652
|
+
*/
|
|
653
|
+
brain?: {
|
|
654
|
+
adapter?: "auto" | "openai" | "anthropic" | "ollama" | "template";
|
|
655
|
+
openai?: { model?: string };
|
|
656
|
+
anthropic?: { model?: string };
|
|
657
|
+
ollama?: { model?: string; baseUrl?: string };
|
|
658
|
+
telemetryOptOut?: boolean;
|
|
659
|
+
};
|
|
621
660
|
}
|
|
622
661
|
|
|
623
662
|
export const CONFIG_FILES = [
|