Skip to content

Commit 1f0a77b

Browse files
tmchowclaude
andauthored
fix(skills): replace shell antipatterns blocked by permission check (#711)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8cc07ac commit 1f0a77b

12 files changed

Lines changed: 422 additions & 96 deletions

File tree

plugins/compound-engineering/AGENTS.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,34 @@ Why: shell-heavy exploration causes avoidable permission prompts in sub-agent wo
225225
- [ ] Never instruct agents to use `find`, `ls`, `cat`, `head`, `tail`, `grep`, `rg`, `wc`, or `tree` through a shell for routine file discovery, content search, or file reading
226226
- [ ] Describe tools by capability class with platform hints — e.g., "Use the native file-search/glob tool (e.g., Glob in Claude Code)" — not by Claude Code-specific tool names alone
227227
- [ ] When shell is the only option (e.g., `ast-grep`, `bundle show`, git commands), instruct one simple command at a time — no action chaining (`cmd1 && cmd2`, `cmd1 ; cmd2`) and no error suppression (`2>/dev/null`, `|| true`). Two narrow exceptions: boolean conditions within if/while guards (`[ -n "$X" ] || [ -n "$Y" ]`) are fine — that is normal conditional logic, not action chaining. **Value-producing preparatory commands** (`VAR=$(cmd1) && cmd2 "$VAR"`) are also fine when `cmd2` strictly consumes `cmd1`'s output and splitting would require manually threading the value through model context across bash calls (e.g., `BODY_FILE=$(mktemp -u) && cat > "$BODY_FILE" <<EOF ... EOF`). Simple pipes (e.g., `| jq .field`) and output redirection (e.g., `> file`) are acceptable when they don't obscure failures
228-
- [ ] **Pre-resolution exception:** `!` backtick pre-resolution commands run at skill load time, not at agent runtime. They may use chaining (`&&`, `||`), error suppression (`2>/dev/null`), and fallback sentinels (e.g., `|| echo '__NO_CONFIG__'`) to produce a clean, parseable value for the model. This is the preferred pattern for environment probes (CLI availability, config file reads) that would otherwise require runtime shell calls with chaining. Example: `` !`command -v codex >/dev/null 2>&1 && echo "AVAILABLE" || echo "NOT_FOUND"` ``
228+
- [ ] **Pre-resolution exception:** `!` backtick pre-resolution commands run at skill load time, not at agent runtime. They may use chaining (`&&`, `||`), error suppression (`2>/dev/null`), and fallback sentinels (e.g., `|| echo '__NO_CONFIG__'`) to produce a clean, parseable value for the model. This is the preferred pattern for environment probes (CLI availability, config file reads) that would otherwise require runtime shell calls with chaining. Three shapes are rejected by Claude Code's safety check and must be avoided in `!` backticks:
229+
- **`case ... esac`** is rejected as `Contains case_statement`. Use `&&` chaining or pipe-to-sed, or extract to a script.
230+
- **`[A] && B || C`** (mixing `&&` and `||` at the same lexical depth) is rejected as `ambiguous syntax with command separators` (issue #710). Wrap the `&&` chain in a subshell so only `||` remains at top level — `(A && B) || C` — or emit the raw value and let the agent's prose decide. Example of the safe shape: `` !`(top=$(...); [ -n "$top" ] && cat "$top/file") || echo '__SENTINEL__'` ``
231+
- **`$(...)` containing a double-quoted string** (e.g., `basename "$(dirname "$common")"`) is rejected as `Unhandled node type: string` (issue #709). Replace nested `$()` with parameter expansion (`${common%/.git}`), pipe to sed (`| sed -E 's|/\.git/?$||; s|.*/||'`), or extract to a script invoked as `` !`bash "${CLAUDE_SKILL_DIR}/scripts/<name>.sh"` ``.
232+
233+
When the logic is non-trivial, prefer extracting to a script under the skill's `scripts/` directory; the safety check then sees only `bash <quoted-path>`, which sidesteps both current and future safety-check tightenings. Tests in `tests/skill-shell-safety.test.ts` enforce all three patterns.
234+
235+
**Permission gate on extracted scripts — invoke from the skill body, not from `!` pre-resolution.** A pre-resolution `bash "${CLAUDE_SKILL_DIR}/scripts/<name>.sh"` form passes the safety check but trips Claude Code's permission check at skill-load time, which does *not* honor `defaultMode: bypassPermissions`. Allow-listing via `allowed-tools` frontmatter is unreliable at *load time*: empirically, broad `Bash(bash *)` patterns appear to load with bypass on but narrow filename-pinned patterns like `Bash(bash *upstream-version.sh)` fail with bypass off. Move the script invocation into the skill body so it runs via the runtime Bash tool instead. Two pieces are required for it to actually work:
236+
237+
1. **Use `${CLAUDE_SKILL_DIR}` for the script path**, not bare relative paths. The runtime Bash tool runs from the user's project CWD, not the skill directory — `bash scripts/<name>.sh` fails with "No such file or directory" empirically. The `${CLAUDE_SKILL_DIR}` env var resolves correctly across `claude --plugin-dir` and standard marketplace-cached installs.
238+
2. **Declare narrow `allowed-tools` patterns** pinned to each script filename. At runtime, `allowed-tools` granting is documented to apply, so users without `bypassPermissions` skip the approval prompt. Pin per filename rather than using broad `Bash(bash *)`.
239+
240+
```yaml
241+
allowed-tools: Bash(bash *upstream-version.sh), Bash(bash *currently-loaded-version.sh)
242+
```
243+
244+
````markdown
245+
## Step 1: Probe X
246+
247+
Run via the Bash tool, in parallel:
248+
249+
```bash
250+
bash "${CLAUDE_SKILL_DIR}/scripts/upstream-version.sh"
251+
bash "${CLAUDE_SKILL_DIR}/scripts/currently-loaded-version.sh"
252+
```
253+
````
254+
255+
Use this whenever a `!` pre-resolution would invoke `bash <path>`. Reserve pre-resolution for commands whose first token already matches common user allow rules (`git status`, `gh api`, `cat <path>`, `command -v <name>`).
229256
- [ ] Do not encode shell recipes for routine exploration when native tools can do the job; encode intent and preferred tool classes instead
230257
- [ ] For shell-only workflows (e.g., `gh`, `git`, `bundle show`, project CLIs), explicit command examples are acceptable when they are simple, task-scoped, and not chained together
231258
@@ -245,9 +272,9 @@ Plugin config lives at `.compound-engineering/config.local.yaml` in the repo roo
245272
246273
2. **Worktrees:** Gitignored files are per-worktree. A config file created in the main checkout does not exist in worktrees. When reading config, fall back to the main repo root if the file is missing in the current worktree:
247274
```
248-
!`(top=$(git rev-parse --show-toplevel 2>/dev/null); [ -n "$top" ] && cat "$top/.compound-engineering/config.local.yaml" 2>/dev/null) || (common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null); [ -n "$common" ] && cat "$(dirname "$common")/.compound-engineering/config.local.yaml" 2>/dev/null) || echo '__NO_CONFIG__'`
275+
!`(top=$(git rev-parse --show-toplevel 2>/dev/null); [ -n "$top" ] && cat "$top/.compound-engineering/config.local.yaml" 2>/dev/null) || (common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null); main="${common%/.git}"; [ -n "$main" ] && cat "$main/.compound-engineering/config.local.yaml" 2>/dev/null) || echo '__NO_CONFIG__'`
249276
```
250-
The first subshell tries the current worktree root. The second derives the main repo root from `git-common-dir` as a fallback. The `[ -n "$top" ]` and `[ -n "$common" ]` guards matter: outside a git repo, both `git rev-parse` invocations emit empty, and an unguarded `cat "$(dirname "")/.compound-engineering/config.local.yaml"` would resolve to a CWD-relative `./...config.local.yaml` and could succeed against a stray file in the user's working directory. Guarded, both branches simply fail and the `__NO_CONFIG__` sentinel takes over. In a regular (non-worktree) checkout, both repo paths are identical.
277+
The first subshell tries the current worktree root. The second derives the main repo root from `git-common-dir` (stripping the trailing `/.git` with `${common%/.git}`) as a fallback. Use parameter expansion rather than `dirname` because Claude Code's safety check rejects nested `$(dirname "$common")` inside double-quoted strings as "Unhandled node type: string" (see issue #709). The `[ -n "$top" ]` and `[ -n "$main" ]` guards matter: outside a git repo, both `git rev-parse` invocations emit empty, and an unguarded `cat "/.compound-engineering/config.local.yaml"` would attempt to read from `/`. Guarded, both branches simply fail and the `__NO_CONFIG__` sentinel takes over. In a regular (non-worktree) checkout, both repo paths are identical.
251278
252279
If neither path has the file, fall through to defaults — never fail or block on missing config.
253280

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

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

2323
## Pre-resolved context
2424

25-
**Repo name (pre-resolved):** !`common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) && [ -n "$common" ] && basename "$(dirname "$common")"`
25+
**Repo name (pre-resolved):** !`git rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed -E 's|/\.git/?$||; s|.*/||'`
2626

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

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

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

1717
## Pre-resolved context
1818

19-
**Repo name (pre-resolved):** !`common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) && [ -n "$common" ] && basename "$(dirname "$common")"`
19+
**Repo name (pre-resolved):** !`git rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed -E 's|/\.git/?$||; s|.*/||'`
2020

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

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ Display the script's output to the user.
4242

4343
### Step 3: Evaluate Results
4444

45-
**Platform detection (pre-resolved):** !`[ -n "${CLAUDE_PLUGIN_ROOT}" ] && echo "CLAUDE_CODE" || echo "OTHER"`
45+
**Plugin root (pre-resolved):** !`echo "${CLAUDE_PLUGIN_ROOT}"`
4646

47-
If the line above resolved to `CLAUDE_CODE`, this is a Claude Code session and `/ce-update` is available. Otherwise, omit any `/ce-update` references from output.
47+
If the line above resolved to an absolute path (starts with `/` and contains no `${`), this is a Claude Code session and `/ce-update` is available. Anything else — empty, the literal `${CLAUDE_PLUGIN_ROOT}` token, or an unresolved command string like `echo "${CLAUDE_PLUGIN_ROOT}"` left in place by a non-Claude harness that doesn't process `!` pre-resolution — means this is not Claude Code; omit any `/ce-update` references from output.
4848

4949
After the diagnostic report, check whether:
5050

@@ -66,7 +66,7 @@ If everything is installed, no repo-local cleanup is needed, and `.compound-engi
6666
Run /ce-setup anytime to re-check.
6767
```
6868

69-
If this is a Claude Code session, append to the message: "Run /ce-update to grab the latest plugin version."
69+
If this is a Claude Code session (the **Plugin root** above resolved to a non-empty path), append to the message: "Run /ce-update to grab the latest plugin version."
7070

7171
Stop here.
7272

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

Lines changed: 36 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ description: |
99
Code — it relies on the plugin harness cache layout.
1010
disable-model-invocation: true
1111
ce_platforms: [claude]
12+
allowed-tools: Bash(bash *upstream-version.sh), Bash(bash *currently-loaded-version.sh), Bash(bash *marketplace-name.sh)
1213
---
1314

1415
# Check Plugin Version
@@ -17,75 +18,72 @@ Verify the installed compound-engineering plugin version matches the upstream
1718
`plugin.json` on `main`, and recommend the update command if it doesn't.
1819
Claude Code only.
1920

20-
## Pre-resolved context
21-
22-
Only the **Skill directory** determines whether this session is Claude Code —
23-
if empty or unresolved, the skill requires Claude Code. The other sections may
24-
contain error sentinels even in valid Claude Code sessions; the decision logic
25-
below handles those cases.
26-
27-
`${CLAUDE_SKILL_DIR}` is a Claude Code-documented substitution that resolves
28-
at skill-load time. For a marketplace-cached install it looks like
29-
`~/.claude/plugins/cache/<marketplace>/compound-engineering/<version>/skills/ce-update`,
30-
so the currently-loaded version is the basename two `dirname` levels up.
31-
3221
The upstream version comes from `plugins/compound-engineering/.claude-plugin/plugin.json`
3322
on `main` rather than the latest GitHub release tag, because the marketplace
3423
installs plugin contents from `main` HEAD. Comparing against release tags
3524
false-positives whenever `main` is ahead of the last tag (the normal state
3625
between releases).
3726

38-
**Skill directory:**
39-
!`echo "${CLAUDE_SKILL_DIR}"`
40-
41-
**Latest upstream version:**
42-
!`version=$(gh api repos/EveryInc/compound-engineering-plugin/contents/plugins/compound-engineering/.claude-plugin/plugin.json --jq '.content | @base64d | fromjson | .version' 2>/dev/null) && [ -n "$version" ] && echo "$version" || echo '__CE_UPDATE_VERSION_FAILED__'`
27+
## Step 1: Probe versions
4328

44-
**Currently loaded version:**
45-
!`echo "${CLAUDE_SKILL_DIR}" | grep -q "/plugins/cache/.*/compound-engineering/.*/skills/ce-update$" && basename "$(dirname "$(dirname "${CLAUDE_SKILL_DIR}")")" || echo '__CE_UPDATE_NOT_MARKETPLACE__'`
29+
Run these three scripts in parallel via the Bash tool. Each prints a single
30+
line of output; capture the values for the decision logic below. Use
31+
`${CLAUDE_SKILL_DIR}` so the path resolves correctly in both `claude --plugin-dir`
32+
local-development sessions and standard marketplace-cached installs.
4633

47-
**Marketplace name:**
48-
!`echo "${CLAUDE_SKILL_DIR}" | grep -q "/plugins/cache/.*/compound-engineering/.*/skills/ce-update$" && basename "$(dirname "$(dirname "$(dirname "$(dirname "${CLAUDE_SKILL_DIR}")")")")" || echo '__CE_UPDATE_NOT_MARKETPLACE__'`
34+
```bash
35+
bash "${CLAUDE_SKILL_DIR}/scripts/upstream-version.sh"
36+
bash "${CLAUDE_SKILL_DIR}/scripts/currently-loaded-version.sh"
37+
bash "${CLAUDE_SKILL_DIR}/scripts/marketplace-name.sh"
38+
```
4939

50-
## Decision logic
40+
`scripts/upstream-version.sh` reads `plugin.json` on `main` via `gh api`. It
41+
prints the version string, or the sentinel `__CE_UPDATE_VERSION_FAILED__` if
42+
`gh` is unavailable or rate-limited.
5143

52-
### 1. Platform gate
44+
`scripts/currently-loaded-version.sh` and `scripts/marketplace-name.sh` parse
45+
`${CLAUDE_SKILL_DIR}` against the marketplace-cache layout
46+
`~/.claude/plugins/cache/<marketplace>/compound-engineering/<version>/skills/ce-update`.
47+
They print the version segment / marketplace segment, or the sentinel
48+
`__CE_UPDATE_NOT_MARKETPLACE__` if the path doesn't match (typical for
49+
`claude --plugin-dir` local development).
5350

