Skip to content

[Bug]: "Always allow" on web_fetch/web_search never takes effect — permission rules are stored as full URLs but matched by host #524

Open

Description

@Alvin424

Product or interface

Desktop app

Version

5.9.3

Platform

Windows

OS version and architecture

Windows 11 x64 (10.0.26300.9711)

Issue area

Tool call / permission / sandbox

Desktop log upload ID (optional)

No response

Steps to reproduce

  1. In MiniMax Code Desktop (permission mode default), ask the agent to fetch a URL with the web_fetch tool (e.g. https://example.com).
  2. The permission card appears. Choose "始终允许 / Always allow" (global scope).
  3. Ask for a different URL (e.g. https://example.org).
  4. The permission card appears again — the approval from step 2 had no effect.
  5. Inspect ~/.minimax/permission.json: a new allow entry was appended for web_fetch, with matcher.kind = "command" and matcher.pattern set to the full URL. One new entry is appended for every distinct URL.

Root cause — the persisted rule and the matcher compare different strings.

1. What is written. On approval, packages/local-runtime/src/api/local-permission-approval-service.ts persists record.ruleMatchers[index] verbatim:

rules: ruleContents.map((ruleContent, index) => ({
  tool_name: ...,
  ...(record.ruleMatchers?.[index]
    ? { matcher: record.ruleMatchers[index] }
    : { rule_content: ruleContent }),
})),

The matcher pattern originates from the permission engine's extractToolInputContent(), which for a tool input carrying a url returns input.url — the full URL, un-normalized.

2. What is matched. packages/local-runtime/src/permissions/rule-match.ts -> permissionInputTarget():

const direct = input.command ?? input.path ?? input.filePath ?? input.file_path ?? input.pattern;
if (typeof direct === 'string') return direct;
// URL field: extract host for matching against domain-level rules.
if (typeof input.url === 'string') {
  try {
    return new URL(input.url).host;
  } catch {
    return input.url;
  }
}

structuredMatcherMatches() then only accepts target === matcher.pattern, or a :* / * suffix variant. For a web_fetch call the target is example.com while the pattern is https://example.com, so the stored rule can never match and the ASK branch is taken on every call. web_search takes the same input.url path and has the same defect.

3. The intended normalization never runs. packages/local-runtime already contains the right helper — normalizeLocalPermissionRuleContent():

if (!FS_PERMISSION_TOOLS.has(toolName)) {
  // URL-like content: normalize to host (domain + non-default port)
  // so that allowing one URL on a domain covers all paths/queries/protocols.
  if (looksLikeUrl(trimmed)) {
    return normalizeUrlToHost(trimmed);
  }
  return trimmed;
}

But it is only reachable through buildLocalPermissionRuleContents(), which the call sites use as a fallback:

ruleContents: decision.ruleContents ?? buildLocalPermissionRuleContents(toolName, input),

Because the engine has already populated decision.ruleContents / decision.ruleMatchers with the raw full URL, the host-normalizing path is short-circuited. The engine and the local runtime thus disagree on the contract of a rule's pattern.

4. The wider scopes exist but are unreachable from the desktop card. The engine models broader persistence scopes (candidateScopes: narrow, byFirstWord, byArgvPrefix2, byDomain, wholeTool) and expects the client to select one via selectedScopeIndex in the reply payload. selectedScopeIndex is never referenced anywhere in packages/local-runtime, so the desktop card always persists the narrow (full-URL) candidate — whether the user picks "始终允许" or "全部始终允许". Neither UI path can produce a rule that matches.

5. This defeats the documented "Always allow" contract. packages/local-runtime/src/permissions/facade.ts explicitly requires that a persisted allow rule be honoured "despite the user clicking 'Always allow'", and routes that check through hasMatchingLocalAllowRule() -> selectMatchingRule() — exactly the code path that fails here.

Expected and actual behavior

Expected: after "Always allow" on a web_fetch call, later web_fetch calls should not prompt again.

Actual: every new URL prompts again. Each click only appends another rule that can never match. ~/.minimax/permission.json accumulates dead rules — 13 entries after 13 clicks in this report.

Evidence — ~/.minimax/permission.json after 13 "Always allow" clicks on web_fetch (hosts and paths redacted):

{
  "version": 2,
  "allow": [
    { "tool_name": "web_fetch",
      "matcher": { "kind": "command",
        "pattern": "https://raw.githubusercontent.com/<owner>/<repo>/<path>" } }
  ],
  "deny": [],
  "ask": []
}

13 entries, 13 distinct full URLs, none of which can match a subsequent web_fetch call.

Workaround verified locally. Replacing the dead entries with a tool-scoped rule makes the prompt disappear:

{
  "version": 2,
  "allow": [ { "tool_name": "web_fetch", "matcher": { "kind": "tool" } } ],
  "deny": [],
  "ask": []
}

matcher.kind = "tool" returns true unconditionally in structuredMatcherMatches(). A host-scoped variant also works — but the pattern must be the host, not the full URL:

{ "tool_name": "web_fetch",
  "matcher": { "kind": "command", "pattern": "raw.githubusercontent.com" } }

Suggested fix (any one of these makes the UI behave as documented):

  1. Normalize at persistence time — run the engine-produced ruleContents / ruleMatchers patterns through normalizeLocalPermissionRuleContent() before writing them to permission.json, so a URL rule stores the host. This matches the intent already stated in that helper's comment and makes one approval cover a whole domain (all paths, queries and protocols).
  2. Or let the matcher accept the full URL — compare input.url against the pattern in addition to the host. Less desirable: it would make the stored rule URL-exact again and silently keep growing permission.json.
  3. Or wire the scope picker — have the desktop permission card send selectedScopeIndex and offer the engine's existing byDomain / wholeTool candidates, so "Always allow" can persist a rule that actually covers subsequent calls.

Option 1 + Option 3 together give the best result: a narrow default that is correct (host-scoped), plus an explicit "always allow this tool" choice.

Redacted error summary

permission.json allow entry after "Always allow" on web_fetch:
  matcher.kind    = "command"
  matcher.pattern = "https://<host>/<path>"     <-- full URL

rule-match.ts permissionInputTarget() for the same call:
  target = new URL(input.url).host = "<host>"     <-- host only

structuredMatcherMatches():
  target === matcher.pattern -> false
  pattern ends with ":*"     -> false
  pattern ends with "*"      -> false
=> rule never matches => ASK on every call

Screenshots

No response

Before submitting

  • I have searched existing issues.
  • I have included my version and removed sensitive information.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdesktopMiniMax Code desktop appneeds-triageAwaiting maintainer assessment

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions