From af3c95e0f5010b1c0f7edfdbb18f88d37e43c825 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Sat, 24 Oct 2026 22:48:37 -0600 Subject: [PATCH] fix: remove false allowedTools/disallowedTools warning and correct tool permission docs (#212) --- CHANGELOG.md | 6 ++ README.md | 16 ++++ docs/ai-sdk-v4/GUIDE.md | 77 +++++++++++---- docs/ai-sdk-v5/GUIDE.md | 98 ++++++++++++++----- examples/README.md | 2 +- examples/tool-management.ts | 127 +++++++++++++++++-------- src/claude-code-language-model.test.ts | 44 +++++++++ src/types.ts | 21 +++- src/validation.test.ts | 34 +++++-- src/validation.ts | 7 -- 10 files changed, 329 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34fa952..cccdd4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **No false warning when combining `allowedTools` and `disallowedTools`; tool permission docs corrected** ([#212](https://github.com/ben-vargas/ai-sdk-provider-claude-code/issues/212)) - Validation warned "Both allowedTools and disallowedTools are specified. Only allowedTools will be used." That was never true: the provider forwards both lists unchanged and the CLI applies both, with matching deny rules taking precedence. The warning is removed (forwarding is unchanged). The README, `ClaudeCodeSettings` JSDoc, guides, and `tool-management.ts` example no longer say the lists can't be combined or that `allowedTools` is an allowlist. `allowedTools` only pre-approves tools (they run without prompting); use `tools` to restrict which built-in tools exist (`tools: []` disables them all), and control MCP tools separately. + ## [3.7.1] - 2026-10-25 ### Changed diff --git a/README.md b/README.md index 7dc12b4..7a054f7 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,22 @@ const model = claudeCode('sonnet', { The `OnUserDialog`, `UserDialogRequest`, and `UserDialogResult` types are re-exported. Note that `UserDialogResult.result` is typed `unknown` — the CLI validates it against the dialog kind's own result schema at runtime, and a result that doesn't match (e.g. the wrong shape or an unknown string) is **silently** replaced by the dialog's default (for `'refusal_fallback_prompt'`, `'cancelled'`), so double-check the result values for each kind you handle. +### Tool availability and pre-approval (`tools`, `allowedTools`, `disallowedTools`) + +These three settings do different jobs, and you can combine them: + +- `tools` sets which built-in tools exist (`['Read', 'Grep']`; `[]` disables all built-in tools). MCP tools are controlled separately, through `mcpServers` and permission rules. +- `allowedTools` only **pre-approves** tools, so they run without a permission prompt. It is not an allowlist: unlisted tools stay available and go through `permissionMode`, `canUseTool`, and hooks as usual. +- `disallowedTools` denies tools (or matching calls, such as `'Bash(rm:*)'`). Deny rules take precedence, so a tool listed in both lists is denied. + +```ts +const model = claudeCode('sonnet', { + tools: ['Read', 'Grep', 'Bash'], // only these built-in tools exist + allowedTools: ['Read', 'Grep', 'Bash(git log:*)'], // run without prompting + disallowedTools: ['Bash(rm:*)'], // calls matching this rule are always denied +}); +``` + ### Permission decisions (`canUseTool` extras) SDK 0.3.x enriched the `canUseTool` callback (no provider change needed — these arrive on the existing `options` argument): diff --git a/docs/ai-sdk-v4/GUIDE.md b/docs/ai-sdk-v4/GUIDE.md index 4a020f8..cef0ee0 100644 --- a/docs/ai-sdk-v4/GUIDE.md +++ b/docs/ai-sdk-v4/GUIDE.md @@ -167,8 +167,8 @@ const { text } = await generateText({ | `maxTurns` | `number` | `undefined` | Maximum conversation turns | | `maxThinkingTokens` | `number` | `undefined` | Maximum thinking tokens | | `permissionMode` | `string` | `'default'` | Permission mode for tools | -| `allowedTools` | `string[]` | `undefined` | Tools to explicitly allow | -| `disallowedTools` | `string[]` | `undefined` | Tools to restrict | +| `allowedTools` | `string[]` | `undefined` | Tools to pre-approve | +| `disallowedTools` | `string[]` | `undefined` | Tools to deny (deny wins) | | `mcpServers` | `object` | `undefined` | MCP server configuration | | `resume` | `string` | `undefined` | Resume an existing session | @@ -232,45 +232,77 @@ Logger options: ### Tool Management -Control which tools Claude Code can use with either `allowedTools` (allowlist) or `disallowedTools` (denylist). These flags work for **both built-in Claude tools and MCP tools**, providing session-only permission overrides. +Control tool permissions with `allowedTools` and `disallowedTools`. The two lists can be combined. These flags work for **both built-in Claude tools and MCP tools**, providing session-only permission rules. + +- `allowedTools` **pre-approves** tools: matching calls run without a permission prompt. It is not an allowlist; unlisted tools stay available and follow normal permissions. +- `disallowedTools` **denies** tools. Deny rules take precedence, so a tool in both lists is denied. + +To restrict which built-in tools exist, current provider versions offer a separate `tools` setting (see the [main README](../../README.md)); MCP tools are controlled separately. In these legacy versions, use `disallowedTools` to remove tools. #### Tool Types - **Built-in tools**: `Bash`, `Edit`, `Read`, `Write`, `LS`, `Grep`, etc. - **MCP tools**: `mcp__serverName__toolName` format -#### Using allowedTools (Allowlist) +#### Pre-approving tools with allowedTools ```typescript import { createClaudeCode } from 'ai-sdk-provider-claude-code'; -// Only allow specific built-in tools +// Pre-approve read-only tools (other tools still follow normal permissions) const readOnlyClaude = createClaudeCode({ - allowedTools: ['Read', 'LS', 'Grep'], + defaultSettings: { + allowedTools: ['Read', 'LS', 'Grep'], + }, }); -// Allow specific Bash commands using specifiers +// Pre-approve specific Bash commands using specifiers const gitOnlyClaude = createClaudeCode({ - allowedTools: ['Bash(git log:*)', 'Bash(git diff:*)', 'Bash(git status)'], + defaultSettings: { + allowedTools: ['Bash(git log:*)', 'Bash(git diff:*)', 'Bash(git status)'], + }, }); // Mix built-in and MCP tools const mixedClaude = createClaudeCode({ - allowedTools: ['Read', 'Bash(npm test:*)', 'mcp__filesystem__read_text_file', 'mcp__git__status'], + defaultSettings: { + allowedTools: [ + 'Read', + 'Bash(npm test:*)', + 'mcp__filesystem__read_text_file', + 'mcp__git__status', + ], + }, }); ``` -#### Using disallowedTools (Denylist) +#### Denying tools with disallowedTools ```typescript // Block dangerous operations const safeClaude = createClaudeCode({ - disallowedTools: ['Write', 'Edit', 'Delete', 'Bash(rm:*)', 'Bash(sudo:*)'], + defaultSettings: { + disallowedTools: ['Write', 'Edit', 'Delete', 'Bash(rm:*)', 'Bash(sudo:*)'], + }, }); // Block all Bash and MCP write operations const restrictedClaude = createClaudeCode({ - disallowedTools: ['Bash', 'mcp__filesystem__write_file', 'mcp__git__commit', 'mcp__git__push'], + defaultSettings: { + disallowedTools: ['Bash', 'mcp__filesystem__write_file', 'mcp__git__commit', 'mcp__git__push'], + }, +}); +``` + +#### Combining allowedTools and disallowedTools + +```typescript +// Pre-approve Read and Bash, except Bash calls that match the deny rule +const combinedClaude = createClaudeCode({ + defaultSettings: { + allowedTools: ['Read', 'Bash'], + disallowedTools: ['Bash(rm:*)'], // denies calls matching this rule + }, }); ``` @@ -278,13 +310,15 @@ const restrictedClaude = createClaudeCode({ ```typescript const baseClaude = createClaudeCode({ - disallowedTools: ['Write', 'Edit'], + defaultSettings: { + disallowedTools: ['Write', 'Edit'], + }, }); // Override for a specific call const { text } = await generateText({ model: baseClaude('opus', { - disallowedTools: [], // Allow everything for this call + disallowedTools: [], // Clear the provider-level deny list for this call }), prompt: 'Create a simple config file...', }); @@ -292,11 +326,11 @@ const { text } = await generateText({ **Key Points**: -- These are **session-only permission overrides** (same syntax as settings.json) -- Higher priority than settings files +- These are **session-only permission rules** (same syntax as settings.json), added to the rules from your settings files +- Deny rules take precedence over allow rules, so `allowedTools` cannot override a matching deny rule - Works for both built-in tools AND MCP tools -- Cannot use both `allowedTools` and `disallowedTools` together -- Empty `allowedTools: []` = Explicit empty allowlist (no tools allowed) +- `allowedTools` and `disallowedTools` can be used together +- Empty `allowedTools: []` = no tools pre-approved (not "no tools allowed") - Omitting the flags entirely = Falls back to normal permission system - Use `/permissions` in Claude to see all available tool names @@ -311,9 +345,10 @@ const { text } = await generateText({ | Configuration | CLI Flag Behavior | Result | |--------------|-------------------|---------| | No config | No `--allowedTools` or `--disallowedTools` | Falls back to settings.json and interactive prompts | -| `allowedTools: []` | `--allowedTools` (empty) | Explicit empty allowlist - blocks all tools | -| `allowedTools: ['Read']` | `--allowedTools Read` | Only allows Read tool | -| `disallowedTools: []` | `--disallowedTools` (empty) | No effect - normal permissions apply | +| `allowedTools: []` | No flag (empty lists are not passed) | No tools pre-approved - normal permissions apply | +| `allowedTools: ['Read']` | `--allowedTools Read` | Read runs without prompting; other tools follow normal permissions | +| `allowedTools: ['Bash', 'Read']` + `disallowedTools: ['Read']` | `--allowedTools Bash,Read --disallowedTools Read` | Read is denied (deny wins); Bash runs without prompting | +| `disallowedTools: []` | No flag (empty lists are not passed) | No effect - normal permissions apply | | `disallowedTools: ['Write']` | `--disallowedTools Write` | Blocks Write tool, others follow normal permissions | ## Advanced Configuration diff --git a/docs/ai-sdk-v5/GUIDE.md b/docs/ai-sdk-v5/GUIDE.md index fadccc2..746a050 100644 --- a/docs/ai-sdk-v5/GUIDE.md +++ b/docs/ai-sdk-v5/GUIDE.md @@ -260,8 +260,8 @@ If you abort with a reason (`controller.abort(new Error('...'))`), `generateText | `maxTurns` | `number` | `undefined` | Maximum conversation turns | | `maxThinkingTokens` | `number` | `undefined` | Maximum thinking tokens | | `permissionMode` | `string` | `'default'` | Permission mode for tools | -| `allowedTools` | `string[]` | `undefined` | Tools to explicitly allow | -| `disallowedTools` | `string[]` | `undefined` | Tools to restrict | +| `allowedTools` | `string[]` | `undefined` | Tools to pre-approve (run without prompting); not an allowlist | +| `disallowedTools` | `string[]` | `undefined` | Tools to deny; takes precedence over `allowedTools` | | `mcpServers` | `object` | `undefined` | MCP server configuration | | `env` | `Record` | `undefined` | Environment variables passed to CLI | | `resume` | `string` | `undefined` | Resume an existing session | @@ -458,59 +458,109 @@ const model = claudeCode('sonnet', { ### Tool Management -Control which tools Claude Code can use with either `allowedTools` (allowlist) or `disallowedTools` (denylist). These flags work for **both built-in Claude tools and MCP tools**, providing session-only permission overrides. +Three settings control tools, and they can be combined: + +- `tools` sets which **built-in** tools exist (`[]` disables all built-in tools). MCP tools are controlled separately, through `mcpServers` and permission rules. +- `allowedTools` **pre-approves** tools: matching calls run without a permission prompt. It is not an allowlist. Unlisted tools stay available and go through `permissionMode`, `canUseTool`, and hooks as usual. +- `disallowedTools` **denies** tools. A bare tool name removes the tool; a rule with a specifier, such as `Bash(rm:*)`, denies only matching calls. Deny rules take precedence over allow rules. + +`allowedTools` and `disallowedTools` work for **both built-in Claude tools and MCP tools**, providing session-only permission rules. #### Tool Types - **Built-in tools**: `Bash`, `Edit`, `Read`, `Write`, `LS`, `Grep`, etc. - **MCP tools**: `mcp__serverName__toolName` format -#### Using allowedTools (Allowlist) +#### Restricting built-in tools with tools ```typescript import { createClaudeCode } from 'ai-sdk-provider-claude-code'; -// Only allow specific built-in tools +// Only these built-in tools exist in the session const readOnlyClaude = createClaudeCode({ - allowedTools: ['Read', 'LS', 'Grep'], + defaultSettings: { + tools: ['Read', 'LS', 'Grep'], + }, +}); + +// No built-in tools at all (MCP tools are unaffected) +const noBuiltinsClaude = createClaudeCode({ + defaultSettings: { + tools: [], + }, }); +``` + +#### Pre-approving tools with allowedTools -// Allow specific Bash commands using specifiers -const gitOnlyClaude = createClaudeCode({ - allowedTools: ['Bash(git log:*)', 'Bash(git diff:*)', 'Bash(git status)'], +```typescript +// Run these without prompting; other tools still follow normal permissions +const gitClaude = createClaudeCode({ + defaultSettings: { + allowedTools: ['Bash(git log:*)', 'Bash(git diff:*)', 'Bash(git status)'], + }, }); // Mix built-in and MCP tools const mixedClaude = createClaudeCode({ - allowedTools: ['Read', 'Bash(npm test:*)', 'mcp__filesystem__read_text_file', 'mcp__git__status'], + defaultSettings: { + allowedTools: [ + 'Read', + 'Bash(npm test:*)', + 'mcp__filesystem__read_text_file', + 'mcp__git__status', + ], + }, }); ``` -#### Using disallowedTools (Denylist) +#### Denying tools with disallowedTools ```typescript // Block dangerous operations const safeClaude = createClaudeCode({ - disallowedTools: ['Write', 'Edit', 'Delete', 'Bash(rm:*)', 'Bash(sudo:*)'], + defaultSettings: { + disallowedTools: ['Write', 'Edit', 'Delete', 'Bash(rm:*)', 'Bash(sudo:*)'], + }, }); // Block all Bash and MCP write operations const restrictedClaude = createClaudeCode({ - disallowedTools: ['Bash', 'mcp__filesystem__write_file', 'mcp__git__commit', 'mcp__git__push'], + defaultSettings: { + disallowedTools: ['Bash', 'mcp__filesystem__write_file', 'mcp__git__commit', 'mcp__git__push'], + }, }); ``` +#### Combining allowedTools and disallowedTools + +```typescript +// Pre-approve Read and Bash, except Bash calls that match the deny rule +const combinedClaude = createClaudeCode({ + defaultSettings: { + allowedTools: ['Read', 'Bash'], + disallowedTools: ['Bash(rm:*)'], // denies calls matching this rule + }, +}); +``` + +Scoped Bash rules match the command as written, so `Bash(rm:*)` does not match forms such as `/bin/rm` or `bash -c 'rm ...'`; those still fall under the broad `Bash` pre-approval. For a hard boundary, deny the whole tool (`disallowedTools: ['Bash']`) or use sandboxing. + +A tool that appears in both lists is denied: `allowedTools: ['Bash', 'Read']` with `disallowedTools: ['Read']` removes Read, while Bash stays pre-approved. + #### Model-Specific Overrides ```typescript const baseClaude = createClaudeCode({ - disallowedTools: ['Write', 'Edit'], + defaultSettings: { + disallowedTools: ['Write', 'Edit'], + }, }); // Override for a specific call const result = await generateText({ model: baseClaude('opus', { - disallowedTools: [], // Allow everything for this call + disallowedTools: [], // Clear the provider-level deny list for this call }), prompt: 'Create a simple config file...', }); @@ -518,11 +568,12 @@ const result = await generateText({ **Key Points**: -- These are **session-only permission overrides** (same syntax as settings.json) -- Higher priority than settings files +- These are **session-only permission rules** (same syntax as settings.json), added to the rules from your settings files +- Deny rules take precedence over allow rules from any source, so `allowedTools` cannot override a matching deny rule - Works for both built-in tools AND MCP tools -- Cannot use both `allowedTools` and `disallowedTools` together -- Empty `allowedTools: []` = Explicit empty allowlist (no tools allowed) +- `allowedTools` and `disallowedTools` can be used together +- `allowedTools` does not restrict which tools exist; use `tools` for built-in tools +- Empty `allowedTools: []` = no tools pre-approved (not "no tools allowed") - Omitting the flags entirely = Falls back to normal permission system - Use `/permissions` in Claude to see all available tool names @@ -537,9 +588,12 @@ const result = await generateText({ | Configuration | CLI Flag Behavior | Result | |--------------|-------------------|---------| | No config | No `--allowedTools` or `--disallowedTools` | Falls back to settings.json and interactive prompts | -| `allowedTools: []` | `--allowedTools` (empty) | Explicit empty allowlist - blocks all tools | -| `allowedTools: ['Read']` | `--allowedTools Read` | Only allows Read tool | -| `disallowedTools: []` | `--disallowedTools` (empty) | No effect - normal permissions apply | +| `allowedTools: []` | No flag (empty lists are not passed) | No tools pre-approved - normal permissions apply | +| `allowedTools: ['Read']` | `--allowedTools Read` | Read runs without prompting; other tools follow normal permissions | +| `allowedTools: ['Bash', 'Read']` + `disallowedTools: ['Read']` | `--allowedTools Bash,Read --disallowedTools Read` | Read is denied (deny wins); Bash runs without prompting | +| `tools: []` | `--tools` (empty) | No built-in tools available; MCP tools are unaffected | +| `tools: ['Read']` | `--tools Read` | Only the Read built-in tool is available | +| `disallowedTools: []` | No flag (empty lists are not passed) | No effect - normal permissions apply | | `disallowedTools: ['Write']` | `--disallowedTools Write` | Blocks Write tool, others follow normal permissions | ## Advanced Configuration diff --git a/examples/README.md b/examples/README.md index ab1a2c3..de12d8d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -167,7 +167,7 @@ npx tsx examples/custom-config.ts npx tsx examples/tool-management.ts ``` -**Key concepts**: Allow/deny lists, tool security, MCP server tools, built-in tools +**Key concepts**: `tools` to restrict built-in tools, `allowedTools` pre-approval (not an allowlist), `disallowedTools` deny rules, combining both lists (deny wins), MCP server tools ### 12. Long-Running Tasks (`long-running-tasks.ts`) diff --git a/examples/tool-management.ts b/examples/tool-management.ts index 2b55d38..ee061d6 100644 --- a/examples/tool-management.ts +++ b/examples/tool-management.ts @@ -1,15 +1,23 @@ /** * Example: Tool Management * - * This example demonstrates how to manage tool permissions for Claude Code. - * The allowedTools and disallowedTools flags work for BOTH: - * - Built-in Claude tools (Bash, Edit, Read, Write, etc.) - * - MCP tools (mcp__serverName__toolName format) + * This example demonstrates how to manage tool access for Claude Code: + * - `tools` sets which built-in tools exist (`[]` = none). MCP tools are + * controlled separately (mcpServers and permission rules). + * - `allowedTools` pre-approves tools so they run without a permission + * prompt. It does NOT restrict which tools are available. + * - `disallowedTools` denies tools. The two lists can be combined, and + * matching deny rules take precedence. * - * These are session-only permission overrides that use the same rule syntax - * as settings.json permissions. + * allowedTools and disallowedTools work for BOTH built-in Claude tools + * (Bash, Edit, Read, Write, etc.) and MCP tools (mcp__serverName__toolName). + * They are session-only permission rules that use the same rule syntax as + * settings.json permissions. */ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { streamText } from 'ai'; import { createClaudeCode } from '../dist/index.js'; // NOTE: Migrating to Claude Agent SDK: @@ -19,11 +27,23 @@ import { createClaudeCode } from '../dist/index.js'; // systemPrompt: { type: 'preset', preset: 'claude_code' } // settingSources: ['user', 'project', 'local'] +// Stream a response and record the names of the tools Claude called. +async function collect(result: ReturnType) { + let text = ''; + const toolCalls: string[] = []; + for await (const part of result.fullStream) { + if (part.type === 'text-start' && text) text += '\n'; + if (part.type === 'text-delta') text += part.text; + if (part.type === 'tool-call') toolCalls.push(part.toolName); + } + return { text: text.trim(), toolCalls }; +} + async function testToolManagement() { console.log('🔧 Testing Claude Code Tool Management\n'); - // 1. Default behavior - all tools allowed - console.log('1️⃣ Default (all tools allowed)'); + // 1. Default behavior - no tool settings + console.log('1️⃣ Default (no tool settings)'); const defaultClaude = createClaudeCode(); try { @@ -38,13 +58,13 @@ async function testToolManagement() { response1 += chunk; } console.log('Response:', response1.trim()); - console.log(' (All built-in and MCP tools would be allowed)'); + console.log(' (All default tools are available; normal permissions apply)'); } catch (error) { console.error('Error:', error); } - console.log('\n2️⃣ Built-in tools: Allow only Bash commands'); - // 2. Allow only specific Bash commands + console.log('\n2️⃣ Built-in tools: Pre-approve specific Bash commands'); + // 2. Pre-approve specific Bash commands (other tools still follow normal permissions) const bashOnlyClaude = createClaudeCode({ defaultSettings: { allowedTools: ['Bash(echo:*)', 'Bash(date)', 'Bash(pwd)'], @@ -63,7 +83,7 @@ async function testToolManagement() { response2 += chunk; } console.log('Response:', response2.trim()); - console.log(' (Only allowed specific Bash commands)'); + console.log(' (These Bash commands run without prompting; other tools are not removed)'); } catch (error) { console.error('Error:', error); } @@ -94,7 +114,7 @@ async function testToolManagement() { } console.log('\n4️⃣ Mixed: Built-in tools + MCP tools'); - // 4. Allow specific built-in tools and MCP tools + // 4. Pre-approve specific built-in tools and MCP tools const mixedClaude = createClaudeCode({ defaultSettings: { allowedTools: [ @@ -120,38 +140,65 @@ async function testToolManagement() { response4 += chunk; } console.log('Response:', response4.trim()); - console.log(' (Only allowed read operations and git status)'); + console.log(' (Listed tools run without prompting; this does not restrict other tools)'); } catch (error) { console.error('Error:', error); } - console.log('\n5️⃣ Security lockdown: No tools at all'); - // 5. Maximum security - explicit empty allowlist blocks all tools + console.log('\n5️⃣ Security lockdown: No built-in tools'); + // 5. Remove every built-in tool with `tools: []`. (`allowedTools: []` would + // only pre-approve nothing; it would not remove any tools.) const noToolsClaude = createClaudeCode({ defaultSettings: { - allowedTools: [], // Empty array = explicit empty allowlist = NO tools allowed + tools: [], // Empty array = no built-in tools; MCP tools are controlled separately }, }); try { - const result5 = streamText({ - model: noToolsClaude('opus'), - prompt: 'What programming language is this: console.log("Hello")?', - }); + const result5 = await collect( + streamText({ + model: noToolsClaude('opus'), + prompt: + 'Use the Bash tool to run `pwd`. If you have no tools, say so in one sentence instead.', + }) + ); + console.log('Response:', result5.text); + console.log('Tool calls:', result5.toolCalls.length ? result5.toolCalls.join(', ') : 'none'); + console.log(' (No built-in tools exist, so Claude cannot run Bash)'); + } catch (error) { + console.error('Error:', error); + } - // Collect text from stream - let response5 = ''; - for await (const chunk of result5.textStream) { - response5 += chunk; - } - console.log('Response:', response5.trim()); - console.log(' (No tools allowed - explicit empty allowlist blocks everything)'); + console.log('\n6️⃣ Combining allowedTools and disallowedTools'); + // 6. Both lists apply together and matching deny rules win: Read is + // pre-approved AND denied, so it is removed; Bash stays pre-approved. + const workDir = mkdtempSync(join(tmpdir(), 'tool-management-')); + writeFileSync(join(workDir, 'note.txt'), 'hello from note.txt\n'); + const combinedClaude = createClaudeCode({ + defaultSettings: { + cwd: workDir, + allowedTools: ['Bash', 'Read'], + disallowedTools: ['Read'], + }, + }); + + try { + const result6 = await collect( + streamText({ + model: combinedClaude('opus'), + prompt: + 'Show me the contents of note.txt. Use the Read tool if you have it; otherwise run `cat note.txt` with Bash.', + }) + ); + console.log('Response:', result6.text); + console.log('Tool calls:', result6.toolCalls.length ? result6.toolCalls.join(', ') : 'none'); + console.log(' (Read is denied even though it is pre-approved; Bash still runs)'); } catch (error) { console.error('Error:', error); } - console.log('\n6️⃣ Model-specific override'); - // 6. Model-specific settings override provider settings + console.log('\n7️⃣ Model-specific override'); + // 7. Model-specific settings override provider settings const baseClaude = createClaudeCode({ defaultSettings: { disallowedTools: ['Bash', 'Write'], // Provider blocks these @@ -159,21 +206,21 @@ async function testToolManagement() { }); try { - const result6 = streamText({ + const result7 = streamText({ model: baseClaude('opus', { - // Override to allow everything for this specific call + // Clear the provider-level deny list for this specific call disallowedTools: [], }), prompt: 'Name a popular web framework.', }); // Collect text from stream - let response6 = ''; - for await (const chunk of result6.textStream) { - response6 += chunk; + let response7 = ''; + for await (const chunk of result7.textStream) { + response7 += chunk; } - console.log('Response:', response6.trim()); - console.log(' (Model override allows all tools for this call)'); + console.log('Response:', response7.trim()); + console.log(' (Model override clears the deny list; normal permissions apply)'); } catch (error) { console.error('Error:', error); } @@ -183,9 +230,11 @@ async function testToolManagement() { console.log('\n📝 Key Points:'); console.log('- Flags work for BOTH built-in tools AND MCP tools'); console.log('- Same rule syntax as settings.json permissions'); - console.log('- Session-only overrides (higher priority than settings files)'); + console.log('- Session-only rules, added to settings-file rules; deny rules always win'); console.log('- Use specifiers for fine-grained control: Bash(git:*)'); - console.log('- Empty allowedTools ([]) = Explicit empty allowlist = No tools allowed'); + console.log('- allowedTools pre-approves tools; it does not restrict which tools exist'); + console.log('- tools restricts built-in tools (tools: [] = none); MCP tools are separate'); + console.log('- allowedTools and disallowedTools can be combined; deny wins'); console.log('- Omitting flags entirely = Falls back to normal permission system'); console.log('\n🛠️ Built-in tool names:'); diff --git a/src/claude-code-language-model.test.ts b/src/claude-code-language-model.test.ts index 8d8060e..6d89791 100644 --- a/src/claude-code-language-model.test.ts +++ b/src/claude-code-language-model.test.ts @@ -842,6 +842,50 @@ describe('ClaudeCodeLanguageModel', () => { expect(call?.options?.includeHookEvents).toBe(true); }); + // #212: both lists are forwarded unchanged; the CLI combines them and + // matching deny rules take precedence. Neither list may be dropped. + it.each([ + { + name: 'top-level settings', + settings: { allowedTools: ['Bash', 'Read'], disallowedTools: ['Read'] }, + }, + { + name: 'sdkOptions overrides', + settings: { + allowedTools: ['Glob'], + disallowedTools: ['Write'], + sdkOptions: { allowedTools: ['Bash', 'Read'], disallowedTools: ['Read'] }, + }, + }, + { + name: 'mixed settings and sdkOptions', + settings: { allowedTools: ['Bash', 'Read'], sdkOptions: { disallowedTools: ['Read'] } }, + }, + ])( + 'should forward allowedTools and disallowedTools unchanged ($name)', + async ({ settings }) => { + const model = new ClaudeCodeLanguageModel({ id: 'sonnet', settings: settings as any }); + vi.mocked(mockQuery).mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + session_id: 's-tool-lists', + usage: { input_tokens: 0, output_tokens: 0 }, + }; + }, + } as any); + + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + } as any); + + const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as any; + expect(call?.options?.allowedTools).toEqual(['Bash', 'Read']); + expect(call?.options?.disallowedTools).toEqual(['Read']); + } + ); + describe.each(['doGenerate', 'doStream'] as const)('%s SDK delivery options', (method) => { const cases: Array<{ name: string; diff --git a/src/types.ts b/src/types.ts index a84bf51..2a69c6c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,7 +72,11 @@ export interface Logger { * maxTurns: 10, * permissionMode: 'auto', * cwd: '/path/to/project', - * allowedTools: ['Read', 'LS'], + * // Pre-approve these tools (no permission prompt); this does not restrict + * // which tools exist. Use `tools` to limit the built-in tool set. + * allowedTools: ['Read', 'LS', 'Bash'], + * // Matching deny rules take precedence over allowedTools, so Bash stays + * // pre-approved except for calls that match `Bash(rm:*)`. * disallowedTools: ['Bash(rm:*)'] * }; * ``` @@ -213,14 +217,25 @@ export interface ClaudeCodeSettings { sessionId?: string; /** - * Tools to explicitly allow during execution + * Tools to pre-approve: matching calls run without a permission prompt. * Examples: ['Read', 'LS', 'Bash(git log:*)'] + * + * This is not an allowlist. Tools not listed here stay available and go + * through `permissionMode`, `canUseTool`, and hooks as usual. To restrict + * which built-in tools exist, use `tools`; MCP tools are controlled + * separately (via `mcpServers` and permission rules). + * + * Can be combined with `disallowedTools`; matching deny rules take precedence. */ allowedTools?: string[]; /** - * Tools to disallow during execution + * Tools to deny. Tool names remove the tool entirely; rules with a + * specifier, such as 'Bash(rm:*)', deny only matching calls. * Examples: ['Write', 'Edit', 'Bash(rm:*)'] + * + * Can be combined with `allowedTools`; matching deny rules take precedence, + * so a tool denied here cannot be used even if it is pre-approved there. */ disallowedTools?: string[]; diff --git a/src/validation.test.ts b/src/validation.test.ts index 9570949..457f15f 100644 --- a/src/validation.test.ts +++ b/src/validation.test.ts @@ -431,17 +431,31 @@ describe('validateSettings', () => { expect(result.warnings[0]).toContain('Very high maxThinkingTokens (80000)'); }); - it('should warn when both allowedTools and disallowedTools are specified', () => { - const settings = { - allowedTools: ['Read'], - disallowedTools: ['Write'], - }; - const result = validateSettings(settings); + // #212: the two lists combine (matching deny rules take precedence), so + // specifying both is valid and must not produce a warning. + it.each([ + { + name: 'top-level settings', + settings: { allowedTools: ['Read'], disallowedTools: ['Write'] }, + }, + { + name: 'sdkOptions overrides', + settings: { sdkOptions: { allowedTools: ['Read'], disallowedTools: ['Write'] } }, + }, + { + name: 'mixed settings and sdkOptions', + settings: { allowedTools: ['Bash', 'Read'], sdkOptions: { disallowedTools: ['Read'] } }, + }, + ])( + 'should accept both allowedTools and disallowedTools without warning ($name)', + ({ settings }) => { + const result = validateSettings(settings); - expect(result.valid).toBe(true); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain('Both allowedTools and disallowedTools are specified'); - }); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + } + ); it('should validate tool name formats', () => { const settings = { diff --git a/src/validation.ts b/src/validation.ts index 25b7f4e..b2bd8bc 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -513,13 +513,6 @@ export function validateSettings(settings: unknown): { ); } - // Check if both allowedTools and disallowedTools are specified - if (validSettings.allowedTools && validSettings.disallowedTools) { - warnings.push( - 'Both allowedTools and disallowedTools are specified. Only allowedTools will be used.' - ); - } - // Validate tool name format const validateToolNames = (tools: string[], type: string) => { tools.forEach((tool) => {