fix(cli): reject unterminated output filter slices (#2315)
This commit is contained in:
1 parent
fb28ec1537
commit
c95315d2ce
2 files changed
+118
-3
No files matched your search
+29
-3
@@ -9,7 +9,7 @@ export type CliOutputArgumentValidation =
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly format: StructuredOutputFormat;
|
||||
readonly code: "UNSUPPORTED_OUTPUT_COMBINATION";
|
||||
readonly code: "UNSUPPORTED_OUTPUT_COMBINATION" | "INVALID_OUTPUT_FILTER";
|
||||
readonly message: string;
|
||||
};
|
||||
|
||||
@@ -89,13 +89,39 @@ export const parseCliOutputArguments = (
|
||||
return parsed(false);
|
||||
};
|
||||
|
||||
/** Reject text-window controls that would corrupt a structured document. */
|
||||
/** Reject slices that cannot advance within Incur's comma-delimited paths. */
|
||||
const hasUnclosedFilterSlice = (expression: string): boolean => {
|
||||
let depth = 0;
|
||||
let unclosed = false;
|
||||
for (const character of expression) {
|
||||
if (character === "[") {
|
||||
depth += 1;
|
||||
unclosed = true;
|
||||
} else if (character === "]") {
|
||||
depth -= 1;
|
||||
// Incur consumes everything through the next closing bracket, including
|
||||
// nested opening brackets. Do not impose a different filter grammar.
|
||||
unclosed = false;
|
||||
} else if (character === "," && depth === 0 && unclosed) return true;
|
||||
}
|
||||
return unclosed;
|
||||
};
|
||||
|
||||
/** Reject output controls that cannot safely produce a complete result. */
|
||||
export const validateCliOutputArguments = (
|
||||
arguments_: readonly string[],
|
||||
): CliOutputArgumentValidation => {
|
||||
const { format, parseError, tokenWindow } =
|
||||
const { filterOutput, format, parseError, tokenWindow } =
|
||||
parseCliOutputArguments(arguments_);
|
||||
if (parseError) return { ok: true };
|
||||
if (filterOutput !== undefined && hasUnclosedFilterSlice(filterOutput))
|
||||
return {
|
||||
ok: false,
|
||||
format: format === "yaml" || format === "jsonl" ? format : "json",
|
||||
code: "INVALID_OUTPUT_FILTER",
|
||||
message:
|
||||
"The --filter-output expression has a slice without a closing ]. Close the slice or remove --filter-output.",
|
||||
};
|
||||
if (
|
||||
tokenWindow &&
|
||||
(format === "json" || format === "jsonl" || format === "yaml")
|
||||
|
||||
@@ -58,6 +58,95 @@ describe("CLI output argument boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("unclosed CLI output filter slices", () => {
|
||||
it.each([
|
||||
"providers[",
|
||||
"providers[0,1].name[",
|
||||
"name,providers[",
|
||||
"][,]",
|
||||
"providers],name[0,1]",
|
||||
])("rejects %s before the filter parser runs", (filter) => {
|
||||
expect(
|
||||
validateCliOutputArguments([
|
||||
"providers",
|
||||
"--json",
|
||||
"--filter-output",
|
||||
filter,
|
||||
]),
|
||||
).toMatchObject({ ok: false, code: "INVALID_OUTPUT_FILTER" });
|
||||
});
|
||||
|
||||
it("preserves effective-filter and earlier flag-error precedence", () => {
|
||||
for (const arguments_ of [
|
||||
[
|
||||
"--filter-output",
|
||||
"providers[",
|
||||
"--filter-output",
|
||||
"capabilities[0,1].provider.id,open",
|
||||
],
|
||||
["--filter-output", "capabilities[-1,].provider.id"],
|
||||
["--filter-output", "capabilities[[0,1]"],
|
||||
["--format", "invalid", "--filter-output", "providers["],
|
||||
["--token-limit", "invalid", "--filter-output", "providers["],
|
||||
])
|
||||
expect(validateCliOutputArguments(arguments_)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
workspaceCliTest(
|
||||
"fails promptly across output formats without invoking the command",
|
||||
async ({ cli }) => {
|
||||
for (const format of ["json", "jsonl", "yaml", "toon", "md"]) {
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"analyze",
|
||||
"/absent/target",
|
||||
"--format",
|
||||
format,
|
||||
"--filter-output",
|
||||
"result[0,1].name[",
|
||||
],
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toContain("INVALID_OUTPUT_FILTER");
|
||||
expect(result.stdout).toContain("closing ]");
|
||||
expect(result.stderr).toBe("");
|
||||
}
|
||||
const splitPath = await cli.run({
|
||||
arguments: ["providers", "--json", "--filter-output", "][,]"],
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
expect(splitPath.exitCode).toBe(1);
|
||||
expect(splitPath.json).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_OUTPUT_FILTER" },
|
||||
});
|
||||
},
|
||||
CLI_INTEGRATION_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
workspaceCliTest(
|
||||
"retains closed slices and multiple filter paths",
|
||||
async ({ cli }) => {
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"providers",
|
||||
"--json",
|
||||
"--filter-output",
|
||||
"capabilities[0,1].provider.id,open",
|
||||
],
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.json).toEqual({
|
||||
capabilities: [{ provider: { id: expect.any(String) } }],
|
||||
open: false,
|
||||
});
|
||||
},
|
||||
CLI_INTEGRATION_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("CLI output valued-flag parsing", () => {
|
||||
workspaceCliTest(
|
||||
"preserves complete JSON errors when native builtin flags follow unusual arguments",
|
||||
|
||||
Reference in new issue
Block a user