Repository navigation
[Bug]: "Always allow" on web_fetch/web_search never takes effect — permission rules are stored as full URLs but matched by host #524
Description
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
- In MiniMax Code Desktop (permission mode
default), ask the agent to fetch a URL with theweb_fetchtool (e.g.https://example.com). - The permission card appears. Choose "始终允许 / Always allow" (global scope).
- Ask for a different URL (e.g.
https://example.org). - The permission card appears again — the approval from step 2 had no effect.
- Inspect
~/.minimax/permission.json: a newallowentry was appended forweb_fetch, withmatcher.kind = "command"andmatcher.patternset 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):
- Normalize at persistence time — run the engine-produced
ruleContents/ruleMatcherspatterns throughnormalizeLocalPermissionRuleContent()before writing them topermission.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). - Or let the matcher accept the full URL — compare
input.urlagainst the pattern in addition to the host. Less desirable: it would make the stored rule URL-exact again and silently keep growingpermission.json. - Or wire the scope picker — have the desktop permission card send
selectedScopeIndexand offer the engine's existingbyDomain/wholeToolcandidates, 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 callScreenshots
No response
Before submitting
- I have searched existing issues.
- I have included my version and removed sensitive information.
Activity