diff --git a/docs/config-reference.md b/docs/config-reference.md index 5ac594f3..3a1c63ce 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -3114,7 +3114,7 @@ These flags override config file settings: | `--no-idle-logout` | Keep authenticated sessions alive past the idle timeout for long-running agents | | `--yolo [pattern]` | Auto-approve tool calls matching pattern (e.g., `allow:read,write` or `deny:delete`) | | `--allowed-tools ` | Only offer and authorize these tools this run; comma-separated or repeated (e.g. `read_file,run_command(git:*)`). A run-only restriction on top of every configured policy: never saved, never widened by a local or extension allowlist | -| `--disallowed-tools ` | Never offer or authorize these tools this run. Matched on the tool the model names, before capability mapping, so `delete_path` stays blocked even when unrestricted. Applies to MCP and delegated tools too | +| `--disallowed-tools ` | Never offer or authorize these tools this run. A bare `*` denies every tool, including automatic specialist dispatch. Matched on the tool the model names, before capability mapping, so `delete_path` stays blocked even when unrestricted. Applies to MCP and delegated tools too | | `--timeout ` | Time window for `--yolo` auto-approval; when it ends, the permission settings from before `--yolo` return and normal approval resumes | | `--max-requests ` | Stop the run after this many model requests, sub-agents included; the turn fails with the limit named and exit code 1 in command mode | | `--max-tokens ` | Stop the run once reported token usage reaches this total, sub-agents included | diff --git a/src/core/agent.ts b/src/core/agent.ts index 99e875e9..04aa2712 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -3,6 +3,7 @@ * Copyright 2025 Autohand AI LLC * SPDX-License-Identifier: Apache-2.0 */ +import { checkRunToolScopeByName, resolveRunToolScope } from '../permissions/runToolScope.js'; import chalk from 'chalk'; import { areProviderBackgroundCallsEnabled } from './agent/AutohandOrchestration.js'; import { selectCodeModeTools } from './agent/CodeModeRunner.js'; @@ -1527,6 +1528,7 @@ export class AutohandAgent { } private async prepareSpecialists(instruction: string): Promise { + if (!checkRunToolScopeByName(resolveRunToolScope(this.runtime.options), 'orchestrate_specialists').allowed) return undefined; if (getFeatureState(this.runtime.config, 'automatic_specialists')?.enabled !== true) { return undefined; } diff --git a/src/modes/acp/adapter.ts b/src/modes/acp/adapter.ts index cda1e9fd..205e4e2a 100644 --- a/src/modes/acp/adapter.ts +++ b/src/modes/acp/adapter.ts @@ -47,6 +47,7 @@ import { FileActionManager } from '../../actions/filesystem.js'; import { ProviderFactory } from '../../providers/ProviderFactory.js'; import { loadConfig } from '../../config.js'; import { prepareBareModeConfig } from '../../runtime/bareMode.js'; +import { resolveRunToolScope } from '../../permissions/runToolScope.js'; import type { AgentOutputEvent, AgentRuntime, CLIOptions, LoadedConfig, LLMToolCall } from '../../types.js'; import type { McpServerConfig } from '../../mcp/types.js'; import { cleanupSessionWorktree, isSessionWorktreeEnabled, prepareSessionWorktree, type SessionWorktreeInfo } from '../../utils/sessionWorktree.js'; @@ -633,6 +634,8 @@ export class AutohandAcpAdapter implements Agent { // Load config once for the lifetime of the connection this.config = await this.ensureConfig(); + const scope = resolveRunToolScope(this.cliOptions); + const allToolsDeny = scope?.denied.some(pattern => pattern.kind === '*' && !pattern.argument) === true; return { protocolVersion: PROTOCOL_VERSION, @@ -665,6 +668,7 @@ export class AutohandAcpAdapter implements Agent { // Context a host prepends to a prompt (``, `<*_context>` blocks) is // not read as the user asking for specialists; see userAuthoredInstruction. hostContext: { version: 1 }, + autohandRunToolScope: { version: 1, allToolsDeny }, ...(this.config.sessions?.communication?.enabled && this.clientCapabilities?._meta?.autohandPeerEvents === 1 ? { peerCommunication: { version: 1 } } : {}), }, diff --git a/src/permissions/runToolScope.ts b/src/permissions/runToolScope.ts index 4c1d9bfe..efb229d7 100644 --- a/src/permissions/runToolScope.ts +++ b/src/permissions/runToolScope.ts @@ -46,7 +46,7 @@ export function checkRunToolScopeByName( const decision = checkRunToolScopeByName(child, toolName); if (!decision.allowed) return decision; } - if (scope.denied.some((pattern) => pattern.kind === toolName && !pattern.argument)) return { allowed: false, reason: 'run_scope_denied' }; + if (scope.denied.some((pattern) => (pattern.kind === toolName || pattern.kind === '*') && !pattern.argument)) return { allowed: false, reason: 'run_scope_denied' }; if (scope.allowed.length > 0 && !scope.allowed.some((pattern) => pattern.kind === toolName)) { return { allowed: false, reason: 'run_scope_not_allowed' }; } @@ -67,7 +67,7 @@ export function checkRunToolScope( const decision = checkRunToolScope(child, call); if (!decision.allowed) return decision; } - if (scope.denied.some((pattern) => matchesToolPattern(pattern, call))) return { allowed: false, reason: 'run_scope_denied' }; + if (scope.denied.some((pattern) => (pattern.kind === '*' && !pattern.argument) || matchesToolPattern(pattern, call))) return { allowed: false, reason: 'run_scope_denied' }; if (scope.allowed.length > 0 && !scope.allowed.some((pattern) => matchesToolPattern(pattern, call))) { return { allowed: false, reason: 'run_scope_not_allowed' }; } diff --git a/tests/modes/acp/adapter.test.ts b/tests/modes/acp/adapter.test.ts index 0aa59e71..7d028d1a 100644 --- a/tests/modes/acp/adapter.test.ts +++ b/tests/modes/acp/adapter.test.ts @@ -352,12 +352,20 @@ describe("AutohandAcpAdapter", () => { }); describe("initialize()", () => { + it("reports whether the process activated an all-tools denial", async () => { + const scoped = new AutohandAcpAdapter(connection, { disallowedTools: ['*'] }); + const initialized = await scoped.initialize(makeInitRequest()); + expect(initialized._meta).toMatchObject({ autohandRunToolScope: { version: 1, allToolsDeny: true } }); + const partial = new AutohandAcpAdapter(connection, { disallowedTools: ['read_file'] }); + expect((await partial.initialize(makeInitRequest()))._meta).toMatchObject({ autohandRunToolScope: { version: 1, allToolsDeny: false } }); + }); + it("tells the client that host context blocks are not read as user intent", async () => { // Clients gate context they prepend to a prompt (response modes, user profile) // on this: an agent that does not advertise it would scan that text for // specialist requests. Advertised to every client, with or without peer events. const result = await adapter.initialize(makeInitRequest()); - expect(result._meta).toMatchObject({ hostContext: { version: 1 } }); + expect(result._meta).toMatchObject({ hostContext: { version: 1 }, autohandRunToolScope: { version: 1, allToolsDeny: false } }); config.sessions = { communication: { enabled: true } }; const withPeers = await adapter.initialize(makeInitRequest({ @@ -368,6 +376,7 @@ describe("AutohandAcpAdapter", () => { autohandWorkflowSkillHooks: 1, autohandWorkflowExecution: 2, hostContext: { version: 1 }, + autohandRunToolScope: { version: 1, allToolsDeny: false }, peerCommunication: { version: 1 }, }); }); diff --git a/tests/permissions/toolAdvertising.spec.ts b/tests/permissions/toolAdvertising.spec.ts index 5f567c85..ab5aefd3 100644 --- a/tests/permissions/toolAdvertising.spec.ts +++ b/tests/permissions/toolAdvertising.spec.ts @@ -43,6 +43,25 @@ describe('filterAdvertisedTools', () => { }); describe('run tool scope', () => { + it('denies every advertised and fabricated tool with the bare all-tools pattern', () => { + const scope = resolveRunToolScope({ disallowedTools: ['*'], allowedTools: ['read_file'] }); + const manager = new PermissionManager({ mode: 'unrestricted' }); + manager.setRunToolScope(scope); + expect(manager.filterAdvertisedTools(tools)).toEqual([]); + for (const kind of ['read_file', 'run_command', 'mcp__future__tool', 'orchestrate_specialists']) { + expect(isToolAdvertisedByScope(scope, kind)).toBe(false); + expect(checkRunToolScope(scope, { kind, target: 'anything' })).toEqual({ allowed: false, reason: 'run_scope_denied' }); + } + }); + + it('keeps a parent all-tools denial when a child allows a tool', () => { + const parent = resolveRunToolScope({ disallowedTools: ['*'] }); + const child = resolveRunToolScope({ allowedTools: ['read_file'] }); + const scope = { allowed: [], denied: [], allOf: [parent!, child!] }; + expect(isToolAdvertisedByScope(scope, 'read_file')).toBe(false); + expect(checkRunToolScope(scope, { kind: 'read_file', target: 'a.ts' }).allowed).toBe(false); + }); + it('collects repeated and comma-separated values', () => { const collected = collectToolPatternOption('read_file,write_file', collectToolPatternOption('run_command(git:*)')); expect(collected).toEqual(['run_command(git:*)', 'read_file,write_file']);