From 87126ed89a86a6a487c803bb9694c587e4b1ed17 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 12 Nov 2026 15:40:03 +1300 Subject: [PATCH 1/2] Enforce deny-all tool scope during ACP teaching Honor wildcard denials for advertised and fabricated tools, and prevent automatic specialists from bypassing the run scope. Advertise the ACP scope capability so Noda can reject unsafe older helpers for F-0457. Co-authored-by: Autohand Evolve --- docs/config-reference.md | 2 +- src/core/agent.ts | 2 ++ src/modes/acp/adapter.ts | 1 + src/permissions/runToolScope.ts | 4 ++-- tests/modes/acp/adapter.test.ts | 3 ++- tests/permissions/toolAdvertising.spec.ts | 19 +++++++++++++++++++ 6 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/config-reference.md b/docs/config-reference.md index 5ac594f3d..3a1c63ce2 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 99e875e9b..04aa27128 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 cda1e9fdc..e411da901 100644 --- a/src/modes/acp/adapter.ts +++ b/src/modes/acp/adapter.ts @@ -665,6 +665,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: true }, ...(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 4c1d9bfe8..efb229d77 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 0aa59e71d..71adb0b15 100644 --- a/tests/modes/acp/adapter.test.ts +++ b/tests/modes/acp/adapter.test.ts @@ -357,7 +357,7 @@ describe("AutohandAcpAdapter", () => { // 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: true } }); config.sessions = { communication: { enabled: true } }; const withPeers = await adapter.initialize(makeInitRequest({ @@ -368,6 +368,7 @@ describe("AutohandAcpAdapter", () => { autohandWorkflowSkillHooks: 1, autohandWorkflowExecution: 2, hostContext: { version: 1 }, + autohandRunToolScope: { version: 1, allToolsDeny: true }, peerCommunication: { version: 1 }, }); }); diff --git a/tests/permissions/toolAdvertising.spec.ts b/tests/permissions/toolAdvertising.spec.ts index 5f567c852..ab5aefd34 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']); From 4a0cfd72b37104eaa2a361c5d3aa835c01d227b0 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 12 Nov 2026 15:51:06 +1300 Subject: [PATCH 2/2] Report the active ACP deny-all scope Let clients require actual process-level enforcement before creating a teaching session, rather than accepting capability support alone. Co-authored-by: Autohand Evolve --- src/modes/acp/adapter.ts | 5 ++++- tests/modes/acp/adapter.test.ts | 12 ++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/modes/acp/adapter.ts b/src/modes/acp/adapter.ts index e411da901..205e4e2ab 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,7 +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: true }, + autohandRunToolScope: { version: 1, allToolsDeny }, ...(this.config.sessions?.communication?.enabled && this.clientCapabilities?._meta?.autohandPeerEvents === 1 ? { peerCommunication: { version: 1 } } : {}), }, diff --git a/tests/modes/acp/adapter.test.ts b/tests/modes/acp/adapter.test.ts index 71adb0b15..7d028d1aa 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 }, autohandRunToolScope: { version: 1, allToolsDeny: true } }); + expect(result._meta).toMatchObject({ hostContext: { version: 1 }, autohandRunToolScope: { version: 1, allToolsDeny: false } }); config.sessions = { communication: { enabled: true } }; const withPeers = await adapter.initialize(makeInitRequest({ @@ -368,7 +376,7 @@ describe("AutohandAcpAdapter", () => { autohandWorkflowSkillHooks: 1, autohandWorkflowExecution: 2, hostContext: { version: 1 }, - autohandRunToolScope: { version: 1, allToolsDeny: true }, + autohandRunToolScope: { version: 1, allToolsDeny: false }, peerCommunication: { version: 1 }, }); });