Skip to content

fix(guardrails): resolve persisted aliases via alias.<sub>.command subkey - #2448

Closed
kyle-sexton wants to merge 1 commit into
mainfrom
cursor/persisted-alias-command-subkey-63e1
Closed

fix(guardrails): resolve persisted aliases via alias.<sub>.command subkey#2448
kyle-sexton wants to merge 1 commit into
mainfrom
cursor/persisted-alias-command-subkey-63e1

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Closes #1022

…bkey

When git stores an alias only under alias.<name>.command, persisted_alias()
now falls back to that subkey after alias.<name> returns empty.

Fixes #1022.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@cursor

cursor Bot commented Aug 12, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

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


Security Review

  • Gather context (PR diff, changed files)
  • Review block-noncanonical-commit.sh changes for security issues
  • Review block-noncanonical-commit.test.sh changes
  • Post findings

This PR touches only plugins/guardrails/hooks/block-noncanonical-commit.sh (the persisted_alias resolver) and its test file. One security-relevant logic flaw found in the guard's own bypass-resistance; no issues in the test file.


IMPORTANT / Confidence: Medium-High — persisted-alias fallback lets the benign alias.<sub> spelling suppress a dangerous alias.<sub>.command sibling, reopening the exact bypass class this guard already defends against for inline aliases

persisted_alias(), lines 483–489:

if [[ -z "${_persisted_alias[$key]+x}" ]]; then
    val=$(git -C "$dir" config --get "alias.$sub" 2>/dev/null)
    if [[ -z "$val" ]]; then
      val=$(git -C "$dir" config --get "alias.$sub.command" 2>/dev/null)
    fi
    _persisted_alias["$key"]="$val"
fi

This checks alias.$sub.command only when the plain alias.$sub is empty. If both keys are set — e.g. git config alias.c status and git config alias.c.command commit — the function returns "status" and never looks at the .command value at all.

This is the same twin-spelling ambiguity this file's own CHANGELOG already documents as security-relevant (#740, applied to inline -c/--config-env aliases in the sibling guard and in this file's inline-alias path at lines 662–667):

"git reads both alias.<sub> and its alias.<sub>.command subkey as the alias for <sub>... Because which spelling git runs when both are set is git-version-dependent, the classifier does NOT mirror git's cross-spelling precedence; it fails closed on the MAX-DANGER UNION... blocking if any resolves to a guarded operation."

The inline-alias path in this same file already implements that "check both spellings independently, union the danger" rule (comment at line 662: "Re-check EVERY spelling (plain and .command) independently so a benign expansion in one never suppresses a dangerous sibling in the other"). The new persisted-config path added by this PR does the opposite: first-non-empty-wins, so a benign plain spelling silently suppresses a dangerous .command sibling.

