diff --git a/src/permission/auto-shell-policy.test.ts b/src/permission/auto-shell-policy.test.ts index b2e8eae4b..b1348e065 100644 --- a/src/permission/auto-shell-policy.test.ts +++ b/src/permission/auto-shell-policy.test.ts @@ -8,6 +8,124 @@ const shellCall = (command: string): ToolCall => ({ arguments: { command }, }); +describe("local file URL glob 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("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", + "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", + "f[i-i le:///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", () => { + 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]", + "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, + ]) { + 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" }); + }); +}); + +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/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 c81261f5e..0df1f23c2 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -1,16 +1,20 @@ -import { describe, test, expect } from "bun:test"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +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"; 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 { secretGuardPlugin, isSensitivePath, + isSensitiveShellToken, commandReferencesSensitivePath, + expandShellToken, + inspectShellSecretReference, } from "./secret-guard-plugin.js"; const next = async (call: ToolCall): Promise => ({ @@ -270,6 +274,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)'", @@ -307,6 +316,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}`, () => @@ -314,6 +327,579 @@ 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()); + } + + // `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(); + }); +}); + +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(); + }); + } + + 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 }); + } + }, + ); + } + + const synthesizedSchemeURLs = [ + "{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", + "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", + ]; + + for (const url of synthesizedSchemeURLs) { + 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("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", + ).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("classifies 1000 remote-only bracket URLs within a bounded time", () => { + 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(); + } + 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(); + } + }); + + 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( + commandReferencesSensitivePath(`curl file:///tmp/${malformed}`), + ).toBeDefined(); + }); + } + + 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", () => { + 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; + 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(() => { + 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; + 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 = [ + "cat $HOME/.env", + "cat ${HOME}/.env", + 'cat "$HOME/.env"', + "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}`, () => + 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); + }); + + 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()); + } +}); + +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 diff --git a/src/plugins/secret-guard-plugin.ts b/src/plugins/secret-guard-plugin.ts index 5ce24be87..375f47c49 100644 --- a/src/plugins/secret-guard-plugin.ts +++ b/src/plugins/secret-guard-plugin.ts @@ -203,14 +203,23 @@ 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 -// 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 @@ -218,20 +227,24 @@ 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 above), not something this filter +// 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("*") || - token.includes("`") - ) + if (token.startsWith("-") || token.includes("*") || token.includes("`")) return false; return ( isAbsolute(token) || @@ -252,21 +265,514 @@ 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}`, `${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 +// 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] ?? match[5]; + const alternate = match[6] ?? match[7]; + if (fallback === undefined && alternate === undefined) { + expanded += value ?? ""; + } else if ( + 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 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; + } + 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("`") ); } +// 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("*"); +} + +// `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:"; +} + +type FileURLPaths = + | { localPaths: readonly string[]; failClosed: false } + | { failClosed: true }; + +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 }; +} + +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])(?::([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, + ]; + 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, + ); + 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; + } + 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 }; + } + } + + return { states: current }; + } + + 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 { + 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, +): FileURLPaths { + if (dialect === "cmd") return { failClosed: true }; + try { + const url = new URL(token); + if (url.host !== "") return { failClosed: true }; + const localPath = decodeURIComponent(url.pathname); + if (/[{}]/.test(url.pathname) || /[{}]/.test(localPath)) { + return { failClosed: true }; + } + 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. 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 globIndex = token.search(/[{}[\]]/); + const scheme = /^[A-Za-z][A-Za-z0-9+.-]*:/.exec(token)?.[0]; + return ( + scheme !== undefined && + scheme.toLowerCase() !== FILE_SCHEME && + (globIndex === -1 || scheme.length <= globIndex) + ); +} + +function normalizeFileURLPaths( + token: string, + dialect: ShellDialect, +): FileURLPaths | undefined { + if (isFileSchemeURL(token)) return normalizedFileURLPath(token, dialect); + if (!/[{}[\]]/.test(token) || hasFixedNonFileScheme(token)) return undefined; + if (!globSyntaxCanProduceFileScheme(token)) return undefined; + if (token.includes("[")) return { failClosed: true }; + + const parsed = parseBraceSequence(token); + if (parsed.sequence === undefined) { + const repaired = repairMissingBraceClosers(token); + if (repaired !== undefined && !globSyntaxCanProduceFileScheme(repaired)) { + return undefined; + } + return { failClosed: true }; + } + + 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 @@ -277,11 +783,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 +810,40 @@ 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; + const fileURLPaths = normalizeFileURLPaths(expanded, dialect); + if (fileURLPaths?.failClosed) return true; + if ( + fileURLPaths !== undefined && + fileURLPaths.localPaths.some((localPath) => + isSensitiveExpandedShellToken( + 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) { @@ -313,6 +853,42 @@ 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 — + // 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 `://` 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 + // 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("://") || isFileSchemeURL(expanded)) && + 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 ( @@ -409,6 +985,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, @@ -433,6 +1161,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));