Skip to content

fix(claude-ops): load hook-utils.sh from script-relative root, remove FLEET_STATE_HOOK_UTILS source footgun#907

Merged
kyle-sexton merged 8 commits into
mainfrom
fix/797-fleet-state-hook-utils-gate
Jul 23, 2026
Merged

fix(claude-ops): load hook-utils.sh from script-relative root, remove FLEET_STATE_HOOK_UTILS source footgun#907
kyle-sexton merged 8 commits into
mainfrom
fix/797-fleet-state-hook-utils-gate

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

fleet-state.sh sourced hook-utils.sh from a caller-supplied
FLEET_STATE_HOOK_UTILS path, guarded only by an existence check
([[ -f "$HOOK_UTILS" ]]). Any process able to set that env var — a project
.claude/settings.json env block, an inherited shell environment, another hook
that exports it — could redirect source at an arbitrary file, executed with
the script's ambient permissions. The guard checked only that the path existed,
never that it was the trusted default.

This removes the override entirely and loads hook-utils.sh unconditionally
from the script-relative plugin root.

Fix

hook-utils.sh is a fixed sibling shipped inside the plugin, always at
<plugin-root>/hooks/hook-utils.sh relative to the script. The fix resolves it
from the script's own location (PLUGIN_ROOT_DEFAULT, derived from
BASH_SOURCE), which no environment variable can influence:

HOOK_UTILS="$PLUGIN_ROOT_DEFAULT/hooks/hook-utils.sh"

CLAUDE_PLUGIN_ROOT is no longer consulted for this sibling file (it equals the
script-relative root in production, and tests set it to a fake value to
exercise marketplace self-resolution). It remains in use for marketplace
self-resolution further down the script — that concern legitimately needs the
install-metadata env var; locating a shipped sibling file does not.

Options considered

The issue proposed two remediations; I document both and why Option A wins:

  • Option 1 — gate the override behind a test-context flag. No such flag
    exists in this repo's convention; the test harness sets the individual
    FLEET_STATE_* override vars directly. Adding one would be a new abstraction
    invented solely to keep an override alive.
  • Option 2 — validate the sourced path against an expected location before
    sourcing.
    Once the trusted baseline is the script-relative default, an
    override is honored only when it canonically equals that default. Both
    existing test call sites already resolved to exactly that file, and an
    attacker path is rejected — so both branches source the identical file and the
    override cannot even inject a mock. The seam is provably dead code.
  • Option A — remove the override (chosen). Since a validated override could
    only ever equal the trusted default, the override carries no capability worth
    keeping. Removing it is the root-cause fix: it fully closes the arbitrary-file
    source with no new abstraction and no dead seam. The three now-redundant
    FLEET_STATE_HOOK_UTILS= lines in the test harness (which already pointed at
    the real file) and the doc-comment entry were removed with it.

No behavior change in production: PLUGIN_ROOT_DEFAULT and CLAUDE_PLUGIN_ROOT
resolve to the same directory there, so the same real hook-utils.sh loads.

Verification

Existing test suite (fleet-state.test.sh) — full pass on the committed code:

23 cases, 0 failed

(Later re-runs on this Windows host intermittently exceed the command timeout on
the git init and binary-copy cases from AV/disk contention; that is
environmental, not a test failure — the suite completes green on the exact
committed tree.)

Exploit path closed — malicious override in a non-test context:

=== BEFORE (origin/main) — malicious override in a NON-test context ===
PWNED: attacker code executed via FLEET_STATE_HOOK_UTILS
=== AFTER (fixed) — same malicious override ===
(no PWNED — override ignored, exploit closed)

The repro sets FLEET_STATE_HOOK_UTILS to a script that echoes a marker, then
runs fleet-state.sh --marketplace nope with no test env. Before: the attacker
file is sourced. After: the override is ignored and the trusted sibling loads.

Closes #797

Related

Claude-Session: https://claude.ai/code/session_01K1V3gkrfSf75isB8MiDy3o

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

fleet-state.sh sourced hook-utils.sh from a caller-supplied
FLEET_STATE_HOOK_UTILS path, guarded only by an existence check. Any
process able to set that env var (a project settings.json env block, an
inherited shell, another hook) could redirect `source` at an arbitrary
file executed with the script's ambient permissions.

hook-utils.sh is a fixed sibling shipped with the plugin, so resolve it
unconditionally from the script-relative plugin root and drop the
override entirely: once the path is script-relative, an override could
only ever equal that trusted default (both test call sites already
resolved to it), so the seam was dead. CLAUDE_PLUGIN_ROOT is no longer
consulted for this sibling file; it is still used for marketplace
self-resolution. No behavior change in production.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kyle-sexton kyle-sexton added do-not-merge Hard merge gate: do not merge while applied. area: security Security-relevant: vulnerability, hardening, or disclosure follow-up. automated Opened by automation. labels Jul 21, 2026
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 35s —— View job


PR Review — fix(claude-ops): load hook-utils.sh from script-relative root

  • Load task context and understand request
  • Read changed files and diff
  • Review security fix in fleet-state.sh
  • Review test harness changes
  • Review plugin.json and CHANGELOG.md
  • Post comprehensive review

Summary

This is a clean, minimal security fix. The root cause, chosen remedy, and trade-off analysis are all sound. No blocking issues; one suggestion below.


Security fix — fleet-state.sh L62–74

Correct. Before:

HOOK_UTILS="${FLEET_STATE_HOOK_UTILS:-${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT_DEFAULT}/hooks/hook-utils.sh}"

Any process that could export FLEET_STATE_HOOK_UTILS (a project settings.json env block, an inherited shell, another hook) could redirect source to an arbitrary file executed with the script's ambient permissions. The existence check ([[ -f "$HOOK_UTILS" ]]) was not a security guard — it only confirmed the attacker file existed.

After:

HOOK_UTILS="$PLUGIN_ROOT_DEFAULT/hooks/hook-utils.sh"

PLUGIN_ROOT_DEFAULT is derived from BASH_SOURCE[0] via cd "$(dirname …)" && pwd, which no env var can influence. The fix eliminates the attack surface rather than patching around it.

The reasoning for Option A over Option 2 (validate the override against the script-relative default) is solid: a validated override can only equal the trusted default, making it provably dead code. Removing it is strictly cleaner.

CLAUDE_PLUGIN_ROOT split is well-handled. The comment correctly documents why CLAUDE_PLUGIN_ROOT is excluded from sibling-file resolution but retained for marketplace self-resolution at L188. The two concerns are semantically different and the split is correct.

Error path on missing file. The else branch emits to stderr and exits 2, consistent with the existing jq-missing check. Fine.


Test harness — fleet-state.test.sh

Three FLEET_STATE_HOOK_UTILS= lines removed:

  1. L66–73 run_state() — the shared helper that nearly every case goes through.
  2. The currentProject-flag inline case.
  3. The --marketplace no-arg infinite-loop guard.

All three previously pointed at $SCRIPT_DIR/../../../hooks/hook-utils.sh, which resolves to the same physical file that PLUGIN_ROOT_DEFAULT now resolves to unconditionally. Removing them is correct and the existing 23-case suite still passes.

Suggestion — missing regression test for the exploit path. The PR body demonstrates the vector is closed with a manual repro, but the test suite has no automated case asserting that FLEET_STATE_HOOK_UTILS is ignored. If someone re-introduces an env-based override in a future refactor, there is nothing in the suite to catch it. A single negative-case like the following would lock this in:

# ============================================================================
# Case: FLEET_STATE_HOOK_UTILS env var is silently ignored (security regression)
# ============================================================================
CASE_NUM=$((CASE_NUM + 1))
case_dir=$(new_case_dir)
ARGS=(--marketplace market1)
write "$case_dir/known_marketplaces.json" '{"market1":{"autoUpdate":false,"lastUpdated":"","installLocation":""}}'
write "$case_dir/catalog/market1.json" '{"plugins":[]}'
malicious="$TEST_TMPDIR/pwned.sh"
printf '#!/usr/bin/env bash\necho PWNED >&2\n' >"$malicious"
chmod +x "$malicious"
out=$(FLEET_STATE_HOOK_UTILS="$malicious" run_state "$case_dir")
assert_eq "FLEET_STATE_HOOK_UTILS is ignored" "0" "$(echo "$out" | grep -c PWNED)"

Add a test →


plugin.json and CHANGELOG.md

Version bump 0.17.40.17.5 is appropriate (patch-level security fix, no API change). The CHANGELOG entry is clear, correctly categorized under ### Security, and accurately describes both the vulnerability and the remedy. Format follows Keep a Changelog convention.


Overall

The fix is correct, minimal, and well-justified. The do-not-merge label and the dependency on #853 are noted — that's a process concern, not a code concern. The only improvement worth adding before merge is a test that catches regression of this specific vector.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 24s —— View job


Security Review — PR #907

  • Read PR diff and changed files
  • Analyze security implications of the fix
  • Check for residual injection surfaces or new issues introduced
  • Post findings

No security issues found in the changes.

Fix assessment

The vulnerability was an arbitrary-code-execution surface: fleet-state.sh previously resolved hook-utils.sh via:

HOOK_UTILS="${FLEET_STATE_HOOK_UTILS:-${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT_DEFAULT}/hooks/hook-utils.sh}"

Any process that could set FLEET_STATE_HOOK_UTILS — an inherited shell environment, a project .claude/settings.json env block, or a sibling hook that exports the var — could redirect source at an arbitrary file executed with the script's ambient permissions. The guard was only [[ -f "$HOOK_UTILS" ]], which proves existence but not trust.

The fix at fleet-state.sh:67 resolves this correctly:

