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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user