From aaa897c4492c5aacfc8e0c393ba55b466bc173e2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 27 Sep 2026 22:16:36 -0700 Subject: [PATCH 01/11] fix(secret-guard): expand shell variables before secret matching Tokens like $HOME/.env or ${CFG}/settings.json skipped every shell filter through a blanket includes("$") exemption, so secret-guard never prompted for them. Expand $VAR, ${VAR}, ${VAR:-default}, and ~ against process.env only (never shells out) at the top of isSensitiveShellToken; unexpandable references fail closed to a prompt while single-quoted command substitution stays literal. cmd keeps $ literal so ADS paths such as .flaskenv::$DATA still match. --- src/plugins/secret-guard-plugin.test.ts | 50 ++++++++- src/plugins/secret-guard-plugin.ts | 131 ++++++++++++++++++++---- 2 files changed, 160 insertions(+), 21 deletions(-) diff --git a/src/plugins/secret-guard-plugin.test.ts b/src/plugins/secret-guard-plugin.test.ts index c81261f5e..fc7dd5e1b 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "bun:test"; +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,6 +10,7 @@ import { loadProjectApprovals } from "../permission/store.js"; import { secretGuardPlugin, isSensitivePath, + isSensitiveShellToken, commandReferencesSensitivePath, } from "./secret-guard-plugin.js"; @@ -314,6 +315,53 @@ describe("commandReferencesSensitivePath", () => { } }); +describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", () => { + const CFG_VALUE = "/tmp/cl-8999-cfg/.corbits"; + let savedCFG: string | undefined; + let savedUnknown: string | undefined; + + beforeEach(() => { + savedCFG = process.env.CFG; + savedUnknown = process.env.UNKNOWN_X; + process.env.CFG = CFG_VALUE; + delete process.env.UNKNOWN_X; + }); + + afterEach(() => { + if (savedCFG === undefined) delete process.env.CFG; + else process.env.CFG = savedCFG; + if (savedUnknown === undefined) delete process.env.UNKNOWN_X; + else process.env.UNKNOWN_X = savedUnknown; + }); + + const expandedSensitive = [ + "cat $HOME/.env", + "cat ${HOME}/.env", + 'cat "$HOME/.env"', + "cat ${CFG}/settings.json", + "cat $CFG/settings.json", + "cat $UNKNOWN_X/.env", + ]; + for (const c of expandedSensitive) { + test(`flags: ${c}`, () => + expect(commandReferencesSensitivePath(c)).toBeDefined()); + } + + test("flags an unexpandable variable reference fail-closed", () => { + expect(isSensitiveShellToken("${BROKEN")).toBe(true); + }); + + test("flags a variable-expanded token directly", () => { + expect(isSensitiveShellToken("$CFG/settings.json")).toBe(true); + }); + + const expandedBenign = ["cat $HOME/README.md", "cat Makefile"]; + for (const c of expandedBenign) { + test(`allows: ${c}`, () => + expect(commandReferencesSensitivePath(c)).toBeUndefined()); + } +}); + describe("secretGuardPlugin run_shell", () => { // Shell commands that mention a secret path are no longer hard-denied here — // they require operator approval at the permission gate. The plugin only diff --git a/src/plugins/secret-guard-plugin.ts b/src/plugins/secret-guard-plugin.ts index 5ce24be87..73c5afd7a 100644 --- a/src/plugins/secret-guard-plugin.ts +++ b/src/plugins/secret-guard-plugin.ts @@ -203,7 +203,9 @@ export function createExtraDeniedPathMatcher( // Path-keyed tools stay hard-denied below. // // RESIDUAL THREAT MODEL: shell detection is best-effort. Token matching defeats -// quoting/escaping and the common env-assignment and redirection forms, but not +// quoting/escaping, the common env-assignment and redirection forms, and direct +// variable references — `$VAR`, `${VAR}`, `${VAR:-default}`, and `~` expand +// against process.env before matching, so `cat $HOME/.env` prompts — but not // dynamic construction of a path the matcher never sees as one token — e.g. // indirection through an unrelated variable (`F=.en; cat ${F}v`), character-by- // character assembly (`printf`), or reading via an interpreter that builds the @@ -218,20 +220,18 @@ export function createExtraDeniedPathMatcher( export const PURE_DIRECTORY_LISTING_PROGRAMS = new Set(["ls", "tree"]); // Worth spending a realpath on: shaped like a path the shell could open -// (a slash, an extension dot, or absolute), not a flag, variable, or fd +// (a slash, an extension dot, or absolute), not a flag, glob, or fd // number — those can never name a file the shell opens, so they skip the -// stat and the hot auto-allow path stays syscall-free for them. Globs are -// skipped here for a different reason: the matcher only sees the unexpanded -// pattern, so `cat *.txt` cannot resolve without running the shell — but a -// glob CAN expand into a symlink at runtime, which stays a stated residual -// (see the threat model below), not something this filter disproves. +// stat and the hot auto-allow path stays syscall-free for them. Shell +// variables reach here already expanded (see expandShellToken), so there is +// no `$` exemption: an unexpandable token fails closed before this filter. +// Globs are skipped here for a different reason: the matcher only sees the +// unexpanded pattern, so `cat *.txt` cannot resolve without running the +// shell — but a glob CAN expand into a symlink at runtime, which stays a +// stated residual (see the threat model below), not something this filter +// disproves. function isPathLikeShellToken(token: string): boolean { - if ( - token.startsWith("-") || - token.includes("$") || - token.includes("*") || - token.includes("`") - ) + if (token.startsWith("-") || token.includes("*") || token.includes("`")) return false; return ( isAbsolute(token) || @@ -252,16 +252,105 @@ export function expandHome(token: string): string { return token; } +// Expand a shell token's `~` and `$` references against process.env only — +// never shells out. Handles `$VAR`, `${VAR}`, `${VAR:-default}` / +// `${VAR-default}`, and a single layer of surrounding quotes; `\$` is a +// literal dollar and unset variables expand to empty. A `$` followed by any +// other character (or at end of token) is a literal dollar, matching shell +// behavior for `$.`, `$"`, and friends. A backtick or `$(` the tokenizer left +// whole comes from single quotes, where the shell never substitutes — it is +// matched as literal text. Returns expandable=false only when the token +// cannot be resolved statically: a malformed `${…}` or an unsupported +// operator (`:=`, `:?`, `:+`, `#`, `%`, `/`). Callers fail closed on +// expandable=false: the shell would compute the value at runtime, so the +// matcher must assume the worst. +export interface ExpandedShellToken { + expanded: string; + expandable: boolean; +} + +const SHELL_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*/; +const SHELL_BRACED_VAR = /^([A-Za-z_][A-Za-z0-9_]*)(:-(.*)|-(.*)|)$/s; + +export function expandShellToken( + token: string, + dialect: ShellDialect = "posix", +): ExpandedShellToken { + let text = token; + if ( + text.length >= 2 && + ((text.startsWith('"') && text.endsWith('"')) || + (text.startsWith("'") && text.endsWith("'"))) + ) { + text = text.slice(1, -1); + } + if (text.includes("`") || text.includes("$(")) { + return { expanded: text, expandable: true }; + } + if (text === "~") text = homedir(); + else if (text.startsWith("~/")) text = joinPath(homedir(), text.slice(2)); + // In cmd `$` is literal (`type .flaskenv::$DATA` names the default ADS + // stream — there is no `$VAR` expansion, only `%VAR%`), so expanding would + // corrupt the token before matching. Only posix-style dialects expand. + if (dialect === "cmd") return { expanded: text, expandable: true }; + let expanded = ""; + for (let i = 0; i < text.length;) { + const char = text[i] ?? ""; + if (char === "\\" && text[i + 1] === "$") { + expanded += "$"; + i += 2; + continue; + } + if (char !== "$") { + expanded += char; + i += 1; + continue; + } + const rest = text.slice(i + 1); + if (rest.startsWith("{")) { + const close = text.indexOf("}", i + 2); + if (close === -1) return { expanded: token, expandable: false }; + const match = SHELL_BRACED_VAR.exec(text.slice(i + 2, close)); + if (match === null) return { expanded: token, expandable: false }; + const value = process.env[match[1] ?? ""]; + const fallback = match[3] ?? match[4]; + if (fallback === undefined) { + expanded += value ?? ""; + } else if ( + value === undefined || + (match[3] !== undefined && value === "") + ) { + const inner = expandShellToken(fallback, dialect); + if (!inner.expandable) return { expanded: token, expandable: false }; + expanded += inner.expanded; + } else { + expanded += value; + } + i = close + 1; + continue; + } + const name = SHELL_VAR_NAME.exec(rest)?.[0]; + if (name !== undefined) { + expanded += process.env[name] ?? ""; + i += 1 + name.length; + continue; + } + expanded += "$"; + i += 1; + } + return { expanded, expandable: true }; +} + // A bare token the shell could open as a cwd-relative file: not a flag, -// variable, glob, or command substitution — same exclusions as the path-like +// glob, or command substitution — same exclusions as the path-like // filter, minus the dot/slash shape requirement, so extensionless names // (`notes`, or `notes` split out of `--file=notes` / `cat -n notes`) still -// get an existence probe below. +// get an existence probe below. Shell variables reach here already expanded, +// so there is no `$` exemption (see expandShellToken). function isBareProbeCandidate(token: string): boolean { return ( token.length > 0 && !token.startsWith("-") && - !token.includes("$") && !token.includes("*") && !token.includes("`") ); @@ -277,11 +366,12 @@ function isBareProbeCandidate(token: string): boolean { // tokens first pay a single lstat existence probe against the cwd-resolved // path — a miss (the common `cat Makefile` case) costs exactly that one // lstat and skips the resolve, a hit (file or symlink, dangling included) -// pays the realpath and matches on the target. Flags, variables, globs, and +// pays the realpath and matches on the target. Flags, globs, and // backticks never probe, so the worst case per command is one lstat per bare // token plus one realpath per existing entry. Relative tokens resolve -// against cwd first because the helper takes absolute paths; `~` expands to -// the home directory before resolving for the same reason. That cwd is the +// against cwd first because the helper takes absolute paths; `~` and +// `$VAR`/`${VAR}` expand against process.env before resolving (see +// expandShellToken) for the same reason. That cwd is the // session/process cwd, not a `cd` prefix inside the command — // `cd sub && cat notes.txt` resolves `notes.txt` against the session cwd // (absent) rather than cwd/sub (present). The chain still fails closed @@ -303,7 +393,8 @@ export function isSensitiveShellToken( isExtraDenied: (value: string) => boolean = () => false, dialect: ShellDialect = nativeShellDialect(process.platform), ): boolean { - const expanded = expandHome(token); + const { expanded, expandable } = expandShellToken(token, dialect); + if (!expandable) return true; if (isSensitivePath(expanded, dialect)) return true; if (isExtraDenied(expanded)) return true; if (!resolveSymlinks) { From 5509622b4f586d4bf6fcd12c675a404c474f9b6f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 07:48:59 -0700 Subject: [PATCH 02/11] fix(secret-guard): close ?/[] glob bypass, resolve :=/:+ over-prompt --- src/plugins/secret-guard-plugin.test.ts | 83 ++++++++++++++++++++++++- src/plugins/secret-guard-plugin.ts | 42 +++++++++---- 2 files changed, 113 insertions(+), 12 deletions(-) diff --git a/src/plugins/secret-guard-plugin.test.ts b/src/plugins/secret-guard-plugin.test.ts index fc7dd5e1b..b23dc9553 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -12,6 +12,7 @@ import { isSensitivePath, isSensitiveShellToken, commandReferencesSensitivePath, + expandShellToken, } from "./secret-guard-plugin.js"; const next = async (call: ToolCall): Promise => ({ @@ -271,6 +272,11 @@ describe("commandReferencesSensitivePath", () => { // Relative-dot prefixes resolve to the same anchored match as a raw token. "cat ./.env", "cat ./secrets/.env", + // `?`/`[…]` globs read a secret the matcher only sees as a pattern. + "cat .en?", + "cat .e?v", + "cat .en[v]", + "head -c 100 .en?", // Runtime env-file loaders — detected so the gate can ask, not hard-deny. "bun --env-file=../../.env.staging run bin/publish.ts", "bun --env-file=.env run -e 'console.log(1)'", @@ -308,6 +314,10 @@ describe("commandReferencesSensitivePath", () => { "sed --f=.envrc input.txt", "grep --fil=.envrc needle", "bun test", + // `*` stays an accepted residual: it cannot resolve without running the + // shell, and prompting on it would fire on every benign `cat *`. + "cat *", + "cat *.txt", ]; for (const c of allowed) { test(`allows: ${c}`, () => @@ -319,12 +329,18 @@ describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", () const CFG_VALUE = "/tmp/cl-8999-cfg/.corbits"; let savedCFG: string | undefined; let savedUnknown: string | undefined; + let savedPort: string | undefined; + let savedEmpty: string | undefined; beforeEach(() => { savedCFG = process.env.CFG; savedUnknown = process.env.UNKNOWN_X; + savedPort = process.env.PORT; + savedEmpty = process.env.EMPTY_X; process.env.CFG = CFG_VALUE; delete process.env.UNKNOWN_X; + delete process.env.PORT; + delete process.env.EMPTY_X; }); afterEach(() => { @@ -332,6 +348,10 @@ describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", () else process.env.CFG = savedCFG; if (savedUnknown === undefined) delete process.env.UNKNOWN_X; else process.env.UNKNOWN_X = savedUnknown; + if (savedPort === undefined) delete process.env.PORT; + else process.env.PORT = savedPort; + if (savedEmpty === undefined) delete process.env.EMPTY_X; + else process.env.EMPTY_X = savedEmpty; }); const expandedSensitive = [ @@ -341,6 +361,8 @@ describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", () "cat ${CFG}/settings.json", "cat $CFG/settings.json", "cat $UNKNOWN_X/.env", + "cat ${UNKNOWN_X:-$CFG/settings.json}", + "cat ${UNKNOWN_X:=.env}", ]; for (const c of expandedSensitive) { test(`flags: ${c}`, () => @@ -355,7 +377,66 @@ describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", () expect(isSensitiveShellToken("$CFG/settings.json")).toBe(true); }); - const expandedBenign = ["cat $HOME/README.md", "cat Makefile"]; + test("resolves := without prompting when the default is benign", () => { + expect(isSensitiveShellToken("${UNKNOWN_X:=fallback.txt}")).toBe(false); + }); + + test("allows := / :+ port defaults without a prompt", () => { + expect( + commandReferencesSensitivePath("bun --port ${PORT:=3000} run x"), + ).toBeUndefined(); + process.env.PORT = "4000"; + expect( + commandReferencesSensitivePath("bun --port ${PORT:=3000} run x"), + ).toBeUndefined(); + delete process.env.PORT; + expect( + commandReferencesSensitivePath("bun --port ${PORT:+3000} run x"), + ).toBeUndefined(); + }); + + test("expands := like :- for unset and empty variables", () => { + expect(expandShellToken("${UNKNOWN_X:=dflt}")).toEqual({ + expanded: "dflt", + expandable: true, + }); + expect(expandShellToken("${CFG:=dflt}").expanded).toBe(CFG_VALUE); + process.env.EMPTY_X = ""; + expect(expandShellToken("${EMPTY_X:=dflt}").expanded).toBe("dflt"); + }); + + test("expands :+ and + only when the variable is set", () => { + expect(expandShellToken("${CFG:+alt}").expanded).toBe("alt"); + expect(expandShellToken("${CFG+alt}").expanded).toBe("alt"); + expect(expandShellToken("${UNKNOWN_X:+alt}")).toEqual({ + expanded: "", + expandable: true, + }); + expect(expandShellToken("${UNKNOWN_X+alt}").expanded).toBe(""); + process.env.EMPTY_X = ""; + expect(expandShellToken("${EMPTY_X:+alt}").expanded).toBe(""); + expect(expandShellToken("${EMPTY_X+alt}").expanded).toBe("alt"); + }); + + test("keeps :?, #, %, / and offsets fail-closed", () => { + for (const token of [ + "${CFG:?must be set}", + "${CFG#prefix}", + "${CFG%post}", + "${CFG/a/b}", + "${CFG:1}", + "${CFG:1:2}", + "${UNKNOWN_X:-${BROKEN}", + ]) { + expect(expandShellToken(token).expandable).toBe(false); + } + }); + + const expandedBenign = [ + "cat $HOME/README.md", + "cat Makefile", + "cat ${UNKNOWN_X:-prefix}", + ]; for (const c of expandedBenign) { test(`allows: ${c}`, () => expect(commandReferencesSensitivePath(c)).toBeUndefined()); diff --git a/src/plugins/secret-guard-plugin.ts b/src/plugins/secret-guard-plugin.ts index 73c5afd7a..a1aa7c036 100644 --- a/src/plugins/secret-guard-plugin.ts +++ b/src/plugins/secret-guard-plugin.ts @@ -225,11 +225,13 @@ export const PURE_DIRECTORY_LISTING_PROGRAMS = new Set(["ls", "tree"]); // stat and the hot auto-allow path stays syscall-free for them. Shell // variables reach here already expanded (see expandShellToken), so there is // no `$` exemption: an unexpandable token fails closed before this filter. -// Globs are skipped here for a different reason: the matcher only sees the +// `*` globs are skipped here for a different reason: the matcher only sees the // unexpanded pattern, so `cat *.txt` cannot resolve without running the // shell — but a glob CAN expand into a symlink at runtime, which stays a -// stated residual (see the threat model below), not something this filter -// disproves. +// stated residual (see the threat model above), not something this filter +// disproves. `?` and `[…]` patterns are not skipped: `cat .en?` reads `.env` +// while the matcher only ever sees the pattern, so they fail closed to a +// prompt in isSensitiveShellToken instead of resolving here. function isPathLikeShellToken(token: string): boolean { if (token.startsWith("-") || token.includes("*") || token.includes("`")) return false; @@ -254,14 +256,15 @@ export function expandHome(token: string): string { // Expand a shell token's `~` and `$` references against process.env only — // never shells out. Handles `$VAR`, `${VAR}`, `${VAR:-default}` / -// `${VAR-default}`, and a single layer of surrounding quotes; `\$` is a +// `${VAR-default}`, `${VAR:=default}`, and `${VAR:+alt}` / `${VAR+alt}`, +// plus a single layer of surrounding quotes; `\$` is a // literal dollar and unset variables expand to empty. A `$` followed by any // other character (or at end of token) is a literal dollar, matching shell // behavior for `$.`, `$"`, and friends. A backtick or `$(` the tokenizer left // whole comes from single quotes, where the shell never substitutes — it is // matched as literal text. Returns expandable=false only when the token // cannot be resolved statically: a malformed `${…}` or an unsupported -// operator (`:=`, `:?`, `:+`, `#`, `%`, `/`). Callers fail closed on +// operator (`:?`, `#`, `%`, `/`). Callers fail closed on // expandable=false: the shell would compute the value at runtime, so the // matcher must assume the worst. export interface ExpandedShellToken { @@ -270,7 +273,8 @@ export interface ExpandedShellToken { } const SHELL_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*/; -const SHELL_BRACED_VAR = /^([A-Za-z_][A-Za-z0-9_]*)(:-(.*)|-(.*)|)$/s; +const SHELL_BRACED_VAR = + /^([A-Za-z_][A-Za-z0-9_]*)(:=(.*)|:-(.*)|-(.*)|:\+(.*)|\+(.*)|)$/s; export function expandShellToken( token: string, @@ -313,19 +317,29 @@ export function expandShellToken( const match = SHELL_BRACED_VAR.exec(text.slice(i + 2, close)); if (match === null) return { expanded: token, expandable: false }; const value = process.env[match[1] ?? ""]; - const fallback = match[3] ?? match[4]; - if (fallback === undefined) { + const fallback = match[3] ?? match[4] ?? match[5]; + const alternate = match[6] ?? match[7]; + if (fallback === undefined && alternate === undefined) { expanded += value ?? ""; } else if ( - value === undefined || - (match[3] !== undefined && value === "") + fallback !== undefined && + (value === undefined || (match[5] === undefined && value === "")) ) { const inner = expandShellToken(fallback, dialect); if (!inner.expandable) return { expanded: token, expandable: false }; expanded += inner.expanded; - } else { + } else if ( + alternate !== undefined && + value !== undefined && + (match[6] === undefined || value !== "") + ) { + const inner = expandShellToken(alternate, dialect); + if (!inner.expandable) return { expanded: token, expandable: false }; + expanded += inner.expanded; + } else if (fallback !== undefined) { expanded += value; } + // Otherwise the alternate form expands to empty — append nothing. i = close + 1; continue; } @@ -404,6 +418,12 @@ export function isSensitiveShellToken( ); } if (dialect === "cmd" && /^\\\\[?.]\\/.test(expanded)) return false; + // A `?` or `[` glob expands at runtime into whatever names match, so the + // matcher only ever sees the pattern while the shell can open a secret + // (`cat .en?` and `cat .en[v]` both read `.env`). Fail closed to a prompt — + // the dual of the `*` exclusions in the filters above, which stay untouched. + // After the cmd device-path exemption so `\\?\…` names keep working. + if (expanded.includes("?") || expanded.includes("[")) return true; if (isPathLikeShellToken(expanded)) { if (isAbsolute(expanded)) { return ( From a7b5d286c079f435eb9d4dbe930b69781609cc8c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 09:20:38 -0700 Subject: [PATCH 03/11] test(secret-guard): add CL-8999 ordering keepers for glob and device-path legs --- src/plugins/secret-guard-plugin.test.ts | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/plugins/secret-guard-plugin.test.ts b/src/plugins/secret-guard-plugin.test.ts index b23dc9553..31b7c7fe7 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -443,6 +443,35 @@ describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", () } }); +describe("secret-guard ordering keepers (CL-8999)", () => { + // H1: the pure-listing leg precedes the `?`/`[` glob check — moving the + // glob check earlier would prompt on a listing that never dumps contents. + test("pure listing with ?/[...] globs still lists freely", () => { + expect(commandReferencesSensitivePath("ls .en?")).toBeUndefined(); + expect(commandReferencesSensitivePath("ls .en[v]")).toBeUndefined(); + }); + + // H3: the cmd device-path exemption precedes the `?` check — the `?` in + // `\\?\…` must not fail closed to a prompt. + test("cmd device-path names keep working", () => { + expect( + isSensitiveShellToken( + String.raw`\\?\C:\repo\notes.txt`, + process.cwd(), + true, + () => false, + "cmd", + ), + ).toBe(false); + }); + + // H7: a piped ls loses the listing exemption and takes the resolve leg, + // so the glob check fires and prompts. + test("piped listing with a ? glob prompts", () => { + expect(commandReferencesSensitivePath("ls .en? | cat")).toBeDefined(); + }); +}); + describe("secretGuardPlugin run_shell", () => { // Shell commands that mention a secret path are no longer hard-denied here — // they require operator approval at the permission gate. The plugin only From bb8e006535db2fc9cc9fc0f2326d201d6aee638d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 09:38:31 -0700 Subject: [PATCH 04/11] fix(secret-guard): narrow ?/[] to file operands, prompt .* globs --- src/plugins/secret-guard-plugin.test.ts | 41 +++++++++++++++ src/plugins/secret-guard-plugin.ts | 70 +++++++++++++++++++++---- 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/src/plugins/secret-guard-plugin.test.ts b/src/plugins/secret-guard-plugin.test.ts index 31b7c7fe7..9526e8665 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -325,6 +325,47 @@ describe("commandReferencesSensitivePath", () => { } }); +describe("secret-guard glob narrowing (CL-8999)", () => { + let savedUnknown: string | undefined; + + beforeEach(() => { + savedUnknown = process.env.UNKNOWN_X; + delete process.env.UNKNOWN_X; + }); + + afterEach(() => { + if (savedUnknown === undefined) delete process.env.UNKNOWN_X; + else process.env.UNKNOWN_X = savedUnknown; + }); + // `?`/`[` fire only on file-operand-shaped tokens: URLs, regex operands, + // and the `[` test builtin itself must not prompt. + const allowed = [ + "curl https://api.example.com/search?q=term", + "grep -E colou?r file.txt", + "grep 'colou?r' file.txt", + "grep [0-9] file.txt", + "grep '[0-9]' file.txt", + "[ -f Makefile ]", + ]; + for (const c of allowed) { + test(`allows: ${c}`, () => + expect(commandReferencesSensitivePath(c)).toBeUndefined()); + } + + // Dotfile-rooted `*` globs deterministically match `.env` in any realistic + // cwd, so they prompt; bare `*` cannot match a leading dot and stays free. + const blocked = ["cat .*", "cat .env*", "cat ${UNKNOWN_X:=.env*}"]; + for (const c of blocked) { + test(`flags: ${c}`, () => + expect(commandReferencesSensitivePath(c)).toBeDefined()); + } + + test("keeps bare * allowed", () => { + expect(commandReferencesSensitivePath("cat *")).toBeUndefined(); + expect(commandReferencesSensitivePath("cat *.txt")).toBeUndefined(); + }); +}); + describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", () => { const CFG_VALUE = "/tmp/cl-8999-cfg/.corbits"; let savedCFG: string | undefined; diff --git a/src/plugins/secret-guard-plugin.ts b/src/plugins/secret-guard-plugin.ts index a1aa7c036..a37f35d7b 100644 --- a/src/plugins/secret-guard-plugin.ts +++ b/src/plugins/secret-guard-plugin.ts @@ -209,10 +209,17 @@ export function createExtraDeniedPathMatcher( // dynamic construction of a path the matcher never sees as one token — e.g. // indirection through an unrelated variable (`F=.en; cat ${F}v`), character-by- // character assembly (`printf`), or reading via an interpreter that builds the -// name at runtime. Unexpanded globs are the same class: `cat *` can open a -// symlink the matcher only ever saw as `*`. Perfect shell sandboxing is out -// of scope; the goal is to force a prompt for the trivial, single-token -// references that make exfiltration easy. Tool-result secret scrub still redacts credential-shaped output. +// name at runtime. Unexpanded globs are narrowed, not closed: `?`/`[` prompt +// only on file-operand-shaped tokens — URLs (`…?q=…`), regex operands +// (`grep -E colou?r`), and bare `[`/`]` test syntax are exempt, and a pattern +// with no `.`, `/`, or `\` cannot match a dotfile secret anyway — while `*` +// prompts only when dotfile-rooted (`.*`, `.env*`) or lexically sensitive +// (`*.pem`). What stays allowed, and why: bare `*` / `*.txt` cannot match a +// leading dot and would fire on every benign `cat *`; non-dotfile-rooted `*` +// (`.config/*`) and dotless-secret `?`/`[` forms (`id_rs?`) are already +// reachable through bare `*`, so closing them alone buys nothing. Perfect +// shell sandboxing is out of scope; the goal is to force a prompt for the +// trivial, single-token references that make exfiltration easy. Tool-result secret scrub still redacts credential-shaped output. // Programs that only print directory names / metadata — listing a name never // dumps file contents. Single owner for this set: the resolve-leg skip below // and classify.ts's pure-listing exemption both read it, so a new names-only @@ -229,9 +236,13 @@ export const PURE_DIRECTORY_LISTING_PROGRAMS = new Set(["ls", "tree"]); // unexpanded pattern, so `cat *.txt` cannot resolve without running the // shell — but a glob CAN expand into a symlink at runtime, which stays a // stated residual (see the threat model above), not something this filter -// disproves. `?` and `[…]` patterns are not skipped: `cat .en?` reads `.env` -// while the matcher only ever sees the pattern, so they fail closed to a -// prompt in isSensitiveShellToken instead of resolving here. +// disproves. The one exception lives in isSensitiveShellToken: dotfile-rooted +// `*` patterns (`.*`, `.env*`) deterministically match `.env`, so they fail +// closed to a prompt there. `?` and `[…]` patterns are likewise not skipped: +// `cat .en?` reads `.env` while the matcher only ever sees the pattern, so +// file-operand-shaped ones fail closed to a prompt in isSensitiveShellToken +// instead of resolving here (URLs, regex operands, and bare `[`/`]` test +// syntax are exempt — see that check). function isPathLikeShellToken(token: string): boolean { if (token.startsWith("-") || token.includes("*") || token.includes("`")) return false; @@ -370,6 +381,16 @@ function isBareProbeCandidate(token: string): boolean { ); } +// Final path segment starts with a literal dot and holds a `*`: `.*`, +// `.env*`, `sub/.*`. Bare `*` / `*.txt` never match a leading dot under +// default shell semantics, so they stay out — as does anything rooted outside +// a dotfile name (`.config/*`). +function isDotfileRootedGlob(token: string): boolean { + if (!token.includes("*")) return false; + const segment = token.split(/[/\\]/).at(-1) ?? token; + return segment.startsWith(".") && segment.includes("*"); +} + // CL-7790: the ONE shell-token matcher both secret-guard call sites share — // commandReferencesSensitivePath below and classify.ts's per-arg sensitive // check. The cheap lexical denylist runs first so the hot auto-allow path @@ -421,9 +442,38 @@ export function isSensitiveShellToken( // A `?` or `[` glob expands at runtime into whatever names match, so the // matcher only ever sees the pattern while the shell can open a secret // (`cat .en?` and `cat .en[v]` both read `.env`). Fail closed to a prompt — - // the dual of the `*` exclusions in the filters above, which stay untouched. - // After the cmd device-path exemption so `\\?\…` names keep working. - if (expanded.includes("?") || expanded.includes("[")) return true; + // but only for file-operand-shaped tokens. The unscoped rule fired on + // non-file operands: query strings (`curl …/search?q=term`), regex operands + // (`grep -E colou?r`, `grep [0-9]`), and the `[` test builtin itself + // (`[ -f Makefile ]`). Three exemptions, each too narrow to reopen a + // bypass: tokens containing `://` are URLs, never a local file the shell + // opens (the lexical denylist above still catches `file://…/.env`); bare + // `[`/`]`/`[[`/`]]` are test syntax, not globs; and a `?`/`[` pattern with + // no `.`, `/`, or `\` cannot name a dotfile secret — `?`/`[…]` never match + // a leading dot under default shell semantics, so the literal dot must be + // present. Dotless secrets (`id_rsa`, `Cookies`) stay reachable through the + // accepted bare-`*` residual below, so exempting their `?`/`[` forms adds + // no new bypass. After the cmd device-path exemption so `\\?\…` names keep + // working. + if ( + (expanded.includes("?") || expanded.includes("[")) && + !expanded.includes("://") && + expanded !== "[" && + expanded !== "]" && + expanded !== "[[" && + expanded !== "]]" && + (expanded.includes(".") || + expanded.includes("/") || + expanded.includes("\\")) + ) + return true; + // Dotfile-rooted `*` globs (`.*`, `.env*`) deterministically match `.env` + // in any realistic cwd, so they prompt — the carve-out from the `*` + // exclusions in the filters above. Bare `*` / `*.txt` cannot match a + // leading dot and stay allowed, as do `*` globs rooted outside a dotfile + // name (`.config/*`). Runs post-expansion, so `${UNKNOWN_X:=.env*}` + // prompts while `${UNKNOWN_X:=fallback.txt}` stays free. + if (isDotfileRootedGlob(expanded)) return true; if (isPathLikeShellToken(expanded)) { if (isAbsolute(expanded)) { return ( From 39da00261f572a5aea7a975513384f581eddf53c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 11:48:03 -0700 Subject: [PATCH 05/11] fix(secret-guard): keep file URLs behind glob guard --- src/plugins/secret-guard-plugin.test.ts | 17 +++++++++++++++++ src/plugins/secret-guard-plugin.ts | 16 +++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/plugins/secret-guard-plugin.test.ts b/src/plugins/secret-guard-plugin.test.ts index 9526e8665..93d7bbfb7 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -360,6 +360,23 @@ describe("secret-guard glob narrowing (CL-8999)", () => { expect(commandReferencesSensitivePath(c)).toBeDefined()); } + // `file:`-scheme URLs are local reads, so the `://` exemption must not + // cover them: query-suffixed and globbed secret names still prompt. + const fileBlocked = [ + "curl file:/home/u/.env?q=x", + "curl file:///home/u/.env?q=x", + "cat file:///home/u/.env?q=x", + "wget file:///home/u/.env?q=x", + "curl file:///home/u/id_rsa?q=x", + "curl file:///home/u/.en?", + "curl file:///home/u/.en[v]", + "curl FILE:///home/u/.env?q=x", + ]; + for (const c of fileBlocked) { + test(`flags: ${c}`, () => + expect(commandReferencesSensitivePath(c)).toBeDefined()); + } + test("keeps bare * allowed", () => { expect(commandReferencesSensitivePath("cat *")).toBeUndefined(); expect(commandReferencesSensitivePath("cat *.txt")).toBeUndefined(); diff --git a/src/plugins/secret-guard-plugin.ts b/src/plugins/secret-guard-plugin.ts index a37f35d7b..635c5cace 100644 --- a/src/plugins/secret-guard-plugin.ts +++ b/src/plugins/secret-guard-plugin.ts @@ -391,6 +391,15 @@ function isDotfileRootedGlob(token: string): boolean { return segment.startsWith(".") && segment.includes("*"); } +// `file:` URLs are local reads (`curl file:///home/u/.env` opens `.env`), +// so only non-file URL schemes receive the `?`/`[` fatigue exemption below. +// Scheme matching is case-insensitive and covers `file:`, `file://`, and +// `FILE://` variants. +function isFileSchemeURL(token: string): boolean { + const scheme = /^[A-Za-z][A-Za-z0-9+.-]*:/.exec(token)?.[0]; + return scheme?.toLowerCase() === "file:"; +} + // CL-7790: the ONE shell-token matcher both secret-guard call sites share — // commandReferencesSensitivePath below and classify.ts's per-arg sensitive // check. The cheap lexical denylist runs first so the hot auto-allow path @@ -446,8 +455,9 @@ export function isSensitiveShellToken( // non-file operands: query strings (`curl …/search?q=term`), regex operands // (`grep -E colou?r`, `grep [0-9]`), and the `[` test builtin itself // (`[ -f Makefile ]`). Three exemptions, each too narrow to reopen a - // bypass: tokens containing `://` are URLs, never a local file the shell - // opens (the lexical denylist above still catches `file://…/.env`); bare + // bypass: tokens containing `://` with a non-file URL scheme receive the + // fatigue exemption, while `file:` URLs are local reads and stay guarded + // (see isFileSchemeURL); bare // `[`/`]`/`[[`/`]]` are test syntax, not globs; and a `?`/`[` pattern with // no `.`, `/`, or `\` cannot name a dotfile secret — `?`/`[…]` never match // a leading dot under default shell semantics, so the literal dot must be @@ -457,7 +467,7 @@ export function isSensitiveShellToken( // working. if ( (expanded.includes("?") || expanded.includes("[")) && - !expanded.includes("://") && + (!expanded.includes("://") || isFileSchemeURL(expanded)) && expanded !== "[" && expanded !== "]" && expanded !== "[[" && From c428e02e39d43480bc8ad6af20ff2be1aa92ddad Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 12:05:02 -0700 Subject: [PATCH 06/11] fix(secret-guard): decode local file URL paths --- src/plugins/secret-guard-plugin.test.ts | 82 ++++++++++++++++++++++++- src/plugins/secret-guard-plugin.ts | 56 +++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/plugins/secret-guard-plugin.test.ts b/src/plugins/secret-guard-plugin.test.ts index 93d7bbfb7..dd10da111 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createPosixTools } from "@intx/tools-posix"; @@ -383,6 +383,86 @@ describe("secret-guard glob narrowing (CL-8999)", () => { }); }); +describe("secret-guard file URL normalization", () => { + const encodedSecrets = [ + "curl file:///tmp/%2Eenv", + "curl file:///tmp/.%65nv", + "curl file:///tmp/%69d_rsa", + "curl FILE:///tmp/%2Eenv", + "curl file:///tmp/secrets%2F%2Eenv", + "curl file:///tmp/secrets/%2e%2e/%2Eenv", + "curl file:./%2Eenv", + "curl file://localhost/tmp/%2Eenv", + "curl file:////tmp/%2Eenv", + ]; + + for (const command of encodedSecrets) { + test(`flags decoded local path: ${command}`, () => { + expect(commandReferencesSensitivePath(command)).toBeDefined(); + }); + } + + test("flags an encoded URL that curl can use to read a real .env", async () => { + const cwd = await mkdtemp(join(tmpdir(), "secret-guard-file-url-")); + try { + await writeFile(join(cwd, ".env"), "SECRET=proof\n"); + expect( + commandReferencesSensitivePath(`curl file://${cwd}/%2Eenv`, cwd), + ).toBeDefined(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + for (const malformed of ["%", "%2", "%GG", "%E0%A4%A"]) { + test(`fails closed for malformed file URL escape: ${malformed}`, () => { + expect( + commandReferencesSensitivePath(`curl file:///tmp/${malformed}`), + ).toBeDefined(); + }); + } + + test("decodes file URL paths exactly once", () => { + expect( + commandReferencesSensitivePath("curl file:///tmp/%252Eenv"), + ).toBeUndefined(); + }); + + test("uses the pathname before a file URL fragment", () => { + expect( + commandReferencesSensitivePath("curl 'file:///tmp/%2Eenv#section'"), + ).toBeDefined(); + }); + + test("allows localhost with a benign decoded path", () => { + expect( + commandReferencesSensitivePath("curl file://localhost/tmp/README.md"), + ).toBeUndefined(); + }); + + test("fails closed for unsupported file URL hosts and Windows forms", () => { + expect(isSensitiveShellToken("file://server/share/README.md")).toBe(true); + expect( + isSensitiveShellToken( + "file:///C:/safe.txt", + process.cwd(), + true, + () => false, + "cmd", + ), + ).toBe(true); + }); + + test("does not decode percent escapes in remote URLs", () => { + expect( + commandReferencesSensitivePath("curl https://example.com/%2Eenv"), + ).toBeUndefined(); + expect( + commandReferencesSensitivePath("curl https://example.com/.env"), + ).toBeDefined(); + }); +}); + describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", () => { const CFG_VALUE = "/tmp/cl-8999-cfg/.corbits"; let savedCFG: string | undefined; diff --git a/src/plugins/secret-guard-plugin.ts b/src/plugins/secret-guard-plugin.ts index 635c5cace..624d6b695 100644 --- a/src/plugins/secret-guard-plugin.ts +++ b/src/plugins/secret-guard-plugin.ts @@ -400,6 +400,32 @@ function isFileSchemeURL(token: string): boolean { return scheme?.toLowerCase() === "file:"; } +type FileURLPath = + | { localPath: string; failClosed: false } + | { failClosed: true }; + +// WHATWG parsing handles slash counts, relative file paths, localhost, and +// dot-segment normalization. Decode pathname exactly once to match URL +// transport semantics; the raw token is checked afterward so query/glob +// handling remains separate. This naturally exposes a sensitive pathname +// before its URL fragment, without interpreting generic fragments or `@file` +// indirection. Unsupported hosts, Windows forms, and malformed escapes prompt +// rather than guessing or throwing. +function normalizeFileURLPath( + token: string, + dialect: ShellDialect, +): FileURLPath | undefined { + if (!isFileSchemeURL(token)) return undefined; + if (dialect === "cmd") return { failClosed: true }; + try { + const url = new URL(token); + if (url.host !== "") return { failClosed: true }; + return { localPath: decodeURIComponent(url.pathname), failClosed: false }; + } catch { + return { failClosed: true }; + } +} + // CL-7790: the ONE shell-token matcher both secret-guard call sites share — // commandReferencesSensitivePath below and classify.ts's per-arg sensitive // check. The cheap lexical denylist runs first so the hot auto-allow path @@ -439,6 +465,36 @@ export function isSensitiveShellToken( ): boolean { const { expanded, expandable } = expandShellToken(token, dialect); if (!expandable) return true; + const fileURLPath = normalizeFileURLPath(expanded, dialect); + if (fileURLPath?.failClosed) return true; + if ( + fileURLPath !== undefined && + isSensitiveExpandedShellToken( + fileURLPath.localPath, + cwd, + resolveSymlinks, + isExtraDenied, + dialect, + ) + ) { + return true; + } + return isSensitiveExpandedShellToken( + expanded, + cwd, + resolveSymlinks, + isExtraDenied, + dialect, + ); +} + +function isSensitiveExpandedShellToken( + expanded: string, + cwd: string, + resolveSymlinks: boolean, + isExtraDenied: (value: string) => boolean, + dialect: ShellDialect, +): boolean { if (isSensitivePath(expanded, dialect)) return true; if (isExtraDenied(expanded)) return true; if (!resolveSymlinks) { From d2ceee87f0bc8c40afa067d95e35f1730cf8d73d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 12:15:56 -0700 Subject: [PATCH 07/11] fix(secret-guard): prompt on local file URL brace globs --- src/permission/auto-shell-policy.test.ts | 32 +++++++++++++ src/plugins/secret-guard-plugin.test.ts | 57 +++++++++++++++++++----- src/plugins/secret-guard-plugin.ts | 16 ++++--- 3 files changed, 89 insertions(+), 16 deletions(-) diff --git a/src/permission/auto-shell-policy.test.ts b/src/permission/auto-shell-policy.test.ts index b2e8eae4b..621eb0812 100644 --- a/src/permission/auto-shell-policy.test.ts +++ b/src/permission/auto-shell-policy.test.ts @@ -8,6 +8,38 @@ const shellCall = (command: string): ToolCall => ({ arguments: { command }, }); +describe("local file URL brace expansion", () => { + const localBraceURLs = [ + "file:///tmp/{%2Eenv,README.md}", + "file:///tmp/{README.md,%2Eenv}", + "file:///tmp/{.env,README.md}", + "file:///tmp/%2E{env,missing}", + "file:///tmp/%7BREADME.md,%2Eenv%7D", + "file:///tmp/{README.md", + "file:///tmp/README.md}", + ]; + + test("requires approval for balanced and malformed local brace syntax", () => { + for (const url of localBraceURLs) { + expect(autoShellRuleForCall(shellCall(`curl '${url}'`))).toMatchObject({ + name: "sensitive-path", + effect: "ask", + }); + } + }); + + test("does not apply the local brace rule to remote URLs", () => { + for (const url of [ + "https://example.com/{one,two}", + "https://example.com/%7Bone,two%7D", + "https://example.com/{one", + "https://example.com/two}", + ]) { + expect(autoShellRuleForCall(shellCall(`curl '${url}'`))).toBeUndefined(); + } + }); +}); + // Base git-global-config routing (--global/--system/--edit, --file targets, // unset/reassignment of GIT_CONFIG_GLOBAL, repo-local pass-through) is pinned // in classify-security.test.ts. This file pins the surface that file does not: diff --git a/src/plugins/secret-guard-plugin.test.ts b/src/plugins/secret-guard-plugin.test.ts index dd10da111..46531d003 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -402,17 +402,41 @@ describe("secret-guard file URL normalization", () => { }); } - test("flags an encoded URL that curl can use to read a real .env", async () => { - const cwd = await mkdtemp(join(tmpdir(), "secret-guard-file-url-")); - try { - await writeFile(join(cwd, ".env"), "SECRET=proof\n"); - expect( - commandReferencesSensitivePath(`curl file://${cwd}/%2Eenv`, cwd), - ).toBeDefined(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); + const braceGlobs = [ + "{%2Eenv,README.md}", + "{README.md,%2Eenv}", + "{.env,README.md}", + "%2E{env,missing}", + ]; + + for (const braceGlob of braceGlobs) { + test.skipIf(Bun.which("curl") === null)( + `flags curl brace expansion that reads a real .env: ${braceGlob}`, + async () => { + const cwd = await mkdtemp(join(tmpdir(), "secret-guard-file-url-")); + try { + await writeFile(join(cwd, ".env"), "CURL_BRACE_PROOF=exfiltrated\n"); + await writeFile(join(cwd, "README.md"), "ordinary file\n"); + const url = `file://${cwd}/${braceGlob}`; + const result = Bun.spawnSync([ + "curl", + "--silent", + "--show-error", + url, + ]); + + expect(result.stdout.toString()).toContain( + "CURL_BRACE_PROOF=exfiltrated", + ); + expect( + commandReferencesSensitivePath(`curl '${url}'`, cwd), + ).toBeDefined(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, + ); + } for (const malformed of ["%", "%2", "%GG", "%E0%A4%A"]) { test(`fails closed for malformed file URL escape: ${malformed}`, () => { @@ -422,10 +446,21 @@ describe("secret-guard file URL normalization", () => { }); } + test("flags percent-encoded local brace syntax", () => { + expect( + commandReferencesSensitivePath("curl file:///tmp/%7BREADME.md,%2Eenv%7D"), + ).toBeDefined(); + }); + test("decodes file URL paths exactly once", () => { expect( commandReferencesSensitivePath("curl file:///tmp/%252Eenv"), ).toBeUndefined(); + expect( + commandReferencesSensitivePath( + "curl file:///tmp/%257BREADME.md,%252Eenv%257D", + ), + ).toBeUndefined(); }); test("uses the pathname before a file URL fragment", () => { diff --git a/src/plugins/secret-guard-plugin.ts b/src/plugins/secret-guard-plugin.ts index 624d6b695..0ddbc4a80 100644 --- a/src/plugins/secret-guard-plugin.ts +++ b/src/plugins/secret-guard-plugin.ts @@ -407,10 +407,12 @@ type FileURLPath = // WHATWG parsing handles slash counts, relative file paths, localhost, and // dot-segment normalization. Decode pathname exactly once to match URL // transport semantics; the raw token is checked afterward so query/glob -// handling remains separate. This naturally exposes a sensitive pathname -// before its URL fragment, without interpreting generic fragments or `@file` -// indirection. Unsupported hosts, Windows forms, and malformed escapes prompt -// rather than guessing or throwing. +// handling remains separate. Curl expands braces in local file URLs before +// reading them, so any brace syntax in the raw or decoded pathname prompts, +// including malformed syntax whose expansion behavior is uncertain. This +// naturally exposes a sensitive pathname before its URL fragment, without +// interpreting generic fragments or `@file` indirection. Unsupported hosts, +// Windows forms, and malformed escapes prompt rather than guessing or throwing. function normalizeFileURLPath( token: string, dialect: ShellDialect, @@ -420,7 +422,11 @@ function normalizeFileURLPath( try { const url = new URL(token); if (url.host !== "") return { failClosed: true }; - return { localPath: decodeURIComponent(url.pathname), failClosed: false }; + const localPath = decodeURIComponent(url.pathname); + if (/[{}]/.test(url.pathname) || /[{}]/.test(localPath)) { + return { failClosed: true }; + } + return { localPath, failClosed: false }; } catch { return { failClosed: true }; } From 3a58cf060a14f92b029ab6a9d75f72b6b24b4110 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 12:32:48 -0700 Subject: [PATCH 08/11] fix(secret-guard): detect brace-synthesized file URLs --- src/permission/auto-shell-policy.test.ts | 28 +++ src/plugins/secret-guard-plugin.test.ts | 75 +++++++ src/plugins/secret-guard-plugin.ts | 261 ++++++++++++++++++++--- 3 files changed, 340 insertions(+), 24 deletions(-) diff --git a/src/permission/auto-shell-policy.test.ts b/src/permission/auto-shell-policy.test.ts index 621eb0812..7098ede3c 100644 --- a/src/permission/auto-shell-policy.test.ts +++ b/src/permission/auto-shell-policy.test.ts @@ -28,6 +28,34 @@ describe("local file URL brace expansion", () => { } }); + test("requires approval for synthesized file URL schemes through wrappers", () => { + const synthesizedURLs = [ + "{file,https}:///tmp/%2Eenv", + "{https,file}:///tmp/%2Eenv", + "file{,s}:///tmp/%2Eenv", + "file{s,}:///tmp/%2Eenv", + "f{ile,oo}:///tmp/%2Eenv", + "f{oo,ile}:///tmp/%2Eenv", + "{file:///tmp/%2Eenv,https://example.com/README.md}", + "{https://example.com/README.md,file:///tmp/%2Eenv}", + "f{i,oo}{le,tp}:///tmp/%2Eenv", + "F{ILE,OO}:///tmp/%2Eenv", + ]; + const commands = synthesizedURLs.flatMap((url) => [ + `curl '${url}'`, + `env curl '${url}'`, + `bash -c "curl '${url}'"`, + `sh -c "curl '${url}'"`, + ]); + + for (const command of commands) { + expect(autoShellRuleForCall(shellCall(command))).toMatchObject({ + name: "sensitive-path", + effect: "ask", + }); + } + }); + test("does not apply the local brace rule to remote URLs", () => { for (const url of [ "https://example.com/{one,two}", diff --git a/src/plugins/secret-guard-plugin.test.ts b/src/plugins/secret-guard-plugin.test.ts index 46531d003..a3b1e1e7c 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -438,6 +438,81 @@ describe("secret-guard file URL normalization", () => { ); } + const schemeBraceURLs = [ + "{file,https}:///tmp/%2Eenv", + "{https,file}:///tmp/%2Eenv", + "file{,s}:///tmp/%2Eenv", + "file{s,}:///tmp/%2Eenv", + "f{ile,oo}:///tmp/%2Eenv", + "f{oo,ile}:///tmp/%2Eenv", + "{file:///tmp/%2Eenv,https://127.0.0.1:1/README.md}", + "{https://127.0.0.1:1/README.md,file:///tmp/%2Eenv}", + "f{i,oo}{le,tp}:///tmp/%2Eenv", + "F{ILE,OO}:///tmp/%2Eenv", + ]; + + for (const url of schemeBraceURLs) { + test.skipIf(Bun.which("curl") === null)( + `flags curl scheme synthesis that reads a real .env: ${url}`, + async () => { + const cwd = await mkdtemp(join(tmpdir(), "secret-guard-file-url-")); + try { + await writeFile(join(cwd, ".env"), "CURL_SCHEME_PROOF=exfiltrated\n"); + const localURL = url.replace("/tmp/%2Eenv", `${cwd}/%2Eenv`); + const result = Bun.spawnSync([ + "curl", + "--silent", + "--show-error", + "--connect-timeout", + "1", + "--max-time", + "2", + localURL, + ]); + + expect(result.stdout.toString()).toContain( + "CURL_SCHEME_PROOF=exfiltrated", + ); + expect( + commandReferencesSensitivePath(`curl '${localURL}'`, cwd), + ).toBeDefined(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, + ); + } + + test("keeps remote-only scheme braces allowed", () => { + const overflow = `{${Array.from({ length: 80 }, (_, index) => + index % 2 === 0 ? "https" : "http", + ).join(",")}}://example.com/{one,two}`; + for (const url of [ + "{https,http}://example.com/{one,two}", + "{http,https}://example.com/{one,two}", + "h{ttp,ttps}://example.com/{one,two}", + overflow, + `https://example.com/${"x".repeat(4_096)}/{one,two}`, + ]) { + expect(commandReferencesSensitivePath(`curl '${url}'`)).toBeUndefined(); + } + }); + + test("fails closed for ambiguous and overflowing file-scheme braces", () => { + const overflow = `f{${Array.from({ length: 80 }, (_, index) => + index === 79 ? "ile" : `x${index}`, + ).join(",")}}:///tmp/%2Eenv`; + const overlength = `f{ile,${"x".repeat(4_096)}}:///tmp/%2Eenv`; + for (const url of [ + "f{ile:,https:///tmp/%2Eenv", + "f{i,{oo,ILE}}:///tmp/%2Eenv", + overflow, + overlength, + ]) { + expect(commandReferencesSensitivePath(`curl '${url}'`)).toBeDefined(); + } + }); + for (const malformed of ["%", "%2", "%GG", "%E0%A4%A"]) { test(`fails closed for malformed file URL escape: ${malformed}`, () => { expect( diff --git a/src/plugins/secret-guard-plugin.ts b/src/plugins/secret-guard-plugin.ts index 0ddbc4a80..5ddc46544 100644 --- a/src/plugins/secret-guard-plugin.ts +++ b/src/plugins/secret-guard-plugin.ts @@ -400,24 +400,170 @@ function isFileSchemeURL(token: string): boolean { return scheme?.toLowerCase() === "file:"; } -type FileURLPath = - | { localPath: string; failClosed: false } +type FileURLPaths = + | { localPaths: readonly string[]; failClosed: false } | { failClosed: true }; -// WHATWG parsing handles slash counts, relative file paths, localhost, and -// dot-segment normalization. Decode pathname exactly once to match URL -// transport semantics; the raw token is checked afterward so query/glob -// handling remains separate. Curl expands braces in local file URLs before -// reading them, so any brace syntax in the raw or decoded pathname prompts, -// including malformed syntax whose expansion behavior is uncertain. This -// naturally exposes a sensitive pathname before its URL fragment, without -// interpreting generic fragments or `@file` indirection. Unsupported hosts, -// Windows forms, and malformed escapes prompt rather than guessing or throwing. -function normalizeFileURLPath( +type BracePart = string | readonly BraceSequence[]; +type BraceSequence = readonly BracePart[]; + +const MAX_BRACE_INPUT_LENGTH = 4_096; +const MAX_BRACE_OUTPUT_LENGTH = 4_096; +const MAX_BRACE_EXPANSIONS = 64; +const MAX_BRACE_DEPTH = 16; +const FILE_SCHEME = "file:"; + +interface BraceParseResult { + sequence?: BraceSequence; +} + +function parseBraceSequence(token: string): BraceParseResult { + if (token.length > MAX_BRACE_INPUT_LENGTH) return {}; + let index = 0; + + function parseSequence( + depth: number, + stopAtAlternative: boolean, + ): { parts: BracePart[]; separator?: "," | "}" } | undefined { + if (depth > MAX_BRACE_DEPTH) return undefined; + const parts: BracePart[] = []; + let literal = ""; + const flushLiteral = () => { + if (literal.length > 0) parts.push(literal); + literal = ""; + }; + + while (index < token.length) { + const char = token[index] ?? ""; + if (stopAtAlternative && (char === "," || char === "}")) { + flushLiteral(); + index++; + return { parts, separator: char }; + } + if (char === "}") return undefined; + if (char !== "{") { + literal += char; + index++; + continue; + } + + flushLiteral(); + index++; + const alternatives: BraceSequence[] = []; + let hasComma = false; + while (true) { + const alternative = parseSequence(depth + 1, true); + if (alternative === undefined) return undefined; + alternatives.push(alternative.parts); + if (alternative.separator === ",") { + hasComma = true; + continue; + } + if (alternative.separator !== "}" || !hasComma) return undefined; + break; + } + parts.push(alternatives); + } + + flushLiteral(); + return { parts }; + } + + const parsed = parseSequence(0, false); + if (parsed === undefined || index !== token.length) return {}; + return { sequence: parsed.parts }; +} + +function sequenceCanStartWithFileScheme(sequence: BraceSequence): boolean { + function consume(parts: BraceSequence, positions: ReadonlySet) { + let current = positions; + for (const part of parts) { + const next = new Set(); + if (typeof part === "string") { + for (const start of current) { + let position = start; + for (const char of part) { + if (position === FILE_SCHEME.length) break; + if (char.toLowerCase() !== FILE_SCHEME[position]) { + position = -1; + break; + } + position++; + } + if (position >= 0) next.add(position); + } + } else { + for (const alternative of part) { + for (const position of consume(alternative, current)) { + next.add(position); + } + } + } + current = next; + if (current.size === 0 || current.has(FILE_SCHEME.length)) break; + } + return current; + } + + return consume(sequence, new Set([0])).has(FILE_SCHEME.length); +} + +function repairMissingBraceClosers(token: string): string | undefined { + let depth = 0; + for (const char of token) { + if (char === "{") depth++; + else if (char === "}") { + if (depth === 0) return undefined; + depth--; + } + } + if (depth === 0 || depth > MAX_BRACE_DEPTH) return undefined; + return `${token}${"}".repeat(depth)}`; +} + +function expandBraceSequence(sequence: BraceSequence): string[] | undefined { + let expanded = [""]; + for (const part of sequence) { + let values: readonly string[]; + if (typeof part === "string") { + values = [part]; + } else { + const alternatives: string[] = []; + for (const alternative of part) { + const valuesForAlternative = expandBraceSequence(alternative); + if ( + valuesForAlternative === undefined || + alternatives.length + valuesForAlternative.length > + MAX_BRACE_EXPANSIONS + ) { + return undefined; + } + alternatives.push(...valuesForAlternative); + } + values = alternatives; + } + if (values.length === 0) return undefined; + const next: string[] = []; + for (const prefix of expanded) { + for (const value of values) { + if ( + next.length === MAX_BRACE_EXPANSIONS || + prefix.length + value.length > MAX_BRACE_OUTPUT_LENGTH + ) { + return undefined; + } + next.push(prefix + value); + } + } + expanded = next; + } + return expanded; +} + +function normalizedFileURLPath( token: string, dialect: ShellDialect, -): FileURLPath | undefined { - if (!isFileSchemeURL(token)) return undefined; +): FileURLPaths { if (dialect === "cmd") return { failClosed: true }; try { const url = new URL(token); @@ -426,12 +572,77 @@ function normalizeFileURLPath( if (/[{}]/.test(url.pathname) || /[{}]/.test(localPath)) { return { failClosed: true }; } - return { localPath, failClosed: false }; + return { localPaths: [localPath], failClosed: false }; } catch { return { failClosed: true }; } } +// WHATWG parsing handles slash counts, relative file paths, localhost, and +// dot-segment normalization. Decode each pathname exactly once to match URL +// transport semantics. Brace alternatives are parsed independently of the +// invoking program because either curl or a shell can expand them. The prefix +// matcher proves remote-only patterns without enumerating their path braces; +// file-capable patterns expand under strict size, depth, and count limits. +// Ambiguous or overflowing file-capable patterns prompt rather than guessing. +// Existing file-URL pathname braces retain their conservative prompt behavior. +function hasFixedNonFileScheme(token: string): boolean { + const braceIndex = token.search(/[{}]/); + const scheme = /^[A-Za-z][A-Za-z0-9+.-]*:/.exec(token)?.[0]; + return ( + scheme !== undefined && + scheme.toLowerCase() !== FILE_SCHEME && + (braceIndex === -1 || scheme.length <= braceIndex) + ); +} + +function literalPrefixCouldBecomeFileScheme(token: string): boolean { + const braceIndex = token.search(/[{}]/); + const literalPrefix = token.slice( + 0, + braceIndex === -1 ? token.length : braceIndex, + ); + return FILE_SCHEME.startsWith(literalPrefix.toLowerCase()); +} + +function normalizeFileURLPaths( + token: string, + dialect: ShellDialect, +): FileURLPaths | undefined { + if (isFileSchemeURL(token)) return normalizedFileURLPath(token, dialect); + if (!/[{}]/.test(token) || hasFixedNonFileScheme(token)) return undefined; + + let parsed = parseBraceSequence(token); + if (parsed.sequence === undefined) { + const repaired = repairMissingBraceClosers(token); + if (repaired === undefined) { + return literalPrefixCouldBecomeFileScheme(token) + ? { failClosed: true } + : undefined; + } + parsed = parseBraceSequence(repaired); + if (parsed.sequence === undefined) { + return literalPrefixCouldBecomeFileScheme(token) + ? { failClosed: true } + : undefined; + } + if (!sequenceCanStartWithFileScheme(parsed.sequence)) return undefined; + return { failClosed: true }; + } + if (!sequenceCanStartWithFileScheme(parsed.sequence)) return undefined; + + const candidates = expandBraceSequence(parsed.sequence); + if (candidates === undefined) return { failClosed: true }; + const localPaths: string[] = []; + for (const candidate of candidates) { + if (!isFileSchemeURL(candidate)) continue; + const normalized = normalizedFileURLPath(candidate, dialect); + if (normalized.failClosed) return normalized; + localPaths.push(...normalized.localPaths); + } + return { localPaths, failClosed: false }; +} + // CL-7790: the ONE shell-token matcher both secret-guard call sites share — // commandReferencesSensitivePath below and classify.ts's per-arg sensitive // check. The cheap lexical denylist runs first so the hot auto-allow path @@ -471,16 +682,18 @@ export function isSensitiveShellToken( ): boolean { const { expanded, expandable } = expandShellToken(token, dialect); if (!expandable) return true; - const fileURLPath = normalizeFileURLPath(expanded, dialect); - if (fileURLPath?.failClosed) return true; + const fileURLPaths = normalizeFileURLPaths(expanded, dialect); + if (fileURLPaths?.failClosed) return true; if ( - fileURLPath !== undefined && - isSensitiveExpandedShellToken( - fileURLPath.localPath, - cwd, - resolveSymlinks, - isExtraDenied, - dialect, + fileURLPaths !== undefined && + fileURLPaths.localPaths.some((localPath) => + isSensitiveExpandedShellToken( + localPath, + cwd, + resolveSymlinks, + isExtraDenied, + dialect, + ), ) ) { return true; From 7980a30946a29979bc8dc395ec96b3d073dc0b51 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 12:52:44 -0700 Subject: [PATCH 09/11] fix(secret-guard): classify remote URL glob schemes --- src/permission/auto-shell-policy.test.ts | 21 +- src/plugins/secret-guard-plugin.test.ts | 65 ++++++- src/plugins/secret-guard-plugin.ts | 237 +++++++++++++++++------ 3 files changed, 261 insertions(+), 62 deletions(-) diff --git a/src/permission/auto-shell-policy.test.ts b/src/permission/auto-shell-policy.test.ts index 7098ede3c..4b5c91d59 100644 --- a/src/permission/auto-shell-policy.test.ts +++ b/src/permission/auto-shell-policy.test.ts @@ -8,7 +8,7 @@ const shellCall = (command: string): ToolCall => ({ arguments: { command }, }); -describe("local file URL brace expansion", () => { +describe("local file URL glob expansion", () => { const localBraceURLs = [ "file:///tmp/{%2Eenv,README.md}", "file:///tmp/{README.md,%2Eenv}", @@ -40,6 +40,12 @@ describe("local file URL brace expansion", () => { "{https://example.com/README.md,file:///tmp/%2Eenv}", "f{i,oo}{le,tp}:///tmp/%2Eenv", "F{ILE,OO}:///tmp/%2Eenv", + "f[i-i]le:///tmp/%2Eenv", + "f[a-z]le:///tmp/%2Eenv", + "F[I-I]LE:///tmp/%2Eenv", + "f[i-i]l{e,x}:///tmp/%2Eenv", + "f[i]le:///tmp/%2Eenv", + "f[i-i le:///tmp/%2Eenv", ]; const commands = synthesizedURLs.flatMap((url) => [ `curl '${url}'`, @@ -57,14 +63,27 @@ describe("local file URL brace expansion", () => { }); test("does not apply the local brace rule to remote URLs", () => { + const remoteSchemes: string[] = Array.from({ length: 800 }, (_, index) => + index % 2 === 0 ? "https" : "http", + ); + const overlengthRemote = `{${remoteSchemes.join(",")}}://example.com/{one,two}`; for (const url of [ "https://example.com/{one,two}", "https://example.com/%7Bone,two%7D", "https://example.com/{one", "https://example.com/two}", + "h[t-t]tp://example.com/[a-z]", + "https://example.com/[a-z]?q=[0-9]", + overlengthRemote, ]) { expect(autoShellRuleForCall(shellCall(`curl '${url}'`))).toBeUndefined(); } + + remoteSchemes[799] = "file"; + const overlengthFileCapable = `{${remoteSchemes.join(",")}}:///tmp/%2Eenv`; + expect( + autoShellRuleForCall(shellCall(`curl '${overlengthFileCapable}'`)), + ).toMatchObject({ name: "sensitive-path", effect: "ask" }); }); }); diff --git a/src/plugins/secret-guard-plugin.test.ts b/src/plugins/secret-guard-plugin.test.ts index a3b1e1e7c..58c223103 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -438,7 +438,7 @@ describe("secret-guard file URL normalization", () => { ); } - const schemeBraceURLs = [ + const synthesizedSchemeURLs = [ "{file,https}:///tmp/%2Eenv", "{https,file}:///tmp/%2Eenv", "file{,s}:///tmp/%2Eenv", @@ -449,9 +449,13 @@ describe("secret-guard file URL normalization", () => { "{https://127.0.0.1:1/README.md,file:///tmp/%2Eenv}", "f{i,oo}{le,tp}:///tmp/%2Eenv", "F{ILE,OO}:///tmp/%2Eenv", + "f[i-i]le:///tmp/%2Eenv", + "f[a-z]le:///tmp/%2Eenv", + "F[I-I]LE:///tmp/%2Eenv", + "f[i-i]l{e,x}:///tmp/%2Eenv", ]; - for (const url of schemeBraceURLs) { + for (const url of synthesizedSchemeURLs) { test.skipIf(Bun.which("curl") === null)( `flags curl scheme synthesis that reads a real .env: ${url}`, async () => { @@ -483,31 +487,84 @@ describe("secret-guard file URL normalization", () => { ); } - test("keeps remote-only scheme braces allowed", () => { + test("keeps remote-only scheme globs allowed", () => { const overflow = `{${Array.from({ length: 80 }, (_, index) => index % 2 === 0 ? "https" : "http", ).join(",")}}://example.com/{one,two}`; + const overlength = `{${Array.from({ length: 800 }, (_, index) => + index % 2 === 0 ? "https" : "http", + ).join(",")}}://example.com/{one,two}`; for (const url of [ "{https,http}://example.com/{one,two}", "{http,https}://example.com/{one,two}", "h{ttp,ttps}://example.com/{one,two}", + "{{https,http},{http,https}}://example.com/{one,two}", + "h{ttp,ttps}{,s}://example.com/{one,two}", + "h[t-t]tp://example.com/[a-z]", + "https://example.com/[a-z]?q=[0-9]", overflow, + overlength, `https://example.com/${"x".repeat(4_096)}/{one,two}`, ]) { expect(commandReferencesSensitivePath(`curl '${url}'`)).toBeUndefined(); } }); - test("fails closed for ambiguous and overflowing file-scheme braces", () => { + test("classifies 1000 remote-only bracket URLs within a bounded time", () => { + const url = `f[t-t]p://example.com/${"[a-z]".repeat(1_000)}`; + const started = performance.now(); + for (let index = 0; index < 1_000; index++) { + expect(commandReferencesSensitivePath(`curl '${url}'`)).toBeUndefined(); + } + expect(performance.now() - started).toBeLessThan(1_000); + }); + + test("fails closed for file-capable scheme braces in any position", () => { + const remote: string[] = Array.from({ length: 800 }, (_, index) => + index % 2 === 0 ? "https" : "http", + ); + const withFileAt = (index: number) => { + const schemes = [...remote]; + schemes[index] = "file"; + return `{${schemes.join(",")}}:///tmp/%2Eenv`; + }; + for (const url of [ + withFileAt(0), + withFileAt(400), + withFileAt(799), + "{{https,http},{ftp,file}}:///tmp/%2Eenv", + "{f,h}{ile,ttps}:///tmp/%2Eenv", + ]) { + expect(commandReferencesSensitivePath(`curl '${url}'`)).toBeDefined(); + } + }); + + test("classifies a 100x remote-only alternative list within a bounded time", () => { + const url = `{${Array.from({ length: 80_000 }, (_, index) => + index % 2 === 0 ? "https" : "http", + ).join(",")}}://example.com/{one,two}`; + const started = performance.now(); + expect(commandReferencesSensitivePath(`curl '${url}'`)).toBeUndefined(); + expect(performance.now() - started).toBeLessThan(1_000); + }); + + test("fails closed for ambiguous and overflowing file-scheme globs", () => { const overflow = `f{${Array.from({ length: 80 }, (_, index) => index === 79 ? "ile" : `x${index}`, ).join(",")}}:///tmp/%2Eenv`; const overlength = `f{ile,${"x".repeat(4_096)}}:///tmp/%2Eenv`; + const malformedOverlength = `{${Array.from({ length: 800 }, (_, index) => + index === 799 ? "f{ile" : "https", + ).join(",")}:///tmp/%2Eenv`; for (const url of [ "f{ile:,https:///tmp/%2Eenv", "f{i,{oo,ILE}}:///tmp/%2Eenv", + "f[i]le:///tmp/%2Eenv", + "f[i-i le:///tmp/%2Eenv", + "f[i,i]le:///tmp/%2Eenv", overflow, overlength, + malformedOverlength, ]) { expect(commandReferencesSensitivePath(`curl '${url}'`)).toBeDefined(); } diff --git a/src/plugins/secret-guard-plugin.ts b/src/plugins/secret-guard-plugin.ts index 5ddc46544..b5f454d01 100644 --- a/src/plugins/secret-guard-plugin.ts +++ b/src/plugins/secret-guard-plugin.ts @@ -474,38 +474,178 @@ function parseBraceSequence(token: string): BraceParseResult { return { sequence: parsed.parts }; } -function sequenceCanStartWithFileScheme(sequence: BraceSequence): boolean { - function consume(parts: BraceSequence, positions: ReadonlySet) { - let current = positions; - for (const part of parts) { - const next = new Set(); - if (typeof part === "string") { - for (const start of current) { - let position = start; - for (const char of part) { - if (position === FILE_SCHEME.length) break; - if (char.toLowerCase() !== FILE_SCHEME[position]) { - position = -1; - break; - } - position++; - } - if (position >= 0) next.add(position); +const FILE_SCHEME_MATCHED_STATE = 1 << FILE_SCHEME.length; +const FILE_SCHEME_DEAD_STATE = 1 << (FILE_SCHEME.length + 1); + +function advanceFileSchemeStates(states: number, char: string): number { + let next = states & FILE_SCHEME_DEAD_STATE; + const lowerChar = char.toLowerCase(); + for (let position = 0; position < FILE_SCHEME.length; position++) { + if ((states & (1 << position)) === 0) continue; + if (lowerChar === FILE_SCHEME[position]) next |= 1 << (position + 1); + else next |= FILE_SCHEME_DEAD_STATE; + } + return next; +} + +interface RangeEmission { + includesExpected: boolean; + includesOther: boolean; + valid: boolean; +} + +function rangeEmission(expression: string, expected: string): RangeEmission { + const range = /^([A-Za-z0-9])-([A-Za-z0-9])(?::([1-9][0-9]*))?$/.exec( + expression, + ); + if (range === null) { + const single = /^[A-Za-z0-9]$/.test(expression); + return { + includesExpected: expression.toLowerCase().includes(expected), + includesOther: !single || expression.toLowerCase() !== expected, + valid: false, + }; + } + + const start = range[1] ?? ""; + const end = range[2] ?? ""; + const step = Number(range[3] ?? "1"); + const sameKind = /[A-Za-z]/.test(start) === /[A-Za-z]/.test(end); + const startCode = start.codePointAt(0) ?? 0; + const endCode = end.codePointAt(0) ?? -1; + if (!sameKind || startCode > endCode) { + return { + includesExpected: expression.toLowerCase().includes(expected), + includesOther: true, + valid: false, + }; + } + + const expectedCodes = [ + expected.toLowerCase().codePointAt(0) ?? -1, + expected.toUpperCase().codePointAt(0) ?? -1, + ]; + const includesExpected = expectedCodes.some( + (code) => + code >= startCode && code <= endCode && (code - startCode) % step === 0, + ); + const outputCount = Math.floor((endCode - startCode) / step) + 1; + return { + includesExpected, + includesOther: outputCount > (includesExpected ? 1 : 0), + valid: true, + }; +} + +function advanceFileSchemeRangeStates( + states: number, + expression: string, +): { states: number; ambiguous: boolean } { + let next = states & FILE_SCHEME_DEAD_STATE; + let ambiguous = false; + for (let position = 0; position < FILE_SCHEME.length; position++) { + if ((states & (1 << position)) === 0) continue; + const emission = rangeEmission(expression, FILE_SCHEME[position] ?? ""); + if (emission.includesExpected) next |= 1 << (position + 1); + if (emission.includesOther) next |= FILE_SCHEME_DEAD_STATE; + if (!emission.valid && emission.includesExpected) ambiguous = true; + } + return { states: next, ambiguous }; +} + +function globSyntaxCanProduceFileScheme(token: string): boolean { + let index = 0; + let matched = false; + let ambiguous = false; + let exceededDepth = false; + + function consumeSequence( + states: number, + depth: number, + stopAtAlternative: boolean, + ): { states: number; separator?: "," | "}" } | undefined { + if (depth > MAX_BRACE_DEPTH) { + exceededDepth = true; + return undefined; + } + let current = states; + + while (index < token.length) { + const char = token[index] ?? ""; + if (stopAtAlternative && (char === "," || char === "}")) { + index++; + return { states: current, separator: char }; + } + if (char === "}") return undefined; + if (char === "[") { + const close = token.indexOf("]", index + 1); + const expression = token.slice( + index + 1, + close === -1 ? undefined : close, + ); + const range = advanceFileSchemeRangeStates(current, expression); + current = range.states; + if (close === -1) { + ambiguous = range.ambiguous; + return undefined; } - } else { - for (const alternative of part) { - for (const position of consume(alternative, current)) { - next.add(position); - } + index = close + 1; + if ((current & FILE_SCHEME_MATCHED_STATE) !== 0) { + matched = true; + return { states: current }; + } + if (current === FILE_SCHEME_DEAD_STATE && !stopAtAlternative) { + return { states: current }; + } + continue; + } + if (char !== "{") { + current = advanceFileSchemeStates(current, char); + index++; + if ((current & FILE_SCHEME_MATCHED_STATE) !== 0) { + matched = true; + return { states: current }; } + if (current === FILE_SCHEME_DEAD_STATE && !stopAtAlternative) { + return { states: current }; + } + continue; + } + + index++; + let alternativeStates = 0; + let hasComma = false; + while (true) { + const alternative = consumeSequence(current, depth + 1, true); + if (alternative === undefined || matched) return alternative; + alternativeStates |= alternative.states; + if (alternative.separator === ",") { + hasComma = true; + continue; + } + if (alternative.separator !== "}" || !hasComma) return undefined; + break; + } + current = alternativeStates; + if ((current & FILE_SCHEME_MATCHED_STATE) !== 0) { + matched = true; + return { states: current }; + } + if (current === FILE_SCHEME_DEAD_STATE && !stopAtAlternative) { + return { states: current }; } - current = next; - if (current.size === 0 || current.has(FILE_SCHEME.length)) break; } - return current; + + return { states: current }; } - return consume(sequence, new Set([0])).has(FILE_SCHEME.length); + const parsed = consumeSequence(1, 0, false); + if (matched) return true; + if (parsed?.states === FILE_SCHEME_DEAD_STATE) return false; + if (parsed === undefined || index !== token.length) { + return ambiguous || exceededDepth; + } + return (parsed.states & FILE_SCHEME_MATCHED_STATE) !== 0; } function repairMissingBraceClosers(token: string): string | undefined { @@ -580,56 +720,39 @@ function normalizedFileURLPath( // WHATWG parsing handles slash counts, relative file paths, localhost, and // dot-segment normalization. Decode each pathname exactly once to match URL -// transport semantics. Brace alternatives are parsed independently of the -// invoking program because either curl or a shell can expand them. The prefix -// matcher proves remote-only patterns without enumerating their path braces; -// file-capable patterns expand under strict size, depth, and count limits. -// Ambiguous or overflowing file-capable patterns prompt rather than guessing. -// Existing file-URL pathname braces retain their conservative prompt behavior. +// transport semantics. Curl brace alternatives and bracket ranges are parsed +// independently of the invoking program because curl can expand either form. +// The prefix matcher proves remote-only patterns without enumerating schemes or +// scanning their URL tails. Brace-only file candidates expand under strict +// size, depth, and count limits; bracket-capable candidates fail closed without +// enumerating their ranges. Existing file-URL pathname globs remain conservative. function hasFixedNonFileScheme(token: string): boolean { - const braceIndex = token.search(/[{}]/); + const globIndex = token.search(/[{}[\]]/); const scheme = /^[A-Za-z][A-Za-z0-9+.-]*:/.exec(token)?.[0]; return ( scheme !== undefined && scheme.toLowerCase() !== FILE_SCHEME && - (braceIndex === -1 || scheme.length <= braceIndex) + (globIndex === -1 || scheme.length <= globIndex) ); } -function literalPrefixCouldBecomeFileScheme(token: string): boolean { - const braceIndex = token.search(/[{}]/); - const literalPrefix = token.slice( - 0, - braceIndex === -1 ? token.length : braceIndex, - ); - return FILE_SCHEME.startsWith(literalPrefix.toLowerCase()); -} - function normalizeFileURLPaths( token: string, dialect: ShellDialect, ): FileURLPaths | undefined { if (isFileSchemeURL(token)) return normalizedFileURLPath(token, dialect); - if (!/[{}]/.test(token) || hasFixedNonFileScheme(token)) return undefined; + if (!/[{}[\]]/.test(token) || hasFixedNonFileScheme(token)) return undefined; + if (!globSyntaxCanProduceFileScheme(token)) return undefined; + if (token.includes("[")) return { failClosed: true }; - let parsed = parseBraceSequence(token); + const parsed = parseBraceSequence(token); if (parsed.sequence === undefined) { const repaired = repairMissingBraceClosers(token); - if (repaired === undefined) { - return literalPrefixCouldBecomeFileScheme(token) - ? { failClosed: true } - : undefined; - } - parsed = parseBraceSequence(repaired); - if (parsed.sequence === undefined) { - return literalPrefixCouldBecomeFileScheme(token) - ? { failClosed: true } - : undefined; + if (repaired !== undefined && !globSyntaxCanProduceFileScheme(repaired)) { + return undefined; } - if (!sequenceCanStartWithFileScheme(parsed.sequence)) return undefined; return { failClosed: true }; } - if (!sequenceCanStartWithFileScheme(parsed.sequence)) return undefined; const candidates = expandBraceSequence(parsed.sequence); if (candidates === undefined) return { failClosed: true }; From 4c5b55a5d1b32b97da4dfaef3501f0d0d375191a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 13:22:08 -0700 Subject: [PATCH 10/11] fix(secret-guard): inspect curl default file protocol --- src/permission/auto-shell-policy.test.ts | 33 +++++ src/plugins/secret-guard-plugin.test.ts | 85 ++++++++++++ src/plugins/secret-guard-plugin.ts | 160 +++++++++++++++++++++++ 3 files changed, 278 insertions(+) diff --git a/src/permission/auto-shell-policy.test.ts b/src/permission/auto-shell-policy.test.ts index 4b5c91d59..bcef65d40 100644 --- a/src/permission/auto-shell-policy.test.ts +++ b/src/permission/auto-shell-policy.test.ts @@ -87,6 +87,39 @@ describe("local file URL glob expansion", () => { }); }); +describe("curl proto-default file", () => { + test("requires approval for inferred local file operands through wrappers", () => { + for (const command of [ + "curl --silent --proto-default file $PWD/%2Eenv", + "curl --proto-default=file /tmp/%2Eenv", + "curl --proto-default FILE ./%2Eenv", + "curl /tmp/%2Eenv --proto-default file", + "curl --proto-default file //localhost/tmp/%2Eenv", + "curl --proto-default file README.md /tmp/%2Eenv", + "env curl --proto-default file /tmp/%2Eenv", + "bash -c 'curl --proto-default file /tmp/%2Eenv'", + "sh -c 'curl --proto-default file /tmp/%2Eenv'", + ]) { + expect(autoShellRuleForCall(shellCall(command))).toMatchObject({ + name: "sensitive-path", + effect: "ask", + }); + } + }); + + test("keeps explicit and default HTTPS operands unflagged", () => { + for (const command of [ + "curl --proto-default file https://example.com/%2Eenv", + "curl --proto-default FILE HTTP://example.com/%2Eenv", + "curl --proto-default https example.com/%2Eenv", + "curl example.com/%2Eenv", + "curl --output /tmp/%2Eenv --proto-default file https://example.com", + ]) { + expect(autoShellRuleForCall(shellCall(command))).toBeUndefined(); + } + }); +}); + // Base git-global-config routing (--global/--system/--edit, --file targets, // unset/reassignment of GIT_CONFIG_GLOBAL, repo-local pass-through) is pinned // in classify-security.test.ts. This file pins the surface that file does not: diff --git a/src/plugins/secret-guard-plugin.test.ts b/src/plugins/secret-guard-plugin.test.ts index 58c223103..52990f991 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -13,6 +13,7 @@ import { isSensitiveShellToken, commandReferencesSensitivePath, expandShellToken, + inspectShellSecretReference, } from "./secret-guard-plugin.js"; const next = async (call: ToolCall): Promise => ({ @@ -570,6 +571,90 @@ describe("secret-guard file URL normalization", () => { } }); + test.skipIf(Bun.which("curl") === null)( + "flags proto-default file after real curl reads an encoded .env", + async () => { + const cwd = await mkdtemp(join(tmpdir(), "secret-guard-proto-default-")); + try { + await writeFile(join(cwd, ".env"), "CURL_PROTO_PROOF=exfiltrated\n"); + const operand = `${cwd}/%2Eenv`; + const result = Bun.spawnSync([ + "curl", + "--silent", + "--show-error", + "--proto-default", + "file", + operand, + ]); + + expect(result.stdout.toString()).toContain( + "CURL_PROTO_PROOF=exfiltrated", + ); + expect( + commandReferencesSensitivePath( + `curl --silent --proto-default file '${operand}'`, + cwd, + ), + ).toBeDefined(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, + ); + + test("classifies proto-default file operands across supported spellings", () => { + const cwd = "/tmp/proto-default-fixture"; + for (const command of [ + "curl --proto-default file /tmp/proto-default-fixture/%2Eenv", + "curl /tmp/proto-default-fixture/%2Eenv --proto-default file", + "curl --proto-default=file /tmp/proto-default-fixture/%2Eenv", + "curl --proto-default=FILE /tmp/proto-default-fixture/%2Eenv", + "curl --proto-default FILE /tmp/proto-default-fixture/%2Eenv", + "curl --proto-default file --url /tmp/proto-default-fixture/%2Eenv", + "curl --proto-default file ./%2Eenv", + "curl --proto-default file $PWD/%2Eenv", + "curl --proto-default file //localhost/tmp/proto-default-fixture/%2Eenv", + "curl --proto-default file README.md /tmp/proto-default-fixture/%2Eenv", + "env curl --proto-default file /tmp/proto-default-fixture/%2Eenv", + "bash -c 'curl --proto-default file /tmp/proto-default-fixture/%2Eenv'", + "sh -c 'curl --proto-default file /tmp/proto-default-fixture/%2Eenv'", + ]) { + expect(commandReferencesSensitivePath(command, cwd)).toBeDefined(); + } + }); + + test("decodes inferred file operands exactly once", () => { + expect( + commandReferencesSensitivePath("curl --proto-default file /tmp/%252Eenv"), + ).toBeUndefined(); + }); + + test("keeps explicit and default HTTPS operands remote", () => { + for (const command of [ + "curl --proto-default file https://example.com/%2Eenv", + "curl --proto-default FILE HTTP://example.com/%2Eenv", + "curl --proto-default https example.com/%2Eenv", + "curl example.com/%2Eenv", + "curl --output /tmp/%2Eenv --proto-default file https://example.com", + "curl --header /tmp/%2Eenv --proto-default file https://example.com", + ]) { + expect(commandReferencesSensitivePath(command)).toBeUndefined(); + } + }); + + test("fails closed for malformed or ambiguous proto-default file options", () => { + for (const command of [ + "curl /tmp/%2Eenv --proto-default", + "curl --proto-default= /tmp/%2Eenv", + "curl --proto-default file --proto-default https /tmp/%2Eenv", + ]) { + const inspection = inspectShellSecretReference(command); + expect(inspection.opaque || inspection.reference !== undefined).toBe( + true, + ); + } + }); + for (const malformed of ["%", "%2", "%GG", "%E0%A4%A"]) { test(`fails closed for malformed file URL escape: ${malformed}`, () => { expect( diff --git a/src/plugins/secret-guard-plugin.ts b/src/plugins/secret-guard-plugin.ts index b5f454d01..f37298556 100644 --- a/src/plugins/secret-guard-plugin.ts +++ b/src/plugins/secret-guard-plugin.ts @@ -978,6 +978,158 @@ interface LiteralPathCandidates { opaque: boolean; } +const CURL_LONG_VALUE_OPTIONS = new Set( + `--abstract-unix-socket --alt-svc --aws-sigv4 --cacert --capath --cert + --cert-type --ciphers --config --connect-timeout --connect-to --continue-at + --cookie --cookie-jar --create-file-mode --crlfile --curves --data + --data-ascii --data-binary --data-raw --data-urlencode --delegation + --dns-interface --dns-ipv4-addr --dns-ipv6-addr --dns-servers --doh-url + --dump-header --egd-file --engine --etag-compare --etag-save + --expect100-timeout --form --form-string --ftp-account + --ftp-alternative-to-user --ftp-method --ftp-port --ftp-ssl-ccc-mode + --happy-eyeballs-timeout-ms --haproxy-clientip --header --help + --hostpubmd5 --hostpubsha256 --hsts --interface --ipfs-gateway --json + --keepalive-time --key --key-type --krb --libcurl --limit-rate + --local-port --login-options --mail-auth --mail-from --mail-rcpt + --max-filesize --max-redirs --max-time --netrc-file --noproxy + --oauth2-bearer --output --output-dir --parallel-max --pass --pinnedpubkey + --proto --proto-default --proto-redir --proxy-cacert --proxy-capath + --proxy-cert --proxy-cert-type --proxy-ciphers --proxy-crlfile + --proxy-header --proxy-key --proxy-key-type --proxy-pass + --proxy-pinnedpubkey --proxy-service-name --proxy-tls13-ciphers + --proxy-tlsauthtype --proxy-tlspassword --proxy-tlsuser --proxy-user + --proxy1.0 --pubkey --quote --random-file --range --rate --referer + --request --request-target --resolve --retry --retry-delay --retry-max-time + --sasl-authzid --service-name --socks4 --socks4a --socks5 + --socks5-gssapi-service --socks5-hostname --speed-limit --speed-time + --stderr --telnet-option --tftp-blksize --time-cond --tls-max + --tls13-ciphers --tlsauthtype --tlspassword --tlsuser --trace + --trace-ascii --trace-config --unix-socket --upload-file --url + --url-query --user --user-agent --variable --write-out` + .split(/\s+/) + .filter(Boolean), +); +const CURL_SHORT_VALUE_OPTIONS = new Set( + "AbcCdDeEFhHKmoPQrtTuUwXyYz".split(""), +); +const EXPLICIT_URL_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/; + +interface CurlFileOperandInspection { + values: string[]; + opaque: boolean; +} + +function normalizeCurlFileOperand( + operand: string, + dialect: ShellDialect, +): { value?: string; opaque: boolean } { + const expansion = expandShellToken(operand, dialect); + if (!expansion.expandable) return { opaque: true }; + let localPath = expansion.expanded; + if (localPath.startsWith("//")) { + const localhost = /^\/\/localhost(?=\/|$)/i.exec(localPath)?.[0]; + if (localhost === undefined) return { opaque: true }; + localPath = localPath.slice(localhost.length) || "/"; + } + try { + localPath = decodeURIComponent(localPath); + } catch { + return { opaque: true }; + } + if (/[{}]/.test(localPath)) return { opaque: true }; + return { value: localPath, opaque: false }; +} + +function curlFileOperands( + command: readonly string[], + executableIndex: number, + program: string, + dialect: ShellDialect, +): CurlFileOperandInspection { + if (program !== "curl" || dialect !== "posix") { + return { values: [], opaque: false }; + } + + const operands: string[] = []; + const protocols: string[] = []; + let malformedProtocol = false; + let optionsEnded = false; + + for (let index = executableIndex + 1; index < command.length; index++) { + const token = command[index] ?? ""; + if (!optionsEnded && token === "--") { + optionsEnded = true; + continue; + } + if (!optionsEnded && token === "--proto-default") { + const protocol = command[index + 1]; + if (protocol === undefined || protocol.startsWith("-")) { + malformedProtocol = true; + } else { + protocols.push(protocol.toLowerCase()); + index++; + } + continue; + } + if (!optionsEnded && token.startsWith("--proto-default=")) { + const protocol = token.slice("--proto-default=".length); + if (protocol.length === 0) malformedProtocol = true; + else protocols.push(protocol.toLowerCase()); + continue; + } + if (!optionsEnded && token === "--url") { + const operand = command[index + 1]; + if (operand === undefined) malformedProtocol = true; + else { + operands.push(operand); + index++; + } + continue; + } + if (!optionsEnded && token.startsWith("--url=")) { + operands.push(token.slice("--url=".length)); + continue; + } + if (!optionsEnded && token.startsWith("--")) { + const equalsIndex = token.indexOf("="); + const option = equalsIndex === -1 ? token : token.slice(0, equalsIndex); + if (equalsIndex === -1 && CURL_LONG_VALUE_OPTIONS.has(option)) index++; + continue; + } + if (!optionsEnded && token.startsWith("-") && token !== "-") { + const options = token.slice(1); + for (let optionIndex = 0; optionIndex < options.length; optionIndex++) { + if (!CURL_SHORT_VALUE_OPTIONS.has(options[optionIndex] ?? "")) continue; + if (optionIndex === options.length - 1) index++; + break; + } + continue; + } + operands.push(token); + } + + const protocolSet = new Set(protocols); + const fileCapable = protocolSet.has("file"); + let opaque = + operands.length > 0 && + (malformedProtocol || (fileCapable && protocolSet.size > 1)); + if (!fileCapable) return { values: [], opaque }; + + const values: string[] = []; + for (const operand of operands) { + const expansion = expandShellToken(operand, dialect); + if (!expansion.expandable) { + opaque = true; + continue; + } + if (EXPLICIT_URL_SCHEME.test(expansion.expanded)) continue; + const normalized = normalizeCurlFileOperand(operand, dialect); + opaque ||= normalized.opaque; + if (normalized.value !== undefined) values.push(normalized.value); + } + return { values, opaque }; +} + function literalPathCandidates( commands: string[][], dialect: ShellDialect, @@ -1002,6 +1154,14 @@ function literalPathCandidates( ); candidates.push(...fileOptions.values); opaque ||= fileOptions.opaque; + const curlOperands = curlFileOperands( + command, + transparent.executableIndex, + program, + dialect, + ); + candidates.push(...curlOperands.values); + opaque ||= curlOperands.opaque; for (const token of command) { if (token.startsWith("--env-file=")) { candidates.push(token.slice("--env-file=".length)); From 02076cb218c961ced1bf7e7278ef934342189f06 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 13:31:59 -0700 Subject: [PATCH 11/11] fix(secret-guard): accept leading-zero curl range steps --- src/permission/auto-shell-policy.test.ts | 6 ++++ src/permission/classify-security.test.ts | 19 +++++++++++ src/plugins/secret-guard-plugin.test.ts | 40 +++++++++++++++++++++++- src/plugins/secret-guard-plugin.ts | 13 ++++++-- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/permission/auto-shell-policy.test.ts b/src/permission/auto-shell-policy.test.ts index bcef65d40..b1348e065 100644 --- a/src/permission/auto-shell-policy.test.ts +++ b/src/permission/auto-shell-policy.test.ts @@ -42,6 +42,9 @@ describe("local file URL glob expansion", () => { "F{ILE,OO}:///tmp/%2Eenv", "f[i-i]le:///tmp/%2Eenv", "f[a-z]le:///tmp/%2Eenv", + "f[a-z:02]le:///tmp/%2Eenv", + "f[a-z:0002]le:///tmp/%2Eenv", + "f[a-z:0]le:///tmp/%2Eenv", "F[I-I]LE:///tmp/%2Eenv", "f[i-i]l{e,x}:///tmp/%2Eenv", "f[i]le:///tmp/%2Eenv", @@ -73,6 +76,9 @@ describe("local file URL glob expansion", () => { "https://example.com/{one", "https://example.com/two}", "h[t-t]tp://example.com/[a-z]", + "f[a-z:3]le:///tmp/%2Eenv", + "f[z-a:02]le:///tmp/%2Eenv", + "h[t-z:02]tp://example.com/%2Eenv", "https://example.com/[a-z]?q=[0-9]", overlengthRemote, ]) { diff --git a/src/permission/classify-security.test.ts b/src/permission/classify-security.test.ts index fd78da7e3..874e7b966 100644 --- a/src/permission/classify-security.test.ts +++ b/src/permission/classify-security.test.ts @@ -580,6 +580,25 @@ describe("sensitive-path shell commands require approval, not a hard deny", () = expect(asked).toBe(1); }); + test("stored curl grants do not authorize stepped file-scheme synthesis", async () => { + let asked = 0; + const gate = createPermissionGate({ + approvals: [{ tool: "run_shell", pattern: "curl *" }], + requestApproval: async () => { + asked++; + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + reactorGated: false, + }); + const verdict = await gate.evaluate( + shellCall("curl 'f[a-z:02]le:///tmp/%2Eenv'"), + ); + expect(verdict.allowed).toBe(true); + expect(asked).toBe(1); + }); + test("stored grants still authorize ordinary shell reads", async () => { let asked = 0; const gate = createPermissionGate({ diff --git a/src/plugins/secret-guard-plugin.test.ts b/src/plugins/secret-guard-plugin.test.ts index 52990f991..0df1f23c2 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { createPosixTools } from "@intx/tools-posix"; import type { ToolCall, ToolResult } from "@intx/types/runtime"; import { buildCorePosixToolPlugins } from "../agent/posix-tool-plugins.js"; +import { autoShellRuleForCall } from "../permission/auto-shell-policy.js"; import { createPermissionGate } from "../permission/gate.js"; import { loadProjectApprovals } from "../permission/store.js"; import { @@ -452,6 +453,8 @@ describe("secret-guard file URL normalization", () => { "F{ILE,OO}:///tmp/%2Eenv", "f[i-i]le:///tmp/%2Eenv", "f[a-z]le:///tmp/%2Eenv", + "f[a-z:02]le:///tmp/%2Eenv", + "f[a-z:0002]le:///tmp/%2Eenv", "F[I-I]LE:///tmp/%2Eenv", "f[i-i]l{e,x}:///tmp/%2Eenv", ]; @@ -488,6 +491,41 @@ describe("secret-guard file URL normalization", () => { ); } + test("auto mode asks for leading-zero curl ranges across wrappers", () => { + for (const step of ["02", "0002"]) { + const url = `f[a-z:${step}]le:///tmp/%2Eenv`; + for (const command of [ + `curl '${url}'`, + `env curl '${url}'`, + `bash -c "curl '${url}'"`, + `sh -c "curl '${url}'"`, + ]) { + expect(autoShellRuleForCall(shell(command), () => false)?.name).toBe( + "sensitive-path", + ); + } + } + }); + + test("classifies stepped scheme ranges without widening remote URLs", () => { + for (const url of [ + "f[a-z:2]le:///tmp/%2Eenv", + "f[a-z:02]le:///tmp/%2Eenv", + "f[a-z:0002]le:///tmp/%2Eenv", + "f[a-z:0]le:///tmp/%2Eenv", + "f[i]le:///tmp/%2Eenv", + ]) { + expect(commandReferencesSensitivePath(`curl '${url}'`)).toBeDefined(); + } + for (const url of [ + "f[a-z:3]le:///tmp/%2Eenv", + "f[z-a:02]le:///tmp/%2Eenv", + "h[t-z:02]tp://example.com/%2Eenv", + ]) { + expect(commandReferencesSensitivePath(`curl '${url}'`)).toBeUndefined(); + } + }); + test("keeps remote-only scheme globs allowed", () => { const overflow = `{${Array.from({ length: 80 }, (_, index) => index % 2 === 0 ? "https" : "http", @@ -512,7 +550,7 @@ describe("secret-guard file URL normalization", () => { }); test("classifies 1000 remote-only bracket URLs within a bounded time", () => { - const url = `f[t-t]p://example.com/${"[a-z]".repeat(1_000)}`; + const url = `f[t-t:0002]p://example.com/${"[a-z:02]".repeat(1_000)}`; const started = performance.now(); for (let index = 0; index < 1_000; index++) { expect(commandReferencesSensitivePath(`curl '${url}'`)).toBeUndefined(); diff --git a/src/plugins/secret-guard-plugin.ts b/src/plugins/secret-guard-plugin.ts index f37298556..375f47c49 100644 --- a/src/plugins/secret-guard-plugin.ts +++ b/src/plugins/secret-guard-plugin.ts @@ -495,9 +495,7 @@ interface RangeEmission { } function rangeEmission(expression: string, expected: string): RangeEmission { - const range = /^([A-Za-z0-9])-([A-Za-z0-9])(?::([1-9][0-9]*))?$/.exec( - expression, - ); + const range = /^([A-Za-z0-9])-([A-Za-z0-9])(?::([0-9]+))?$/.exec(expression); if (range === null) { const single = /^[A-Za-z0-9]$/.test(expression); return { @@ -525,6 +523,15 @@ function rangeEmission(expression: string, expected: string): RangeEmission { expected.toLowerCase().codePointAt(0) ?? -1, expected.toUpperCase().codePointAt(0) ?? -1, ]; + if (!Number.isInteger(step) || step <= 0) { + return { + includesExpected: expectedCodes.some( + (code) => code >= startCode && code <= endCode, + ), + includesOther: true, + valid: false, + }; + } const includesExpected = expectedCodes.some( (code) => code >= startCode && code <= endCode && (code - startCode) % step === 0,