feat: allow curl/wget with silent file-output, block stdout floods (#166)

Algorithm: split chained commands, evaluate each segment independently.
Allow when ALL conditions met:
1. Output to file (-o, --output, >, >>)
2. NOT stdout alias (-o -, /dev/stdout)
3. Silent flag present (-s/--silent for curl, -q/--quiet for wget)
4. No verbose/trace flags (-v, --verbose, --trace)

Example allowed: curl -sLo /tmp/file.tar.gz https://url
Example blocked: curl https://url (stdout flood)
Example blocked: curl -o file url (no -s, progress bar floods stderr)

9 new tests covering allow/block/chain cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mert Koseoglu
2026-03-22 12:24:07 +03:00
co-authored by Claude Opus 4.6
parent af524eb5a7
commit 2731ca2527
2 changed files with 114 additions and 7 deletions
+45 -7
View File
@@ -180,14 +180,52 @@ export function routePreToolUse(toolName, toolInput, projectDir, platform) {
// like `gh issue edit --body "text with curl in it"` (Issue #63).
const stripped = stripQuotedContent(command);
// curl/wget → replace with echo redirect
// curl/wget — allow silent file-output downloads, block stdout floods (#166).
// Algorithm: split chained commands, evaluate each segment independently.
if (/(^|\s|&&|\||\;)(curl|wget)\s/i.test(stripped)) {
return {
action: "modify",
updatedInput: {
command: `echo "context-mode: curl/wget blocked. You MUST use ${t("ctx_fetch_and_index")}(url, source) to fetch URLs, or ${t("ctx_execute")}(language, code) to run HTTP calls in sandbox. Do NOT retry with curl/wget."`,
},
};
// Split on chain operators (&&, ||, ;) to evaluate each segment
const segments = stripped.split(/\s*(?:&&|\|\||;)\s*/);
const hasDangerousSegment = segments.some(seg => {
const s = seg.trim();
// Only evaluate segments that contain curl or wget
if (!/(^|\s)(curl|wget)\s/i.test(s)) return false;
const isCurl = /\bcurl\b/i.test(s);
const isWget = /\bwget\b/i.test(s);
// Check for file output flags
const hasFileOutput = isCurl
? /\s(-o|--output)\s/.test(s) || /\s*>\s*/.test(s) || /\s*>>\s*/.test(s)
: /\s(-O|--output-document)\s/.test(s) || /\s*>\s*/.test(s) || /\s*>>\s*/.test(s);
if (!hasFileOutput) return true; // no file output → dangerous
// Stdout aliases: -o -, -o /dev/stdout, -O -
if (isCurl && /\s(-o|--output)\s+(-|\/dev\/stdout)(\s|$)/.test(s)) return true;
if (isWget && /\s(-O|--output-document)\s+(-|\/dev\/stdout)(\s|$)/.test(s)) return true;
// Verbose/trace flags flood stderr → context
if (/\s(-v|--verbose|--trace|-D\s+-)\b/.test(s)) return true;
// Must be silent (curl: -s/--silent, wget: -q/--quiet) to prevent progress bar stderr flood
const isSilent = isCurl
? /\s-[a-zA-Z]*s|--silent/.test(s)
: /\s-[a-zA-Z]*q|--quiet/.test(s);
if (!isSilent) return true;
return false; // safe: silent + file output + no verbose + no stdout alias
});
if (hasDangerousSegment) {
return {
action: "modify",
updatedInput: {
command: `echo "context-mode: curl/wget blocked. You MUST use ${t("ctx_fetch_and_index")}(url, source) to fetch URLs, or ${t("ctx_execute")}(language, code) to run HTTP calls in sandbox. Do NOT retry with curl/wget."`,
},
};
}
// All segments safe → allow through
return null;
}
// Inline HTTP detection: strip only heredocs (not quotes) so that
+69
View File
@@ -59,6 +59,75 @@ describe("routePreToolUse", () => {
);
});
// ─── curl/wget file-output allow-list (#166) ────────────
it("allows curl -sLo file (silent + file output)", () => {
const result = routePreToolUse("Bash", {
command: "curl -sL https://example.com/file.tar.gz -o /tmp/file.tar.gz",
});
expect(result).toBeNull(); // null = allow through
});
it("allows curl -s --output file", () => {
const result = routePreToolUse("Bash", {
command: "curl -s --output /tmp/stripe.tar.gz https://github.com/stripe/stripe-cli/releases/download/v1.38.1/stripe.tar.gz",
});
expect(result).toBeNull();
});
it("allows wget -q -O file (quiet + file output)", () => {
const result = routePreToolUse("Bash", {
command: "wget -q -O /tmp/terraform.zip https://releases.hashicorp.com/terraform/1.0.0/terraform_1.0.0_linux_amd64.zip",
});
expect(result).toBeNull();
});
it("allows curl -s > file (silent + shell redirect)", () => {
const result = routePreToolUse("Bash", {
command: "curl -s https://example.com/data.json > /tmp/data.json",
});
expect(result).toBeNull();
});
it("blocks curl -o - (stdout alias)", () => {
const result = routePreToolUse("Bash", {
command: "curl -s -o - https://example.com",
});
expect(result).not.toBeNull();
expect(result!.action).toBe("modify");
});
it("blocks curl -o file WITHOUT silent flag", () => {
const result = routePreToolUse("Bash", {
command: "curl -L -o /tmp/file.tar.gz https://example.com/file.tar.gz",
});
expect(result).not.toBeNull();
expect(result!.action).toBe("modify");
});
it("blocks curl -o file with --verbose", () => {
const result = routePreToolUse("Bash", {
command: "curl -s --verbose -o /tmp/file.tar.gz https://example.com/file.tar.gz",
});
expect(result).not.toBeNull();
expect(result!.action).toBe("modify");
});
it("blocks chained: curl -sLo file && curl url (second floods)", () => {
const result = routePreToolUse("Bash", {
command: "curl -sL -o /tmp/file.tar.gz https://example.com/a.tar.gz && curl https://example.com/api",
});
expect(result).not.toBeNull();
expect(result!.action).toBe("modify");
});
it("allows chained: curl -sLo file && tar xzf file (both safe)", () => {
const result = routePreToolUse("Bash", {
command: "curl -sL -o /tmp/file.tar.gz https://example.com/a.tar.gz && tar xzf /tmp/file.tar.gz -C /tmp",
});
expect(result).toBeNull();
});
it("denies inline fetch() with modify action", () => {
const result = routePreToolUse("Bash", {
command: 'node -e "fetch(\'https://api.example.com/data\')"',