@@ -29,8 +29,104 @@ non-literal forms remain rejected as before — they cannot be safely evaluated
2929
3030## Scope
3131
32- - ` BlockWorktreeIsolationViolation.java ` — the Java class that implements the pre-bash hook logic
33- - Unit tests in ` BlockWorktreeIsolationViolationTest.java `
32+ - ` client/src/main/java/io/github/cowwoc/cat/claude/hook/ShellParser.java ` — add
33+ ` parseScriptAssignments(String script) ` static method
34+ - ` client/src/main/java/io/github/cowwoc/cat/claude/hook/bash/BlockWorktreeIsolationViolation.java `
35+ — merge script assignments into the ` envLookup ` before calling ` expandEnvVars `
36+ - ` client/src/test/java/io/github/cowwoc/cat/client/test/BlockWorktreeIsolationViolationTest.java `
37+ — add three new test cases
38+
39+ ## Research Findings
40+
41+ ### Existing expansion flow
42+
43+ ` BlockWorktreeIsolationViolation.check() ` already handles ` $VAR ` references via:
44+
45+ ``` java
46+ if (target. contains(" $" ))
47+ {
48+ String expanded = ShellParser . expandEnvVars(target, envLookup);
49+ if (expanded == null )
50+ {
51+ // block with warning
52+ }
53+ target = expanded;
54+ }
55+ ```
56+
57+ ` ShellParser.expandEnvVars(target, envLookup) ` returns ` null ` when any referenced variable is
58+ undefined (lookup returned ` null ` ), triggering the conservative block.
59+
60+ ### Merged lookup approach
61+
62+ Build a merged lookup from script-level literal assignments + the existing ` envLookup ` :
63+
64+ ``` java
65+ Map<String , String > scriptVars = ShellParser . parseScriptAssignments(command);
66+ Function<String , String > mergedLookup = varName - >
67+ {
68+ String scriptValue = scriptVars. get(varName);
69+ if (scriptValue != null )
70+ return scriptValue;
71+ return envLookup. apply(varName);
72+ };
73+ String expanded = ShellParser . expandEnvVars(target, mergedLookup);
74+ ```
75+
76+ Script-level bindings take precedence; if the variable is not in the script, fall back to the
77+ process environment.
78+
79+ ### Pattern for literal assignments
80+
81+ Match lines that begin with a variable assignment of a pure-literal value:
82+
83+ ```
84+ (?m)^\s*([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"$`\\]*)"|'([^']*)')
85+ ```
86+
87+ - ` (?m) ` — multiline: ` ^ ` anchors at each line start
88+ - ` [A-Za-z_][A-Za-z0-9_]* ` — valid shell identifier
89+ - ` "([^"$ ` \\ ] * )"` — double-quoted literal: no ` $`, no backtick, no backslash (excludes
90+ expansions and escape sequences)
91+ - ` '([^']*)' ` — single-quoted literal: always literal, no single-quote inside
92+
93+ Groups: 1 = variable name, 2 = value from double-quoted form, 3 = value from single-quoted form.
94+
95+ Command substitution ` $(...) ` is excluded because the first character inside ` $ ` is ` ( ` , not a
96+ word character, so it does not match ` $VAR ` . Unquoted assignments (` VAR=value ` ) are intentionally
97+ excluded — they may contain spaces or special characters that are ambiguous without full shell
98+ parsing.
99+
100+ ### Updated warning message
101+
102+ After the change, when expansion still fails (the variable is genuinely unresolvable), the warning
103+ should guide the agent to use literals or to define the variable as a literal:
104+
105+ ```
106+ WARNING: Cannot verify Bash redirect to variable-expanded path: %s
107+
108+ One or more variables in the path could not be resolved.
109+ Variables must be defined earlier in the same script as a simple literal assignment, e.g.:
110+ VAR="/absolute/path"
111+ cmd > "${VAR}"
112+
113+ Variables set via command substitution ($(..)) or unset variables cannot be resolved statically.
114+ If this targets a path outside your worktree, it bypasses worktree isolation.
115+ Use the Edit or Write tools with an explicit absolute path instead:
116+ Use: %s/plugin/file.txt
117+ Not: $UNSET_VAR/plugin/file.txt
118+ ```
119+
120+ ### Test pattern (injectable env)
121+
122+ Existing tests that exercise env-var expansion use the two-arg constructor:
123+ ``` java
124+ BlockWorktreeIsolationViolation handler = new BlockWorktreeIsolationViolation (scope, env);
125+ ```
126+
127+ New tests for script-level assignments use an empty (or irrelevant) env map and embed the
128+ assignment in the command string. The script text and redirect are passed as a single multi-line
129+ ` command ` string.
34130
35131## Post-conditions
36132
@@ -43,3 +139,266 @@ non-literal forms remain rejected as before — they cannot be safely evaluated
43139 blocked with the existing worktree-isolation violation message
44140- [ ] All existing tests pass (` mvn -f client/pom.xml verify -e ` )
45141- [ ] New unit tests cover the three new cases above
142+
143+ ## Jobs
144+
145+ ### Job 1
146+
147+ ** Step 1 — Add ` ShellParser.parseScriptAssignments(String script) ` **
148+
149+ File: ` client/src/main/java/io/github/cowwoc/cat/claude/hook/ShellParser.java `
150+
151+ Add a new private pattern constant after the existing ` ENV_VAR_EXPAND_PATTERN ` :
152+
153+ ``` java
154+ // Matches a literal shell variable assignment at the start of a line:
155+ // VAR="value" (double-quoted: no $, backtick, or backslash — ensures pure literal)
156+ // VAR='value' (single-quoted: always literal)
157+ // Groups: 1=name, 2=double-quoted value, 3=single-quoted value
158+ private static final Pattern SCRIPT_ASSIGNMENT_PATTERN =
159+ Pattern . compile(" (?m)^\\ s*([A-Za-z_][A-Za-z0-9_]*)=(?:\" ([^\" $`\\\\ ]*)\" |'([^']*)')" );
160+ ```
161+
162+ Add the new public static method after ` expandEnvVars(String, Function) ` :
163+
164+ ``` java
165+ /**
166+ * Scans {@code script } for simple literal variable assignments and returns them as a map.
167+ * <p >
168+ * Only assignments whose value is a pure literal — double-quoted strings containing no
169+ * {@code $ }, backtick, or backslash characters, or single-quoted strings — are captured.
170+ * Assignments via command substitution ({@code VAR=$(...) }) and unquoted assignments are
171+ * ignored because they cannot be evaluated statically.
172+ * <p >
173+ * When the same variable is assigned multiple times, the last assignment wins (matching
174+ * bash semantics where each assignment shadows the previous).
175+ *
176+ * @param script the full bash command or script text to scan
177+ * @return a mutable map from variable name to its literal value; empty if no literal
178+ * assignments are found
179+ * @throws NullPointerException if {@code script } is null
180+ */
181+ public static Map<String , String > parseScriptAssignments(String script)
182+ {
183+ requireThat(script, " script" ). isNotNull();
184+ Map<String , String > assignments = new LinkedHashMap<> ();
185+ Matcher assignmentMatcher = SCRIPT_ASSIGNMENT_PATTERN . matcher(script);
186+ while (assignmentMatcher. find())
187+ {
188+ String varName = assignmentMatcher. group(1 );
189+ String value;
190+ if (assignmentMatcher. group(2 ) != null )
191+ value = assignmentMatcher. group(2 );
192+ else
193+ value = assignmentMatcher. group(3 );
194+ assignments. put(varName, value);
195+ }
196+ return assignments;
197+ }
198+ ```
199+
200+ Add ` import java.util.LinkedHashMap; ` and ` import java.util.Map; ` to the imports section.
201+
202+ ** Step 2 — Merge script assignments into ` BlockWorktreeIsolationViolation.check() ` **
203+
204+ File: ` client/src/main/java/io/github/cowwoc/cat/claude/hook/bash/BlockWorktreeIsolationViolation.java `
205+
206+ Locate the block that handles ` $ ` -containing targets (around line 147–164 in the current file).
207+ Replace the existing ` if (target.contains("$")) ` block with:
208+
209+ ``` java
210+ if (target. contains(" $" ))
211+ {
212+ // First, extract any literal variable assignments defined earlier in the same script
213+ // (e.g. OUT="/path/file.txt") and merge them with the process environment so that
214+ // redirects to "${OUT}" can be statically resolved without blocking needlessly.
215+ Map<String , String > scriptVars = ShellParser . parseScriptAssignments(command);
216+ Function<String , String > mergedLookup = varName - >
217+ {
218+ String scriptValue = scriptVars. get(varName);
219+ if (scriptValue != null )
220+ return scriptValue;
221+ return envLookup. apply(varName);
222+ };
223+ String expanded = ShellParser . expandEnvVars(target, mergedLookup);
224+ if (expanded == null )
225+ {
226+ String message = " " "
227+ WARNING: Cannot verify Bash redirect to variable-expanded path: %s
228+
229+ One or more variables in the path could not be resolved.
230+ Variables must be defined earlier in the same script as a simple literal assignment, e.g.:
231+ VAR=" / absolute/ path"
232+ cmd > " ${VAR }"
233+
234+ Variables set via command substitution ($(...)) or unset variables cannot be resolved statically.
235+ If this targets a path outside your worktree, it bypasses worktree isolation.
236+ Use the Edit or Write tools with an explicit absolute path instead:
237+ Use: %s/plugin/file.txt
238+ Not: $UNSET_VAR/plugin/file.txt" " " . formatted(target, context. absoluteWorktreePath());
239+ return Result . block(message);
240+ }
241+ // Replacing the variable reference with the concrete path lets the worktree isolation
242+ // check below evaluate it the same way it handles any literal redirect target.
243+ target = expanded;
244+ }
245+ ```
246+
247+ Add ` import java.util.Map; ` to the imports (it may already be present for the constructor overload).
248+
249+ ** Step 3 — Add three new test cases to ` BlockWorktreeIsolationViolationTest.java ` **
250+
251+ File: ` client/src/test/java/io/github/cowwoc/cat/client/test/BlockWorktreeIsolationViolationTest.java `
252+
253+ Add the following three test methods. Each is self-contained with its own temporary directory:
254+
255+ ** Test 1: literal assignment inside the script allows the redirect**
256+
257+ ``` java
258+ /**
259+ * Verifies that a redirect is allowed when the variable is defined as a literal path earlier
260+ * in the same script block and that path is inside the active worktree.
261+ * <p >
262+ * The hook must scan the script for {@code VAR="/path" } assignments and use them to resolve
263+ * the redirect target without relying on the process environment.
264+ *
265+ * @throws IOException if test setup fails
266+ */
267+ @Test
268+ public void redirectAllowedWhenVariableDefinedLiterallyInScript() throws IOException
269+ {
270+ Path projectPath = Files . createTempDirectory(" bwiv-test-" );
271+ try (TestClaudeHook scope = new TestClaudeHook (projectPath, projectPath, projectPath))
272+ {
273+ TestUtils . writeLockFile(scope, ISSUE_ID , SESSION_ID );
274+ Path worktreeDir = TestUtils . createWorktreeDir(scope, ISSUE_ID );
275+ String insidePath = worktreeDir. resolve(" plugin/file.txt" ). toString();
276+ // Embed the literal assignment in the same script block as the redirect
277+ String command = " OUT=\" " + insidePath + " \"\n some-command > \" ${OUT}\" " ;
278+ Map<String , String > env = Map . of(); // variable not in env — must come from script
279+
280+ BlockWorktreeIsolationViolation handler = new BlockWorktreeIsolationViolation (scope, env);
281+ BashHandler . Result result = handler. check(
282+ TestUtils . bashHook(command, projectPath. toString(), SESSION_ID , scope));
283+
284+ requireThat(result. blocked(), " blocked" ). isFalse();
285+ }
286+ finally
287+ {
288+ TestUtils . deleteDirectoryRecursively(projectPath);
289+ }
290+ }
291+ ```
292+
293+ ** Test 2: literal assignment resolves to a path outside the worktree — blocked**
294+
295+ ``` java
296+ /**
297+ * Verifies that a redirect is blocked when the variable is defined as a literal path earlier
298+ * in the same script but that path is outside the active worktree (inside the project directory).
299+ * <p >
300+ * Resolving the variable successfully does not grant permission — the resolved path must still
301+ * pass the worktree-isolation check.
302+ *
303+ * @throws IOException if test setup fails
304+ */
305+ @Test
306+ public void redirectBlockedWhenLiteralVariableResolvesOutsideWorktree() throws IOException
307+ {
308+ Path projectPath = Files . createTempDirectory(" bwiv-test-" );
309+ try (TestClaudeHook scope = new TestClaudeHook (projectPath, projectPath, projectPath))
310+ {
311+ TestUtils . writeLockFile(scope, ISSUE_ID , SESSION_ID );
312+ TestUtils . createWorktreeDir(scope, ISSUE_ID );
313+ Path outsidePath = projectPath. resolve(" plugin/file.txt" );
314+ String command = " OUT=\" " + outsidePath + " \"\n some-command > \" ${OUT}\" " ;
315+ Map<String , String > env = Map . of();
316+
317+ BlockWorktreeIsolationViolation handler = new BlockWorktreeIsolationViolation (scope, env);
318+ BashHandler . Result result = handler. check(
319+ TestUtils . bashHook(command, projectPath. toString(), SESSION_ID , scope));
320+
321+ requireThat(result. blocked(), " blocked" ). isTrue();
322+ requireThat(result. reason(), " reason" ). contains(" Worktree isolation violation" );
323+ }
324+ finally
325+ {
326+ TestUtils . deleteDirectoryRecursively(projectPath);
327+ }
328+ }
329+ ```
330+
331+ ** Test 3: command-substitution assignment remains blocked**
332+
333+ ``` java
334+ /**
335+ * Verifies that a redirect remains blocked when the variable is assigned via command
336+ * substitution ({@code VAR=$(mktemp) }) rather than a literal value.
337+ * <p >
338+ * Command substitutions cannot be evaluated statically, so the hook must conservatively
339+ * block the redirect even if the variable name appears in the script.
340+ *
341+ * @throws IOException if test setup fails
342+ */
343+ @Test
344+ public void redirectRemainsBlockedWhenVariableAssignedViaCommandSubstitution() throws IOException
345+ {
346+ Path projectPath = Files . createTempDirectory(" bwiv-test-" );
347+ try (TestClaudeHook scope = new TestClaudeHook (projectPath, projectPath, projectPath))
348+ {
349+ TestUtils . writeLockFile(scope, ISSUE_ID , SESSION_ID );
350+ TestUtils . createWorktreeDir(scope, ISSUE_ID );
351+ // Command substitution: $(mktemp) cannot be statically resolved
352+ String command = " OUT=$(mktemp)\n some-command > \" ${OUT}\" " ;
353+ Map<String , String > env = Map . of();
354+
355+ BlockWorktreeIsolationViolation handler = new BlockWorktreeIsolationViolation (scope, env);
356+ BashHandler . Result result = handler. check(
357+ TestUtils . bashHook(command, projectPath. toString(), SESSION_ID , scope));
358+
359+ requireThat(result. blocked(), " blocked" ). isTrue();
360+ requireThat(result. reason(), " reason" ). contains(" could not be resolved" );
361+ }
362+ finally
363+ {
364+ TestUtils . deleteDirectoryRecursively(projectPath);
365+ }
366+ }
367+ ```
368+
369+ ** Step 4 — Update the existing ` variableExpansionDollarSignIsBlocked ` test**
370+
371+ The existing test checks that the reason contains ` "unset in the hook process environment" ` . Since
372+ the warning message is being updated, update the assertion to match the new text
373+ ` "could not be resolved" ` :
374+
375+ ``` java
376+ requireThat(result. reason(), " reason" ). contains(" could not be resolved" );
377+ ```
378+
379+ (The test itself does not change — it already verifies that a genuinely-undefined variable blocks
380+ the redirect. Only the expected message substring changes.)
381+
382+ ** Step 5 — Run the full build and fix any issues**
383+
384+ ``` bash
385+ mvn -f client/pom.xml verify -e
386+ ```
387+
388+ All tests must pass before committing. Fix any Checkstyle or PMD violations.
389+
390+ ** Step 6 — Commit and update index.json**
391+
392+ Commit all changes with type ` bugfix: ` . Update
393+ ` .cat/issues/v2/v2.1/expand-vars-in-pre-bash-check/index.json ` to:
394+ ``` json
395+ {
396+ "status" : " closed" ,
397+ "resolution" : " implemented" ,
398+ "dependencies" : [],
399+ "blocks" : [],
400+ "target_branch" : " v2.1"
401+ }
402+ ```
403+
404+ Include the ` index.json ` update in the ** same commit** as the implementation changes.
0 commit comments