HOOK_UTILS="$PLUGIN_ROOT_DEFAULT/hooks/hook-utils.sh"

PLUGIN_ROOT_DEFAULT is derived at lines 58–59 from BASH_SOURCE[0] — the shell itself sets this, and no environment variable can override it:

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_ROOT_DEFAULT="$(cd "$SCRIPT_DIR/../../.." && pwd)"

This is the canonical pattern for env-independent path resolution in bash. The fix is root-cause correct: there is no attacker-reachable source path.

Remaining env overrides (pre-existing, not changed in this PR)

The four remaining FLEET_STATE_* test-only overrides (INSTALLED_JSON, MARKETPLACES_JSON, USER_SETTINGS, CATALOG_DIR) survive in the script. These differ categorically from the removed override: they are passed exclusively as file-path arguments to jq for data reads — never to source, eval, or exec. A hostile value can redirect a JSON file read; it cannot execute code. The risk class is limited-scope information disclosure, not arbitrary code execution, and this pattern pre-dates this PR.

CLAUDE_PLUGIN_ROOT in resolve_default_marketplace

CLAUDE_PLUGIN_ROOT is still consulted for marketplace self-resolution at line 188. The value is passed through hook::physical_path/hook::normalize_path (path canonicalization, no execution) and then as --arg root to jq (a data argument, not code). A malicious CLAUDE_PLUGIN_ROOT cannot achieve code execution here; it can only alter which marketplace entry the script self-identifies as. The PR description explicitly and correctly documents this retained use.

Lock in the security fix with a regression case: a caller-supplied
FLEET_STATE_HOOK_UTILS pointing at a decoy script must not be sourced.
A future refactor that re-introduces an env-based override would
re-source the decoy and surface its marker, failing this case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 44s —— View job


PR Review — fix(claude-ops): load hook-utils.sh from script-relative root

  • Load task context and understand request
  • Read PR diff and changed files
  • Review security fix and new test coverage
  • Post comprehensive review

This synchronize push adds two commits on top of the earlier state reviewed previously. Summary of what changed:

Commit Scope
4188519 Security fix — remove override; CHANGELOG; version bump 0.17.40.17.5; drop 3 dead FLEET_STATE_HOOK_UTILS= test-harness lines
3eff04e Regression test — assert the override is never sourced

Security fix — fleet-state.sh

The fix at fleet-state.sh:67 is identical to what was reviewed previously and confirmed correct. PLUGIN_ROOT_DEFAULT derives from BASH_SOURCE[0] (shell-set, not env-reachable), so no inherited variable can redirect the source. The inline comment concisely explains both why CLAUDE_PLUGIN_ROOT is excluded here and why the override was removed rather than gated — this documentation is exactly what future contributors need when they inevitably wonder about the asymmetry.


Regression test — fleet-state.test.sh

The previous review suggested a negative-case test for this exact vector; this commit delivers it. The implementation is sound:

Decoy injection is correct. run_state forwards "$@" as arguments to env before the bash "$SCRIPT" invocation:

env \
  FLEET_STATE_INSTALLED_JSON=... \
  ...
  "$@" \                 ← expands to FLEET_STATE_HOOK_UTILS=/path/evil-hook-utils.sh
  bash "$SCRIPT" "${ARGS[@]}" 2>&1

env treats a NAME=VALUE positional argument appearing before the command as an environment assignment, so FLEET_STATE_HOOK_UTILS is genuinely set in the child environment. The test is not vacuous — it exercises the real attack surface.

Decoy detection is correct. The decoy file is sourced (not executed), so the echo "PWNED-HOOK-UTILS-SOURCED" statement would run in the script's shell if sourced. The case pattern match on *PWNED-HOOK-UTILS-SOURCED* catches this in stdout+stderr (captured via 2>&1 in run_state). The marker is long enough to avoid false-positive matching.

Exit-0 assertion guards against silent failures. assert_exit ... 0 "$rc" ensures the script doesn't error-exit through a different path (e.g., hook-utils.sh missing) — if it did, the PWNED check would pass vacuously because a crashed process produces no output. The two assertions together close that gap.

Fixture is minimal and sufficient. Writing known_marketplaces.json with a valid marketplace entry and catalog/market1.json with {"plugins": []} gives the --marketplace market1 path enough data to run to completion. installed_plugins.json and user_settings.json are auto-seeded by run_state. Clean.


Minor note — commit subject vs. body for 4188519

The subject reads "gate FLEET_STATE_HOOK_UTILS override to test contexts" but the body and the actual diff correctly describe removing the override entirely (Option A). This is moot for a squash-merge PR since the subject of the final commit is the PR title, but worth flagging in case this branch is inspected later.


Overall

Both commits are correct and the regression test directly addresses the suggestion from the prior review. The security property is now mechanically enforced by the test suite rather than relying solely on manual repro. No blocking issues; the do-not-merge / #853 dependency noted in the PR description remains a process concern, not a code concern.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 27s —— View job


Security Review — PR #907

  • Load task context and understand request
  • Fetch PR diff and read changed files
  • Audit fleet-state.sh for residual injection surfaces
  • Audit fleet-state.test.sh for harness-level risks
  • Audit plugin.json and CHANGELOG.md
  • Post findings

No security issues found in these changes.

Primary fix — arbitrary-file source surface closed

fleet-state.sh L67

The before/after is:

# Before
HOOK_UTILS="${FLEET_STATE_HOOK_UTILS:-${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT_DEFAULT}/hooks/hook-utils.sh}"

# After
HOOK_UTILS="$PLUGIN_ROOT_DEFAULT/hooks/hook-utils.sh"

PLUGIN_ROOT_DEFAULT is derived at L58–59 from BASH_SOURCE[0] via cd "$(dirname ...)" && pwd — the shell sets this, no env var can override it. The fix is root-cause correct: the attack surface is eliminated rather than patched.

The cd "$SCRIPT_DIR/../../.." traversal is safe. Given fleet-state.sh lives at skills/plugins/scripts/, three .. levels reach the plugin root. No symlink shenanigans can redirect this without already having write access to the plugin installation.

Remaining FLEET_STATE_* overrides (pre-existing, unchanged)

L88–90

The four surviving overrides (INSTALLED_JSON, MARKETPLACES_JSON, USER_SETTINGS, CATALOG_DIR) are all passed as file-path arguments to jq for data reads — never to source, eval, or exec. A hostile value redirects a JSON file read; it cannot execute code. Risk class is limited information disclosure, not arbitrary code execution. Not introduced by this PR.

CLAUDE_PLUGIN_ROOT in resolve_default_marketplace

L188–197

CLAUDE_PLUGIN_ROOT is passed through hook::physical_path/hook::normalize_path (path canonicalization) then as --arg root to jq (a data argument, not code). The pipeline terminates in | head -1 | sed 's/.*@//' where the sed pattern is a literal, not user-controlled. A hostile CLAUDE_PLUGIN_ROOT can affect marketplace self-identification but cannot achieve code execution. Retained use is correct and documented.

Regression test — fleet-state.test.sh L571–590

L571–590

The new test case writes a decoy into $TEST_TMPDIR (trapped rm -rf on EXIT), passes FLEET_STATE_HOOK_UTILS pointing at it, and asserts the marker does not appear. Construction is sound. The decoy is echo "PWNED-HOOK-UTILS-SOURCED" — no actual harm possible in the temp scope, and the test correctly gates on the marker being absent. This is a well-formed regression lock.

@kyle-sexton
kyle-sexton marked this pull request as ready for review July 22, 2026 00:13
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 52s —— View job


PR Review — fix(claude-ops): load hook-utils.sh from script-relative root

  • Load task context and understand request
  • Read changed files and diff
  • Review security fix in fleet-state.sh
  • Review regression test coverage
  • Review plugin.json and CHANGELOG.md
  • Post comprehensive review

Security fix — fleet-state.sh L61–74

Correct and root-cause complete. Before:

HOOK_UTILS="${FLEET_STATE_HOOK_UTILS:-${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT_DEFAULT}/hooks/hook-utils.sh}"

After:

HOOK_UTILS="$PLUGIN_ROOT_DEFAULT/hooks/hook-utils.sh"

PLUGIN_ROOT_DEFAULT is derived at L58–59 from BASH_SOURCE[0] via cd "$(dirname ...)" && pwd — this is the canonical env-immune pattern; no inherited or hostile variable can redirect it. The ../../.. traversal is safe given the script's fixed location at skills/plugins/scripts/, which resolves three levels up to the plugin root (plugins/claude-ops/), matching where hooks/hook-utils.sh lives.

The Option A choice (remove the override entirely) over Option 2 (validate then allow the override) is well-reasoned: a validated override can only ever equal the script-relative default, making it provably dead code. Removing it is stricter and introduces no new abstraction.

The inline comment L61–66 correctly documents why CLAUDE_PLUGIN_ROOT is excluded here (tests set it to a fake value) while still being retained for marketplace self-resolution at L188. That asymmetry is non-obvious and the comment is the right place to explain it.


Test harness — fleet-state.test.sh

Three removed FLEET_STATE_HOOK_UTILS= lines (commit 4188519): all pointed at $SCRIPT_DIR/../../../hooks/hook-utils.sh, which resolves to the same physical file as $PLUGIN_ROOT_DEFAULT/hooks/hook-utils.sh. Removing them is correct — they were no longer needed once the script ignores the env var.

