Diff
1diff --git a/src/core/deterministic.ts b/src/core/deterministic.ts
2index e476c427090ede47d492a601676b6218377f4883..9ed19c526c8c0cd4eb6d8fcbc803ad943b32001f 100644
3--- a/src/core/deterministic.ts
4+++ b/src/core/deterministic.ts
5@@ -113,7 +113,12 @@ function isStaticOptionAssignment(prefix: string): boolean {
6 return /^--[A-Za-z0-9][A-Za-z0-9_-]*=$/.test(prefix);
7 }
8
9-function hasUnsafeWordSyntax(command: string, allowStaticGlobs = false): boolean {
10+function isSafeGrepIncludeGlob(command: string, wordStart: number): boolean {
11+ const word = command.slice(wordStart).split(/\s/, 1)[0] ?? "";
12+ return /^--include=\*\.(?!(?:env[A-Za-z0-9._-]*|pem|key|p12|pfx)$)[A-Za-z0-9]+$/i.test(word);
13+}
14+
15+function hasUnsafeWordSyntax(command: string, allowStaticGlobs = false, program?: string): boolean {
16 let quote: "'" | '"' | undefined;
17 let wordStarted = false;
18 let quotedWord = false;
19@@ -198,7 +203,12 @@ function hasUnsafeWordSyntax(command: string, allowStaticGlobs = false): boolean
20 wordStarted = true;
21 continue;
22 }
23- if (!allowStaticGlobs && (character === "*" || character === "?" || character === "[")) return true;
24+ if (
25+ !allowStaticGlobs &&
26+ (character === "*" || character === "?" || character === "[") &&
27+ !(program === "grep" && isSafeGrepIncludeGlob(command, wordStart))
28+ )
29+ return true;
30 if (character === "~") {
31 if (wordStarted || (nextCharacter !== undefined && nextCharacter !== "/" && !/\s/.test(nextCharacter))) {
32 return true;
33@@ -570,7 +580,7 @@ function checkBash(command: string, redirects: readonly BashRedirect[] = [], par
34 checkSystemPathMutation(cleaned);
35 if (askDecision) return askDecision;
36 const [program, ...arguments_] = commandWords(cleaned);
37- if (hasUnsafeWordSyntax(source, program === "ls")) return undefined;
38+ if (hasUnsafeWordSyntax(source, program === "ls", program)) return undefined;
39 if (program && hasInlineInterpreterCode(program, arguments_)) return undefined;
40 return checkDocker(cleaned, parsed) ?? checkAllow(cleaned, parsed);
41 }
42diff --git a/test/core/deterministic.test.ts b/test/core/deterministic.test.ts
43index 0c48cd2720c29b41843482a88bd1b3cf7caa50d2..4c7af4f3990f727694bd133325c8806bed3b2337 100644
44--- a/test/core/deterministic.test.ts
45+++ b/test/core/deterministic.test.ts
46@@ -97,18 +97,20 @@ test("allows static pathname globs only for ls", () => {
47 }
48 });
49
50-test("allows static quoted option values while rejecting mixed quoted paths", () => {
51+test("allows static grep include globs while rejecting mixed quoted paths", () => {
52 for (const command of [
53 'grep -rn "NewOrchestrator\\|ScratchModel\\|scratch" --include="*.go"',
54 "grep -rn 'NewOrchestrator\\|ScratchModel\\|scratch' --include='*.go'",
55 'grep --include="*.go"',
56+ 'grep -rn "WaitReady" --include=*.go .',
57+ 'grep -rn "8080" --include=*.go --include=*.json --include=Makefile .',
58 ]) {
59 expect(checkDeterministic("bash", { command })).toMatchObject({ decision: "allow" });
60 }
61
62- expect(checkDeterministic("bash", { command: 'grep -rn scratch --include=".env"' })).toMatchObject({
63- decision: "ask",
64- });
65+ for (const command of ['grep -rn scratch --include=".env"', "grep -rn scratch --include=*.env ."]) {
66+ expect(checkDeterministic("bash", { command })?.decision).not.toBe("allow");
67+ }
68
69 for (const command of ['cat .e"nv"', 'echo > /e"tc"/x', 'rm -rf "$(echo -n /)"']) {
70 expect(checkDeterministic("bash", { command })?.decision).not.toBe("allow");