Tool authorization — gating what tools the model may run

Overview

Every tool call the model initiates — and every @shell command the user types in the composer — passes through an authorization gate before the runner executes. The user picks one of three primary modes per tool — Off, Ask, Allow — from a one-tap segmented control. An allowlist is an advanced refinement of Ask, not a fourth choice. The default is ask, so first use prompts for approval.

The mode has two scopes, and the surface decides which one is written:

SurfaceScopeStored in
Chat Tools card, composer tool popupthis chat onlythe chat record (app SQLite store), chat.toolAuth
Settings → Project tool treethe project<projectDir>/.mouaif.json / .mcp.json

A chat-scoped choice never writes the project file, and it never changes another chat. Project settings is the only place a project-wide default is changed.

Usage

Modes

ModeBehavior
offThe tool is hidden from the model (its declaration is dropped from the request, so it costs no prompt tokens). Any call that still arrives — a stale chat filter, a hand-crafted REST call — returns ETOOL_DISABLED.
askEvery call must be approved by the user in the UI. The chat shows a prompt with the command, working dir, and the proposed timeout; the user taps Allow or Deny.
allowlistCalls whose cmd matches an allowlist regex run without prompting. Calls that do not match fall through to ask. This is the advanced form of ask; patterns are stored in the project file. The app-level MCP page keeps an Auto-approve list textarea; project-level patterns are edited from the raw .mouaif.json / .mcp.json.
allowEvery call is auto-approved.

The mode is picked with one segmented control, rendered by a single shared component (ToolAuthSeg in frontend/src/components/settings/toolAuth.js) on every surface: the tool row in the chat Tools card, the composer Tools popup, and project settings. subagent is not special-cased — it is the same row, same control, and same save path as shell, task, and the file-family gate, so a change to the control lands everywhere at once. ask_user narrows the component to the binary Off / Ask choices.

Where a mode is stored, and what wins

A tool's effective mode resolves most-specific-first:

  1. this chat's override — the choice made in the chat's Tools card / tool popup;

  2. the project value — tools.<name>.mode in .mouaif.json, or the MCP block in .mcp.json;

  3. the app-level value — e.g. mcp.authorization in the app SQLite store;

  4. the built-in default, ask.

    The project record keeps the shape it always had — written by settings, never by a chat:

// <projectDir>/.mouaif.json   (SETTINGS → PROJECT only)
{
"tools": {
"shell": {
"mode":      "allowlist",     // 'off' | 'ask' | 'allowlist' | 'allow'
"allowlist": [
"^npm (test|run build|lint)$",
"^git (status|diff|log|show)$"
],
"defaultTimeoutMs": 30000,
"maxTimeoutMs":     600000
}
}
}

The per-chat overrides live on the chat record instead, under chat.toolAuth:

// chat record (app SQLite store) — never a project file
{
"native": {
"shell": { "mode": "allow", "allowlist": [] },   // this chat only
"file":  { "mode": "off" }                        // all five file ops
},
"mcp": {
"shared":  { "mode": "ask" },                     // every MCP call in this chat
"servers": { "filesystem": { "mode": "allow" } }, // one server, by slug
"tools":   { "mcp__git__commit": { "mode": "off" } }
}
}

Tapping a segment in a chat writes that chat's override; the surface first clears the chat's own entry for that tool, so the stored value always equals the tap. Clearing the last override drops the map entirely, so "this chat has no overrides" has exactly one representation.

MCP tools — layered, per-server and per-tool

MCP calls resolve through the layered gate, most specific first, with this chat's own overrides sitting on top of the project / app layers:

  1. this chat's mcp.tools.<composedName> — one tool on one server, this chat;

  2. this chat's mcp.servers.<serverSlug> — every tool on that server, this chat;

  3. this chat's mcp.shared — every MCP call, this chat;

  4. authorization.tools.<composedName> — one tool on one server (mcp__filesystem__write_file). (project .mcp.json)

  5. authorization.servers.<serverSlug> — every tool on that server. (project .mcp.json)

  6. authorization (mode + allowlist) — the project's default for every MCP call. (project .mcp.json)

  7. mcp.authorization (mode + allowlist) — the app-level default, the fallback for every project that has no project default. (app SQLite store)

    The first entry with a mode wins; ask counts as a decision (so a per-server ask can tighten a project allow, and a chat override can tighten every layer below it). A null value in a PUT patch deletes an entry, restoring inheritance. off at any level hides exactly the specs it covers — one tool, one server, or every mcp__* spec — at zero prompt-token cost. The app layer carries only the single shared gate (mode + allowlist); per-server / per-tool maps are project-scoped, because the app store has no server registry.