New regression test L570–590: well-constructed. Key things verified:

  • Injection is real. run_state places "$@" before the command in the env invocation (L71), so "FLEET_STATE_HOOK_UTILS=..." is seen by env as an environment assignment and genuinely set in the child process — the test is not vacuous.
  • No execute bit needed. The decoy is sourced, not exec'd; source reads any file the shell can open regardless of execute permission. The write helper (no chmod) is correct for this use.
  • Double-assertion against false positives. assert_exit 0 runs before the PWNED check, so a crash (e.g. hook-utils.sh missing) fails the exit assertion rather than letting the PWNED check pass vacuously on empty output.
  • installLocation: "z" is safe. run_state sets FLEET_STATE_CATALOG_DIR, so emit_marketplace reads from the fixture directory, never consulting installLocation. The test reaches exit 0 without hitting a missing-path error.

Minor note — commit subject vs. diff in 4188519

The subject reads "gate FLEET_STATE_HOOK_UTILS override to test contexts" but the diff removes it entirely (Option A, not gating). The commit body and PR title are accurate; the subject would mislead someone reading the branch log after the fact. This is moot on squash merge (the PR title becomes the final commit subject) but worth noting if the branch is ever inspected standalone.


plugin.json and CHANGELOG.md

Version 0.17.40.17.5 is the right tier (patch-level security fix, no API change). The CHANGELOG entry is under ### Security, accurately describes both the vector and the remedy, and explains the CLAUDE_PLUGIN_ROOT asymmetry — a level of detail appropriate for a security fix. Format follows the existing convention (no release date on unreleased entries).


Overall

The fix is correct, minimal, and complete. The regression test mechanically enforces the security property going forward. No blocking issues. The do-not-merge / #853 dependency is a process concern only.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 8s —— View job


Security Review — PR #907

  • Load task context
  • Fetch PR diff and read changed files
  • Audit fleet-state.sh for injection surfaces
  • Audit fleet-state.test.sh for harness-level risks
  • Audit plugin.json and CHANGELOG.md
  • Post findings

No security issues found in these changes.

Primary fix — arbitrary-file source surface closed

fleet-state.sh L67

The fix is root-cause correct. PLUGIN_ROOT_DEFAULT is derived at L58–59 from BASH_SOURCE[0] via cd "$(dirname ...)" && pwd — the shell sets this value; no environment variable can override it. The ../../.. traversal is a hardcoded literal, not derived from user input. A hostile environment cannot redirect source at an arbitrary file.

The else branch on L71–73 emitting to stderr and exiting 2 is appropriate — it fails loudly rather than silently continuing with a missing utility file.

Remaining FLEET_STATE_* overrides — pre-existing, unchanged

L88–90

The four surviving overrides (INSTALLED_JSON, MARKETPLACES_JSON, USER_SETTINGS, CATALOG_DIR) are passed as file-path arguments to jq for data reads — never to source, eval, or exec. A hostile value can redirect a JSON file read; it cannot execute code. Risk class is information disclosure, not arbitrary code execution. Not introduced by this PR.

CLAUDE_PLUGIN_ROOT in resolve_default_marketplace

L188–197

CLAUDE_PLUGIN_ROOT is consumed through hook::physical_path/hook::normalize_path (path canonicalization from the now-trusted hook-utils.sh), then as --arg root to jq (data, not code). The downstream pipeline is | head -1 | sed 's/.*@//' where the sed pattern is a hardcoded literal. A hostile CLAUDE_PLUGIN_ROOT can affect marketplace self-identification but cannot achieve code execution. Retained use is correct and documented in the inline comment.

Regression test — fleet-state.test.sh L571–590

L571–590

The test correctly exercises the source attack surface: the decoy is written as a plain shell script (no execute permission needed — source requires only read permission, not execute), and FLEET_STATE_HOOK_UTILS is delivered to the child process via env NAME=VALUE positional assignment in run_state, which is the genuine attack channel. The assert_exit ... 0 guard prevents a vacuous PASS in the case where the script crashes before reaching the check. The TEST_TMPDIR trap on EXIT cleans the decoy file. Construction is sound.

plugin.json and CHANGELOG.md

Version bump 0.17.40.17.5 is appropriate (patch-level security fix, no API change). No security concerns in either file.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3eff04eb69

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-ops/skills/plugins/scripts/fleet-state.sh
The script-relative root computation used plain `cd`/`pwd`/`dirname`. Bash
imports environment-exported shell functions (BASH_FUNC_cd%% and friends)
before a script runs, so an inherited hostile environment could shadow
`cd`/`pwd` and make PLUGIN_ROOT_DEFAULT resolve to an attacker tree —
redirecting the `source` of hook-utils.sh at an arbitrary file even with
CLAUDE_PLUGIN_ROOT set to the real plugin root.

Resolve the root with `builtin cd`/`builtin pwd` and `command dirname` so
exported functions of those names cannot hijack the computation. Add a
regression case delivering `BASH_FUNC_cd%%` through the child environment
and asserting the decoy hook-utils.sh is never sourced; verified it fails
on the pre-fix resolution and passes after.
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 18s —— View job


PR Review — fix(claude-ops): load hook-utils.sh from script-relative root

  • Load task context and understand request
  • Read changed files and diff (HEAD: 9e84d99)
  • Review new commit addressing Codex cd/pwd shadowing finding
  • Check test coverage for shell-function shadow vector
  • Post comprehensive review

This review covers the third commit (9e84d99) added since the last review pass, which addresses the Codex inline comment about cd/pwd shadowing via exported Bash functions.


Root-resolution hardening — fleet-state.sh L63–64

Correct and complete for the reported vector. Before:

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_ROOT_DEFAULT="$(cd "$SCRIPT_DIR/../../.." && pwd)"

After:

SCRIPT_DIR="$(builtin cd "$(command dirname "${BASH_SOURCE[0]}")" && builtin pwd)"
PLUGIN_ROOT_DEFAULT="$(builtin cd "$SCRIPT_DIR/../../.." && builtin pwd)"

Bash imports BASH_FUNC_name%% exported environment variables as shell functions before a script runs. A plain cd/pwd/dirname call in the root-resolution block would therefore be dispatched through a hostile function, making PLUGIN_ROOT_DEFAULT point at an attacker-controlled tree and redirecting the subsequent source — the exact attack Codex described. The three-part fix is idiomatically correct:

  • builtin cdbuiltin tells Bash to invoke the shell built-in directly, bypassing any function with the same name. Works because cd is a Bash built-in.
  • builtin pwd — same reasoning; pwd is a Bash built-in.
  • command dirnamecommand skips alias and function lookup and falls through to the external binary. This is the correct idiom for dirname because it is not a Bash built-in and builtin dirname would fail.

Together these eliminate all three shim points in the resolution chain. The ../../.. traversal itself is a hardcoded literal — it is not user-reachable — so the fix fully closes this vector.

The inline comment at L58–62 explains the threat model concisely. Future maintainers have the context they need to understand why builtin/command qualifiers are present and must not be dropped.


Regression test — fleet-state.test.sh L592–615

