mouaif 0.3.0

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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1,932 @@
1
+ 'use strict';
2
+
3
+ const { Worker } = require('node:worker_threads');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const settings = require('../settings.js');
7
+ const trace = require('../trace.js');
8
+
9
+ const MODES = new Set(['off', 'ask', 'allowlist', 'allow']);
10
+ const DECISIONS = new Set(['allow-once', 'allow-session', 'allow-always', 'deny']);
11
+ const DEFAULT_TIMEOUT_MS = 30_000;
12
+ const MAX_TIMEOUT_MS = 600_000;
13
+ const sessions = new Map();
14
+
15
+ function typedError(code, message) {
16
+ return Object.assign(new Error(message), { code });
17
+ }
18
+
19
+ function sessionKey(projectDir, chatId) {
20
+ return String(projectDir || '') + '\0' + String(chatId || '');
21
+ }
22
+
23
+ function getSession(projectDir, chatId) {
24
+ const key = sessionKey(projectDir, chatId);
25
+ let session = sessions.get(key);
26
+ if (!session) {
27
+ session = {
28
+ projectDir,
29
+ chatId,
30
+ grants: new Set(),
31
+ allowedCallIds: new Map(),
32
+ deniedCallIds: new Set(),
33
+ pending: new Map()
34
+ };
35
+ sessions.set(key, session);
36
+ }
37
+ return session;
38
+ }
39
+
40
+ // Reject every wait parked on a decision the caller is discarding.
41
+ //
42
+ // A pending entry owns the promise the tool loop is awaiting. Deleting it
43
+ // without settling that promise leaves the loop parked forever — the
44
+ // upstream-abort signal never fires while the loop is waiting on a user
45
+ // decision rather than a fetch — so the chat keeps its running marker and
46
+ // every retry bounces off 409 EALREADY_RUNNING. Rejecting with EDENIED
47
+ // unwinds the loop through its normal error path, which clears the marker
48
+ // and persists the failure. Returns how many waits were rejected.
49
+ function rejectPending(session, message) {
50
+ if (!session || !session.pending.size) return 0;
51
+ let count = 0;
52
+ for (const [callId, pending] of session.pending) {
53
+ session.pending.delete(callId);
54
+ try { pending.reject(typedError('EDENIED', message)); } catch { /* already settled */ }
55
+ count++;
56
+ }
57
+ return count;
58
+ }
59
+
60
+ // Revoke a chat's session grants (a reopened chat loses its blanket
61
+ // "allow" state). The pending map is settled first: this used to delete
62
+ // the session outright, so a call already parked on `await
63
+ // authResult.wait` was orphaned — `recordDecision` then threw ENOTFOUND
64
+ // for the decision the user was still able to make, and the run never
65
+ // resumed. Returns the number of pending waits that were rejected.
66
+ function clearGrants(projectDir, chatId) {
67
+ if (!projectDir || !chatId) return 0;
68
+ const key = sessionKey(projectDir, chatId);
69
+ const session = sessions.get(key);
70
+ if (!session) return 0;
71
+ const rejected = rejectPending(session, 'chat session was reopened');
72
+ sessions.delete(key);
73
+ return rejected;
74
+ }
75
+
76
+ // Reject every pending authorization wait for a chat. Called when the
77
+ // SSE client disconnects mid-run. The session itself is kept (its grants
78
+ // are still meaningful if the user reconnects), but every parked wait is
79
+ // settled so the tool loop can unwind.
80
+ function cancelSession(projectDir, chatId) {
81
+ if (!projectDir || !chatId) return 0;
82
+ return rejectPending(sessions.get(sessionKey(projectDir, chatId)), 'client disconnected');
83
+ }
84
+
85
+ function listPending(projectDir, chatId) {
86
+ if (!projectDir || !chatId) return [];
87
+ const session = sessions.get(sessionKey(projectDir, chatId));
88
+ if (!session || !session.pending.size) return [];
89
+ return Array.from(session.pending.entries()).map(([callId, pending]) => {
90
+ const request = pending && pending.request && typeof pending.request === 'object' ? pending.request : {};
91
+ return Object.assign({ callId, tool: pending.tool }, request);
92
+ });
93
+ }
94
+
95
+ function normalizeConfig(raw, source, enabled, tool) {
96
+ const value = raw && typeof raw === 'object' ? raw : {};
97
+ // Binary-mode tools only support { off, ask } — clamp any legacy
98
+ // allowlist / allow values to `ask` so a hand-edited project file
99
+ // from a future migration can't bypass the prompt.
100
+ let mode = MODES.has(value.mode) ? value.mode : 'ask';
101
+ if (tool && BINARY_MODE_TOOLS.has(tool) && mode !== 'off' && mode !== 'ask') mode = 'ask';
102
+ // Tools that are OFF until the project opts in. This only moves the
103
+ // *default*: a stored mode (project, app, or chat) is honored as written,
104
+ // so the only thing this changes is what an unconfigured project reports.
105
+ // The set is currently empty (its only member, the image tool, was
106
+ // removed); it stays wired for a future tool whose first use has a cost.
107
+ if (tool && DEFAULT_OFF_TOOLS.has(tool) && !Object.prototype.hasOwnProperty.call(value, 'mode')) mode = 'off';
108
+ // A per-chat override is a decision the USER just made in this chat
109
+ // (decisions §17), not a config file that could carry a stale or
110
+ // hand-edited shape. It is the most specific layer of all.
111
+ if (source === 'chat') {
112
+ return {
113
+ enabled: enabled !== false,
114
+ mode,
115
+ allowlist: Array.isArray(value.allowlist) ? value.allowlist.filter((x) => typeof x === 'string') : [],
116
+ defaultTimeoutMs: Number.isFinite(value.defaultTimeoutMs)
117
+ ? Math.max(1, Math.min(MAX_TIMEOUT_MS, Math.round(value.defaultTimeoutMs)))
118
+ : Math.min(DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS),
119
+ maxTimeoutMs: MAX_TIMEOUT_MS,
120
+ source
121
+ };
122
+ }
123
+ const maxTimeoutMs = Number.isFinite(value.maxTimeoutMs)
124
+ ? Math.max(1, Math.min(MAX_TIMEOUT_MS, Math.round(value.maxTimeoutMs)))
125
+ : MAX_TIMEOUT_MS;
126
+ const defaultTimeoutMs = Number.isFinite(value.defaultTimeoutMs)
127
+ ? Math.max(1, Math.min(maxTimeoutMs, Math.round(value.defaultTimeoutMs)))
128
+ : Math.min(DEFAULT_TIMEOUT_MS, maxTimeoutMs);
129
+ return {
130
+ enabled: enabled !== false,
131
+ mode,
132
+ allowlist: Array.isArray(value.allowlist) ? value.allowlist.filter((x) => typeof x === 'string') : [],
133
+ defaultTimeoutMs,
134
+ maxTimeoutMs,
135
+ source
136
+ };
137
+ }
138
+
139
+ // Tools whose authorization is project-scoped (in addition to MCP, which
140
+ // has its own block under project.mcp.authorization). The same shape
141
+ // works for any future native tool: { mode, allowlist, defaultTimeoutMs,
142
+ // maxTimeoutMs } under project.tools.<name>.
143
+ const NATIVE_TOOLS = new Set(['shell', 'subagent', 'file', 'ask_user', 'report_progress', 'task', 'webpreview', 'restart_app']);
144
+ // Tools that only support a binary `off` / `ask` mode. `ask_user` is
145
+ // the first of its kind: the model can't predict the user's answer,
146
+ // so allowlist / allow make no sense. The authorization module still
147
+ // owns the gate (so the rest of the pipeline — UI cards, the SSE
148
+ // event, the audit log — works the same), but the mode enum is
149
+ // narrowed to { off, ask }.
150
+ const BINARY_MODE_TOOLS = new Set(['ask_user']);
151
+ // Tools whose effective mode is `off` until a mode is stored for them.
152
+ // Everything else defaults to `ask` — a prompt on first use — which is the
153
+ // right default for a read-mostly tool. These are the tools whose *first use
154
+ // without an answer* would already have a cost, so they stay off.
155
+ //
156
+ // Currently empty: the only member was the image-generation tool, which was
157
+ // removed. The set (and the default-off branch in normalizeConfig) is kept
158
+ // so a future native tool with a cost can opt in by name alone.
159
+ const DEFAULT_OFF_TOOLS = new Set([]);
160
+ // The model-facing file operations. Their specs are collected one per
161
+ // name, and an `off` on the family (`tools.file`) hides all of them.
162
+ const FILE_TOOL_NAMES = new Set(['read_file', 'list_files', 'search_files', 'write_file', 'edit_file']);
163
+ // Every tool that resolves its authorization through the File tools family
164
+ // (the `file` gate plus its per-leaf overrides). Used by the effective-mode
165
+ // resolver, the chat-override reader/writer, and the GET /api/tools/
166
+ // authorization view — the family's children, not just its five read/write
167
+ // operations.
168
+ const FILE_FAMILY_TOOLS = new Set([...FILE_TOOL_NAMES]);
169
+ const MCP_FILE = '.mcp.json';
170
+
171
+ // ---- Per-chat authorization overrides (decisions §17) --------------------
172
+ //
173
+ // The Off / Ask / Allow segments in the chat's Tools card and in the
174
+ // composer tool popup are CHAT preferences, not project settings: they
175
+ // live on the chat record (`chat.toolAuth`, app SQLite store) and are
176
+ // never written to `.mouaif.json` / `.mcp.json`. Project settings — and
177
+ // only project settings — is where a project-wide gate is changed.
178
+ //
179
+ // Shape (all keys optional, `null`/absent = inherit):
180
+ // {
181
+ // native: { shell: { mode, allowlist }, file: { mode }, … },
182
+ // mcp: {
183
+ // shared: { mode, allowlist }, // every MCP call in this chat
184
+ // servers: { <slug>: { mode } }, // one server (matched by slug OR id)
185
+ // tools: { mcp__<slug>__<t>: { mode } } // one composed tool
186
+ // }
187
+ // }
188
+ //
189
+ // The legacy flat shape (`{ shell: { mode } }`) is still read, so an
190
+ // older writer degrades to a native-only override instead of a crash.
191
+ //
192
+ // `chatId` is accepted for symmetry with the other entry points. Reads
193
+ // resolve the chat through chats.getChat(), which the tool loop already
194
+ // calls for every request, so no extra lookup is added: by the time the
195
+ // advertisement gate runs, the record is in the SQLite page cache.
196
+ function readChatAuthOverrides(projectDir, chatId) {
197
+ if (!projectDir || !chatId) return null;
198
+ let chat = null;
199
+ try { chat = require('../chats.js').getChat(projectDir, chatId); }
200
+ catch { return null; }
201
+ const raw = chat && chat.toolAuth;
202
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
203
+ const out = { native: {}, mcp: null };
204
+ let any = false;
205
+ // Native half: strict `native` key first, then the legacy flat shape
206
+ // (a raw `mcp` key is never a tool name, so it cannot collide).
207
+ const nativeSource = (raw.native && typeof raw.native === 'object' && !Array.isArray(raw.native))
208
+ ? raw.native
209
+ : raw;
210
+ for (const name of [...NATIVE_TOOLS, ...FILE_FAMILY_TOOLS]) {
211
+ const entry = nativeSource[name];
212
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
213
+ if (typeof entry.mode !== 'string' || !MODES.has(entry.mode)) continue;
214
+ if (!out.native[name]) out.native[name] = {};
215
+ out.native[name].mode = entry.mode;
216
+ if (Array.isArray(entry.allowlist)) out.native[name].allowlist = entry.allowlist.filter((x) => typeof x === 'string');
217
+ any = true;
218
+ }
219
+ // MCP half: shared gate, then per-server / per-tool overrides, in the
220
+ // same layering order the project gate uses (decisions §18).
221
+ const m = (raw.mcp && typeof raw.mcp === 'object' && !Array.isArray(raw.mcp)) ? raw.mcp : null;
222
+ if (m) {
223
+ const mcp = {};
224
+ const modeOf = (entry) => (entry && typeof entry === 'object' && !Array.isArray(entry) && typeof entry.mode === 'string' && MODES.has(entry.mode))
225
+ ? entry.mode
226
+ : null;
227
+ const shared = modeOf(m.shared);
228
+ if (shared) { mcp.shared = { mode: shared }; any = true; }
229
+ for (const key of ['servers', 'tools']) {
230
+ const map = (m[key] && typeof m[key] === 'object' && !Array.isArray(m[key])) ? m[key] : null;
231
+ if (!map) continue;
232
+ const kept = {};
233
+ for (const [name, entry] of Object.entries(map)) {
234
+ const mode = modeOf(entry);
235
+ if (!mode) continue;
236
+ kept[name] = Object.assign({}, entry, { mode });
237
+ }
238
+ if (Object.keys(kept).length) { mcp[key] = kept; any = true; }
239
+ }
240
+ if (any) out.mcp = mcp;
241
+ }
242
+ if (!any) return null;
243
+ return out;
244
+ }
245
+
246
+ // One native tool's per-chat override, or null. Config-tool names
247
+ // (`read_file` → `file`) resolve to the same family key the project
248
+ // gate uses, so the file operations stay behind one control.
249
+ function chatNativeOverride(projectDir, chatId, tool) {
250
+ const overrides = readChatAuthOverrides(projectDir, chatId);
251
+ if (!overrides) return null;
252
+ const family = configToolName(tool);
253
+ return overrides.native[family] || overrides.native[tool] || null;
254
+ }
255
+
256
+ // One MCP tool's per-chat override, or null. `servers` may be keyed by
257
+ // the server's slug OR its display id (the write path stores whatever
258
+ // the UI sent), so both are tried; the chat's own maps are tiny.
259
+ function chatMcpOverride(projectDir, chatId, tool) {
260
+ const overrides = readChatAuthOverrides(projectDir, chatId);
261
+ if (!overrides || !overrides.mcp) return null;
262
+ const mcp = overrides.mcp;
263
+ const parsed = parseMcpName(tool);
264
+ if (parsed) {
265
+ const byTool = mcp.tools && mcp.tools[tool];
266
+ if (byTool && byTool.mode) return { value: byTool, source: 'chat-tool' };
267
+ if (mcp.servers) {
268
+ const direct = mcp.servers[parsed.slug];
269
+ if (direct && direct.mode) return { value: direct, source: 'chat-server' };
270
+ for (const [key, entry] of Object.entries(mcp.servers)) {
271
+ if (!entry || !entry.mode) continue;
272
+ const keySlug = serverSlugFor(projectDir, key);
273
+ if (keySlug === parsed.slug) return { value: entry, source: 'chat-server' };
274
+ }
275
+ }
276
+ }
277
+ if (mcp.shared && mcp.shared.mode) return { value: mcp.shared, source: 'chat' };
278
+ return null;
279
+ }
280
+
281
+ // Resolve one registered server's canonical slug from whatever key the
282
+ // caller used. Falls back to the key itself when the registry is
283
+ // unavailable or the key is unknown (a stale override is harmless).
284
+ function serverSlugFor(projectDir, key) {
285
+ let registry;
286
+ try { registry = require('../mcp.js').listServers(projectDir); } catch { registry = null; }
287
+ for (const s of (registry || [])) {
288
+ if (!s) continue;
289
+ if (s.id === key && typeof s.slug === 'string') return s.slug;
290
+ if (s.slug === key) return s.slug;
291
+ }
292
+ return key;
293
+ }
294
+
295
+ // The chat-scoped authorization view the chat UI reads: what THIS chat
296
+ // ends up with (chat override → project → app → default) plus the raw
297
+ // per-chat override maps so the surfaces can label an override.
298
+ function getMcpConfig(projectDir) {
299
+ if (!projectDir || typeof projectDir !== 'string') return {};
300
+ const file = path.join(projectDir, MCP_FILE);
301
+ if (!fs.existsSync(file)) return {};
302
+ try {
303
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
304
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
305
+ } catch {
306
+ return {};
307
+ }
308
+ }
309
+
310
+ function writeMcpConfig(projectDir, config) {
311
+ const file = path.join(projectDir, MCP_FILE);
312
+ fs.mkdirSync(path.dirname(file), { recursive: true });
313
+ fs.writeFileSync(file, JSON.stringify(config || {}, null, 2) + '\n', 'utf8');
314
+ }
315
+
316
+ // Model-facing file operations share the single project.tools.file gate.
317
+ // Keep the original operation name for session grants and audit events, but
318
+ // resolve enablement and authorization mode through the canonical family.
319
+ function configToolName(tool) {
320
+ return FILE_FAMILY_TOOLS.has(tool) ? 'file' : tool;
321
+ }
322
+
323
+ // Split `mcp__<serverSlug>__<toolName>` into its parts. Returns null for
324
+ // anything that is not a well-formed MCP tool name.
325
+ function parseMcpName(tool) {
326
+ if (typeof tool !== 'string' || !tool.startsWith('mcp__')) return null;
327
+ const rest = tool.slice('mcp__'.length);
328
+ const sep = rest.indexOf('__');
329
+ if (sep <= 0 || sep === rest.length - 2) return null;
330
+ return { slug: rest.slice(0, sep), toolName: rest.slice(sep + 2) };
331
+ }
332
+
333
+ // True when an authorization entry actually carries a decision
334
+ // (anything with a mode counts, including `mode: 'ask'`).
335
+ function hasMcpOverride(entry) {
336
+ return !!(entry && typeof entry === 'object' && typeof entry.mode === 'string' && entry.mode);
337
+ }
338
+
339
+ // MCP authorization is layered, most specific first (decisions §18):
340
+ // 1. authorization.tools.<composedName> — one tool on one server
341
+ // 2. authorization.servers.<serverSlug> — every tool on that server
342
+ // 3. authorization — the project-wide MCP gate
343
+ // 4. app.mcp.authorization — app-level default
344
+ // The per-server / per-tool maps only apply to the project layer: the
345
+ // app store has no server registry, so it keeps the single shared gate.
346
+ function mcpLayeredConfig(projectDir, project, app, tool) {
347
+ const mcpConfig = getMcpConfig(projectDir);
348
+ const auth = (mcpConfig && mcpConfig.authorization) || (project && project.mcp && project.mcp.authorization) || {};
349
+ const parsed = parseMcpName(tool);
350
+ if (parsed) {
351
+ const toolValue = auth.tools && auth.tools[tool];
352
+ if (hasMcpOverride(toolValue)) return { value: toolValue, source: 'project-tool' };
353
+ const serversBySlug = auth.servers ? mcpServersBySlug(projectDir, auth.servers) : undefined;
354
+ const serverValue = serversBySlug && serversBySlug[parsed.slug];
355
+ if (hasMcpOverride(serverValue)) return { value: serverValue, source: 'project-server' };
356
+ }
357
+ if (hasMcpOverride(auth)) return { value: auth, source: 'project' };
358
+ const appValue = app && app.mcp && app.mcp.authorization;
359
+ if (appValue && typeof appValue === 'object') return { value: appValue, source: 'app' };
360
+ return { value: {}, source: 'default' };
361
+ }
362
+
363
+ // The persisted per-server / per-tool maps for a project's MCP
364
+ // authorization. Used by getAuthorization so the REST surface (and the
365
+ // Settings UI) can render every override, including ones whose tool is
366
+ // not currently advertised.
367
+ function mcpOverrideMaps(projectDir) {
368
+ const mcpConfig = getMcpConfig(projectDir);
369
+ const auth = (mcpConfig && mcpConfig.authorization) || {};
370
+ const servers = (auth.servers && typeof auth.servers === 'object' && !Array.isArray(auth.servers)) ? auth.servers : {};
371
+ const tools = (auth.tools && typeof auth.tools === 'object' && !Array.isArray(auth.tools)) ? auth.tools : {};
372
+ return { auth, servers, tools };
373
+ }
374
+
375
+ // Build an id -> slug map for every MCP server registered for the project.
376
+ // Used both by the read-path re-key (mcpServersBySlug) and the write-path
377
+ // cleanup in setAuthorization. Returns an empty map when the registry is
378
+ // unavailable (MCP module failure, bad dir), in which case callers no-op.
379
+ function mcpIdToSlug(projectDir) {
380
+ let registry;
381
+ try { registry = require('../mcp.js').listServers(projectDir); } catch { registry = null; }
382
+ const idToSlug = new Map();
383
+ for (const s of (registry || [])) {
384
+ if (s && typeof s.id === 'string' && s.slug && typeof s.slug === 'string') idToSlug.set(s.id, s.slug);
385
+ }
386
+ return idToSlug;
387
+ }
388
+
389
+ // MCP override maps are layered and looked up by the server's canonical
390
+ // *slug*, but hand-edited .mcp.json files (and legacy configs) may key a
391
+ // server override by its display *id* instead. A server whose slug and id
392
+ // diverge (e.g. id "chrome-debug", slugified to "chrome_debug") would then
393
+ // silently fail both the settings checkbox read and the authorize-time gate.
394
+ // Re-key the override map onto slugs so the stored key no longer matters:
395
+ // an id-keyed override resolves to the same server the slug paths read.
396
+ // Servers not present in the registry pass through unchanged (a stale key
397
+ // for a deleted server is harmless and left alone).
398
+ function mcpServersBySlug(projectDir, servers) {
399
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return servers;
400
+ const idToSlug = mcpIdToSlug(projectDir);
401
+ if (!idToSlug.size) return servers;
402
+ const out = {};
403
+ let changed = false;
404
+ for (const [key, entry] of Object.entries(servers)) {
405
+ const target = idToSlug.has(key) ? idToSlug.get(key) : key;
406
+ if (target !== key) changed = true;
407
+ // If two keys collapse onto the same slug (id key plus an existing
408
+ // slug key), the explicit slug wins — it is the canonical form.
409
+ if (target !== key && Object.prototype.hasOwnProperty.call(out, target)) continue;
410
+ out[target] = entry;
411
+ }
412
+ return changed ? out : servers;
413
+ }
414
+
415
+ function effectiveConfig(projectDir, tool, chatId) {
416
+ const requestedTool = tool;
417
+ tool = configToolName(tool);
418
+ // Chat override wins over everything below it — the user made that
419
+ // choice in this chat's own Tools card, and this is the gate that
420
+ // decides whether the tool is offered to the model at all.
421
+ const chatOverride = chatNativeOverride(projectDir, chatId, requestedTool);
422
+ if (chatOverride) return normalizeConfig(chatOverride, 'chat', true, tool);
423
+ const resolved = settings.getResolved(projectDir);
424
+ const project = settings.getProject(projectDir);
425
+ const app = settings.getApp();
426
+ if (NATIVE_TOOLS.has(tool)) {
427
+ const projectToolValue = FILE_FAMILY_TOOLS.has(requestedTool) && project && project.tools && project.tools[requestedTool];
428
+ const appToolValue = FILE_FAMILY_TOOLS.has(requestedTool) && app && app.tools && app.tools[requestedTool];
429
+ const projectFamilyValue = project && project.tools && project.tools[tool];
430
+ const appFamilyValue = app && app.tools && app.tools[tool];
431
+ let value;
432
+ let source;
433
+ if (FILE_FAMILY_TOOLS.has(requestedTool)) {
434
+ // File-tool checkboxes (the five operations) can persist per-leaf
435
+ // overrides, but a
436
+ // family-level Allow must mean Allow for every non-disabled leaf. A
437
+ // stale per-leaf `ask` / `allowlist` entry must not mask
438
+ // `tools.file.mode = allow`, or the prompt says File tools are allowed
439
+ // while read_file still asks. Only an explicit per-leaf `off` tightens
440
+ // family Allow.
441
+ if (projectToolValue && projectToolValue.mode === 'off') { value = projectToolValue; source = 'project-tool'; }
442
+ else if (projectFamilyValue && projectFamilyValue.mode === 'allow') { value = projectFamilyValue; source = 'project'; }
443
+ else if (projectToolValue) { value = projectToolValue; source = 'project-tool'; }
444
+ else if (projectFamilyValue) { value = projectFamilyValue; source = 'project'; }
445
+ else if (appToolValue && appToolValue.mode === 'off') { value = appToolValue; source = 'app-tool'; }
446
+ else if (appFamilyValue && appFamilyValue.mode === 'allow') { value = appFamilyValue; source = 'app'; }
447
+ else if (appToolValue) { value = appToolValue; source = 'app-tool'; }
448
+ else if (appFamilyValue) { value = appFamilyValue; source = 'app'; }
449
+ else { value = {}; source = 'default'; }
450
+ } else {
451
+ value = projectFamilyValue || appFamilyValue || {};
452
+ source = projectFamilyValue ? 'project' : (appFamilyValue ? 'app' : 'default');
453
+ }
454
+ // Built-in tools are part of the base agent surface and are always
455
+ // discoverable. Authorization mode is the gate: `ask` prompts on first
456
+ // use, `allow` runs directly, and `off` explicitly disables execution.
457
+ // Keep accepting legacy `enabled` fields in project files, but do not let
458
+ // a missing/false flag make a base tool disappear from the model.
459
+ //
460
+ // The *requested* name decides the default, not the family name: a leaf
461
+ // listed in DEFAULT_OFF_TOOLS resolves to `off` while its siblings
462
+ // default to `ask`. Storing any mode — family or leaf — overrides that.
463
+ return normalizeConfig(value, source, true, requestedTool);
464
+ }
465
+ if (tool.startsWith('mcp__')) {
466
+ const chatOverride = chatMcpOverride(projectDir, chatId, tool);
467
+ if (chatOverride) return normalizeConfig(chatOverride.value, chatOverride.source, true, tool);
468
+ const { value, source } = mcpLayeredConfig(projectDir, project, app, tool);
469
+ return normalizeConfig(value, source, true, tool);
470
+ }
471
+ return normalizeConfig({ mode: 'off' }, 'default', false, tool);
472
+ }
473
+
474
+ function getAuthorization(projectDir, chatId) {
475
+ // mcp.servers / mcp.tools mirror the persisted override maps (not the
476
+ // layered result) so the Settings UI can render every configured
477
+ // override, including ones whose tool or server is currently stopped.
478
+ // Override keys are re-keyed onto server slugs so a hand-edited id-keyed
479
+ // override still lines up with the UI's `servers.<slug>` lookups.
480
+ const { servers, tools: toolOverrides } = mcpOverrideMaps(projectDir);
481
+ const mcp = effectiveConfig(projectDir, 'mcp__any__tool', chatId);
482
+ mcp.servers = chatId
483
+ ? Object.assign({}, mcpServersBySlug(projectDir, servers), (readChatAuthOverrides(projectDir, chatId) || {}).mcp
484
+ ? (readChatAuthOverrides(projectDir, chatId).mcp.servers || {})
485
+ : {})
486
+ : mcpServersBySlug(projectDir, servers);
487
+ mcp.tools = toolOverrides;
488
+ const chatOverrides = chatId ? readChatAuthOverrides(projectDir, chatId) : null;
489
+ const view = {
490
+ tools: {
491
+ shell: effectiveConfig(projectDir, 'shell', chatId),
492
+ subagent: effectiveConfig(projectDir, 'subagent', chatId),
493
+ file: effectiveConfig(projectDir, 'file', chatId),
494
+ ...Object.fromEntries(Array.from(FILE_TOOL_NAMES, (name) => [name, effectiveConfig(projectDir, name, chatId)])),
495
+ ask_user: effectiveConfig(projectDir, 'ask_user', chatId),
496
+ report_progress: effectiveConfig(projectDir, 'report_progress', chatId),
497
+ task: effectiveConfig(projectDir, 'task', chatId),
498
+ webpreview: effectiveConfig(projectDir, 'webpreview', chatId),
499
+ restart_app: effectiveConfig(projectDir, 'restart_app', chatId)
500
+ },
501
+ mcp
502
+ };
503
+ // Chat-scoped reads advertise what this chat has pinned; a project-scoped
504
+ // read never invents a `chat` block, so project settings cannot mistake a
505
+ // per-chat override for a project value.
506
+ if (chatId) view.chat = chatOverrides || null;
507
+ return view;
508
+ }
509
+
510
+ // App-level MCP authorization gate. This is layer 4 of `mcpLayeredConfig`
511
+ // (app.mcp.authorization) — the shared fallback for every project that has
512
+ // not set its own MCP gate. The app store has no server registry, so it
513
+ // carries only the single shared gate (mode + allowlist); per-server and
514
+ // per-tool overrides remain project-scoped by design (decisions §18).
515
+ function getAppMcpAuthorization() {
516
+ const app = settings.getApp();
517
+ const value = (app && app.mcp && app.mcp.authorization) || {};
518
+ const cfg = normalizeConfig(value, 'app', true);
519
+ // Match the project MCP shape so the UI can share one renderer, but the
520
+ // app layer intentionally exposes no server/tool maps.
521
+ return { mcp: { mode: cfg.mode, allowlist: cfg.allowlist, servers: {}, tools: {} } };
522
+ }
523
+
524
+ // Write the app-level MCP shared gate. Accepts { mode?, allowlist? }; the
525
+ // off / allow / ask modes persist only { mode }, allowlist persists the
526
+ // pattern list. Returns the re-read app authorization view.
527
+ function setAppMcpAuthorization(patch) {
528
+ if (!patch || typeof patch !== 'object') throw typedError('EBADINPUT', 'authorization patch is required');
529
+ const p = patch.mcp || patch;
530
+ if (typeof p.mode !== 'string' || !p.mode) throw typedError('EBADINPUT', 'app mcp authorization must set mode');
531
+ const app = settings.getApp();
532
+ const auth = (app && app.mcp && app.mcp.authorization && typeof app.mcp.authorization === 'object')
533
+ ? Object.assign({}, app.mcp.authorization)
534
+ : {};
535
+ const cfg = normalizeConfig(p, 'app', true);
536
+ const shaped = mcpPersistShape(cfg);
537
+ auth.mode = shaped.mode;
538
+ if (Object.prototype.hasOwnProperty.call(shaped, 'allowlist')) auth.allowlist = shaped.allowlist;
539
+ else delete auth.allowlist;
540
+ const mcp = Object.assign({}, app && app.mcp, { authorization: auth });
541
+ settings.setApp({ mcp });
542
+ return getAppMcpAuthorization();
543
+ }
544
+
545
+ // Shape a normalized MCP auth entry for persistence. `off` / `allow` /
546
+ // `ask` write only { mode }; `allowlist` also writes the pattern list.
547
+ // Everything else (timeouts) is dropped — the shared gate's timeouts
548
+ // still apply to every MCP call.
549
+ function mcpPersistShape(cfg) {
550
+ const out = { mode: cfg.mode };
551
+ if (cfg.mode === 'allowlist') out.allowlist = cfg.allowlist;
552
+ return out;
553
+ }
554
+
555
+ // Write the per-chat authorization overrides (decisions §17). This is the
556
+ // ONLY writer for the chat's Tools card / tool popup controls, and it
557
+ // deliberately never touches `.mouaif.json` or `.mcp.json`: changing a
558
+ // project-wide gate is a job for project settings.
559
+ //
560
+ // Patch shape — every key optional:
561
+ // { native: { shell: { mode, allowlist } | null, … },
562
+ // mcp: { shared: { mode } | null,
563
+ // servers: { <slug|id>: { mode } | null },
564
+ // tools: { <composedName>: { mode } | null } } }
565
+ // A `null` entry clears that one override; `null`/omitted sections are
566
+ // left untouched. The whole map is replaced with `null` when the last
567
+ // override is cleared, so "no overrides" keeps a single representation.
568
+ function setChatAuthorization(projectDir, chatId, patch) {
569
+ if (!projectDir || typeof projectDir !== 'string') throw typedError('EBADINPUT', 'projectDir is required');
570
+ if (!chatId || typeof chatId !== 'string') throw typedError('EBADINPUT', 'chatId is required');
571
+ const chats = require('../chats.js');
572
+ const existing = chats.getChat(projectDir, chatId);
573
+ if (!existing) throw typedError('ENOTFOUND', 'chat not found');
574
+ const current = readChatAuthOverrides(projectDir, chatId) || { native: {}, mcp: null };
575
+ const next = {
576
+ native: Object.assign({}, current.native),
577
+ mcp: current.mcp ? JSON.parse(JSON.stringify(current.mcp)) : null
578
+ };
579
+ const p = (patch && typeof patch === 'object' && !Array.isArray(patch)) ? patch : {};
580
+ // Native tools: `patch.native` when present, otherwise treat the patch
581
+ // itself as the native map (same tolerance as the read path).
582
+ const nativePatch = (p.native && typeof p.native === 'object' && !Array.isArray(p.native)) ? p.native : p;
583
+ for (const [name, entry] of Object.entries(nativePatch)) {
584
+ if (name === 'mcp' || name === 'native') continue;
585
+ const family = configToolName(name);
586
+ if (!NATIVE_TOOLS.has(family)) continue;
587
+ if (entry === null) { delete next.native[family]; continue; }
588
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
589
+ const mode = MODES.has(entry.mode) ? entry.mode : 'ask';
590
+ const shaped = { mode };
591
+ if (Array.isArray(entry.allowlist)) shaped.allowlist = entry.allowlist.map((x) => String(x)).filter(Boolean);
592
+ next.native[family] = shaped;
593
+ }
594
+ if (p.mcp && typeof p.mcp === 'object' && !Array.isArray(p.mcp)) {
595
+ const m = next.mcp || {};
596
+ if (Object.prototype.hasOwnProperty.call(p.mcp, 'shared')) {
597
+ const entry = p.mcp.shared;
598
+ if (entry === null) delete m.shared;
599
+ else if (entry && typeof entry === 'object' && MODES.has(entry.mode)) {
600
+ m.shared = { mode: entry.mode };
601
+ if (Array.isArray(entry.allowlist)) m.shared.allowlist = entry.allowlist.map((x) => String(x)).filter(Boolean);
602
+ }
603
+ }
604
+ for (const key of ['servers', 'tools']) {
605
+ const incoming = p.mcp[key];
606
+ if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) continue;
607
+ const map = m[key] || {};
608
+ for (const [name, entry] of Object.entries(incoming)) {
609
+ if (typeof name !== 'string' || !name) continue;
610
+ if (entry === null) { delete map[name]; continue; }
611
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
612
+ if (!MODES.has(entry.mode)) continue;
613
+ map[name] = { mode: entry.mode };
614
+ }
615
+ if (Object.keys(map).length) m[key] = map;
616
+ else delete m[key];
617
+ }
618
+ next.mcp = Object.keys(m).length ? m : null;
619
+ }
620
+ const empty = !Object.keys(next.native).length && !next.mcp;
621
+ chats.updateChat(projectDir, chatId, { toolAuth: empty ? null : next });
622
+ return getAuthorization(projectDir, chatId);
623
+ }
624
+
625
+ function setAuthorization(projectDir, patch) {
626
+ if (!projectDir || typeof projectDir !== 'string') throw typedError('EBADINPUT', 'projectDir is required');
627
+ if (!patch || typeof patch !== 'object') throw typedError('EBADINPUT', 'authorization patch is required');
628
+ const project = settings.getProject(projectDir);
629
+ const next = {};
630
+ // Each native tool (shell, file, ...) gets its own block under
631
+ // project.tools.<name>. The shape is the same: { mode, allowlist,
632
+ // defaultTimeoutMs, maxTimeoutMs }. The caller's `enabled` flag is
633
+ // owned by the project tools toggle (a different setting) and is
634
+ // not duplicated here.
635
+ for (const name of [...NATIVE_TOOLS, ...FILE_FAMILY_TOOLS]) {
636
+ if (patch.tools && patch.tools[name]) {
637
+ const cfg = normalizeConfig(patch.tools[name], 'project', true);
638
+ // Preserve entries already applied from this patch. A request can
639
+ // update the file family and every nested file tool atomically.
640
+ next.tools = Object.assign({}, project.tools, next.tools);
641
+ next.tools[name] = Object.assign({}, project.tools && project.tools[name], {
642
+ mode: cfg.mode,
643
+ allowlist: cfg.allowlist,
644
+ defaultTimeoutMs: cfg.defaultTimeoutMs,
645
+ maxTimeoutMs: cfg.maxTimeoutMs
646
+ });
647
+ }
648
+ }
649
+ if (patch.mcp) {
650
+ const p = patch.mcp;
651
+ const hasShape = (typeof p.mode === 'string' && p.mode)
652
+ || (p.servers && typeof p.servers === 'object' && !Array.isArray(p.servers))
653
+ || (p.tools && typeof p.tools === 'object' && !Array.isArray(p.tools));
654
+ if (!hasShape) throw typedError('EBADINPUT', 'mcp authorization must set mode, servers, or tools');
655
+ const mcpConfig = getMcpConfig(projectDir);
656
+ const auth = (mcpConfig && mcpConfig.authorization && typeof mcpConfig.authorization === 'object')
657
+ ? Object.assign({}, mcpConfig.authorization)
658
+ : {};
659
+ if (typeof p.mode === 'string' && p.mode) {
660
+ // The shared gate. Tighten the persisted shape the same way the
661
+ // overrides are stored (off/allow/ask write only { mode }).
662
+ const mcpAuth = normalizeConfig(p, 'project', true);
663
+ const shaped = mcpPersistShape(mcpAuth);
664
+ shaped.defaultTimeoutMs = mcpAuth.defaultTimeoutMs;
665
+ shaped.maxTimeoutMs = mcpAuth.maxTimeoutMs;
666
+ auth.mode = shaped.mode;
667
+ if (Object.prototype.hasOwnProperty.call(shaped, 'allowlist')) auth.allowlist = shaped.allowlist;
668
+ else delete auth.allowlist;
669
+ auth.defaultTimeoutMs = shaped.defaultTimeoutMs;
670
+ auth.maxTimeoutMs = shaped.maxTimeoutMs;
671
+ }
672
+ for (const key of ['servers', 'tools']) {
673
+ const map = p[key];
674
+ if (!map || typeof map !== 'object' || Array.isArray(map)) continue;
675
+ if (!auth[key] || typeof auth[key] !== 'object' || Array.isArray(auth[key])) auth[key] = {};
676
+ // Server overrides are stored under the server's canonical *slug*,
677
+ // but callers (and hand-edited files) may key by the display *id*.
678
+ // Normalize the write key to the slug up front: without this, an
679
+ // id-keyed write lands under the id and the cleanup pass below then
680
+ // deletes the just-written entry (its twin, the slug key, was never
681
+ // stored) — the override silently vanishes, so the settings
682
+ // checkbox looks like it never saved.
683
+ const idToSlug = key === 'servers' ? mcpIdToSlug(projectDir) : new Map();
684
+ for (const [name, entry] of Object.entries(map)) {
685
+ if (typeof name !== 'string' || !name) continue;
686
+ const storeKey = idToSlug.has(name) ? idToSlug.get(name) : name;
687
+ if (entry == null) { delete auth[key][storeKey]; continue; }
688
+ const cfg = normalizeConfig(entry, 'project', true);
689
+ auth[key][storeKey] = mcpPersistShape(cfg);
690
+ }
691
+ // Normalize the server map on write too: a hand-edited override
692
+ // keyed by a server's display *id* (e.g. "chrome-debug") should not
693
+ // linger next to the canonical slug entry. When a server override is
694
+ // written or cleared here, drop any other key that resolves to the
695
+ // same slug, so the stored map never holds a stale twin that a later
696
+ // read (and the settings checkbox) has to second-guess.
697
+ if (key === 'servers' && Object.keys(auth.servers).length && idToSlug.size) {
698
+ // Drop any stale twin keys (id-keyed leftovers) that resolve to a
699
+ // slug this patch just wrote, so the stored map never holds two
700
+ // entries for the same server.
701
+ for (const written of Object.keys(map)) {
702
+ const writtenSlug = idToSlug.has(written) ? idToSlug.get(written) : written;
703
+ for (const candidate of Object.keys(auth.servers)) {
704
+ if (candidate === writtenSlug) continue;
705
+ const candidateSlug = idToSlug.has(candidate) ? idToSlug.get(candidate) : candidate;
706
+ if (candidateSlug === writtenSlug) delete auth.servers[candidate];
707
+ }
708
+ }
709
+ }
710
+ // Drop empty maps so .mcp.json stays small and honest.
711
+ if (!Object.keys(auth[key]).length) delete auth[key];
712
+ }
713
+ writeMcpConfig(projectDir, Object.assign({}, mcpConfig, { authorization: auth }));
714
+ }
715
+ if (!Object.keys(next).length && !patch.mcp) throw typedError('EBADINPUT', 'tools.shell, tools.file, or mcp authorization is required');
716
+ if (Object.keys(next).length) settings.setProject(projectDir, next);
717
+ return getAuthorization(projectDir);
718
+ }
719
+
720
+ function regexMatch(pattern, value, timeoutMs = 1) {
721
+ return new Promise((resolve) => {
722
+ const source = String(pattern || '');
723
+ const anchored = source.startsWith('^') ? source : '^(?:' + source + ')$';
724
+ let settled = false;
725
+ let worker;
726
+ try {
727
+ worker = new Worker(
728
+ "const { parentPort, workerData } = require('node:worker_threads');" +
729
+ "parentPort.once('message', (data) => {" +
730
+ "try { parentPort.postMessage(new RegExp(data.pattern).test(data.value)); }" +
731
+ "catch { parentPort.postMessage(false); }});",
732
+ { eval: true }
733
+ );
734
+ } catch {
735
+ resolve(false);
736
+ return;
737
+ }
738
+ const finish = (result) => {
739
+ if (settled) return;
740
+ settled = true;
741
+ if (timer) clearTimeout(timer);
742
+ worker.terminate().catch(() => {});
743
+ resolve(result === true);
744
+ };
745
+ let timer = null;
746
+ worker.once('online', () => {
747
+ // The timeout guards against catastrophic backtracking inside the
748
+ // worker's RegExp — it is NOT a deadline for worker startup. A
749
+ // freshly spawned worker thread can take tens of milliseconds to
750
+ // come online on a loaded machine; starting the timeout before
751
+ // the pattern is even posted would reject legitimate patterns
752
+ // that simply lost the startup race. Start the clock only once
753
+ // the worker is actually running the regex.
754
+ timer = setTimeout(() => finish(false), Math.max(timeoutMs, 1000));
755
+ worker.postMessage({ pattern: anchored, value: String(value || '') });
756
+ });
757
+ worker.once('message', finish);
758
+ worker.once('error', () => finish(false));
759
+ worker.once('exit', () => finish(false));
760
+ });
761
+ }
762
+
763
+ async function matchesAllowlist(summary, allowlist) {
764
+ for (const pattern of allowlist || []) {
765
+ if (await regexMatch(pattern, summary, 1)) return true;
766
+ }
767
+ return false;
768
+ }
769
+
770
+ function clampTimeout(requested, config) {
771
+ const value = Number.isFinite(requested) ? Math.round(requested) : config.defaultTimeoutMs;
772
+ return Math.max(1, Math.min(config.maxTimeoutMs, value));
773
+ }
774
+
775
+ async function authorize(input) {
776
+ const projectDir = input && input.projectDir;
777
+ const chatId = input && input.chatId;
778
+ const tool = input && input.tool;
779
+ const callId = input && input.callId;
780
+ if (!projectDir || !chatId || !tool || !callId) {
781
+ throw typedError('EBADINPUT', 'projectDir, chatId, tool, and callId are required');
782
+ }
783
+ const config = effectiveConfig(projectDir, tool, chatId);
784
+ if (!config.enabled || config.mode === 'off') throw typedError('ETOOL_DISABLED', tool + ' is disabled');
785
+
786
+ const session = getSession(projectDir, chatId);
787
+ // Binary-mode tools (`ask_user`) must ALWAYS reach the prompt: the user
788
+ // is the only source of truth, so no session state may silently resolve
789
+ // the gate. A stale deny or grant would otherwise short-circuit here and
790
+ // the dispatcher would fall back to a `cancelled: true` result even
791
+ // though the user was never asked (or already answered a re-issued call).
792
+ if (!BINARY_MODE_TOOLS.has(tool)) {
793
+ if (session.deniedCallIds.has(callId)) throw typedError('EDENIED', 'user denied');
794
+ if (session.grants.has(tool)) return { decision: 'allow', timeoutMs: clampTimeout(input.timeoutMs, config) };
795
+ if (session.allowedCallIds.get(callId) === tool) {
796
+ session.allowedCallIds.delete(callId);
797
+ return { decision: 'allow', timeoutMs: clampTimeout(input.timeoutMs, config) };
798
+ }
799
+ }
800
+ if (config.mode === 'allow') return { decision: 'allow', timeoutMs: clampTimeout(input.timeoutMs, config) };
801
+ if (config.mode === 'allowlist' && await matchesAllowlist(input.summary || input.cmd || '', config.allowlist)) {
802
+ return { decision: 'allow', timeoutMs: clampTimeout(input.timeoutMs, config) };
803
+ }
804
+
805
+ const existing = session.pending.get(callId);
806
+ if (existing) return existing.publicResult;
807
+
808
+ let resolveWait;
809
+ let rejectWait;
810
+ const wait = new Promise((resolve, reject) => { resolveWait = resolve; rejectWait = reject; });
811
+ if (input.flow === 'retry') wait.catch(() => {});
812
+ const publicResult = {
813
+ decision: 'prompt',
814
+ timeoutMs: clampTimeout(input.timeoutMs, config),
815
+ wait
816
+ };
817
+ session.pending.set(callId, {
818
+ tool,
819
+ flow: input.flow === 'retry' ? 'retry' : 'wait',
820
+ resolve: resolveWait,
821
+ reject: rejectWait,
822
+ publicResult,
823
+ request: {
824
+ chatId,
825
+ callId,
826
+ tool,
827
+ cmd: input.cmd,
828
+ path: input.path,
829
+ query: input.query,
830
+ summary: input.summary,
831
+ timeoutMs: input.timeoutMs,
832
+ projectDir,
833
+ args: input.args && typeof input.args === 'object' ? input.args : undefined
834
+ }
835
+ });
836
+ return publicResult;
837
+ }
838
+
839
+ function appendAudit(projectDir, chatId, tool, callId, decision) {
840
+ try {
841
+ const project = settings.getProject(projectDir);
842
+ const chat = Array.isArray(project.chats) && project.chats.find((item) => item && item.id === chatId);
843
+ if (!chat || chat.trace !== true) return;
844
+ const stream = trace.open(projectDir, chatId);
845
+ trace.write(stream, 'system', { event: 'auth_decision', tool, callId, decision });
846
+ trace.close(stream);
847
+ } catch { /* audit failure must not execute or deny a tool */ }
848
+ }
849
+
850
+ function recordDecision(projectDir, chatId, callId, decision, payload) {
851
+ if (!DECISIONS.has(decision)) throw typedError('EBADINPUT', 'invalid authorization decision');
852
+ const session = sessions.get(sessionKey(projectDir, chatId));
853
+ const pending = session && session.pending.get(callId);
854
+ if (!pending) throw typedError('ENOTFOUND', 'authorization request not found');
855
+ session.pending.delete(callId);
856
+
857
+ // Binary-mode tools never earn session/persistent state from a decision:
858
+ // an allow is for this one question only, and a deny must not block a
859
+ // re-issued call later in the session. Otherwise a single Dismiss would
860
+ // make every subsequent ask_user auto-cancel without ever prompting.
861
+ const binary = BINARY_MODE_TOOLS.has(pending.tool);
862
+ if (decision === 'allow-session' && !binary) session.grants.add(pending.tool);
863
+ if (decision === 'allow-always' && !binary) {
864
+ const family = configToolName(pending.tool);
865
+ if (NATIVE_TOOLS.has(family)) {
866
+ const current = effectiveConfig(projectDir, family);
867
+ setAuthorization(projectDir, { tools: { [family]: {
868
+ mode: 'allow',
869
+ allowlist: current.allowlist,
870
+ defaultTimeoutMs: current.defaultTimeoutMs,
871
+ maxTimeoutMs: current.maxTimeoutMs
872
+ } } });
873
+ } else if (family.startsWith('mcp__')) {
874
+ // "Always allow" on an MCP call pins THAT tool (and nothing else)
875
+ // to mode 'allow'. Writing the shared gate to 'allow' would
876
+ // silently auto-approve every other server and tool in the
877
+ // project — the opposite of the per-MCP, per-tool granularity the
878
+ // gate now exposes.
879
+ setAuthorization(projectDir, { mcp: { tools: { [pending.tool]: { mode: 'allow' } } } });
880
+ }
881
+ session.grants.add(pending.tool);
882
+ }
883
+ if (decision === 'allow-once' && pending.flow === 'retry' && !binary) session.allowedCallIds.set(callId, pending.tool);
884
+ if (decision === 'deny' && !binary) session.deniedCallIds.add(callId);
885
+
886
+ appendAudit(projectDir, chatId, pending.tool, callId, decision);
887
+ if (decision === 'deny') pending.reject(typedError('EDENIED', 'user denied'));
888
+ else {
889
+ // `ask_user` carries a structured answer alongside the decision
890
+ // so the runner can hand the user's { choice, extra } to the
891
+ // model. For every other tool `payload` is undefined and the
892
+ // wait() resolves to the original { decision: 'allow' } shape.
893
+ if (payload && typeof payload === 'object') {
894
+ pending.resolve({ decision: 'allow', payload });
895
+ } else {
896
+ pending.resolve({ decision: 'allow' });
897
+ }
898
+ }
899
+ return { ok: true };
900
+ }
901
+
902
+ module.exports = {
903
+ MODES,
904
+ FILE_TOOL_NAMES,
905
+ // Every tool that resolves through the File tools family (`file` gate plus
906
+ // its per-leaf overrides): the five read/write operations.
907
+ FILE_FAMILY_TOOLS,
908
+ readChatAuthOverrides,
909
+ setChatAuthorization,
910
+ getAuthorization,
911
+ setAuthorization,
912
+ getAppMcpAuthorization,
913
+ setAppMcpAuthorization,
914
+ // The layered read path that every tool-advertisement gate uses to drop
915
+ // tools whose resolved mode is `off`. Both were defined but never
916
+ // exported, so each `authz.effectiveConfig(...)` call threw a TypeError
917
+ // that the surrounding `catch {}` swallowed. The visible effect was that
918
+ // a per-project MCP `off` override kept advertising its tools: the
919
+ // `.mcp.json` authorization block was read, but nothing acted on it.
920
+ effectiveConfig,
921
+ mcpLayeredConfig,
922
+ authorize,
923
+ recordDecision,
924
+ clearGrants,
925
+ cancelSession,
926
+ listPending,
927
+ regexMatch,
928
+ matchesAllowlist,
929
+ configToolName,
930
+ parseMcpName,
931
+ _sessions: sessions
932
+ };