Skip to content

Commit c48439e

Browse files
committed
feat(core): add disabledTools workspace setting (#4175 Wave 4 PR 17)
Introduces a per-workspace skip-registration mechanism for tool names, distinct from `permissions.deny` (which keeps the tool registered and blocks invocation). Tools listed in `disabledTools` are not registered at all and never appear in `/tools`, `getAllTools()`, or function-call discovery — both built-ins and MCP-discovered tools flow through `ToolRegistry.registerTool` / `registerFactory`, so gating there covers every registration path. - `ConfigParameters.disabledTools?: string[]` (frozen into a `ReadonlySet` at Config construction; queried via `Config.getDisabledTools()`) - `ToolRegistry.registerTool` and `ToolRegistry.registerFactory` skip when the tool name is in the disabled set, with a debug log line - New `settings.tools.disabled: string[]` (UNION merge across scopes), wired from `loadCliConfig` into ConfigParameters - Tests pin the contract: skip at register, lazy factory skip, and the "next refresh" semantic (already-registered tools are unaffected by a subsequent toggle — the disabled set is consulted at register time, not at lookup time) Foundation for the `POST /workspace/tools/:name/enable` route in a follow-up commit; the bridge will write the settings file directly, and the next ACP child spawn will pick up the change. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
1 parent 489fcd7 commit c48439e

5 files changed

Lines changed: 132 additions & 0 deletions

File tree

packages/cli/src/config/config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,6 +1419,20 @@ export async function loadCliConfig(
14191419
addDisabled(name);
14201420
}
14211421

1422+
// Resolve the per-workspace tool denylist (#4175 Wave 4 PR 17). De-duplicate
1423+
// while preserving original casing; downstream lookups go through
1424+
// `Config.getDisabledTools()` which materializes a Set, so the order here
1425+
// is only meaningful for diagnostic output.
1426+
const disabledTools: string[] = [];
1427+
const seenDisabledTools = new Set<string>();
1428+
for (const raw of settings.tools?.disabled ?? []) {
1429+
if (typeof raw !== 'string') continue;
1430+
const trimmed = raw.trim();
1431+
if (!trimmed || seenDisabledTools.has(trimmed)) continue;
1432+
seenDisabledTools.add(trimmed);
1433+
disabledTools.push(trimmed);
1434+
}
1435+
14221436
// Helper: check if a tool is explicitly covered by an allow rule OR by the
14231437
// coreTools whitelist. Uses alias matching for coreTools (via isToolEnabled)
14241438
// to preserve the original behaviour where "ShellTool", "Shell", and
@@ -1636,6 +1650,7 @@ export async function loadCliConfig(
16361650
excludeTools: mergedDeny,
16371651
disabledSlashCommands:
16381652
disabledSlashCommands.length > 0 ? disabledSlashCommands : undefined,
1653+
disabledTools: disabledTools.length > 0 ? disabledTools : undefined,
16391654
// New unified permissions (PermissionManager source of truth).
16401655
permissions: {
16411656
allow: mergedAllow.length > 0 ? mergedAllow : undefined,

packages/cli/src/config/settingsSchema.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1571,6 +1571,17 @@ const SETTINGS_SCHEMA = {
15711571
showInDialog: false,
15721572
mergeStrategy: MergeStrategy.UNION,
15731573
},
1574+
disabled: {
1575+
type: 'array',
1576+
label: 'Disabled Tools',
1577+
category: 'Tools',
1578+
requiresRestart: true,
1579+
default: undefined as string[] | undefined,
1580+
description:
1581+
'Tool names hidden from the registry. Differs from permissions.deny: disabled tools are not registered at all, so they never appear in /tools and cannot be discovered by the model. Managed by the daemon mutation route POST /workspace/tools/:name/enable.',
1582+
showInDialog: false,
1583+
mergeStrategy: MergeStrategy.UNION,
1584+
},
15741585
approvalMode: {
15751586
type: 'enum',
15761587
label: 'Tool Approval Mode',

packages/core/src/config/config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,18 @@ export interface ConfigParameters {
470470
* the `QWEN_DISABLED_SLASH_COMMANDS` environment variable.
471471
*/
472472
disabledSlashCommands?: string[];
473+
/**
474+
* Tool names hidden from the registry at construction time. Unlike
475+
* `permissions.deny` (which keeps the tool registered and rejects
476+
* invocation), tools listed here are not registered at all and never
477+
* appear in `/tools`, `getAllTools()`, or function-call discovery.
478+
* Sourced from `settings.tools.disabled` and the daemon mutation route
479+
* `POST /workspace/tools/:name/enable {enabled:false}` (#4175 Wave 4 PR
480+
* 17). Active sessions retain already-registered tools — the disabled
481+
* set is consulted at register time, so toggling takes effect on the
482+
* next ACP child spawn or `ToolRegistry.refresh()`.
483+
*/
484+
disabledTools?: string[];
473485
/** Merged permission rules from all sources (settings + CLI args). */
474486
permissions?: {
475487
allow?: string[];
@@ -742,6 +754,7 @@ export class Config {
742754
private readonly allowedTools: string[] | undefined;
743755
private readonly excludeTools: string[] | undefined;
744756
private readonly disabledSlashCommands: readonly string[];
757+
private readonly disabledTools: ReadonlySet<string>;
745758
private readonly permissionsAllow: string[];
746759
private readonly permissionsAsk: string[];
747760
private readonly permissionsDeny: string[];
@@ -896,6 +909,7 @@ export class Config {
896909
this.disabledSlashCommands = Object.freeze([
897910
...(params.disabledSlashCommands ?? []),
898911
]);
912+
this.disabledTools = new Set(params.disabledTools ?? []);
899913
this.permissionsAllow = params.permissions?.allow || [];
900914
this.permissionsAsk = params.permissions?.ask || [];
901915
this.permissionsDeny = params.permissions?.deny || [];
@@ -2267,6 +2281,17 @@ export class Config {
22672281
return this.disabledSlashCommands;
22682282
}
22692283

2284+
/**
2285+
* Returns the read-only set of tool names hidden from this Config's
2286+
* ToolRegistry. Consulted by `ToolRegistry.registerTool` and
2287+
* `ToolRegistry.registerFactory` to skip registration. Toggling at
2288+
* runtime requires re-spawning the ACP child (the set is frozen at
2289+
* construction time). See `disabledTools` in ConfigParameters.
2290+
*/
2291+
getDisabledTools(): ReadonlySet<string> {
2292+
return this.disabledTools;
2293+
}
2294+
22702295
getToolCallCommand(): string | undefined {
22712296
return this.toolCallCommand;
22722297
}

packages/core/src/tools/tool-registry.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,63 @@ describe('ToolRegistry', () => {
195195
expect(resolved).not.toBeInstanceOf(DiscoveredMCPTool);
196196
expect(resolved!.name).toBe('structured_output');
197197
});
198+
199+
it('skips tools whose name is in Config.disabledTools (#4175 Wave 4 PR 17)', () => {
200+
const disabledConfig = new Config({
201+
...baseConfigParams,
202+
disabledTools: ['Bash', 'mcp__github__create_issue'],
203+
});
204+
const registry = new ToolRegistry(disabledConfig);
205+
registry.registerTool(new MockTool({ name: 'Bash' }));
206+
registry.registerTool(new MockTool({ name: 'Read' }));
207+
registry.registerTool(
208+
new MockTool({ name: 'mcp__github__create_issue' }),
209+
);
210+
expect(registry.getTool('Bash')).toBeUndefined();
211+
expect(registry.getTool('Read')).toBeDefined();
212+
expect(registry.getTool('mcp__github__create_issue')).toBeUndefined();
213+
});
214+
215+
it('skips lazy factories whose name is in Config.disabledTools', async () => {
216+
const disabledConfig = new Config({
217+
...baseConfigParams,
218+
disabledTools: ['structured_output'],
219+
});
220+
const registry = new ToolRegistry(disabledConfig);
221+
registry.registerFactory(
222+
'structured_output',
223+
async () => new MockTool({ name: 'structured_output' }),
224+
);
225+
registry.registerFactory(
226+
'sequential_thinking',
227+
async () => new MockTool({ name: 'sequential_thinking' }),
228+
);
229+
// Disabled factory never materializes.
230+
expect(await registry.ensureTool('structured_output')).toBeUndefined();
231+
// Non-disabled factory still materializes.
232+
const live = await registry.ensureTool('sequential_thinking');
233+
expect(live).toBeDefined();
234+
expect(live!.name).toBe('sequential_thinking');
235+
});
236+
237+
it('does not retroactively unregister tools registered before toggle (next-refresh semantic)', () => {
238+
// Toggle semantics are documented as "effective on next refresh /
239+
// ACP child spawn"; a Set lookup at register time cannot un-do a
240+
// prior registration. This test pins the contract.
241+
const registry = new ToolRegistry(config);
242+
registry.registerTool(new MockTool({ name: 'live-tool' }));
243+
expect(registry.getTool('live-tool')).toBeDefined();
244+
// Simulate a "fresh Config" with the tool now disabled.
245+
const reconfigured = new Config({
246+
...baseConfigParams,
247+
disabledTools: ['live-tool'],
248+
});
249+
const next = new ToolRegistry(reconfigured);
250+
next.registerTool(new MockTool({ name: 'live-tool' }));
251+
// The new registry skips; the old registry is unaffected.
252+
expect(next.getTool('live-tool')).toBeUndefined();
253+
expect(registry.getTool('live-tool')).toBeDefined();
254+
});
198255
});
199256

200257
describe('getAllTools', () => {

packages/core/src/tools/tool-registry.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,29 @@ export class ToolRegistry {
204204
);
205205
}
206206

207+
/**
208+
* Returns true when `name` is in the Config's `disabledTools` set, in
209+
* which case `registerTool` / `registerFactory` will skip it. This is
210+
* the chokepoint for the daemon mutation route at `POST /workspace/
211+
* tools/:name/enable {enabled:false}` (#4175 Wave 4 PR 17); both
212+
* built-ins and MCP-discovered tools flow through `registerTool`, so
213+
* gating here covers every registration path.
214+
*/
215+
private isToolDisabled(name: string): boolean {
216+
return this.config.getDisabledTools().has(name);
217+
}
218+
207219
/**
208220
* Registers a tool definition.
209221
* @param tool - The tool object containing schema and execution logic.
210222
*/
211223
registerTool(tool: AnyDeclarativeTool): void {
224+
if (this.isToolDisabled(tool.name)) {
225+
debugLogger.info(
226+
`Tool "${tool.name}" skipped: present in disabledTools set.`,
227+
);
228+
return;
229+
}
212230
// A name collision can happen against either the eager `tools` map
213231
// (already-instantiated tools) or the lazy `factories` map (registered
214232
// but not yet constructed — `structured_output` lives here when
@@ -240,6 +258,12 @@ export class ToolRegistry {
240258
* is not instantiated until {@link ensureTool} or {@link warmAll} is called.
241259
*/
242260
registerFactory(name: string, factory: ToolFactory): void {
261+
if (this.isToolDisabled(name)) {
262+
debugLogger.info(
263+
`Tool factory "${name}" skipped: present in disabledTools set.`,
264+
);
265+
return;
266+
}
243267
this.factories.set(name, factory);
244268
}
245269

0 commit comments

Comments
 (0)