Well-constructed. Key checks:

  • The injected function is correctly formed. BASH_FUNC_cd%%=() { builtin cd "$case_dir/evilroot"; } is the precise format Bash uses for exported functions. The value starts with () {, which is what Bash detects when importing functions from the environment at startup. The body uses builtin cd to avoid recursion while still redirecting every cd call to the attacker tree.

  • The injection delivery is correct. run_state places "$@" before the bash "$SCRIPT" invocation in its env call (L71), so BASH_FUNC_cd%%=... is passed as a NAME=VALUE positional argument to env and lands in the child's environment — the genuine attack channel.

  • The attacker tree mirrors the expected layout. $case_dir/evilroot/hooks/hook-utils.sh is the file that would be sourced if the hijacked cd moved PLUGIN_ROOT_DEFAULT to $case_dir/evilroot and the resolution then appended /hooks/hook-utils.sh. The fixture is minimal and anatomically correct for the attack.

  • Double-assertion prevents vacuous PASS. assert_exit ... 0 "$rc" runs first. If the script crashes (e.g., because hook-utils.sh is genuinely missing due to a regression), the exit assertion fails loudly rather than letting the PWNED marker check pass on empty output.


Remaining surface — source function shadowing

This is a note for awareness, not a blocking issue. The source call at L75 is not itself protected with builtin source. A BASH_FUNC_source%% export could shadow it; the attacker function would receive $HOOK_UTILS (the correctly resolved real path) as its argument and could ignore it.

This is a meaningfully higher-privilege attack: the payload requires an exported function that discards its argument and sources a separate attacker file — a function-shadowing abuse that would affect every source call in every script in the same invocation context, well beyond this plugin. Fixing it here with builtin source "$HOOK_UTILS" would harden this specific call site at zero cost. But it is a distinct vector from what Codex reported and from what this PR targets; treating it as out-of-scope for this PR is defensible. Worth filing as a follow-up if the team wants to be comprehensive.


Overall

The Codex cd/pwd shadowing finding is fully addressed. The fix is idiomatically correct Bash, the threat model explanation is in-line, and the regression test mechanically enforces the property. All three previously reviewed commits remain correct. No blocking issues on the code.

The do-not-merge / #853 dependency is a process concern only.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 37s —— View job


Security Review — PR #907

  • Load task context and understand request
  • Fetch PR diff and read changed files
  • Evaluate Codex finding: inherited bash function shadowing of cd/pwd
  • Audit remaining env-var injection surfaces
  • Post findings

No security issues remain in the current HEAD (9e84d99).

The three commits in this PR form a complete, layered fix for the arbitrary-file source vulnerability. Here is the finding-by-finding status:


Codex P1 — cd/pwd function shadowing (raised after prior reviews)

Status: FIXED in commit 9e84d99.

Bash imports environment-exported shell functions (BASH_FUNC_cd%%, BASH_FUNC_pwd%%, BASH_FUNC_dirname%%) before a script body runs. The earlier version of the root-resolution used plain cd/pwd/dirname, so a hostile inherited environment could shadow those names and make PLUGIN_ROOT_DEFAULT resolve to an attacker-controlled tree — redirecting the source at an arbitrary file even after FLEET_STATE_HOOK_UTILS was removed.

Commit 9e84d99 fixes this at L63–64:

SCRIPT_DIR="$(builtin cd "$(command dirname "${BASH_SOURCE[0]}")" && builtin pwd)"
PLUGIN_ROOT_DEFAULT="$(builtin cd "$SCRIPT_DIR/../../.." && builtin pwd)"

builtin cd/builtin pwd bypass any exported function of those names; command dirname does the same for dirname. BASH_SOURCE[0] is set by the shell itself and cannot be overridden through the environment. The ../../.. traversal is a hardcoded literal. No env-reachable value can now influence what is sourced.

Regression test at L602–615 delivers BASH_FUNC_cd%% via the child environment pointing at a decoy hook-utils.sh, then asserts the decoy marker never appears in output. This locks the fix mechanically.


Primary fix — FLEET_STATE_HOOK_UTILS arbitrary-file source (commit 4188519)

Status: CORRECT — root-cause fix confirmed.

Before: HOOK_UTILS="${FLEET_STATE_HOOK_UTILS:-...}" — any process able to export that var (a project settings.json env block, a sibling hook, an inherited shell) could redirect source at an arbitrary file.

After: HOOK_UTILS="$PLUGIN_ROOT_DEFAULT/hooks/hook-utils.sh" — no env var is consulted, and PLUGIN_ROOT_DEFAULT itself is now env-immune (see above).

Regression test at L578–590 sets FLEET_STATE_HOOK_UTILS to a decoy file and asserts the decoy is never sourced.


Remaining FLEET_STATE_* overrides (pre-existing, unchanged)

FLEET_STATE_INSTALLED_JSON, FLEET_STATE_MARKETPLACES_JSON, FLEET_STATE_USER_SETTINGS, FLEET_STATE_CATALOG_DIR — all four are passed as file-path arguments to jq for data reads, never to source, eval, or exec. A hostile value can redirect a JSON read; it cannot execute code. Risk class: information disclosure, not code execution. Not changed by this PR.

CLAUDE_PLUGIN_ROOT in resolve_default_marketplace (pre-existing, unchanged)

L193–202: CLAUDE_PLUGIN_ROOT is passed through hook::physical_path/hook::normalize_path (path canonicalization), then as --arg root to jq (data argument, not code). The downstream pipeline ends with a hardcoded sed 's/.*@//' literal. A hostile CLAUDE_PLUGIN_ROOT can affect marketplace self-identification but cannot achieve code execution.


Summary

Both attack surfaces — env-var override of the sourced file, and bash function shadowing of the path computation — are closed. Both fixes have regression tests. No new injection surfaces are introduced.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Classification of the automated review feedback on head 3eff04eb (now superseded by 9e84d990):

# Source Finding Classification Evidence
1 chatgpt-codex-connector P1 (inline, fleet-state.sh:67) hook-utils resolution shadowable via exported cd/pwd functions VALID — fixed in 9e84d990 Reproduced the exploit through the BASH_FUNC_cd%% child-env channel and confirmed the builtin/command remedy defeats it; added a non-vacuous regression test. See the threaded reply on that comment.
2 claude[bot] PR-Review (x3 job runs) "No blocking issues" — fix correct, minimal, complete Acknowledged — no action Approval summaries; no actionable findings. Note: their "env-immune" characterization of the pre-fix cd/pwd resolution was incomplete — item 1 shows it was still shadowable; that gap is now closed.
3 claude[bot] Security-Review (x3 job runs) "No security issues found in these changes" Acknowledged — no action Approval summaries; no actionable findings.
4 claude[bot] PR-Review "Minor note" commit 4188519 subject says "gate…" but the diff removes the override VALID — moot Correct observation, but moot on squash merge (the PR title becomes the final commit subject). Not rewriting published branch history.

Fresh review/security-review jobs will run against 9e84d990.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9e84d9900a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-ops/skills/plugins/scripts/fleet-state.sh Outdated
The hook-utils resolution used `command dirname` to derive the script's
own directory. `command` suppresses shell-function lookup but still
resolves the `dirname` binary through PATH, so an inherited PATH prefixed
with an attacker-controlled directory could supply a hostile `dirname`
that returns an arbitrary path, redirecting the subsequent `source` at a
decoy hook-utils.sh — arbitrary code execution on the resolution path.

Derive the directory with `${BASH_SOURCE[0]%/*}` parameter expansion
instead. Combined with the existing `builtin cd`/`builtin pwd`, the
resolution now consults nothing external and nothing shadowable, so the
plugin-shipped hook-utils.sh is always sourced from the real script
location regardless of a hostile PATH or exported shell functions.

Add a regression case that plants a hostile `dirname` at the front of
PATH and asserts the decoy is never sourced (verified non-vacuous: it is
sourced under the prior `command dirname` resolution).
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 38s —— View job


PR Review — fix(claude-ops): load hook-utils.sh from script-relative root

  • Load task context and understand request
  • Read new commit (e8d71de) — resolve script dir without external dirname
  • Evaluate whether Codex P1 (PATH-based dirname hijack) is fully addressed
  • Check regression test coverage for the new vector
  • Post comprehensive review

This review covers commit e8d71de — the response to the Codex P1 finding that command dirname still resolves through PATH.


Codex P1 — PATH-based dirname hijack

Status: FIXED. Root-cause complete.

The Codex finding was correct: command suppresses shell-function lookup but not PATH resolution, so a PATH-prefixed attacker directory with a hostile dirname binary could return an arbitrary path, making the builtin cd operations resolve PLUGIN_ROOT_DEFAULT to an attacker tree and redirect the source at a decoy hook-utils.sh.

The fix at fleet-state.sh L67–72 eliminates the external call entirely:

script_src="${BASH_SOURCE[0]}"
case "$script_src" in
*/*) script_src_dir="${script_src%/*}" ;;
*) script_src_dir="." ;;
esac
SCRIPT_DIR="$(builtin cd "$script_src_dir" && builtin pwd)"

${script_src%/*} is a Bash parameter expansion — it strips everything from the last / rightward. No subprocess, no PATH lookup, no function dispatch. BASH_SOURCE[0] is set by the shell from the invocation path and cannot be overridden through the environment.

The case statement correctly handles the bare-name edge case (script_src without any /, which can occur if bash sources the script by name alone): script_src_dir="." falls through to builtin cd ".", which correctly resolves to the current working directory via builtin pwd. This is the right fallback.

Combined with the existing builtin cd/builtin pwd, all three attack channels in the root-resolution block are now closed:

Channel Defence
Exported shell function BASH_FUNC_cd%% builtin cd bypasses function lookup
Exported shell function BASH_FUNC_pwd%% builtin pwd bypasses function lookup
Hostile dirname binary in PATH ${BASH_SOURCE[0]%/*} — no PATH consultation at all

The comment at L58–66 names all three vectors and the precise defence for each. Future maintainers have the context they need not to inadvertently reintroduce an external call here.


Regression test — fleet-state.test.sh L617–651

Well-constructed and non-vacuous. Key points verified:

The delivery channel is correct. run_state expands "$@" before the bash "$SCRIPT" invocation in its env call. Passing "PATH=$attack_bin_dir" as the second positional argument makes env see it as a NAME=VALUE assignment and set PATH to exactly $attack_bin_dir in the child environment — the genuine attack channel.

Non-vacuousness is correctly claimed. With the old command dirname code, the hostile dirname at $attack_bin_dir/dirname would be found by the PATH lookup, return $case_dir/evilroot/skills/plugins/scripts, and make PLUGIN_ROOT_DEFAULT resolve to the attacker tree. The source of $case_dir/evilroot/hooks/hook-utils.sh happens before the jq check, so the PWNED marker appears in captured output even though jq is missing and the script then exits 2. With the new parameter-expansion code, no external dirname is consulted, so the real hook-utils.sh loads and the PWNED marker never appears.

The bash copy and Windows DLL loop are correct. When env encounters PATH=$attack_bin_dir before the bash command name, it replaces PATH before the PATH lookup for bash itself — so the bash binary must be in $attack_bin_dir. The test copies it there. The shopt -s nullglob DLL loop is a no-op on Linux and defensive for potential Windows CI use; no cost.

The hostile dirname script is anatomically correct. It ignores its argument (printf "%%s\n" "$decoy_scripts"%s hardcoded, argument discarded) and returns the decoy path unconditionally. This is precisely the attack Codex described.

One minor note: unlike the cd-shadow test at L608–615, this new case does not have an assert_exit 0 guard before the PWNED check. It wouldn't be vacuous without it (see above — the hostile dirname fires before jq is checked), but the symmetry is worth noting. Adding rc=$?; assert_exit "hostile PATH dirname ignored: runs to completion (exit 0)" 0 "$rc" after the out=$(...) line would make the pattern uniform and give a clear signal if the script crashes for a different reason. Not a blocking issue; a minor hygiene point.


Remaining surface — source function shadowing (pre-existing, unchanged)

fleet-state.sh L84 still calls source "$HOOK_UTILS" without builtin. An exported BASH_FUNC_source%% could shadow it. This is a meaningfully higher-bar attack — the hostile function must discard the correctly-resolved trusted path and source a separate file, affecting all source calls in the invocation context — and was noted as out-of-scope in the prior review. Calling it out again for completeness; builtin source "$HOOK_UTILS" would close it at zero cost if the team wants to be comprehensive. Add builtin


Overall

All three env-reachable attack channels in the root-resolution block are now closed. The fix is idiomatically correct Bash (parameter expansion is the canonical way to strip a path component without external tools), the comment is precise, and the regression test is non-vacuous and exercises the genuine delivery channel. The single non-blocking hygiene note (assert_exit symmetry) is cosmetic.

No blocking issues. The do-not-merge / #853 dependency remains a process concern only.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 25s —— View job


Security Review — PR #907

  • Load task context and read PR diff
  • Audit fleet-state.shdirname PATH-hijack finding (Codex P1 on HEAD)
  • Audit fleet-state.sh — remaining env overrides and execution surfaces
  • Audit fleet-state.test.sh — harness-level risks
  • Audit plugin.json and CHANGELOG.md
  • Post findings

Two findings. The primary fix (env-var override removal, builtin cd/builtin pwd, parameter-expansion dirname bypass) is correct and closes the three documented attack vectors. The findings below are residuals in the same threat model.


IMPORTANT — source without builtin at L84

Confidence: HIGH

fleet-state.sh L84

source "$HOOK_UTILS"

This PR hardens cd and pwd against exported bash-function shadowing (via builtin cd, builtin pwd) but leaves the source call itself unprotected. source is a Bash built-in, but Bash imports environment-exported functions before the script body runs — BASH_FUNC_source%%=() { source /attacker/file; } defines a function named source that shadows the built-in. A plain source "$HOOK_UTILS" invokes the attacker function; that function receives the correctly-resolved $HOOK_UTILS path as its argument but can silently ignore it and source any file instead.

The attack channel is the same one already closed for cd/pwd in this PR: an environment that exports BASH_FUNC_source%% before the script runs (a project settings.json env block, an inherited shell environment, a sibling hook). The difference is that source is the final step — even with $HOOK_UTILS correctly resolved, the exploitation succeeds.

Fix: one word — builtin source "$HOOK_UTILS". This bypasses the function-export channel for the same reason builtin cd does.

Fix this →


SUGGESTION — PATH-overriding test case can pass vacuously (missing assert_exit)

Confidence: HIGH

fleet-state.test.sh L647–651

out=$(run_state "$case_dir" "PATH=$attack_bin_dir")
case "$out" in
*PWNED-DIRNAME-PATH-SOURCED*) fail "..." ;;
*) pass "..." ;;      # ← passes if jq is missing, without testing anything
esac

$attack_bin_dir contains only the bash binary (and any co-located DLLs) — it does not contain jq, tr, git, or any other tool required by fleet-state.sh. When PATH=$attack_bin_dir, the command -v jq check at L90 fails immediately; the script prints ERROR: jq required and exits 2. The output never contains PWNED-DIRNAME-PATH-SOURCED, so the case falls to *) pass — but not because the hostile dirname was bypassed; because jq was missing.

The two other new regression tests both guard against this with rc=$?; assert_exit "... (exit 0)" 0 "$rc" before the marker check. This test omits that guard entirely, so a broken fix (or a future regression that also happens to break jq lookup) passes the test silently.

The fix is to either (a) add rc=$?; assert_exit "hostile PATH dirname ignored: runs to completion (exit 0)" 0 "$rc" before the case, or (b) populate $attack_bin_dir with symlinks to all tools the script needs (jq, tr, git, sed, head, etc.) in addition to the hostile dirname. Option (b) is more work but produces a more faithful adversarial environment; option (a) is the minimal fix that at least catches silent failures.

Fix this →


No issues found in the remaining surfaces

  • CLAUDE_PLUGIN_ROOT in resolve_default_marketplace L202–211: passed through hook::physical_path/hook::normalize_path (path canonicalization), then as --arg root to jq (data argument, not code). The downstream sed 's/.*@//' uses a hardcoded pattern. A hostile value can affect marketplace self-identification but cannot achieve code execution.
  • Four surviving FLEET_STATE_* overrides L102–104: file-path arguments to jq for data reads only — never source, eval, or exec. Risk class is information disclosure, not code execution. Pre-existing, unchanged.
  • plugin.json and CHANGELOG.md: No security concerns. Version bump is patch-level; CHANGELOG accurately describes both the vulnerability and the remedy.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e8d71de8ee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-ops/skills/plugins/scripts/fleet-state.sh
…ion shadowing

The `source "$HOOK_UTILS"` call could be hijacked by an inherited
environment exporting a shell function named `source`
(BASH_FUNC_source%%): bash imports the function before the script runs,
so `source` invokes the attacker's function instead of the builtin. The
function receives the correctly resolved real path but can ignore it and
run arbitrary code, defeating the trusted-path resolution entirely.

Use `builtin source` so the exported function is bypassed and the
resolved hook-utils.sh is always sourced by the real builtin. This
completes the function-shadowing defense already applied to cd/pwd on the
resolution path — the source line is the actual execution point.

Add a regression case exporting BASH_FUNC_source%% that asserts the decoy
never runs (verified non-vacuous: it runs under a plain `source`).
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 5m 14s —— View job


PR Review — fix(claude-ops): load hook-utils.sh from script-relative root

  • Load task context and understand request
  • Read current HEAD (4505929) — fleet-state.sh and test file
  • Evaluate Codex P1 finding: BASH_FUNC_builtin%% shadows builtin itself
  • Assess remediation options and their practical limits
  • Post comprehensive review

This review covers the latest commit 4505929builtin source to close the BASH_FUNC_source%% vector — and assesses the Codex P1 finding about BASH_FUNC_builtin%% raised against e8d71de8.


builtin source fix — fleet-state.sh L87

Correct. The fix addresses the BASH_FUNC_source%% shadowing vector. The inline comment at L83–85 explains the threat accurately. The test at L664–673 is well-formed: the decoy function body is executed by Bash when source is called normally, and builtin source bypasses it.


Codex P1 — BASH_FUNC_builtin%% shadows the builtin keyword itself

Status: VALID. One more commit needed.

builtin is a Bash extension, not a POSIX special built-in. Bash imports BASH_FUNC_<name>%% environment variables as shell functions before the script body runs, and builtin is a valid function name. An environment that exports BASH_FUNC_builtin%% makes every subsequent builtin X call in the script dispatch through the attacker's function. This defeats all three guards added across commits 9e84d99, e8d71de, and 4505929 in a single step:

Call site Guard Defeated by
SCRIPT_DIR=$(builtin cd ... && builtin pwd) BASH_FUNC_cd%% / BASH_FUNC_pwd%% BASH_FUNC_builtin%% returning attacker tree from pwd
builtin source "$HOOK_UTILS" BASH_FUNC_source%% BASH_FUNC_builtin%% ignoring the resolved path and sourcing decoy

Fix — one line before the first builtin call (L72):

# Clear any inherited shell function named `builtin`; BASH_FUNC_builtin%% in
# the environment shadows the builtin keyword itself and would defeat every
# subsequent `builtin X` guard in this script.
unset -f builtin 2>/dev/null || true

This is the correct fence for this vector. The only way to defeat it is to also export BASH_FUNC_unset%%, making it a two-variable attack — and unset approaches special-built-in territory (listed as a POSIX special built-in, and more scrutinized than builtin). The practical depth of in-script defense ends here; beyond this, the defense must be at the invocation layer (stripping BASH_FUNC_* from the env before launching the script). Acknowledging that limit in a comment alongside the unset -f is appropriate.

Regression test to add (mirrors the existing four security cases):

CASE_NUM=$((CASE_NUM + 1))
case_dir=$(new_case_dir)
write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}'
write "$case_dir/catalog/market1.json" '{"plugins": []}'
ARGS=(--marketplace market1)
# An inherited function named `builtin` would shadow the builtin keyword and
# receive every `builtin cd / pwd / source` call — defeating all prior guards.
out=$(run_state "$case_dir" 'BASH_FUNC_builtin%%=() { echo "PWNED-BUILTIN-SHADOW"; }')
rc=$?
assert_exit "exported builtin shadow ignored: runs to completion (exit 0)" 0 "$rc"
case "$out" in
*PWNED-BUILTIN-SHADOW*) fail "exported builtin shadow ignored: hijacked builtin must NOT intercept source" "decoy marker present in output" ;;
*) pass "exported builtin shadow ignored: builtin function cleared at script start" ;;
esac

Add unset -f builtin and test →


Minor — two of the four security tests missing assert_exit 0 guard

The PATH dirname test (L647–651) and the source-shadow test (L669–673) lack the rc=$?; assert_exit ... 0 "$rc" guard present in the first two security cases. The PATH dirname case is the more notable gap: $attack_bin_dir contains only bash (and DLLs), so jq is absent and the script always exits 2 — the decoy IS sourced before the jq check (at L82–91, before L93), so the PWNED marker would appear in the attack-success path and the test is not fully vacuous, but the exit-2 prevents confirming the script completed normally. Worth addressing for consistency if assert_exit 0 is added to the builtin test above.


All prior commits — no new findings

The chain from commit 4188519 through 4505929 is correct: env-var override removed, builtin cd/builtin pwd guards added, ${BASH_SOURCE[0]%/*} eliminates PATH dependency, builtin source closes the source-shadow channel. Each has a non-vacuous regression test. No new injection surfaces are introduced.

The do-not-merge / #853 dependency remains a process concern only.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 34s —— View job


Security Review — PR #907

  • Load task context and understand request
  • Read PR diff and changed files (HEAD: 4505929)
  • Evaluate latest Codex P1 — BASH_FUNC_builtin%% shadows builtin
  • Audit remaining injection surfaces
  • Post findings

One security finding. The four previously reviewed vectors (env-var override, BASH_FUNC_cd%%, hostile PATH dirname, BASH_FUNC_source%%) are correctly closed. A fifth vector raised in the Codex P1 comment on commit e8d71de8ee is unaddressed in HEAD.


IMPORTANT — BASH_FUNC_builtin%% defeats all builtin-qualified protections in this PR

Confidence: HIGH

fleet-state.sh L72–73, L87

Every defense added in this PR routes through builtin:

SCRIPT_DIR="$(builtin cd "$script_src_dir" && builtin pwd)"   # L72
PLUGIN_ROOT_DEFAULT="$(builtin cd "$SCRIPT_DIR/../../.." && builtin pwd)"  # L73
...
builtin source "$HOOK_UTILS"  # L87

builtin is a Bash built-in but is not a POSIX special built-in, so Bash imports a BASH_FUNC_builtin%% environment export as a shell function that shadows it before the script body runs. With that shadow in place:

  • builtin cd "$script_src_dir" → invokes the attacker's builtin function with args cd "$script_src_dir", which can ignore the args and cd anywhere
  • builtin pwd → same; can return an attacker-controlled path
  • builtin source "$HOOK_UTILS" → same; receives the correctly-resolved trusted path but can discard it and source any file

One BASH_FUNC_builtin%% export simultaneously bypasses the cd-shadow fix, the PATH-dirname fix, and the source-shadow fix committed in this PR. The Codex P1 comment on e8d71de8ee confirmed the vector; commit 4505929 added builtin source to address BASH_FUNC_source%% but did not address BASH_FUNC_builtin%% itself.

No regression test exists for this vector. The four new test cases in fleet-state.test.sh cover FLEET_STATE_HOOK_UTILS, BASH_FUNC_cd%%, hostile-PATH dirname, and BASH_FUNC_source%%, but not BASH_FUNC_builtin%%.

Practical fix — add before any builtin usage:

# Unset any BASH_FUNC_builtin%% export before relying on `builtin`.
# Uses `unset` directly (not `builtin unset`), which avoids the shadow
# for the common attack of exporting only BASH_FUNC_builtin%%.
# An attacker who also exports BASH_FUNC_unset%% can defeat this step,
# but that requires two coordinated exports rather than one.
unset -f builtin 2>/dev/null || true

Paired regression test (same pattern as the existing four):

CASE_NUM=$((CASE_NUM + 1))
case_dir=$(new_case_dir)
write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}'
write "$case_dir/catalog/market1.json" '{"plugins": []}'
mkdir -p "$case_dir/evilroot/hooks"
write "$case_dir/evilroot/hooks/hook-utils.sh" 'echo "PWNED-BUILTIN-SHADOW-SOURCED"'
ARGS=(--marketplace market1)
# Exports a function named `builtin` that redirects pwd to the attacker tree.
out=$(run_state "$case_dir" "BASH_FUNC_builtin%%=() { if [[ \"\$1\" == pwd ]]; then printf '%s\n' '$case_dir/evilroot'; else command \"\$@\"; fi; }")
rc=$?
assert_exit "exported builtin shadow ignored: runs to completion (exit 0)" 0 "$rc"
case "$out" in
*PWNED-BUILTIN-SHADOW-SOURCED*) fail "exported builtin shadow ignored: hijacked builtin must NOT redirect source" "decoy marker present in output" ;;
*) pass "exported builtin shadow ignored: hook-utils resolved from real script location" ;;
esac

Fix this →


SUGGESTION — PATH-dirname test lacks assert_exit 0 guard

Confidence: HIGH

fleet-state.test.sh L647–651

out=$(run_state "$case_dir" "PATH=$attack_bin_dir")
case "$out" in
*PWNED-DIRNAME-PATH-SOURCED*) fail ... ;;
*) pass ...   # ← passes if the script crashes early for any other reason
esac

Unlike the three other new test cases at L586, L611, and L669, this case has no rc=$?; assert_exit ... 0 "$rc" before the case statement. With PATH=$attack_bin_dir (containing only bash and the hostile dirname), jq is not found; the script exits 2 after the source step. In the fixed code the test is not vacuous (the decoy hook-utils.sh would emit the PWNED marker during source, before jq is checked), but a future regression that causes the script to exit before the source step would also produce no PWNED marker and pass silently. Pattern is inconsistent with the other three cases.


No issues found in the remaining surfaces

All findings previously reported across the five prior reviews (FLEET_STATE_HOOK_UTILS override, cd/pwd function shadowing, PATH dirname, source function shadowing, CLAUDE_PLUGIN_ROOT in resolve_default_marketplace, the four surviving FLEET_STATE_* data-only overrides) are either correctly closed or remain correctly scoped as pre-existing. No new surfaces introduced in this PR's diff.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Re: the awareness note that source "$HOOK_UTILS" (L84) was not itself protected with builtin source.

# Finding Classification Evidence
1 source is shadowable by an exported BASH_FUNC_source%%, which receives the correctly resolved real path but can ignore it and run arbitrary code — making the trusted-path resolution moot VALID — fixed (treated as blocking despite the "awareness" framing: it's the same function-shadowing class as the cd/pwd defense, and the source line is the actual code-execution point) Reproduced on-machine: with BASH_FUNC_source%% exported, plain source ran the decoy function (real hook-utils never sourced — subsequent hook::* calls errored command not found); builtin source sourced the real file and the script produced normal output.

Fixed in 45059298:

  • fleet-state.sh L84 now uses builtin source "$HOOK_UTILS", bypassing any exported source function so the resolved hook-utils.sh is always sourced by the real builtin.
  • Added a regression case exporting BASH_FUNC_source%% that asserts the decoy never runs. Verified non-vacuous: it runs under a plain source (source line reached with a real sibling present) and does not under builtin source.

This closes the last shadowing gap on the resolution+source path: cd/pwd (prior commit), the external dirname PATH dependency (e8d71de8), and now source all resolve through builtins/parameter expansion — nothing external or shadowable feeds the plugin-code source. System tools invoked afterward (jq/git/tr) remain PATH-resolved by design, an unchanged trust boundary.

… honestly

The prior comments claimed the builtin/parameter-expansion resolution made
hook-utils.sh sourced from the real location "regardless of a hostile
environment." That overclaims: an exported `BASH_FUNC_builtin%%` shadows
`builtin` itself, and `BASH_ENV` executes before the script's first line —
neither is stoppable in-script. Both sit at the same environment-trust
boundary as the PATH-resolved jq/git/tr the script already relies on.

Narrow the comments to the property that actually holds: resolution uses no
external process and the builtin prefixes cover the common single-function
cd/pwd/source shadow, with the broader environment-control vectors named as
the out-of-scope boundary they are. No behavior change.
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 53s —— View job


Claude finished @kyle-sexton's task ——


PR Review — fix(claude-ops): load hook-utils.sh from script-relative root

  • Load task context and understand request
  • Read current HEAD diff and changed files (HEAD: dffeffa)
  • Assess BASH_FUNC_builtin%% scoping decision
  • Check PATH-dirname and source-shadow test assert_exit gaps
  • Review updated comments for honest scoping
  • Post comprehensive review

This review covers the final commit dffeffa — a documentation-only change that narrows the claimed security invariant in response to the Codex BASH_FUNC_builtin%% finding.


Comment hardening — fleet-state.sh L58–67 and L83–87

Correct decision. The scoping is accurate.

The previous comment claimed the builtin/parameter-expansion resolution made hook-utils.sh sourced from the real location "regardless of a hostile environment." That overclaims: an inherited BASH_FUNC_builtin%% shadows the builtin keyword itself before the script body runs, defeating all the builtin cd/builtin pwd/builtin source guards in a single export.

The new comment correctly narrows to what actually holds:

"This is defense against the common cd/pwd/dirname channels, not a guarantee against a fully attacker-controlled environment: an exported BASH_FUNC_builtin%% shadows builtin itself, and BASH_ENV runs before this script's first line — both sit at the same environment-trust boundary as the PATH-resolved jq/git/tr used later, out of scope for an in-script fix."

This is the right scope boundary. The reasoning holds:

  • The script already trusts PATH for jq, git, tr, sed, and head. A fully hostile PATH (or BASH_ENV) is equally lethal through those calls. Defending builtin without hardening those tool calls would be an incomplete defense that overclaims.
  • BASH_FUNC_builtin%% and BASH_ENV sit at the same environment-trust level as PATH — if the caller's environment is that hostile, in-script remediation can't catch up.
  • Choosing not to add unset -f builtin (which only raises the bar from a 1-variable attack to a 2-variable one) and instead honestly documenting the limit is the more correct engineering call.

The updated builtin source comment at L83–87 mirrors this: "covers the common single-function shadow, not a shadowed builtin or BASH_ENV." Precise and consistent.


Minor note — two security tests still missing assert_exit guards

This was raised in prior reviews and remains unaddressed. Neither is critical, but for consistency:

PATH-dirname test fleet-state.test.sh L647–651 — The test is non-vacuous as-is (the decoy hook-utils.sh is sourced at L89, before the jq check at L95, so the PWNED marker appears in output if the attack succeeds). However, a future regression that crashes the script before L89 would produce empty output, pass the marker check vacuously, and go undetected. Adding rc=$?; assert_exit "hostile PATH dirname ignored: exits normally" 0 "$rc" after the out=$(...) line would close that gap — though note that with PATH=$attack_bin_dir the script exits 2 (jq missing), so the assert would need to accept exit 2 rather than 0.

Source-shadow test fleet-state.test.sh L669–673 — The BASH_FUNC_source%% export doesn't include real hook-utils functions; if the attack succeeded, hook::* calls would fail and the script would exit non-zero. A script that crashes before the marker check would also pass vacuously. Adding rc=$?; assert_exit "exported source shadow ignored: runs to completion (exit 0)" 0 "$rc" here would match the pattern of the cd-shadow test at L610–611.

Fix assert_exit gaps →


plugin.json and CHANGELOG.md

No changes in this commit. The version bump 0.17.40.17.5 and CHANGELOG entry from earlier commits remain correct.


Overall

The final commit correctly resolves the tension the Codex BASH_FUNC_builtin%% finding created: rather than adding a partial defense (unset -f builtin) that would still overclaim the invariant, it narrows the documented property to what actually holds and names the out-of-scope boundary consistently. The six-commit chain — env-var override removal, BASH_FUNC_cd%% guard, PATH-dirname elimination, BASH_FUNC_source%% guard, regression tests, and honest comment scoping — is coherent and the codebase is in a better security posture than before with accurate documentation of its limits.

The two missing assert_exit guards are the only remaining minor unresolved items from the full review thread. No blocking issues. The do-not-merge / #853 dependency remains a process concern only.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 27s —— View job


Security Review — PR #907

  • Load task context and read PR diff
  • Read changed files (fleet-state.sh, fleet-state.test.sh)
  • Audit BASH_FUNC_builtin%% vector — the open Codex P1 finding
  • Audit remaining injection surfaces
  • Post findings

One SUGGESTION. No CRITICAL or IMPORTANT findings in HEAD (dffeffa).

The primary vulnerability (arbitrary-file source via FLEET_STATE_HOOK_UTILS) is fully closed. The four attack channels addressed across the commit chain are all correctly handled and have non-vacuous regression tests. The scoping decision on the one residual vector is defensible, with reasoning I agree with — documented below.


Closed attack surfaces (confirmed correct)

Vector Fix Regression test
FLEET_STATE_HOOK_UTILS env-var redirect Removed override; HOOK_UTILS hardcoded to $PLUGIN_ROOT_DEFAULT/hooks/hook-utils.sh L578–590 — non-vacuous: decoy sourced before jq check, so marker appears regardless of jq availability
BASH_FUNC_cd%% / BASH_FUNC_pwd%% function shadow builtin cd / builtin pwd in resolution block L602–615
Hostile dirname binary in PATH ${BASH_SOURCE[0]%/*} parameter expansion — no external process consulted L627–651
BASH_FUNC_source%% function shadow builtin source "$HOOK_UTILS" at L89 L664–673

SUGGESTION — BASH_FUNC_builtin%% shadow of the builtin keyword itself

Confidence: HIGH (the vector is real). Severity: SUGGESTION (see scoping rationale below).

fleet-state.sh L73–74

Bash treats builtin as an ordinary built-in (not a POSIX special built-in), so BASH_FUNC_builtin%% in the environment is imported as a shell function that shadows the builtin keyword before the script body runs. With that function in place, builtin cd, builtin pwd, and builtin source all dispatch through the attacker's function — which can ignore the arguments and execute arbitrary code. This was raised as Codex P1 on commit e8d71de8 and confirmed against 4505929.

The current HEAD (dffeffa) explicitly scopes this out-of-scope with the comment at L63–67:

"This is defense against the common cd/pwd/dirname channels, not a guarantee against a fully attacker-controlled environment: an exported BASH_FUNC_builtin%% shadows builtin itself, and BASH_ENV runs before this script's first line — both sit at the same environment-trust boundary as the PATH-resolved jq/git/tr used later, out of scope for an in-script fix."

This scoping decision is correct. The two reasons it is the right call:

  1. The natural countermove (unset -f builtin) is not a meaningful security boundary. unset is itself shadowable via BASH_FUNC_unset%%. An attacker who exports both BASH_FUNC_builtin%% and BASH_FUNC_unset%% bypasses the unset call before builtin is cleared. Furthermore, BASH_ENV executes a file before the script's first statement — including before any unset -f builtin. So the fence would only raise the cost from one export to two, while BASH_ENV remains a one-export bypass.

  2. The same environment-trust boundary already admits equivalent RCE. This script calls command jq, git, tr, head, and sed via PATH throughout. An attacker with sufficient environment control to export BASH_FUNC_builtin%% also controls PATH for those tools. The script never hardens those calls with builtin, and doing so is not feasible cross-platform. The builtin cd/pwd/source guards protect the specific hook-utils.sh loading path against the common single-function-export channel — an attacker who has gone to two coordinated function exports (or BASH_ENV) has bypassed the plugin's entire trust model, not just this script.

The comment is accurate, the scoping rationale is sound, and no regression test for this vector is needed (since the defense would be an unset -f builtin that is itself bypassable at the same trust level).

If future work closes the environment-trust boundary at the runtime layer (e.g., Claude Code filtering BASH_FUNC_* variables before spawning hooks), the in-script hardening would become unnecessary. That is the correct place to address this class of attack comprehensively.


No issues found in remaining surfaces

CLAUDE_PLUGIN_ROOT in resolve_default_marketplace L207–216: processed through hook::physical_path / hook::normalize_path (path canonicalization from the trusted, now-unconditionally-script-relative hook-utils.sh), then as --arg root to jq (data, not code). Downstream pipeline uses a hardcoded sed 's/.*@//' literal. A hostile CLAUDE_PLUGIN_ROOT can affect marketplace self-identification but cannot achieve code execution.

Four surviving FLEET_STATE_* overrides L107–109: all consumed as file-path arguments to jq for data reads — never passed to source, eval, or exec. Risk class is information disclosure; not changed by this PR.

plugin.json and CHANGELOG.md: no security concerns. Patch-level version bump; CHANGELOG accurately describes the vector and remedy.


@kyle-sexton

Copy link
Copy Markdown
Contributor Author

🔒 merge-drive lane claiming this PR for on-branch fix work this cycle (amendment-round: 16, worker tier). Will fix clear branch-owned CI/bot findings, defer non-DEFER-FORBIDDEN items with reply+resolve, push, then re-check the merge gate.

Resolve claude-ops version/changelog collision from concurrent landings:
restack the branch's 0.17.5 security bump to 0.18.2 (next patch above main's
landed 0.18.1) and reorder CHANGELOG so the fleet-state hook-utils.sh security
entry sits newest-on-top above main's 0.18.1 and 0.18.0 entries.
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 38s —— View job


PR Review — fix(claude-ops): load hook-utils.sh from script-relative root

  • Load task context and understand request
  • Read current HEAD diff and changed files (HEAD: b25fa1d — merge of origin/main)
  • Review security fix chain and test coverage
  • Review assert_exit gap findings from prior reviews
  • Post comprehensive review

This review covers the full current HEAD, which is the merge commit (b25fa1d) that restacked the version bump to 0.18.2 (avoiding a CHANGELOG/semver collision with concurrent 0.18.0/0.18.1 landings on main). The merge introduced no code changes to fleet-state.sh or fleet-state.test.sh; it only updated plugin.json and CHANGELOG.md for the version collision.


Security fix chain — summary across all commits

The four-layer defence is correct and complete for the stated threat model:

Layer Implementation Regression test
Remove env-var override HOOK_UTILS="$PLUGIN_ROOT_DEFAULT/hooks/hook-utils.sh" — no FLEET_STATE_HOOK_UTILS consulted L578–590 — assert_exit 0 + PWNED marker check ✓
BASH_FUNC_cd%% / BASH_FUNC_pwd%% function shadow builtin cd / builtin pwd in resolution block L602–615 — assert_exit 0 + PWNED marker check ✓
Hostile dirname binary in PATH ${BASH_SOURCE[0]%/*} parameter expansion — zero external process L627–651 — PWNED marker check only (see note below)
BASH_FUNC_source%% function shadow builtin source "$HOOK_UTILS" L664–673 — PWNED marker check only (see note below)

The BASH_FUNC_builtin%% boundary is explicitly and correctly scoped out-of-scope in the comment at L63–67. The reasoning (BASH_ENV runs before the script's first line; PATH-resolved jq/git/tr are already trusted; the guard would only raise cost from 1 to 2 exports without closing the boundary) is sound and matches what the security-review bot confirmed.


NOTABLE — CHANGELOG 0.18.2 entry describes a superseded implementation

CHANGELOG.md L24–26

The entry reads:

"The script-relative path is computed with builtin cd/builtin pwd and command dirname…"

But the actual implementation (fleet-state.sh L68–73) uses ${BASH_SOURCE[0]%/*} parameter expansion, not command dirname:

script_src="${BASH_SOURCE[0]}"
case "$script_src" in
*/*) script_src_dir="${script_src%/*}" ;;
*) script_src_dir="." ;;
esac
SCRIPT_DIR="$(builtin cd "$script_src_dir" && builtin pwd)"

