Diff
1diff --git a/src/core/deterministic.ts b/src/core/deterministic.ts
2index 6b5904ac796ec9f4c3a34a05d25bc3154c5297ad..2ff930c42c1c2f0822ffd9737d619e1e86175edc 100644
3--- a/src/core/deterministic.ts
4+++ b/src/core/deterministic.ts
5@@ -88,6 +88,23 @@ function shellWords(command: string): string[] {
6 });
7 }
8
9+const ENVIRONMENT_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
10+
11+function commandWords(command: string): string[] {
12+ const words = shellWords(command);
13+ const programIndex = words.findIndex((word) => !ENVIRONMENT_ASSIGNMENT_RE.test(word));
14+ return programIndex === -1 ? [] : words.slice(programIndex);
15+}
16+
17+function withoutEnvironmentAssignments(command: string): string {
18+ let source = command.trimStart();
19+ while (true) {
20+ const assignment = source.match(/^[A-Za-z_][A-Za-z0-9_]*=[^\s]*\s+/)?.[0];
21+ if (!assignment) return source;
22+ source = source.slice(assignment.length);
23+ }
24+}
25+
26 function variableCharacter(character: string | undefined): boolean {
27 return character !== undefined && /[A-Za-z0-9_]/.test(character);
28 }
29@@ -264,7 +281,7 @@ function hasOption(arguments_: string[], optionNames: ReadonlySet<string>): bool
30 }
31
32 function hasSecretPathOperand(command: string): boolean {
33- const [program, ...arguments_] = shellWords(command);
34+ const [program, ...arguments_] = commandWords(command);
35 if (!program || program === "cd") return false;
36
37 if (program === "grep" || program === "rg") {
38@@ -368,7 +385,7 @@ function isSafeFind(arguments_: string[]): boolean {
39 }
40
41 function checkHomeFind(command: string): Decision | undefined {
42- const [program, ...arguments_] = shellWords(command);
43+ const [program, ...arguments_] = commandWords(command);
44 const home = process.env.HOME;
45 if (
46 program !== "find" ||
47@@ -383,7 +400,7 @@ function checkHomeFind(command: string): Decision | undefined {
48 }
49
50 function checkDestructiveRm(command: string): Decision | undefined {
51- const [program, ...arguments_] = shellWords(command);
52+ const [program, ...arguments_] = commandWords(command);
53 if (program !== "rm") return undefined;
54
55 if (!positionalWords(arguments_).some(isDestructiveRmTarget)) return undefined;
56@@ -404,7 +421,7 @@ function isDevNull(word: string): boolean {
57 }
58
59 function checkSystemPathMutation(command: string): Decision | undefined {
60- const [program, ...arguments_] = shellWords(command);
61+ const [program, ...arguments_] = commandWords(command);
62 if (!LOCAL_MUTATION_COMMANDS.has(program)) return undefined;
63 if (!arguments_.some((argument) => argument.split("=", 2).some(isSystemPath))) return undefined;
64 return {
65@@ -420,7 +437,7 @@ function dockerRemovesVolumes(argument: string): boolean {
66
67 function checkDocker(command: string, parsed: boolean): Decision | undefined {
68 if (!parsed && hasShellControl(command)) return undefined;
69- const [program, ...arguments_] = shellWords(command);
70+ const [program, ...arguments_] = commandWords(command);
71 if (program !== "docker") return undefined;
72
73 const commandIndex = arguments_.findIndex(
74@@ -480,7 +497,7 @@ function isSpecialAllowedCommand(program: string, arguments_: string[]): boolean
75 function checkAllow(command: string, parsed: boolean): Decision | undefined {
76 const cmd = command.trim();
77 if (!cmd || (!parsed && hasShellControl(cmd))) return undefined;
78- const [program, ...arguments_] = shellWords(cmd);
79+ const [program, ...arguments_] = commandWords(cmd);
80 if (!program || (!ALLOW_COMMANDS.has(program) && !isSpecialAllowedCommand(program, arguments_))) return undefined;
81 return {
82 decision: "allow",
83@@ -542,13 +559,14 @@ function checkBash(command: string, redirects: readonly BashRedirect[] = [], par
84 if (redirects.some((redirect) => redirect.dynamic)) return undefined;
85 const redirectDecision = checkRedirects(redirects);
86 if (redirectDecision) return redirectDecision;
87+ const semanticCommand = withoutEnvironmentAssignments(cleaned);
88 const askDecision =
89- checkAskPatterns(cleaned) ??
90+ checkAskPatterns(semanticCommand) ??
91 checkHomeFind(cleaned) ??
92 checkDestructiveRm(cleaned) ??
93 checkSystemPathMutation(cleaned);
94 if (askDecision) return askDecision;
95- const [program, ...arguments_] = shellWords(cleaned);
96+ const [program, ...arguments_] = commandWords(cleaned);
97 if (hasUnsafeWordSyntax(source, program === "ls")) return undefined;
98 if (program && hasInlineInterpreterCode(program, arguments_)) return undefined;
99 return checkDocker(cleaned, parsed) ?? checkAllow(cleaned, parsed);
100diff --git a/src/core/rules.ts b/src/core/rules.ts
101index eeb2be5615b1327d16ee50330a57a391120ef989..cbe280bf3be91fb4de5c6e45abc186afc320097f 100644
102--- a/src/core/rules.ts
103+++ b/src/core/rules.ts
104@@ -1,7 +1,7 @@
105 import { DECISION_CATEGORIES } from "./types";
106
107 // Bump to invalidate all cached decisions when rules change.
108-export const POLICY_VERSION = 36;
109+export const POLICY_VERSION = 37;
110
111 // Trailing stderr redirections that are safe to strip before pattern matching.
112 // `2>&1` and `2>/dev/null` have no security implication but would otherwise
113diff --git a/test/core/deterministic.test.ts b/test/core/deterministic.test.ts
114index ff8465775814ecac2306a97e46d53968aa1e95ac..3fc24d649735d25ad5743d9957da1b4e1eec95e2 100644
115--- a/test/core/deterministic.test.ts
116+++ b/test/core/deterministic.test.ts
117@@ -75,6 +75,17 @@ test("allows recognized deterministic command grammars", () => {
118 }
119 });
120
121+test("supports static environment assignments", () => {
122+ expect(
123+ checkParsedBash("GIT_SEQUENCE_EDITOR=: GIT_EDITOR=true git rebase --onto origin/profiling 7c7f359", []),
124+ ).toMatchObject({ decision: "allow" });
125+ expect(checkDeterministic("bash", { command: "GIT_EDITOR=true git reset --hard" })).toMatchObject({
126+ decision: "ask",
127+ });
128+ expect(checkDeterministic("bash", { command: "EDITOR=true cat .env" })).toMatchObject({ decision: "ask" });
129+ expect(checkDeterministic("bash", { command: 'EDITOR="$EDITOR" git status' })?.decision).not.toBe("allow");
130+});
131+
132 test("allows static pathname globs only for ls", () => {
133 expect(checkDeterministic("bash", { command: "ls internal/*/" })).toMatchObject({ decision: "allow" });
134 expect(checkDeterministic("bash", { command: "ls internal/[ab]?/" })).toMatchObject({ decision: "allow" });