Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/claude-security-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:
# not. runner-policy rejects inputs outside the reviewed contract but
# cannot REQUIRE one, so dropping this line re-widens the exception with
# nothing said. That residual gap is standards#308.
skip-actors: dependabot[bot],claude[bot],melodic-ai[bot],melodic-standards-sync[bot]
skip-actors: dependabot[bot],claude[bot],melodic-ai[bot],melodic-standards-sync[bot],cursor[bot]
# One named secret (least privilege), never `secrets: inherit`.
secrets:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
Expand Down Expand Up @@ -122,5 +122,5 @@ jobs:
LANE_REVIEW_RAN: ${{ needs.security-review.outputs.review-ran }}
LANE_REVIEW_FAILED: ${{ needs.security-review.outputs.review-failed }}
LANE_FAILURE_CLASS: ${{ needs.security-review.outputs.failure-class }}
SKIP_ACTORS: dependabot[bot],claude[bot],melodic-ai[bot],melodic-standards-sync[bot]
SKIP_ACTORS: dependabot[bot],claude[bot],melodic-ai[bot],melodic-standards-sync[bot],cursor[bot]
run: bash scripts/verify-security-review-evidence.sh
63 changes: 46 additions & 17 deletions lib/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -340,22 +340,28 @@ hook::expand_8dot3() {
# spelling of an in-project path cannot dodge it (the long-form prefix would
# never match). GNU realpath ships with Git Bash and Linux coreutils;
# readlink -f covers the BSD/macOS hosts that have no realpath. When neither
# resolver exists the caller falls back to comparing the lexical path as
# before — the guard is defense-in-depth scoping for a file the agent already
# wrote via its own tools, so degrading to the historical comparison beats
# silently disabling the hook on those hosts. The 8.3 expansion applies only
# on the resolver's success path: an unchanged return is the documented
# signature of failed canonicalization, and consumers that fail closed on that
# signature must not see a form-converted path instead.
# resolver exists the caller still receives the lexical path unchanged — the
# guard is defense-in-depth scoping for a file the agent already wrote via its
# own tools, so degrading to the historical comparison beats silently disabling
# the hook on those hosts — but the answer is now DISTINGUISHABLE: return 1 and
# HOOK_PHYSICAL_PATH_UNRESOLVED=1. Success (return 0) means resolved; advisory
# callers that ignore the status keep today's behavior. Guards that must fail
# closed branch on the return code or on HOOK_PHYSICAL_PATH_UNRESOLVED. The 8.3
# expansion applies only on the resolver's success path: consumers that fail
# closed on an unresolved signature must not see a form-converted path instead.
# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_PHYSICAL_PATH_UNRESOLVED
hook::physical_path() {
local resolved
HOOK_PHYSICAL_PATH_UNRESOLVED=0
if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then
if [[ -n "$resolved" ]]; then
hook::expand_8dot3 "$resolved"
return
return 0
fi
fi
HOOK_PHYSICAL_PATH_UNRESOLVED=1
printf '%s' "$1"
return 1
}

# True when <normalized-path> sits inside one of this host's temp trees.
Expand Down Expand Up @@ -466,19 +472,30 @@ hook::read_file_path() {
# Resolve the repository root (working-tree top) for a path inside the tree.
# markdownlint config auto-discovery is CWD-anchored, so the hook cd's here
# before linting. File-anchored (`git -C "$hint" rev-parse --show-toplevel`)
# so it is correct for clones, linked worktrees, and bare-hub clones; falls
# back to the hint (with a trailing /.claude stripped) when git cannot resolve.
# so it is correct for clones, linked worktrees, and bare-hub clones; when git
# cannot resolve, still returns the hint (with a trailing /.claude stripped) so
# advisory callers keep today's fallback — but the answer is now
# DISTINGUISHABLE: return 1 and HOOK_REPO_ROOT_UNRESOLVED=1. Success (return 0)
# means git answered; advisory callers that ignore the status are unchanged.
# Guards that must fail closed branch on the return code or on
# HOOK_REPO_ROOT_UNRESOLVED.
# ROOT=$(hook::repo_root "$some_path")
# shellcheck disable=SC2034 # public contract: advisory callers may read HOOK_REPO_ROOT_UNRESOLVED
hook::repo_root() {
local hint="${1:-.}"
local root
HOOK_REPO_ROOT_UNRESOLVED=0
root=$(git -C "$hint" rev-parse --show-toplevel 2>/dev/null | tr -d '\r')
if [[ -z "$root" ]]; then
root="$hint"
root="${root%/.claude}"
root="${root%\\.claude}"
if [[ -n "$root" ]]; then
printf '%s' "$root"
return 0
fi
root="$hint"
root="${root%/.claude}"
root="${root%\\.claude}"
HOOK_REPO_ROOT_UNRESOLVED=1
printf '%s' "$root"
return 1
}

# Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe
Expand Down Expand Up @@ -1710,9 +1727,10 @@ hook::git_alias_expansion() {

# Single linear pass: read the command into a char array once (O(n)), then walk
# it splitting top-level segments on UNQUOTED control operators and tokenizing
# each segment into argv words honoring '…', "…", $'…', and backslash escapes
# (including backslash-newline continuation). Each completed segment is passed
# to the callback as it closes, so no full segment list is retained.
# each segment into argv words honoring '…', "…", $'…', backslash escapes
# (including backslash-newline continuation), and unquoted `#` comments to EOL
# (quoted `#` preserved). Each completed segment is passed to the callback as it
# closes, so no full segment list is retained.
# Call as: hook::bash_parse_segments <command-string> <callback>; the callback
# receives one segment's argv words as "$@".
# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape
Expand Down Expand Up @@ -1804,6 +1822,17 @@ hook::bash_parse_segments() {
have=0
fi
;;
'#')
# Unquoted `#` starts a shell comment to EOL only at a word boundary (no
# word currently being assembled). Mid-word `#` (`x#y`) stays literal.
if ((have)); then
Comment thread
kyle-sexton marked this conversation as resolved.
word+="#"
else
while ((i + 1 < n)) && [[ "${chars[i + 1]}" != $'\n' ]]; do
((i++))
done
fi
;;
'>' | '<')
# Redirection: bash removes the operator and its target word from
# argv (redirections may appear anywhere in a simple command), so
Expand Down
150 changes: 150 additions & 0 deletions lib/hook-utils.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2592,6 +2592,156 @@ ie_probe "ENABLED+SURVIVED" "explicit true returns true" \
ie_probe "DISABLED+SURVIVED" "explicit false returns false WITHOUT exiting" \
CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED=false

# --- hook::physical_path / hook::repo_root: unresolved is distinguishable -------
physical_path_resolved() {
local target="$1" probe rc flag out
probe=$(
bash -c '
# shellcheck source=hook-utils.sh
source "$1"
_tmp=$(mktemp)
hook::physical_path "$2" >"$_tmp"
printf "%s\n%s\n%s" "$?" "$HOOK_PHYSICAL_PATH_UNRESOLVED" "$(cat "$_tmp")"
rm -f "$_tmp"
' _ "$HOOK_DIR/hook-utils.sh" "$target"
)
rc=$(printf '%s\n' "$probe" | sed -n '1p')
flag=$(printf '%s\n' "$probe" | sed -n '2p')
out=$(printf '%s\n' "$probe" | sed -n '3p')
if ((rc == 0 && flag == 0)) && [[ -n "$out" ]]; then
ok "physical_path: resolved $target"
else
fail "physical_path: expected resolved for $target (rc=$rc flag=$flag out=$out)"
fi
}

physical_path_unresolved() {
local target="$1" out rc flag probe
local no_canon
no_canon="$(mktemp)"
cat >"$no_canon" <<'EOF'
realpath() { return 1; }
readlink() { return 1; }
EOF
probe=$(
BASH_ENV="$no_canon" bash -c '
# shellcheck source=hook-utils.sh
source "$1"
_tmp=$(mktemp)
hook::physical_path "$2" >"$_tmp"
printf "%s\n%s\n%s" "$?" "$HOOK_PHYSICAL_PATH_UNRESOLVED" "$(cat "$_tmp")"
rm -f "$_tmp"
' _ "$HOOK_DIR/hook-utils.sh" "$target"
)
rc=$(printf '%s\n' "$probe" | sed -n '1p')
flag=$(printf '%s\n' "$probe" | sed -n '2p')
out=$(printf '%s\n' "$probe" | sed -n '3p')
rm -f "$no_canon"
if ((rc == 1 && flag == 1)) && [[ "$out" == "$target" ]]; then
ok "physical_path: unresolved returns lexical path for $target"
else
fail "physical_path unresolved $target: rc=$rc flag=$flag out=$out"
fi
}

PP_TARGET="$(mktemp)"
printf 'probe' >"$PP_TARGET"
physical_path_resolved "$PP_TARGET"
physical_path_unresolved "$PP_TARGET"
rm -f "$PP_TARGET"

repo_root_resolved() {
local hint="$1" probe rc flag out
probe=$(
bash -c '
# shellcheck source=hook-utils.sh
source "$1"
_tmp=$(mktemp)
hook::repo_root "$2" >"$_tmp"
printf "%s\n%s\n%s" "$?" "$HOOK_REPO_ROOT_UNRESOLVED" "$(cat "$_tmp")"
rm -f "$_tmp"
' _ "$HOOK_DIR/hook-utils.sh" "$hint"
)
rc=$(printf '%s\n' "$probe" | sed -n '1p')
flag=$(printf '%s\n' "$probe" | sed -n '2p')
out=$(printf '%s\n' "$probe" | sed -n '3p')
if ((rc == 0 && flag == 0)) && [[ -n "$out" ]]; then
ok "repo_root: git resolved from $hint"
else
fail "repo_root: expected git resolution from $hint (rc=$rc flag=$flag out=$out)"
fi
}

repo_root_unresolved() {
local hint="$1" out rc flag probe
probe=$(
bash -c '
# shellcheck source=hook-utils.sh
source "$1"
_tmp=$(mktemp)
hook::repo_root "$2" >"$_tmp"
printf "%s\n%s\n%s" "$?" "$HOOK_REPO_ROOT_UNRESOLVED" "$(cat "$_tmp")"
rm -f "$_tmp"
' _ "$HOOK_DIR/hook-utils.sh" "$hint"
)
rc=$(printf '%s\n' "$probe" | sed -n '1p')
flag=$(printf '%s\n' "$probe" | sed -n '2p')
out=$(printf '%s\n' "$probe" | sed -n '3p')
if ((rc == 1 && flag == 1)) && [[ "$out" == "$hint" ]]; then
ok "repo_root: unresolved falls back to hint for $hint"
else
fail "repo_root unresolved $hint: rc=$rc flag=$flag out=$out"
fi
}

repo_root_resolved "."
RR_NOGIT="$(mktemp -d)"
repo_root_unresolved "$RR_NOGIT"
rm -rf "$RR_NOGIT"

# --- hook::bash_parse_segments: unquoted # comments to EOL --------------------
bps_last=()
bps_collect() {
bps_last=("$@")
}

bps_words_are() {
local desc="$1"
shift
local want="$*"
local got
got="${bps_last[*]-}"
if [[ "$got" == "$want" ]]; then
ok "bash_parse_segments: $desc"
else
fail "bash_parse_segments $desc: got [$got], want [$want]"
fi
}

hook::bash_parse_segments 'git b # -C other-repo' bps_collect
bps_words_are 'unquoted # drops trailing words' 'git b'

hook::bash_parse_segments "git '#'" bps_collect
bps_words_are 'single-quoted # preserved' "git #"

hook::bash_parse_segments 'git "#"' bps_collect
bps_words_are 'double-quoted # preserved' 'git #'

hook::bash_parse_segments 'git status; git b # -C other' bps_collect
bps_words_are 'comment only affects its segment' 'git b'

bps_all=()
bps_collect_all() {
bps_all+=("${*}")
}

hook::bash_parse_segments 'echo x#y; git reset --hard' bps_collect_all
if [[ "${bps_all[*]-}" == "echo x#y git reset --hard" ]]; then
ok "bash_parse_segments: mid-word # stays literal through reset"
else
fail "bash_parse_segments mid-word # stays literal through reset: got [${bps_all[*]-}], want [echo x#y git reset --hard]"
fi

echo
echo "PASS=$PASS FAIL=$FAIL"
[[ $FAIL -eq 0 ]]
4 changes: 2 additions & 2 deletions plugins/actionlint/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "actionlint",
"version": "0.8.11",
"version": "0.8.12",
"description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.",
"author": {
"name": "Melodic Software",
Expand All @@ -26,7 +26,7 @@
"stdin_read_timeout": {
"type": "number",
"title": "Hook stdin read timeout (seconds)",
"description": "Idle bound on reading the hook payload from stdin \u2014 how long the pipe may go silent before the hook gives up and fails open",
"description": "Idle bound on reading the hook payload from stdin how long the pipe may go silent before the hook gives up and fails open",
"default": 2,
"min": 1
}
Expand Down
6 changes: 6 additions & 0 deletions plugins/actionlint/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
All notable changes to the `actionlint` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.8.12]

### Fixed

- **hook-utils:** distinguish unresolved `physical_path`/`repo_root` answers and honor unquoted `#` in `bash_parse_segments` (#1487).

## [0.8.11]

### Fixed
Expand Down
Loading
Loading