The command dirname approach was commit 9e84d990, then replaced by the ${BASH_SOURCE[0]%/*} approach in e8d71de8 specifically to close the PATH-hijack vector. The CHANGELOG was written against the earlier state and never updated. The code is more secure than the CHANGELOG describes — but the entry is factually wrong and will mislead future auditors.

Fix this →


MINOR — two of the four security tests missing assert_exit guards (raised in prior reviews, still open)

This was flagged in at least three prior review passes and remains unaddressed. Both tests lack rc=$? capture and an assert_exit before the PWNED marker check.

PATH-dirname testfleet-state.test.sh L647–651:

out=$(run_state "$case_dir" "PATH=$attack_bin_dir")
# ← no rc=$? or assert_exit here
case "$out" in
*PWNED-DIRNAME-PATH-SOURCED*) fail … ;;
*) pass … ;;
esac

The restricted PATH=$attack_bin_dir (containing only bash and the hostile dirname) means jq is absent; the script exits 2 after builtin source "$HOOK_UTILS" succeeds. The decoy is sourced before the jq check, so the test is not fully vacuous — a successful attack would print the PWNED marker before the jq abort. However, a future regression that causes the script to crash before the source call would also produce no PWNED marker and pass silently. The guard would be assert_exit "hostile PATH dirname ignored: jq-missing exit" 2 "$rc" (exit 2, not 0, because jq is absent from the attack PATH).

Source-shadow testfleet-state.test.sh L669–673:

out=$(run_state "$case_dir" 'BASH_FUNC_source%%=() { echo "PWNED-SOURCE-SHADOW"; }')
# ← no rc=$? or assert_exit here
case "$out" in
*PWNED-SOURCE-SHADOW*) fail … ;;
*) pass … ;;
esac

If the attack succeeded, the decoy function runs instead of sourcing real hook-utils.sh, so hook::* calls fail and the script exits non-zero. A crash before the shadow is even invoked would also produce no PWNED marker and pass vacuously. assert_exit "exported source shadow ignored: runs to completion (exit 0)" 0 "$rc" would close that gap and match the pattern of the other two tests.

Fix both →


Version bump — plugin.json 0.18.2

Correct. The merge commit resolved a semver collision: main had landed 0.18.0 and 0.18.1 concurrently, so the branch's 0.17.5 was restacked to 0.18.2. The resulting version sequence (0.17.40.18.2, skipping 0.17.50.18.1) is technically non-linear but the only alternative (cherry-picking in dependency order) is more churn with the same net result. Acceptable given the do-not-merge / #853 constraint.


Overall

The security fix is correct and complete within the documented trust boundary. Two items remain before merge:

  1. CHANGELOG text (notable): The 0.18.2 entry describes command dirname which was superseded by parameter expansion — straightforward one-line correction.
  2. assert_exit guards (minor): Two of the four security regression tests still lack exit-code guards — consistent with the other two and worth adding now rather than carrying as permanent tech debt.

The do-not-merge / #853 dependency remains a process concern only.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 47s —— View job


Security Review — PR #907

  • Read PR diff and changed files
  • Audit fleet-state.sh for injection surfaces and residual vectors
  • Audit fleet-state.test.sh for harness-level risks
  • Audit plugin.json and CHANGELOG.md
  • Post findings

One SUGGESTION. No CRITICAL or IMPORTANT findings in HEAD (b25fa1d).

The four attack surfaces documented across the commit chain are correctly closed and mechanically tested. No new injection surfaces are introduced.


Closed attack surfaces (all confirmed correct)

Vector Defense Regression test
FLEET_STATE_HOOK_UTILS env-var redirect Override removed; HOOK_UTILS hardcoded to $PLUGIN_ROOT_DEFAULT/hooks/hook-utils.sh L578–590 — non-vacuous: decoy sourced before jq check, marker appears in attack-success path
BASH_FUNC_cd%% / BASH_FUNC_pwd%% function shadow builtin cd / builtin pwd bypass exported functions L602–615 — has assert_exit 0 guard
Hostile dirname binary in PATH ${BASH_SOURCE[0]%/*} parameter expansion — no external process or PATH lookup L627–651
BASH_FUNC_source%% function shadow builtin source "$HOOK_UTILS" at L89 L664–673

BASH_FUNC_builtin%% scoping decision — The comment at L63–67 correctly names this as out-of-scope for an in-script fix. The reasoning is sound: BASH_FUNC_builtin%% and BASH_ENV sit at the same environment-trust boundary as the PATH-resolved jq/git/tr that the script already trusts; hardening builtin without hardening those tools would be an incomplete and misleading defense. No further action needed here.


SUGGESTION — CHANGELOG entry describes command dirname but the implementation uses parameter expansion

Confidence: HIGH. Non-blocking.

CHANGELOG.md

The security entry reads:

"The script-relative path is computed with builtin cd/builtin pwd and command dirname…"

But the actual implementation at L68–73 uses ${BASH_SOURCE[0]%/*} — parameter expansion, no external process at all:

script_src="${BASH_SOURCE[0]}"
case "$script_src" in
*/*) script_src_dir="${script_src%/*}" ;;
*) script_src_dir="." ;;
esac
SCRIPT_DIR="$(builtin cd "$script_src_dir" && builtin pwd)"

