Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <patterns>` | 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 <patterns>` | 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 <patterns>` | 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 <seconds>` | Time window for `--yolo` auto-approval; when it ends, the permission settings from before `--yolo` return and normal approval resumes |
| `--max-requests <n>` | 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 <n>` | Stop the run once reported token usage reaches this total, sub-agents included |
Expand Down
2 changes: 2 additions & 0 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1527,6 +1528,7 @@ export class AutohandAgent {
}

private async prepareSpecialists(instruction: string): Promise<string | undefined> {
if (!checkRunToolScopeByName(resolveRunToolScope(this.runtime.options), 'orchestrate_specialists').allowed) return undefined;
if (getFeatureState(this.runtime.config, 'automatic_specialists')?.enabled !== true) {
return undefined;
}
Expand Down
4 changes: 4 additions & 0 deletions src/modes/acp/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -665,6 +668,7 @@ export class AutohandAcpAdapter implements Agent {
// Context a host prepends to a prompt (`<autohand_*>`, `<*_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 } } : {}),
},
Expand Down
4 changes: 2 additions & 2 deletions src/permissions/runToolScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
}
Expand All @@ -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' };
}
Expand Down
11 changes: 10 additions & 1 deletion tests/modes/acp/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -368,6 +376,7 @@ describe("AutohandAcpAdapter", () => {
autohandWorkflowSkillHooks: 1,
autohandWorkflowExecution: 2,
hostContext: { version: 1 },
autohandRunToolScope: { version: 1, allToolsDeny: false },
peerCommunication: { version: 1 },
});
});
Expand Down
19 changes: 19 additions & 0 deletions tests/permissions/toolAdvertising.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
Loading