// <projectDir>/.mcp.json   (SETTINGS → PROJECT only)
{
  "authorization": {
    "mode": "ask",
    "servers": {
      "filesystem": { "mode": "allow" },
      "playwright": { "mode": "allowlist", "allowlist": ["^mcp__playwright__navigate"] }
    },
    "tools": {
      "mcp__filesystem__write_file": { "mode": "off" }
    }
  }
}

MCP allowlists match against the summary "<composedName> <firstStringArg>" (e.g. mcp__filesystem__read_file src/index.js), so a pattern can pin either the tool itself (^mcp__fs__read_file$) or the resource it touches (^mcp__fs__read_file src/.*). Choosing Always allow on an MCP prompt pins only that one tool to mode: "allow" — it never flips the shared gate.

Where the off gate is applied

The "at zero prompt-token cost" property is not automatic: off hides tools when the advertised tool list is built, in two places that must stay in step — src/ai-stream.js (the streaming request) and src/server-handlers-chats.js (the tools-list endpoint behind the chat UI). Both run the same three passes:

  1. the native-family loop, which drops every member of a family whose resolved family mode is off (file drops all five operations, shell drops shell, …);

  2. a per-leaf file loop, which resolves each read_file / list_files / search_files / write_file / edit_file spec through authorization.effectiveConfig(projectDir, name) so a single operation's off override (e.g. tools.read_file.mode = "off" while tools.file.mode = "allow") hides exactly that operation;

  3. the MCP loop, which resolves each mcp__* spec through the same effectiveConfig.

    effectiveConfig is a module export of src/tools/authorization.js, and every call site sits inside a catch {} whose documented fallback is "authorization state unreadable; keep every tool advertised". If that export is ever dropped, the calls throw TypeError, the fallback swallows it, and an off override silently stops hiding anything — the project keeps paying prompt tokens for tools it disabled, with no error anywhere. scripts/test-mcp-authorization-gate.js locks down the export, the layered resolution (per-tool over per-server), and the no-override case that must not resolve to off.

Approval flow

When the gate is ask and the model initiates a call, the chat pauses the stream and renders an Authorization required card. The card shows:

Behavior

REST surface

RequestScope
GET /api/tools/authorization?projectDir=<abs>project view — what settings renders and edits. Carries no chat block.
GET /api/tools/authorization?projectDir=<abs>&chatId=<id>chat view — every mode resolved chat-over-project, plus chat with this chat's raw override maps. 404 when the chat does not exist.
GET /api/tools/authorization?scope=appthe app-level shared MCP gate.
PUT /api/tools/authorization body { scope: 'chat', projectDir, chatId, chat }writes this chat's overrides. Needs both projectDir and chatId (400 otherwise); 404 for an unknown chat. Never touches the project file.
PUT /api/tools/authorization body { scope: 'app', mcp }writes the app-level shared MCP gate.
PUT /api/tools/authorization body { projectDir, tools, mcp }writes the project gate. Legacy shape, and the one settings sends.
POST /api/tools/authorization/decisionresolves a parked approval prompt (unchanged).

A chat patch is { native: { shell: { mode, allowlist } | null, … }, mcp: { shared | null, servers: { <slug|id>: { mode } | null }, tools: { <name>: { mode } | null } } }. A null entry clears that override; clearing the last one drops chat.toolAuth entirely.

Regression test: scripts/test-chat-tool-authorization.js — it locks down the storage split (the project file stays byte-identical), chat-over-project resolution, per-chat off advertisement, override clearing, and the REST scoping (404/400 cases included).