54-
If **Skill directory** is empty or unresolved: tell the user this skill
55-
requires Claude Code and stop. No further action.
51+
## Step 2: Apply decision logic
5652

57-
### 2. Handle failure cases
53+
### Handle failure cases
5854

59-
If **Latest upstream version** contains `__CE_UPDATE_VERSION_FAILED__`: tell
55+
If `scripts/upstream-version.sh` printed `__CE_UPDATE_VERSION_FAILED__`: tell
6056
the user the upstream version could not be fetched (gh may be unavailable or
6157
rate-limited) and stop.
6258

63-
If **Currently loaded version** contains `__CE_UPDATE_NOT_MARKETPLACE__`: this
64-
session loaded the skill from outside the standard marketplace cache (typical
65-
when using `claude --plugin-dir` for local development, or for a non-standard
66-
install). Tell the user (substituting the actual path):
59+
If `scripts/currently-loaded-version.sh` printed
60+
`__CE_UPDATE_NOT_MARKETPLACE__`: the skill is loaded from outside the
61+
standard marketplace cache. Two cases collapse to the same handling: a
62+
`claude --plugin-dir` local-development session, or a non-Claude-Code
63+
platform (this skill is Claude Code-only because it relies on the plugin
64+
harness cache layout). Tell the user:
6765

