Skip to content

Commit 0b8b3da

Browse files
authored
feat(cli): add slashCommands.disabled setting to gate slash commands (#3445)
* feat(cli): add slashCommands.disabled setting to gate slash commands Introduces a first-class way for operators to hide and refuse to execute specific slash commands. Useful for multi-tenant / enterprise / sandboxed deployments where different users should see different command subsets. The denylist is sourced from three unioned inputs: * `slashCommands.disabled` settings key (string[], UNION merge), so workspace scopes can only add to a denylist set at user or system scope, never shrink it — matching the shape already used by `permissions.deny`. * `--disabled-slash-commands` CLI flag (comma-separated or repeated). * `QWEN_DISABLED_SLASH_COMMANDS` environment variable. Matching is case-insensitive against the final (post-rename) command name, so extension commands are addressable by their disambiguated form (e.g. `firebase.deploy`). Disabled commands are removed from `CommandService`'s output, so they disappear from autocomplete and produce the standard unknown-command path in both interactive TUI and non-interactive (`--prompt`) modes. The scope of this change is slash commands only: it does not affect tool permissions (still `permissions.deny`) or keyboard shortcuts. * chore(cli): regenerate settings.schema.json for slashCommands.disabled Regenerates the companion JSON schema consumed by the VS Code extension after adding the `slashCommands.disabled` entry to the TS schema in the previous commit. Required by the "Check settings schema is up-to-date" CI lint step. * fix(cli): route disabled slash commands to unsupported, not no_command handleSlashCommand was passing the disabled denylist straight into CommandService.create, so disabled commands disappeared from `allCommands` too. The fallback existence check that distinguishes "known but not allowed in non-interactive mode" from "truly unknown" then failed, and disabled commands like `/help` fell through to `no_command` — causing the caller to forward them to the model as plain prompt text. Keep `allCommands` unfiltered and apply the denylist only when constructing the executable set and when producing the unsupported response. A disabled command now returns `unsupported` with a "disabled by the current configuration" reason and never reaches the model. Added three regression tests covering the primary case, case-insensitive match, and the preserved no_command path for genuinely unknown input.
1 parent 7cded6e commit 0b8b3da

14 files changed

Lines changed: 398 additions & 41 deletions

File tree

docs/users/configuration/settings.md

Lines changed: 70 additions & 36 deletions
Large diffs are not rendered by default.

packages/cli/src/commands/auth/handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export async function handleQwenAuth(
106106
maxSessionTurns: undefined,
107107
coreTools: undefined,
108108
excludeTools: undefined,
109+
disabledSlashCommands: undefined,
109110
authType: undefined,
110111
channel: undefined,
111112
systemPrompt: undefined,

packages/cli/src/config/config.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ export interface CliArgs {
160160
maxSessionTurns: number | undefined;
161161
coreTools: string[] | undefined;
162162
excludeTools: string[] | undefined;
163+
disabledSlashCommands: string[] | undefined;
163164
authType: string | undefined;
164165
channel: string | undefined;
165166
jsonFd?: number | undefined;
@@ -530,6 +531,17 @@ export async function parseArguments(): Promise<CliArgs> {
530531
coerce: (tools: string[]) =>
531532
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
532533
})
534+
.option('disabled-slash-commands', {
535+
type: 'array',
536+
string: true,
537+
description:
538+
'Slash command names to hide/disable (comma-separated or ' +
539+
'repeated). Merged with the `slashCommands.disabled` setting ' +
540+
'and QWEN_DISABLED_SLASH_COMMANDS. Matched case-insensitively ' +
541+
'against the final command name.',
542+
coerce: (names: string[]) =>
543+
names.flatMap((n) => n.split(',').map((t) => t.trim())),
544+
})
533545
.option('auth-type', {
534546
type: 'string',
535547
choices: [
@@ -926,6 +938,29 @@ export async function loadCliConfig(
926938
if (t && !mergedDeny.includes(t)) mergedDeny.push(t);
927939
}
928940

941+
// Merge the slash-command denylist from settings + CLI flag + env var.
942+
// Settings merge (UNION across scopes) is already handled upstream; we
943+
// only de-duplicate while preserving case for diagnostic purposes.
944+
const disabledSlashCommands: string[] = [];
945+
const seenDisabled = new Set<string>();
946+
const addDisabled = (value: string | undefined) => {
947+
if (!value) return;
948+
const trimmed = value.trim();
949+
if (!trimmed) return;
950+
const key = trimmed.toLowerCase();
951+
if (!seenDisabled.has(key)) {
952+
seenDisabled.add(key);
953+
disabledSlashCommands.push(trimmed);
954+
}
955+
};
956+
for (const name of settings.slashCommands?.disabled ?? []) addDisabled(name);
957+
for (const name of argv.disabledSlashCommands ?? []) addDisabled(name);
958+
for (const name of (process.env['QWEN_DISABLED_SLASH_COMMANDS'] ?? '').split(
959+
',',
960+
)) {
961+
addDisabled(name);
962+
}
963+
929964
// Helper: check if a tool is explicitly covered by an allow rule OR by the
930965
// coreTools whitelist. Uses alias matching for coreTools (via isToolEnabled)
931966
// to preserve the original behaviour where "ShellTool", "Shell", and
@@ -1093,6 +1128,8 @@ export async function loadCliConfig(
10931128
? argv.allowedTools || undefined
10941129
: argv.allowedTools || settings.tools?.allowed || undefined,
10951130
excludeTools: mergedDeny,
1131+
disabledSlashCommands:
1132+
disabledSlashCommands.length > 0 ? disabledSlashCommands : undefined,
10961133
// New unified permissions (PermissionManager source of truth).
10971134
permissions: {
10981135
allow: mergedAllow.length > 0 ? mergedAllow : undefined,

packages/cli/src/config/settings.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,34 @@ describe('Settings Loading and Merging', () => {
954954
expect(settings.merged.advanced?.excludedEnvVars).toHaveLength(2);
955955
});
956956

957+
it('should UNION-merge slashCommands.disabled across user and workspace scopes', () => {
958+
(mockFsExistsSync as Mock).mockReturnValue(true);
959+
const userSettings = {
960+
slashCommands: { disabled: ['auth', 'quit'] },
961+
};
962+
const workspaceSettings = {
963+
// Workspace overlaps with user and adds one entry. UNION de-dupes the
964+
// overlap and merges the new entry; it cannot remove user entries.
965+
slashCommands: { disabled: ['quit', 'clear'] },
966+
};
967+
968+
(fs.readFileSync as Mock).mockImplementation(
969+
(p: fs.PathOrFileDescriptor) => {
970+
if (p === USER_SETTINGS_PATH) return JSON.stringify(userSettings);
971+
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
972+
return JSON.stringify(workspaceSettings);
973+
return '{}';
974+
},
975+
);
976+
977+
const settings = loadSettings(MOCK_WORKSPACE_DIR);
978+
const disabled = settings.merged.slashCommands?.disabled ?? [];
979+
expect(disabled).toEqual(
980+
expect.arrayContaining(['auth', 'quit', 'clear']),
981+
);
982+
expect(disabled).toHaveLength(3);
983+
});
984+
957985
it('should merge all settings files with the correct precedence', () => {
958986
(mockFsExistsSync as Mock).mockReturnValue(true);
959987
const systemDefaultsContent = {

packages/cli/src/config/settingsSchema.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,36 @@ const SETTINGS_SCHEMA = {
11511151
},
11521152
},
11531153

1154+
slashCommands: {
1155+
type: 'object',
1156+
label: 'Slash Commands',
1157+
category: 'Advanced',
1158+
requiresRestart: true,
1159+
default: {},
1160+
description:
1161+
'Configuration for slash commands exposed by the CLI. Useful for ' +
1162+
'locking down the command surface in multi-tenant or enterprise ' +
1163+
'deployments.',
1164+
showInDialog: false,
1165+
properties: {
1166+
disabled: {
1167+
type: 'array',
1168+
label: 'Disabled Slash Commands',
1169+
category: 'Advanced',
1170+
requiresRestart: true,
1171+
default: undefined as string[] | undefined,
1172+
description:
1173+
'Slash command names to hide and refuse to execute. Matched ' +
1174+
'case-insensitively against the final command name (for extension ' +
1175+
'commands this is the disambiguated form, e.g. "myext.deploy"). ' +
1176+
'Merged as a union across settings scopes, so workspace settings ' +
1177+
'can add to but not remove entries defined in system/user settings.',
1178+
showInDialog: false,
1179+
mergeStrategy: MergeStrategy.UNION,
1180+
},
1181+
},
1182+
},
1183+
11541184
tools: {
11551185
type: 'object',
11561186
label: 'Tools',

packages/cli/src/gemini.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,7 @@ describe('gemini.tsx main function kitty protocol', () => {
607607
resume: undefined,
608608
coreTools: undefined,
609609
excludeTools: undefined,
610+
disabledSlashCommands: undefined,
610611
authType: undefined,
611612
maxSessionTurns: undefined,
612613
experimentalLsp: undefined,

packages/cli/src/nonInteractiveCli.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ describe('runNonInteractive', () => {
151151
setRegisterCallback: vi.fn(),
152152
getRunning: vi.fn().mockReturnValue([]),
153153
}),
154+
getDisabledSlashCommands: vi.fn().mockReturnValue([]),
154155
} as unknown as Config;
155156

156157
mockSettings = {

packages/cli/src/nonInteractiveCliCommands.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ describe('handleSlashCommand', () => {
3636
getFolderTrustFeature: vi.fn().mockReturnValue(false),
3737
getFolderTrust: vi.fn().mockReturnValue(false),
3838
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
39+
getDisabledSlashCommands: vi.fn().mockReturnValue([]),
3940
storage: {},
4041
} as unknown as Config;
4142

@@ -122,6 +123,68 @@ describe('handleSlashCommand', () => {
122123
}
123124
});
124125

126+
it('should return unsupported (not no_command) for a disabled command so it is not forwarded to the model', async () => {
127+
const mockInitCommand = {
128+
name: 'init',
129+
description: 'Initialize project',
130+
kind: CommandKind.BUILT_IN,
131+
action: vi.fn(),
132+
};
133+
mockGetCommands.mockReturnValue([mockInitCommand]);
134+
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['init']);
135+
136+
const result = await handleSlashCommand(
137+
'/init',
138+
abortController,
139+
mockConfig,
140+
mockSettings,
141+
['init'], // Would normally be allowed; denylist must still block it.
142+
);
143+
144+
expect(result.type).toBe('unsupported');
145+
if (result.type === 'unsupported') {
146+
expect(result.reason).toContain('/init');
147+
expect(result.reason).toContain('disabled');
148+
}
149+
expect(mockInitCommand.action).not.toHaveBeenCalled();
150+
});
151+
152+
it('should match disabled names case-insensitively', async () => {
153+
const mockInitCommand = {
154+
name: 'init',
155+
description: 'Initialize project',
156+
kind: CommandKind.BUILT_IN,
157+
action: vi.fn(),
158+
};
159+
mockGetCommands.mockReturnValue([mockInitCommand]);
160+
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['INIT']);
161+
162+
const result = await handleSlashCommand(
163+
'/init',
164+
abortController,
165+
mockConfig,
166+
mockSettings,
167+
['init'],
168+
);
169+
170+
expect(result.type).toBe('unsupported');
171+
expect(mockInitCommand.action).not.toHaveBeenCalled();
172+
});
173+
174+
it('should still return no_command for truly unknown slash commands even when a denylist is set', async () => {
175+
mockGetCommands.mockReturnValue([]);
176+
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['help']);
177+
178+
const result = await handleSlashCommand(
179+
'/does-not-exist',
180+
abortController,
181+
mockConfig,
182+
mockSettings,
183+
);
184+
185+
expect(result.type).toBe('no_command');
186+
});
187+
125188
it('should execute allowed built-in commands', async () => {
126189
const mockInitCommand = {
127190
name: 'init',

packages/cli/src/nonInteractiveCliCommands.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,19 @@ export const handleSlashCommand = async (
254254
: 'non_interactive';
255255

256256
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
257+
const disabledSlashCommandsRaw = config.getDisabledSlashCommands();
258+
const disabledNameSet = new Set<string>();
259+
for (const name of disabledSlashCommandsRaw) {
260+
const trimmed = name.trim();
261+
if (trimmed) disabledNameSet.add(trimmed.toLowerCase());
262+
}
263+
const isDisabled = (cmd: { name: string }) =>
264+
disabledNameSet.has(cmd.name.toLowerCase());
257265

258-
// Load all commands to check if the command exists but is not allowed
266+
// Load the full command set (unfiltered by the denylist) so that the
267+
// fallback existence check below can distinguish a disabled command from a
268+
// truly unknown one. Without this, a disabled command would fall through to
269+
// `no_command` and be forwarded to the model as plain prompt text.
259270
const allLoaders = [
260271
new BuiltinCommandLoader(config),
261272
new BundledSkillLoader(config),
@@ -270,7 +281,7 @@ export const handleSlashCommand = async (
270281
const filteredCommands = filterCommandsForNonInteractive(
271282
allCommands,
272283
allowedBuiltinSet,
273-
);
284+
).filter((cmd) => !isDisabled(cmd));
274285

275286
// First, try to parse with filtered commands
276287
const { commandToExecute, args } = parseSlashCommand(
@@ -286,6 +297,16 @@ export const handleSlashCommand = async (
286297
);
287298

288299
if (knownCommand) {
300+
if (isDisabled(knownCommand)) {
301+
return {
302+
type: 'unsupported',
303+
reason: t(
304+
'The command "/{{command}}" is disabled by the current configuration.',
305+
{ command: knownCommand.name },
306+
),
307+
originalType: 'filtered_command',
308+
};
309+
}
289310
// Command exists but is not allowed in non-interactive mode
290311
return {
291312
type: 'unsupported',
@@ -380,7 +401,14 @@ export const getAvailableCommands = async (
380401
]
381402
: [new BundledSkillLoader(config), new FileCommandLoader(config)];
382403

383-
const commandService = await CommandService.create(loaders, abortSignal);
404+
const disabledSlashCommands = config.getDisabledSlashCommands();
405+
const commandService = await CommandService.create(
406+
loaders,
407+
abortSignal,
408+
disabledSlashCommands.length > 0
409+
? new Set(disabledSlashCommands)
410+
: undefined,
411+
);
384412
const commands = commandService.getCommands();
385413
const filteredCommands = filterCommandsForNonInteractive(
386414
commands,

packages/cli/src/services/CommandService.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,80 @@ describe('CommandService', () => {
310310
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
311311
});
312312

313+
describe('disabledNames filtering', () => {
314+
it('should omit commands whose names are in the disabled set', async () => {
315+
const loader = new MockCommandLoader([
316+
mockCommandA,
317+
mockCommandB,
318+
mockCommandC,
319+
]);
320+
const service = await CommandService.create(
321+
[loader],
322+
new AbortController().signal,
323+
new Set(['command-b']),
324+
);
325+
const names = service.getCommands().map((cmd) => cmd.name);
326+
expect(names).toEqual(expect.arrayContaining(['command-a', 'command-c']));
327+
expect(names).not.toContain('command-b');
328+
});
329+
330+
it('should match disabled names case-insensitively', async () => {
331+
const loader = new MockCommandLoader([mockCommandA, mockCommandB]);
332+
const service = await CommandService.create(
333+
[loader],
334+
new AbortController().signal,
335+
new Set(['COMMAND-A']),
336+
);
337+
const names = service.getCommands().map((cmd) => cmd.name);
338+
expect(names).toEqual(['command-b']);
339+
});
340+
341+
it('should ignore empty entries and whitespace in the disabled set', async () => {
342+
const loader = new MockCommandLoader([mockCommandA, mockCommandB]);
343+
const service = await CommandService.create(
344+
[loader],
345+
new AbortController().signal,
346+
new Set(['', ' ', ' command-a ']),
347+
);
348+
const names = service.getCommands().map((cmd) => cmd.name);
349+
expect(names).toEqual(['command-b']);
350+
});
351+
352+
it('should be a no-op when disabledNames is undefined or empty', async () => {
353+
const loader = new MockCommandLoader([mockCommandA, mockCommandB]);
354+
const undefinedResult = await CommandService.create(
355+
[loader],
356+
new AbortController().signal,
357+
);
358+
expect(undefinedResult.getCommands()).toHaveLength(2);
359+
360+
const emptyResult = await CommandService.create(
361+
[new MockCommandLoader([mockCommandA, mockCommandB])],
362+
new AbortController().signal,
363+
new Set<string>(),
364+
);
365+
expect(emptyResult.getCommands()).toHaveLength(2);
366+
});
367+
368+
it('should disable extension commands by their renamed (final) name', async () => {
369+
const builtin = createMockCommand('deploy', CommandKind.BUILT_IN);
370+
const extension = {
371+
...createMockCommand('deploy', CommandKind.FILE),
372+
extensionName: 'firebase',
373+
description: '[firebase] Deploy to Firebase',
374+
};
375+
const loader = new MockCommandLoader([builtin, extension]);
376+
const service = await CommandService.create(
377+
[loader],
378+
new AbortController().signal,
379+
new Set(['firebase.deploy']),
380+
);
381+
const names = service.getCommands().map((cmd) => cmd.name);
382+
// Built-in /deploy remains; the renamed extension command is gone.
383+
expect(names).toEqual(['deploy']);
384+
});
385+
});
386+
313387
it('should handle multiple secondary conflicts with incrementing suffixes', async () => {
314388
// User has /deploy, /gcp.deploy, and /gcp.deploy1
315389
const userCommand1 = createMockCommand('deploy', CommandKind.FILE);

0 commit comments

Comments
 (0)