Skip to content

Commit 5e04534

Browse files
tmchowclaude
andauthored
fix(ce-compound,ce-sessions): handle non-git CWD in pre-resolved git branch (#731)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 265cb42 commit 5e04534

4 files changed

Lines changed: 102 additions & 3 deletions

File tree

plugins/compound-engineering/skills/ce-compound/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Captures problem solutions while context is fresh, creating structured documenta
2424

2525
**Repo name (pre-resolved):** !`common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null); repo="${common%/.git}"; echo "${repo##*/}"`
2626

27-
**Git branch (pre-resolved):** !`git rev-parse --abbrev-ref HEAD 2>/dev/null`
27+
**Git branch (pre-resolved):** !`git rev-parse --abbrev-ref HEAD 2>/dev/null || true`
2828

2929
If the lines above resolved to plain values (a folder name like `my-repo` and a branch name like `feat/my-branch`), pass them into the Session Historian dispatch in Phase 1 so the agent does not waste a turn deriving them. If they still contain backtick command strings or are empty, omit them from the dispatch and let the agent derive them at runtime.
3030

plugins/compound-engineering/skills/ce-sessions/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Search your session history.
1818

1919
**Repo name (pre-resolved):** !`common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null); repo="${common%/.git}"; echo "${repo##*/}"`
2020

21-
**Git branch (pre-resolved):** !`git rev-parse --abbrev-ref HEAD 2>/dev/null`
21+
**Git branch (pre-resolved):** !`git rev-parse --abbrev-ref HEAD 2>/dev/null || true`
2222

2323
If the lines above resolved to plain values (a folder name like `my-repo` and a branch name like `feat/my-branch`), they are ready to pass to the agent. If they still contain backtick command strings or are empty, they did not resolve — omit them from the dispatch and let the agent derive them at runtime.
2424

plugins/compound-engineering/skills/ce-work-beta/references/codex-delegation-workflow.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ If `inside_sandbox` is true, delegation would recurse or fail.
5656
**2. Availability Check**
5757

5858
**Codex CLI path (pre-resolved):**
59-
!`command -v codex 2>/dev/null`
59+
!`command -v codex 2>/dev/null || true`
6060

6161
If the line above shows an absolute path (starts with `/`, e.g., `/opt/homebrew/bin/codex`), the Codex CLI is available — proceed to the next check.
6262
Otherwise — empty, an unresolved command string like `command -v codex 2>/dev/null` left in place by a non-Claude harness that doesn't process `!` pre-resolution, or any other non-path value — run `command -v codex` via the shell/Bash tool to verify at runtime. If that prints an absolute path, the Codex CLI is available; proceed. If it fails or prints nothing, emit "Codex CLI not found (install via `npm install -g @openai/codex` or `brew install codex`) -- using standard mode." and set `delegation_active` to false.

tests/skill-shell-safety.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ import { describe, expect, test } from "bun:test"
2525
* - ce-compound and ce-sessions used `git rev-parse ... | sed -E '...'` which
2626
* trips the permission checker as "multiple operations". Fix: replace the
2727
* pipe with bash parameter expansion (e.g. strip suffix, strip prefix).
28+
* - ce-compound and ce-sessions used `git rev-parse --abbrev-ref HEAD 2>/dev/null`
29+
* with no fallback. Outside a git repo, `git rev-parse` exits 128;
30+
* `2>/dev/null` suppresses stderr but the non-zero exit propagates and
31+
* Claude Code reports "Shell command failed for pattern" (issue #730). Fix:
32+
* pair `2>/dev/null` with `|| true` or `|| echo '__SENTINEL__'`.
2833
*/
2934

3035
const PLUGIN_SKILLS_GLOB = ["plugins/compound-engineering/skills", "plugins/coding-tutor/skills"]
@@ -141,6 +146,45 @@ function hasNestedQuotedStringInCommandSubst(cmd: string): boolean {
141146
return findCommandSubstitutionContents(cmd).some(s => s.includes('"'))
142147
}
143148

149+
/**
150+
* Returns true when the command's *trailing* top-level statement suppresses
151+
* stderr with `2>/dev/null` but has no `||` fallback. The trailing statement
152+
* determines the command's overall exit code (after `;` or `&&` chains). When
153+
* that exit code is non-zero, Claude Code reports "Shell command failed for
154+
* pattern" and aborts skill load before its body runs (issue #730). Established
155+
* defensive pattern: pair `2>/dev/null` with `|| true` or `|| echo '__SENTINEL__'`.
156+
*
157+
* `2>/dev/null` inside an earlier `;`-chained statement is fine if the trailing
158+
* statement (e.g., a final `echo`) succeeds — that case is not flagged.
159+
*/
160+
function hasUnguardedErrorSuppression(cmd: string): boolean {
161+
let depth = 0
162+
let inSingleQuote = false
163+
let inDoubleQuote = false
164+
let lastSeparatorEnd = 0
165+
166+
for (let i = 0; i < cmd.length; i++) {
167+
const c = cmd[i]
168+
const next = cmd[i + 1]
169+
170+
if (!inDoubleQuote && c === "'") { inSingleQuote = !inSingleQuote; continue }
171+
if (!inSingleQuote && c === '"') { inDoubleQuote = !inDoubleQuote; continue }
172+
if (inSingleQuote || inDoubleQuote) continue
173+
174+
if (c === '$' && next === '(') { depth++; i++; continue }
175+
if (c === '(') { depth++; continue }
176+
if (c === ')') { depth--; continue }
177+
178+
if (depth === 0) {
179+
if (c === ';') { lastSeparatorEnd = i + 1; continue }
180+
if (c === '&' && next === '&') { lastSeparatorEnd = i + 2; i++; continue }
181+
if (c === '|' && next === '|') { lastSeparatorEnd = i + 2; i++; continue }
182+
}
183+
}
184+
185+
return cmd.slice(lastSeparatorEnd).includes("2>/dev/null")
186+
}
187+
144188
/**
145189
* Returns true when the command contains a top-level pipe (`|` that is not
146190
* `||`). Claude Code's permission checker treats piped commands as separate
@@ -232,6 +276,48 @@ describe("hasNestedQuotedStringInCommandSubst", () => {
232276
})
233277
})
234278

279+
describe("hasUnguardedErrorSuppression", () => {
280+
test("flags single-statement `2>/dev/null` with no fallback", () => {
281+
expect(hasUnguardedErrorSuppression("git rev-parse --abbrev-ref HEAD 2>/dev/null")).toBe(true)
282+
})
283+
284+
test("flags `command -v <name> 2>/dev/null` with no fallback", () => {
285+
expect(hasUnguardedErrorSuppression("command -v codex 2>/dev/null")).toBe(true)
286+
})
287+
288+
test("does not flag `2>/dev/null || true`", () => {
289+
expect(hasUnguardedErrorSuppression("git rev-parse --abbrev-ref HEAD 2>/dev/null || true")).toBe(false)
290+
})
291+
292+
test("does not flag `2>/dev/null || echo '__SENTINEL__'`", () => {
293+
expect(hasUnguardedErrorSuppression("git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo '__DEFAULT_BRANCH_UNRESOLVED__'")).toBe(false)
294+
})
295+
296+
test("does not flag commands without `2>/dev/null`", () => {
297+
expect(hasUnguardedErrorSuppression("git status")).toBe(false)
298+
})
299+
300+
test("does not flag `2>/dev/null` inside a guarded subshell", () => {
301+
expect(hasUnguardedErrorSuppression("(top=$(git rev-parse --show-toplevel 2>/dev/null); cat \"$top/file\") || echo '__NO_CONFIG__'")).toBe(false)
302+
})
303+
304+
test("does not flag `2>/dev/null` in non-trailing statement followed by always-succeeding tail", () => {
305+
expect(hasUnguardedErrorSuppression('common=$(git rev-parse --git-common-dir 2>/dev/null); repo="${common%/.git}"; echo "${repo##*/}"')).toBe(false)
306+
})
307+
308+
test("flags `2>/dev/null` on the trailing statement of a `;` chain", () => {
309+
expect(hasUnguardedErrorSuppression("setup; cmd 2>/dev/null")).toBe(true)
310+
})
311+
312+
test("flags `2>/dev/null` on the trailing statement of an `&&` chain", () => {
313+
expect(hasUnguardedErrorSuppression("setup && cmd 2>/dev/null")).toBe(true)
314+
})
315+
316+
test("flags `2>/dev/null` on the trailing statement of an `||` chain (a `||` belonging to an earlier statement does not protect a later unguarded one)", () => {
317+
expect(hasUnguardedErrorSuppression("probe || git rev-parse --abbrev-ref HEAD 2>/dev/null")).toBe(true)
318+
})
319+
})
320+
235321
describe("hasTopLevelPipe", () => {
236322
test("flags a simple pipe", () => {
237323
expect(hasTopLevelPipe("git rev-parse --git-common-dir 2>/dev/null | sed -E 's|x||'")).toBe(true)
@@ -302,6 +388,19 @@ describe("skill `!` pre-resolution commands avoid Claude Code denylist", () => {
302388
).toEqual([])
303389
})
304390

391+
test(`${rel} pre-resolution commands that suppress stderr with \`2>/dev/null\` also include a \`||\` fallback (issue #730)`, () => {
392+
const offenders = preResolutionCommands.filter(({ command }) =>
393+
hasUnguardedErrorSuppression(command),
394+
)
395+
const formatted = offenders
396+
.map(({ lineNumber, command }) => ` line ${lineNumber}: ${command}`)
397+
.join("\n")
398+
expect(
399+
offenders,
400+
`\`2>/dev/null\` only suppresses stderr — the non-zero exit code still propagates. Outside the success path (e.g., \`git rev-parse\` outside a git repo), Claude Code reports "Shell command failed for pattern" and aborts skill load. Pair \`2>/dev/null\` with \`|| true\` (when an empty result is fine) or \`|| echo '__SENTINEL__'\` (when the agent should distinguish "did not resolve" from "resolved to empty").\nOffending commands:\n${formatted}`,
401+
).toEqual([])
402+
})
403+
305404
test(`${rel} pre-resolution commands do not use top-level pipes (triggers permission check for multiple operations)`, () => {
306405
const offenders = preResolutionCommands.filter(({ command }) =>
307406
hasTopLevelPipe(command),

0 commit comments

Comments
 (0)