The CHANGELOG was written when command dirname was still in the code; the stronger e8d71de8 commit replaced it with parameter expansion but the CHANGELOG wasn't updated. The actual fix is strictly better than what the changelog describes — parameter expansion eliminates the entire PATH dependency — but someone reading the changelog to audit or re-implement the defense would get an inaccurate picture.

Fix CHANGELOG →


SUGGESTION — Two security tests lack assert_exit guards (pre-existing, flagged in prior reviews)

Confidence: HIGH. Non-blocking.

  • PATH-dirname test L647–651: no rc=$?; assert_exit before the case statement. With PATH=$attack_bin_dir the script exits 2 (jq not found); a future regression crashing before the source step would also produce no PWNED marker and pass vacuously. The other three regression tests all guard with assert_exit first.
  • Source-shadow test L669–673: same gap — no assert_exit 0 guard before the PWNED check.

Fix both →


No issues found in remaining surfaces

  • Four surviving FLEET_STATE_* overrides L107–109: all consumed as file-path arguments to jq for data reads — never source, eval, or exec. Risk class: information disclosure only. Pre-existing, unchanged.
  • CLAUDE_PLUGIN_ROOT in resolve_default_marketplace L207–216: processed through hook::physical_path/hook::normalize_path (canonicalization from the now-unconditionally-script-relative hook-utils.sh), then as --arg root to jq (data argument, not code). Cannot achieve code execution.
  • plugin.json and CHANGELOG.md: Version bump 0.18.10.18.2 is appropriate (patch-level security fix). No security concerns beyond the documentation inaccuracy noted above.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Classification of the two automated review summaries on head b25fa1d (safe-tier babysit pass — this PR carries do-not-merge, so it is not merge-eligible regardless of readiness; classifying + surfacing only, no thread resolution).

