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.
This commit is contained in:
ihubanov 2026-04-20 06:06:26 +03:00 committed by GitHub
parent 7cded6e0df
commit 0b8b3da836
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 398 additions and 41 deletions

View file

@ -160,6 +160,7 @@ export interface CliArgs {
maxSessionTurns: number | undefined;
coreTools: string[] | undefined;
excludeTools: string[] | undefined;
disabledSlashCommands: string[] | undefined;
authType: string | undefined;
channel: string | undefined;
jsonFd?: number | undefined;
@ -530,6 +531,17 @@ export async function parseArguments(): Promise<CliArgs> {
coerce: (tools: string[]) =>
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
})
.option('disabled-slash-commands', {
type: 'array',
string: true,
description:
'Slash command names to hide/disable (comma-separated or ' +
'repeated). Merged with the `slashCommands.disabled` setting ' +
'and QWEN_DISABLED_SLASH_COMMANDS. Matched case-insensitively ' +
'against the final command name.',
coerce: (names: string[]) =>
names.flatMap((n) => n.split(',').map((t) => t.trim())),
})
.option('auth-type', {
type: 'string',
choices: [
@ -926,6 +938,29 @@ export async function loadCliConfig(
if (t && !mergedDeny.includes(t)) mergedDeny.push(t);
}
// Merge the slash-command denylist from settings + CLI flag + env var.
// Settings merge (UNION across scopes) is already handled upstream; we
// only de-duplicate while preserving case for diagnostic purposes.
const disabledSlashCommands: string[] = [];
const seenDisabled = new Set<string>();
const addDisabled = (value: string | undefined) => {
if (!value) return;
const trimmed = value.trim();
if (!trimmed) return;
const key = trimmed.toLowerCase();
if (!seenDisabled.has(key)) {
seenDisabled.add(key);
disabledSlashCommands.push(trimmed);
}
};
for (const name of settings.slashCommands?.disabled ?? []) addDisabled(name);
for (const name of argv.disabledSlashCommands ?? []) addDisabled(name);
for (const name of (process.env['QWEN_DISABLED_SLASH_COMMANDS'] ?? '').split(
',',
)) {
addDisabled(name);
}
// Helper: check if a tool is explicitly covered by an allow rule OR by the
// coreTools whitelist. Uses alias matching for coreTools (via isToolEnabled)
// to preserve the original behaviour where "ShellTool", "Shell", and
@ -1093,6 +1128,8 @@ export async function loadCliConfig(
? argv.allowedTools || undefined
: argv.allowedTools || settings.tools?.allowed || undefined,
excludeTools: mergedDeny,
disabledSlashCommands:
disabledSlashCommands.length > 0 ? disabledSlashCommands : undefined,
// New unified permissions (PermissionManager source of truth).
permissions: {
allow: mergedAllow.length > 0 ? mergedAllow : undefined,