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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- 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
|
+
};
|