From 6528ce4fb326b7427f3ee03bc210097f775b13ce Mon Sep 17 00:00:00 2001 From: Victor <70475442+vsolano9@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:23:57 +0200 Subject: [PATCH] fix: validate numeric CLI flags Parse --max, --max-tool, and --price through one finite non-negative guard. Preserve zero budgets, fail with the exact flag and received value, and cover the real CLI process. --- CHANGELOG.md | 7 ++++++ README.md | 2 ++ bin/tokencut.mjs | 19 +++++++++++++--- test/cli.test.mjs | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 test/cli.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d087ac1..51b2cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [Unreleased] + +### Fixed + +- Invalid or negative numeric CLI flags now fail clearly instead of producing + `NaN` costs or silently skipping token-budget compaction. + ## [0.1.1] - 2026-08-06 ### Changed diff --git a/README.md b/README.md index 88b65a9..dc631c0 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ tokencut --compact cut, print savings --json machine-readable output ``` +Numeric flags must be finite and non-negative. A zero token budget is valid. + ## License [MIT](LICENSE) diff --git a/bin/tokencut.mjs b/bin/tokencut.mjs index ca496b9..e408a75 100644 --- a/bin/tokencut.mjs +++ b/bin/tokencut.mjs @@ -23,7 +23,20 @@ Payload: an array of messages, or { system, messages } (Anthropic or OpenAI styl process.exit(file ? 0 : 1); } -const price = Number(flag("--price", 3)); +const numericFlag = (name, def) => { + const raw = flag(name, def); + const value = Number(raw); + if (typeof raw === "boolean" || !Number.isFinite(value) || value < 0) { + console.error( + `${name} must be a finite, non-negative number; received ${JSON.stringify(raw)}`, + ); + process.exit(1); + } + return value; +}; +const price = numericFlag("--price", 3); +const maxTokens = has("--max") ? numericFlag("--max", null) : null; +const maxToolResultTokens = numericFlag("--max-tool", 500); let payload; try { payload = JSON.parse(readFileSync(file, "utf8")); } catch (e) { console.error(`could not read ${file}: ${e.message}`); process.exit(1); } @@ -33,8 +46,8 @@ const usd = (n) => "$" + n.toFixed(n < 0.01 ? 5 : 4); if (has("--compact")) { const res = compact(payload, { - maxTokens: flag("--max", null) ? Number(flag("--max", null)) : null, - maxToolResultTokens: Number(flag("--max-tool", 500)), + maxTokens, + maxToolResultTokens, dropDuplicates: !has("--no-dedupe"), }); if (flag("--out", null)) { writeFileSync(String(flag("--out", null)), JSON.stringify(res.payload, null, 2)); } diff --git a/test/cli.test.mjs b/test/cli.test.mjs new file mode 100644 index 0000000..f0eba43 --- /dev/null +++ b/test/cli.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import test, { after } from "node:test"; + +const fixtureDir = mkdtempSync(join(tmpdir(), "tokencut-cli-")); +const fixture = join(fixtureDir, "payload.json"); +writeFileSync(fixture, JSON.stringify([{ role: "user", content: "hello" }])); +after(() => rmSync(fixtureDir, { recursive: true, force: true })); + +function run(...args) { + return spawnSync(process.execPath, ["bin/tokencut.mjs", fixture, ...args], { + cwd: new URL("..", import.meta.url), + encoding: "utf8", + }); +} + +test("rejects invalid numeric flags with the flag and received value", async (t) => { + const cases = [ + { + args: ["--compact", "--max", "abc"], + flag: "--max", + received: '"abc"', + }, + { + args: ["--compact", "--max-tool", "-1"], + flag: "--max-tool", + received: '"-1"', + }, + { args: ["--price", "NaN"], flag: "--price", received: '"NaN"' }, + { + args: ["--price", "Infinity"], + flag: "--price", + received: '"Infinity"', + }, + { args: ["--price"], flag: "--price", received: "true" }, + ]; + + for (const scenario of cases) { + await t.test(scenario.flag + " " + scenario.received, () => { + const result = run(...scenario.args); + assert.notEqual(result.status, 0); + assert.equal( + result.stderr, + `${scenario.flag} must be a finite, non-negative number; received ${scenario.received}\n`, + ); + assert.equal(result.stdout, ""); + }); + } +}); + +test("accepts zero as a compact token budget", () => { + const result = run("--compact", "--max", "0", "--json"); + assert.equal(result.status, 0, result.stderr); + assert.doesNotThrow(() => JSON.parse(result.stdout)); +});