68-
> "Skill is loaded from `{skill-directory}` — not the standard marketplace
69-
> cache at `~/.claude/plugins/cache/`. This is normal when using
66+
> "Skill is loaded from outside the marketplace cache at
67+
> `~/.claude/plugins/cache/`. This is normal when using
7068
> `claude --plugin-dir` for local development. No action for this session.
7169
> Your marketplace install (if any) is unaffected — run `/ce-update` in a
7270
> regular Claude Code session (no `--plugin-dir`) to check that cache."
7371
7472
Then stop.
7573

76-
### 3. Compare versions
74+
### Compare versions
7775

78-
**Up to date**`{currently loaded} == {latest upstream}`:
76+
**Up to date**`currently_loaded == upstream`:
7977

8078
> "compound-engineering **v{version}** is installed and up to date."
8179
82-
**Out of date**`{currently loaded} != {latest upstream}`:
80+
**Out of date**`currently_loaded != upstream`:
8381

84-
> "compound-engineering is on **v{currently loaded}** but **v{latest upstream}** is available.
82+
> "compound-engineering is on **v{currently_loaded}** but **v{upstream}** is available.
8583
>
8684
> Update with:
8785
> ```
88-
> claude plugin update compound-engineering@{marketplace-name}
86+
> claude plugin update compound-engineering@{marketplace_name}
8987
> ```
9088
> Then restart Claude Code to apply."
9189
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/usr/bin/env bash
2+
# Print the version segment of the skill's own location when it matches the
3+
# marketplace cache layout `~/.claude/plugins/cache/<marketplace>/compound-engineering/<version>/skills/ce-update`,
4+
# or the literal sentinel `__CE_UPDATE_NOT_MARKETPLACE__` otherwise.
5+
#
6+
# Derives skill_dir from BASH_SOURCE rather than $CLAUDE_SKILL_DIR because
7+
# CLAUDE_SKILL_DIR is documented as a SKILL.md content substitution and is
8+
# not a guaranteed environment variable for Bash tool subprocesses. The
9+
# script is invoked as `bash "${CLAUDE_SKILL_DIR}/scripts/<name>.sh"`, so
10+
# BASH_SOURCE[0] is the absolute script path either way and self-locating
11+
# is reliable.
12+
13+
set -u
14+
15+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16+
skill_dir="$(dirname "$script_dir")"
17+
18+
# Match `.../plugins/cache/*/compound-engineering/<version>/skills/ce-update[/]?`
19+
# Capture group 1 is the version segment.
20+
version=$(printf '%s\n' "$skill_dir" | sed -nE 's|.*/plugins/cache/[^/]+/compound-engineering/([^/]+)/skills/ce-update/?$|\1|p')
21+
22+
if [ -n "$version" ]; then
23+
echo "$version"
24+
else
25+
echo '__CE_UPDATE_NOT_MARKETPLACE__'
26+
fi
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/usr/bin/env bash
2+
# Print the marketplace-name segment of the skill's own location when it
3+
# matches the marketplace cache layout
4+
# `~/.claude/plugins/cache/<marketplace>/compound-engineering/<version>/skills/ce-update`,
5+
# or the literal sentinel `__CE_UPDATE_NOT_MARKETPLACE__` otherwise.
6+
#
7+
# Derives skill_dir from BASH_SOURCE rather than $CLAUDE_SKILL_DIR — see
8+
# currently-loaded-version.sh for the rationale.
9+
10+
set -u
11+
12+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13+
skill_dir="$(dirname "$script_dir")"
14+
15+
# Capture group 1 is the marketplace segment.
16+
marketplace=$(printf '%s\n' "$skill_dir" | sed -nE 's|.*/plugins/cache/([^/]+)/compound-engineering/[^/]+/skills/ce-update/?$|\1|p')
17+
18+
if [ -n "$marketplace" ]; then
19+
echo "$marketplace"
20+
else
21+
echo '__CE_UPDATE_NOT_MARKETPLACE__'
22+
fi
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/usr/bin/env bash
2+
# Print the upstream `version` field from plugins/compound-engineering/.claude-plugin/plugin.json
3+
# on main, or the literal sentinel `__CE_UPDATE_VERSION_FAILED__` if the lookup fails.
4+
#
5+
# Compared to release tags, this reads the current main HEAD because the marketplace
6+
# installs plugin contents from main HEAD; comparing against tags false-positives
7+
# whenever main is ahead of the last tag.
8+
9+
set -u
10+
11+
version=$(gh api repos/EveryInc/compound-engineering-plugin/contents/plugins/compound-engineering/.claude-plugin/plugin.json --jq '.content | @base64d | fromjson | .version' 2>/dev/null)
12+
13+
if [ -n "$version" ]; then
14+
echo "$version"
15+
else
16+
echo '__CE_UPDATE_VERSION_FAILED__'
17+
fi

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ After extracting tokens from arguments, resolve the delegation state using this
4343
3. **Hard default** -- `false` (delegation off)
4444

4545
**Config (pre-resolved):**
46-
!`(top=$(git rev-parse --show-toplevel 2>/dev/null); [ -n "$top" ] && cat "$top/.compound-engineering/config.local.yaml" 2>/dev/null) || (common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null); [ -n "$common" ] && cat "$(dirname "$common")/.compound-engineering/config.local.yaml" 2>/dev/null) || echo '__NO_CONFIG__'`
46+
!`(top=$(git rev-parse --show-toplevel 2>/dev/null); [ -n "$top" ] && cat "$top/.compound-engineering/config.local.yaml" 2>/dev/null) || (common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null); main="${common%/.git}"; [ -n "$main" ] && cat "$main/.compound-engineering/config.local.yaml" 2>/dev/null) || echo '__NO_CONFIG__'`
4747

4848
If the block above contains YAML key-value pairs, extract values for the keys listed below.
4949
If it shows `__NO_CONFIG__`, the file does not exist — all settings fall through to defaults.

0 commit comments

Comments
 (0)