# Source Finding Classification Evidence Reacted
1 claude PR-review (5048929015) NOTABLE + Security-review (5048930208) SUGGESTION CHANGELOG.md [0.18.2] Security entry says the path is "computed with builtin cd/builtin pwd and command dirname", but the shipped code uses ${BASH_SOURCE[0]%/*} parameter expansion — no dirname VALID — carried into the pending conflict resolution, not a separate commit Confirmed: fleet-state.sh L68–75 resolves script_src_dir via ${script_src%/*} + builtin cd/builtin pwd; there is no dirname (external or command dirname) on the resolution path. The CHANGELOG text describes the superseded 9e84d990 implementation that e8d71de8 replaced. 👍
2 claude PR-review (5048929015) MINOR + Security-review (5048930208) SUGGESTION Two security regression tests lack an rc=$?/assert_exit guard before the PWNED-marker case (PATH-dirname test, source-shadow test) VALID — surfaced to repo owner, not auto-fixed this pass Confirmed: both cases fall straight into case "$out" in with no exit capture, unlike the hook-utils-override and cd-shadow cases which guard with assert_exit first. A crash before the source step would pass vacuously. 👍

Why no fix is pushed on this pass:

  • Finding 1plugins/claude-ops/CHANGELOG.md is the sole file currently in merge conflict with origin/main (this branch is behind base; git merge-tree reports a single content conflict there). Safe-tier babysit reports a merge conflict as a blocker and does not resolve it autonomously, so editing that same file for a one-line prose correction would compound an unresolved conflict. The correction is a known, verified edit to fold into that conflict resolution — held on the branch, not deferred to a follow-up issue.
  • Finding 2 — this is a security regression test on an area: security PR. The correct guard requires a platform-specific exit-code assertion (the reviewer proposes exit 2 for the jq-absent PATH-dirname case and exit 0 for the source-shadow case); a safe automated pass will not assert exit codes on a security test without empirical verification, so it is surfaced to the owner rather than auto-fixed. Non-blocking.

For the record, both summaries independently agree the BASH_FUNC_builtin%% / BASH_ENV residual is correctly scoped out-of-scope for an in-script fix ("No further action needed here"), matching the earlier threaded reply that flagged it to the repo owner as an invocation-layer design decision.

Blockers this pass: (a) do-not-merge label — hard merge gate (the failing do-not-merge check is that intentional gate, not a code failure); (b) merge conflict in CHANGELOG.md vs origin/main (branch behind base) — reported, not resolved in safe tier. All other required checks are green.

@kyle-sexton kyle-sexton removed the do-not-merge Hard merge gate: do not merge while applied. label Jul 23, 2026
Resolve the claude-ops version/CHANGELOG collision: main landed claude-ops
0.18.2 (shared hook-utils.sh git-option parser sync, #740) while this branch
also targeted 0.18.2 for its fleet-state FLEET_STATE_HOOK_UTILS source hardening.
Restack this branch's Security entry to 0.18.3 (plugin.json + CHANGELOG), keeping
main's 0.18.2 entry intact. fleet-state.test.sh passes (27/27) on the merged tree.
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@github-actions

Copy link
Copy Markdown

Warning

Automated review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@github-actions

Copy link
Copy Markdown

Warning

Automated security review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@kyle-sexton
kyle-sexton merged commit 7b19012 into main Jul 23, 2026
25 checks passed
@kyle-sexton
kyle-sexton deleted the fix/797-fleet-state-hook-utils-gate branch July 23, 2026 07:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: security Security-relevant: vulnerability, hardening, or disclosure follow-up. automated Opened by automation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

claude-ops: fleet-state.sh's FLEET_STATE_HOOK_UTILS test override is not gated to test contexts

1 participant