fix(cli): allowed extension tools in allowlists

Deferred --tools validation until extension discovery, then validated against built-in and registered tool names while preserving strict rejection of unknown names.

Fixes #8421
This commit is contained in:
roboomp
2026-08-13 08:53:59 +00:00
parent 326d24bd40
commit 6980275a50
9 changed files with 74 additions and 30 deletions
+11 -3
View File
@@ -108,7 +108,8 @@ export interface Args {
const PARSE_DEPS: ParseDeps = {
logger,
parseThinking: parseCliThinkingLevel,
builtinToolNames: [...BUILTIN_TOOL_NAMES, ...HIDDEN_TOOL_NAMES],
toolNames: [...BUILTIN_TOOL_NAMES, ...HIDDEN_TOOL_NAMES],
validateToolNames: false,
normalizeToolNames,
thinkingEfforts: CLI_THINKING_LEVELS,
};
@@ -142,12 +143,19 @@ function consumeBuiltInStringValue(flag: string, args: string[], valueIndex: num
return { value, index: valueIndex };
}
export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
export function parseArgs(
inputArgs: string[],
extensionFlags?: Map<string, { type: "boolean" | "string" }>,
extensionToolNames: readonly string[] = [],
): Args {
// Work on a copy: the `--option=value` handling below splices the value
// into the array, and callers reuse the same argv (the post-extension
// reparse in `runRootCommand` parses it a second time). Mutating the input
// would corrupt that later parse, so never touch the caller's array.
const args = [...inputArgs];
const parseDeps: ParseDeps = extensionFlags
? { ...PARSE_DEPS, toolNames: [...PARSE_DEPS.toolNames, ...extensionToolNames], validateToolNames: true }
: PARSE_DEPS;
const result: Args = {
messages: [],
fileArgs: [],
@@ -214,7 +222,7 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
if (i + 1 < args.length && args[i + 1] !== PROFILE_BOOTSTRAP_BOUNDARY_ARG) {
const consumed = consumeBuiltInStringValue(arg, args, i + 1);
i = consumed.index;
STRING_SETTERS[arg](result, consumed.value, PARSE_DEPS);
STRING_SETTERS[arg](result, consumed.value, parseDeps);
}
} else if (OPTIONAL_VALUE_FLAGS.has(arg)) {
const config = OPTIONAL_FLAGS[arg];
@@ -8,6 +8,7 @@ import { type Args, parseArgs } from "./args";
*/
export interface ExtensionFlagSink {
getFlags(): Map<string, { type: "boolean" | "string" }>;
getToolNames(): readonly string[];
setFlagValue(name: string, value: boolean | string): void;
}
@@ -29,18 +30,14 @@ export interface ExtensionFlagSink {
* semantics and surfaces in `unknownFlags` — without consuming the following
* message or overwriting the built-in field. No built-in name list to maintain.
*
* Returns `null` when there is no sink or no registered extension flags, in
* which case the caller keeps its original startup parse (an extension-aware
* re-parse would be identical anyway).
* Returns `null` only when there is no sink. Once extensions have loaded, the
* reparse always runs so `--tools` can be validated against their registered
* tools even when no extension registered CLI flags.
*/
export function applyExtensionFlags(runner: ExtensionFlagSink | undefined, rawArgs: string[]): Args | null {
const extensionFlags = runner?.getFlags();
if (!runner || !extensionFlags || extensionFlags.size === 0) {
return null;
}
const parsed = parseArgs(rawArgs, extensionFlags);
// `parseArgs` only records registered extension flags in `unknownFlags`, so
// every entry here is a flag this runner owns that was actually passed.
if (!runner) return null;
const parsed = parseArgs(rawArgs, runner.getFlags(), runner.getToolNames());
// `parseArgs` records extension flag values in `unknownFlags`.
for (const [name, value] of parsed.unknownFlags) {
runner.setFlagValue(name, value);
}
+11 -7
View File
@@ -47,7 +47,8 @@ import { CliUsageError } from "./usage-error";
export interface ParseDeps {
logger: { warn: (message: string, meta?: Record<string, unknown>) => void };
parseThinking: (value: string | null | undefined) => ConfiguredThinkingLevel | undefined;
builtinToolNames: readonly string[];
toolNames: readonly string[];
validateToolNames: boolean;
normalizeToolNames: (values: Iterable<string>) => string[];
thinkingEfforts: readonly string[];
}
@@ -191,12 +192,15 @@ export const STRING_SETTERS: Record<string, StringSetter> = {
);
// An unknown name silently narrowing the toolset is worse than a failed
// launch: scripts keep running believing the tool is available (e.g. a
// stale `--tools bash,ssh` after the ssh tool's removal).
const unknown = names.filter(name => !deps.builtinToolNames.includes(name));
if (unknown.length > 0) {
throw new CliUsageError(
`Unknown tool${unknown.length === 1 ? "" : "s"} in --tools: ${unknown.join(", ")}. Valid tools: ${deps.builtinToolNames.join(", ")}.`,
);
// stale `--tools bash,ssh` after the ssh tool's removal). The startup
// parse defers this check until extensions have registered their tools.
if (deps.validateToolNames) {
const unknown = names.filter(name => !deps.toolNames.includes(name));
if (unknown.length > 0) {
throw new CliUsageError(
`Unknown tool${unknown.length === 1 ? "" : "s"} in --tools: ${unknown.join(", ")}. Valid tools: ${deps.toolNames.join(", ")}.`,
);
}
}
result.tools = names;
},