Skip to content

Commit 6f6acba

Browse files
committed
bugfix: expand literal script var assignments in pre-bash redirect check
1 parent 3f0e52b commit 6f6acba

5 files changed

Lines changed: 531 additions & 7 deletions

File tree

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
{
2-
"status" : "open"
3-
}
2+
"status" : "closed",
3+
"resolution" : "implemented",
4+
"dependencies" : [],
5+
"blocks" : [],
6+
"target_branch" : "v2.1"
7+
}

.cat/issues/v2/v2.1/expand-vars-in-pre-bash-check/plan.md

Lines changed: 361 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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 + "\"\nsome-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 + "\"\nsome-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)\nsome-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

Comments
 (0)