Exploit scenario: an attacker/agent (the guard's own threat model already covers config-alias smuggling) sets, in a repo or global config reachable before the hook runs:

git config alias.c status
git config alias.c.command commit

then runs git c -m $'Subject\n\nnon-canonical multi-line body'. persisted_alias resolves to "status", check_segment recurses into git status ..., never sets saw_commit=1, and the hook exits 0 (allow) — while on a git version where the .command table-form spelling actually wins at dispatch time, the real command executed is git commit -m '...', i.e. exactly the noncanonical commit this guard exists to block. This is a full fail-open bypass of the tool's core purpose, not just a corner case.

Suggested fix: mirror the existing MAX-DANGER-UNION approach used for inline aliases — fetch both alias.$sub and alias.$sub.command unconditionally and treat the alias as present (and re-check both expansions) whenever either is non-empty, rather than short-circuiting on the first non-empty value.

Fix this →


No other security issues found in this diff. The test file addition (block-noncanonical-commit.test.sh) only adds coverage for the case where alias.<sub> is unset and .command alone is set — it does not exercise the both-set precedence case above, which is consistent with the gap not being caught.

Supply-chain / unpinned-action / permissions / dangerous-trigger concerns are out of scope here per instructions (zizmor's lane) — none of the changed files are workflow files regardless.

Branch: cursor/persisted-alias-command-subkey-63e1

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

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


Review complete

Scope: plugins/guardrails/hooks/block-noncanonical-commit.sh and its test file (per REVIEW.md, this repo has a claude-security-review.yml workflow, so security-only findings are left to that lane — the finding below is a functional correctness gap in the guard's own detection logic, not an OWASP-style vuln).

Findings (posted inline)

  1. 🔴 Important — persisted_alias()'s new .command fallback doesn't apply the "max-danger union" the inline (-c) alias path already enforces. It treats alias.$sub and alias.$sub.command as mutually exclusive (fallback only when plain is empty), but both keys can be set simultaneously in git config. A benign alias.c value can mask a dangerous alias.c.command value, reopening the exact "one spelling masks the other" bypass this codebase already fixed and tests for on the inline path (hook-utils.sh#L1644-L1663, block-noncanonical-commit.test.sh#L316-L319). See inline comment on block-noncanonical-commit.sh#L483-L489 for a concrete bypass example and suggested fix direction.
  2. 🟡 Suggestion — missing "union" regression tests for the persisted-alias path, mirroring the existing inline-alias union tests. See inline comment on the test file.

Additional note (no changed line to anchor this to)

  • This plugin maintains a strict per-fix CHANGELOG.md entry + plugin.json version bump convention (every entry in plugins/guardrails/CHANGELOG.md references its issue number, current version 0.28.8). This PR doesn't add a changelog entry or bump the version — worth doing before merge if that's still required for this plugin, especially once finding chore: initialize marketplace scaffold #1 is addressed.

Not reviewed here

  • Security-specific concerns (if any) are left to the claude-security-review lane per this repo's REVIEW.md split.

@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: 2d4aa172dc

ℹ️ 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 on lines +485 to +486
if [[ -z "$val" ]]; then
val=$(git -C "$dir" config --get "alias.$sub.command" 2>/dev/null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stop resolving a different dotted alias as the requested name

When only alias.c.command is configured, Git treats everything after alias. as the alias name: this defines git c.command, not git c, consistent with the official alias.* documentation. I verified with Git 2.43.0 that git c.command runs the expansion while git c reports an unknown command, but this fallback re-expands git c as commit and can falsely block it; it also adds a second git config process to every ordinary alias miss. Only alias.$sub should be queried—the dotted alias will already be found naturally when $sub itself is c.command.

Useful? React with 👍 / 👎.

Comment on lines +483 to +489
if [[ -z "${_persisted_alias[$key]+x}" ]]; then
val=$(git -C "$dir" config --get "alias.$sub" 2>/dev/null)
if [[ -z "$val" ]]; then
val=$(git -C "$dir" config --get "alias.$sub.command" 2>/dev/null)
fi
_persisted_alias["$key"]="$val"
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Important — persisted alias resolution doesn't apply the same "max-danger union" the inline (-c) path already enforces, reopening the exact bypass this file's own docs warn about.

This fallback treats alias.$sub and alias.$sub.command as mutually exclusive (checks .command only when the plain form is empty). But git config allows both keys to be set simultaneously ([alias] c = ... and [alias "c"] command = ... are distinct config keys), and this codebase already has an established, tested policy for exactly that ambiguity — see hook::git_alias_expansion in hook-utils.sh (used for the inline -c alias.c=... path), which independently collects both spellings into HOOK_GIT_ALIAS_EXPS so the caller re-checks each one:

# git config names are case-insensitive: fold both sides of the exact key match. Keep
# the LAST value WITHIN each spelling separately, never collapsed across the two, so one
# spelling's value cannot mask the other's.
for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do
cv="${HOOK_GIT_CONFIG_VALUES[i]}"
key="${cv%%=*}"
kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}"
if [[ "${key,,}" == "alias.${sub,,}" ]]; then
plain_exp="${cv#*=}"
plain_kind="$kind"
elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then
cmd_exp="${cv#*=}"
cmd_kind="$kind"
fi
done
# Max-danger union: an env spelling in either place is unreadable — value-blind refusal.
[[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2
[[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp")
[[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp")
((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0

That function's own comment states the rationale directly: "Keep the LAST value WITHIN each spelling separately, never collapsed across the two, so one spelling's value cannot mask the other's." The inline path even has dedicated regression tests for this ("MAX-DANGER UNION") in this PR's own test file:

run "commit plain masked by a benign .command (blocked by union)" "git -c alias.c=commit -c alias.c.command=status c -m 'x${NL}y'" 2
run "commit .command masked by a benign plain (blocked by union)" "git -c alias.c=status -c alias.c.command=commit c -m 'x${NL}y'" 2
run "commit plain, benign .command decoy first (blocked by union)" "git -c alias.c.command=status -c alias.c=commit c -m 'x${NL}y'" 2
run "both spellings benign non-commit (allowed)" "git -c alias.c=status -c alias.c.command=log c" 0

The pre-existing docblock immediately above this function (lines 507-511, unchanged by this PR) describes the same intended design for the whole alias-reexpansion path: "every hop re-checks both alias spellings (alias.<sub> and alias.<sub>.command) independently, so a chain where each hop defines both walks 2^depth analysis paths." persisted_alias() as written can never produce that branching — it always resolves to a single merged value.

Concrete bypass: with a persisted config git config alias.c status + git config alias.c.command commit (or the equivalent in .git/config/~/.gitconfig), git c -m 'bypass$'\n'b' would commit through the (unblocked) .command spelling while persisted_alias() only ever reports the benign status value, since it's checked first and is non-empty. This mirrors the inline "commit .command masked by a benign plain" case the test suite already guards against — just not for the persisted-config path.

Suggest resolving both keys unconditionally (a single git config --get-regexp '^alias\.'"$sub"'(\.command)?$' fork could get both without doubling the cost) and returning both values (e.g. an array, mirroring HOOK_GIT_ALIAS_EXPS) so check_segment's persisted-alias branch loops over each present spelling independently, matching the inline branch's existing loop at line 667.

Fix this →

Comment on lines +445 to +462
# --- persisted alias.<sub>.command when alias.<sub> is empty (#1022) ----------
PCMD="$TEST_TMPDIR/persisted-command-subkey"
mkdir -p "$PCMD"
(
cd "$PCMD" || exit 1
git init -q .
git config user.email t@e.st
git config user.name t
git config alias.c.command commit
) >/dev/null 2>&1

if [[ -d "$PCMD/.git" ]]; then
MSYS_NO_PATHCONV=1 jq -n --arg c "git c -m \$'bypass\nb'" --arg d "$PCMD" \
'{tool_name:"Bash",tool_input:{command:$c},cwd:$d}' |
bash "$HOOK" >/dev/null 2>&1
assert_exit "persisted alias.<sub>.command only: git c -m" 2 $?
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Suggestion — test coverage: missing the "max-danger union" cases for the persisted-alias path.

This only tests the reported scenario (plain empty, .command set). The inline -c path has dedicated "union" tests a few hundred lines up (commit plain masked by a benign .command, commit .command masked by a benign plain, both spellings benign non-commit) — see lines 316-319. Given the correctness concern raised on persisted_alias() above, this suite should gain the persisted-config equivalents, e.g.:

git config alias.c status
git config alias.c.command commit
# git c -m '...\n...' should still be BLOCKED (currently would be allowed)

and the reverse ordering / both-benign case, mirroring lines 316-319.

@github-actions

Copy link
Copy Markdown

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Closing as duplicate of #2456, which includes version bump, CHANGELOG, broader persisted_alias_expansions refactor, and additional regression coverage for #1022.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

guardrails: block-noncanonical-commit file-based alias resolution misses alias.<name>.command subkey (git config --get returns empty)

2 participants