@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.
@@ -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
+ }
@@ -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
+ }
@@ -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 = [