fix(hooks): add size threshold so small Bash/WebFetch calls skip interception (#817)

This commit is contained in:
Mert Koseoglu
2026-06-21 18:22:39 +03:00
parent a880c4bf24
commit dcab56fda5
2 changed files with 110 additions and 0 deletions
+42
View File
@@ -76,6 +76,37 @@ function getExternalMcpNudgeEvery() {
return parsed;
}
// #817: size threshold so small Bash calls skip the routing nudge.
//
// PreToolUse fires BEFORE the command runs, so the actual output size is
// unknowable here. The only deterministic pre-execution signal is the command
// string itself. The Gemini CLI adapter solves the same over-interception
// problem with a matcher that only fires on large-output tools — "avoids
// unnecessary hook overhead on lightweight tools" (README). We mirror that at
// the routing layer: when CONTEXT_MODE_BASH_NUDGE_MIN_COMMAND_BYTES is set to
// N>0, an unbounded Bash command whose UTF-8 byte length is below N is treated
// as expected-lightweight and the generic routing nudge is suppressed.
//
// Default is 0 (unset) → CURRENT BEHAVIOR: every unbounded command is nudged.
// This preserves the context-saving guarantee for large outputs by default —
// the threshold is strictly opt-in. Bounds [0, 100000]; invalid/zero/negative
// values fall back to 0 (disabled). The threshold gates ONLY the generic Bash
// nudge — curl/wget, inline-HTTP, and build-tool redirects run earlier and are
// never relaxed, because those are deterministic floods regardless of command
// length.
const BASH_NUDGE_MIN_BYTES_ENV = "CONTEXT_MODE_BASH_NUDGE_MIN_COMMAND_BYTES";
const BASH_NUDGE_MIN_BYTES_MAX = 100_000;
function getBashNudgeMinCommandBytes() {
const raw = process.env[BASH_NUDGE_MIN_BYTES_ENV];
if (raw == null || raw === "") return 0;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > BASH_NUDGE_MIN_BYTES_MAX) {
return 0;
}
return parsed;
}
function defaultGuidanceId() {
return process.env.VITEST_WORKER_ID
? `${process.ppid}-w${process.env.VITEST_WORKER_ID}`
@@ -794,6 +825,17 @@ export function routePreToolUse(toolName, toolInput, projectDir, platform, sessi
return null;
}
// #817: opt-in size threshold. When the operator configures
// CONTEXT_MODE_BASH_NUDGE_MIN_COMMAND_BYTES, a short unbounded command is
// treated as expected-lightweight and passes through untouched — reserving
// the nudge for commands large/complex enough to plausibly flood context.
// Default (0) preserves current behavior, so large-output savings are not
// weakened unless the operator explicitly opts in.
const minCommandBytes = getBashNudgeMinCommandBytes();
if (minCommandBytes > 0 && Buffer.byteLength(command, "utf8") < minCommandBytes) {
return null;
}
// allow all other Bash commands, but inject routing nudge (once per session)
return guidanceOnce("bash", bashGuidance, sessionId);
}
+68
View File
@@ -425,3 +425,71 @@ describe("Bash structurally-bounded allowlist: newline injection (#470)", () =>
expect(isStructurallyBounded("git status\rfind /")).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────
// #817: size threshold so small Bash/WebFetch calls skip interception.
//
// PreToolUse cannot observe a command's ACTUAL output size (the command has
// not run yet). The only deterministic pre-execution signal is the command
// string itself. The Gemini CLI adapter solves the same problem with a matcher
// that only fires on large-output tools — "avoids unnecessary hook overhead on
// lightweight tools" (README L193). We mirror that at the routing layer with an
// env-configurable command-length threshold: when CONTEXT_MODE_BASH_NUDGE_MIN_COMMAND_BYTES
// is set to N>0, an unbounded Bash command whose string is shorter than N bytes
// is treated as expected-lightweight and the routing nudge is skipped.
//
// Sane default: UNSET / 0 → current behavior (every unbounded command nudged),
// so the context-saving guarantee for large outputs is NOT silently weakened.
// Opt-in only — the operator chooses the threshold.
// ─────────────────────────────────────────────────────────────────────────
describe("Bash nudge size threshold (#817)", () => {
const SID = "threshold-817";
const ENV = "CONTEXT_MODE_BASH_NUDGE_MIN_COMMAND_BYTES";
beforeEach(() => {
resetGuidanceThrottle(SID);
delete process.env[ENV];
});
it("default (unset): short unbounded command STILL nudges — no behavior change", () => {
// Regression guard: without the env var, nothing changes. `ps` is short and
// unbounded — it must keep getting the nudge so the default stays safe.
const decision = routePreToolUse("Bash", { command: "ps" }, "/test", "claude-code", SID);
expect(decision?.action).toBe("context");
});
it("threshold set: short unbounded command below threshold SKIPS the nudge", () => {
process.env[ENV] = "64";
// "ps aux" is 6 bytes — below 64 → expected-lightweight → pass through.
const decision = routePreToolUse("Bash", { command: "ps aux" }, "/test", "claude-code", SID);
expect(decision, "short command below threshold should pass through untouched").toBeNull();
});
it("threshold set: long unbounded command at/above threshold STILL nudges", () => {
process.env[ENV] = "16";
// A long pipeline (> 16 bytes) can flood — must still intercept.
const long = "find / -type f -name '*.log' -exec cat {} +";
const decision = routePreToolUse("Bash", { command: long }, "/test", "claude-code", SID);
expect(decision?.action, "long command must still be nudged").toBe("context");
});
it("threshold does NOT relax curl/wget redirects (those stay deterministic)", () => {
process.env[ENV] = "4096"; // generous threshold — would otherwise mark this short cmd lightweight
// The threshold gates ONLY the generic Bash routing nudge. The curl/wget
// branch runs earlier and returns a `modify` redirect (or null only when
// MCP is unavailable) — it must NEVER be turned into a "pass-through-because-short".
// Assert the decision is NOT the generic "context" nudge: the threshold must
// not reclassify a curl flood as a lightweight bounded command.
const curl = routePreToolUse("Bash", { command: "curl https://x.io" }, "/test", "claude-code", SID);
expect(curl?.action ?? "modify-or-passthrough", "curl path must not become the generic nudge").not.toBe("context");
});
it("invalid / zero env value falls back to default (every unbounded cmd nudged)", () => {
for (const bad of ["0", "-5", "abc", ""]) {
resetGuidanceThrottle(SID);
process.env[ENV] = bad;
const decision = routePreToolUse("Bash", { command: "ps" }, "/test", "claude-code", SID);
expect(decision?.action, `env="${bad}" should behave as default`).toBe("context");
}
});
});