chore(pwg): fail-closed persona-write-guard hook (#813) - #34
Conversation
Adds the Persona-Write-Guard PreToolUse hook: default-denies git push / gh pr create / gh pr merge unless the session is on a sanctioned <persona>/… branch (AD-22). Generated + synced from dev-environment lib/pwg.sh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThe Bash pre-tool hook in ChangesPWG guard update
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.claude/settings.json:
- Line 9: Update the command guard’s checkout/switch target extraction in the
`ct`/`n` logic to recognize every branch-changing form, including checkout or
switch with an explicit branch target and long create options such as
`--create`, not only `-[bBc]`. Use the parsed target when validating the branch
before push or PR operations, and retain the existing ambiguous-target rejection
when multiple branch targets are detected.
- Line 9: The branch-target extraction in the command guard must not derive
checkout targets from arbitrary command text. Replace the text-wide
checkout/switch regex used to populate ct in the command hook with structural
parsing of actual git invocations, or fail closed when parsing is ambiguous;
ensure strings such as printf arguments cannot authorize a subsequent git push.
- Line 9: The command guard’s repository detection in the “command” setting only
captures the final git -C target, allowing earlier unauthorized pushes in
compound commands. Update the guard to detect all git -C targets and either
evaluate each write operation independently or reject commands containing
multiple repository targets before branch validation; preserve existing handling
for single targets and current-directory commands.
- Line 9: Update the `git -C` target resolution and branch-selection logic in
the command hook so an unexpanded or otherwise unresolved target exits nonzero
instead of setting `u=1`, warning, and falling back to the current directory’s
branch. Preserve normal branch validation for resolvable targets, but fail
closed before the sanctioned-branch check when the repository cannot be
identified.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
| { | ||
| "type": "command", | ||
| "command": ": pwg-guard-v3; i=$(cat); c=$(printf '%s' \"$i\" | jq -r '.tool_input.command // empty' 2>/dev/null); [ -z \"$c\" ] && c=$i; printf '%s' \"$c\" | grep -Eq '(^|[;&|])[[:space:]]*(git([[:space:]]+(-[Cc][[:space:]]+[^[:space:];&|]+|--[A-Za-z][A-Za-z-]*(=[^[:space:];&|]*)?|-[A-Za-z]+))*[[:space:]]+push|gh[[:space:]]+pr[[:space:]]+(create|merge))([[:space:];&|]|$)' || exit 0; d=''; t=$(printf '%s' \"$c\" | sed -n 's/.*git[[:space:]][[:space:]]*-C[[:space:]][[:space:]]*\\([^[:space:];&][^[:space:];&]*\\).*/\\1/p'); [ -n \"$t\" ] && d=$t; if [ -z \"$d\" ]; then p=$(printf '%s' \"$c\" | sed -n 's/^[[:space:]]*cd[[:space:]][[:space:]]*\\([^[:space:];&][^[:space:];&]*\\).*/\\1/p'); [ -n \"$p\" ] && d=$p; fi; case $d in '~') d=$HOME;; '~/'*) d=$HOME/${d#\\~/};; esac; if [ -n \"$d\" ]; then b=$(git -C \"$d\" rev-parse --abbrev-ref HEAD 2>/dev/null); else b=$(git rev-parse --abbrev-ref HEAD 2>/dev/null); fi; printf '%s' \"$b\" | grep -Eqi '^(AGENT-PREAMBLE|bob|dia|dr-sommer|engels|gary|hans-cee|jack|james|lenin|linus|lorne-cee|maschin|obi|rumi|sensei|seo-[a-z-]+|steffi|sven|trillian)/' && exit 0; echo \"Write blocked (PWG/#813): branch '$b' is not a sanctioned <persona>/… branch. A no-persona/cloud session must PROPOSE, not push or open PRs.\" >&2; exit 2", | ||
| "command": ": pwg-guard-v5; i=$(cat); c=$(printf '%s' \"$i\" | jq -r '.tool_input.command // empty' 2>/dev/null); [ -z \"$c\" ] && c=$i; k=$(printf '%s' \"$c\" | awk 'BEGIN{h=0} h==1{if($0 ~ \"^[[:blank:]]*\" d \"[[:blank:]]*$\"){h=0} next} {if(match($0,/<<-?[[:blank:]]*[\\047\\042]?[A-Za-z_][A-Za-z0-9_]*[\\047\\042]?/)){s=substr($0,RSTART,RLENGTH);sub(/^<<-?[[:blank:]]*/,\"\",s);gsub(/[\\047\\042]/,\"\",s);d=s;h=1} print}'); printf '%s' \"$k\" | grep -Eq '(^|[;&|])[[:space:]]*(git([[:space:]]+(-[Cc][[:space:]]+[^[:space:];&|]+|--[A-Za-z][A-Za-z-]*(=[^[:space:];&|]*)?|-[A-Za-z]+))*[[:space:]]+push|gh[[:space:]]+pr[[:space:]]+(create|merge))([[:space:];&|]|$)' || exit 0; ct=$(printf '%s' \"$k\" | grep -Eo '(checkout|switch)[[:space:]]+(-[qf][[:space:]]+)*-[bBc][[:space:]]+[^[:space:];&|]+' | sed -E 's/.*[[:space:]]//' | grep -v '^$' | sort -u); n=$(printf '%s' \"$ct\" | grep -c .); [ \"$n\" -gt 1 ] && { echo \"Write blocked (PWG/#446): ambiguous branch target — this command checks out more than one branch, so the guard cannot tell which one is pushed. Split the calls.\" >&2; exit 2; }; d=''; u=0; t=$(printf '%s' \"$k\" | sed -n 's/.*git[[:space:]][[:space:]]*-C[[:space:]][[:space:]]*\\([^[:space:];&][^[:space:];&]*\\).*/\\1/p'); [ -n \"$t\" ] && d=$t; if [ -z \"$d\" ]; then p=$(printf '%s' \"$k\" | sed -n 's/^[[:space:]]*cd[[:space:]][[:space:]]*\\([^[:space:];&][^[:space:];&]*\\).*/\\1/p'); [ -n \"$p\" ] && d=$p; fi; case $d in *'$'*) u=1; d='';; esac; case $d in '~') d=$HOME;; '~/'*) d=$HOME/${d#\\~/};; esac; if [ \"$n\" -eq 1 ]; then b=$ct; elif [ -n \"$d\" ]; then b=$(git -C \"$d\" rev-parse --abbrev-ref HEAD 2>/dev/null); else b=$(git rev-parse --abbrev-ref HEAD 2>/dev/null); fi; [ \"$u\" -eq 1 ] && echo \"PWG/#446: the -C target is an unexpanded variable and cannot be resolved; judging against the current-directory branch '$b' instead.\" >&2; printf '%s' \"$b\" | grep -Eqi '^(bob|dia|dr-sommer|engels|gary|hans-cee|jack|james|lenin|linus|lorne-cee|maschin|obi|rumi|sensei|seo-[a-z-]+|steffi|sven|trillian)/' && exit 0; echo \"Write blocked (PWG/#813): branch '$b' is not a sanctioned <persona>/… branch. A no-persona/cloud session must PROPOSE, not push or open PRs.\" >&2; exit 2", |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift
Validate every checkout/switch target, not only branch-creation flags.
The ct pattern only recognizes checkout/switch with -[bBc]. From a sanctioned branch, git checkout untrusted && git push or git switch --create untrusted && git push is therefore judged against the pre-command sanctioned branch and allowed. Parse all branch-changing forms and reject ambiguous targets.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.claude/settings.json at line 9, Update the command guard’s checkout/switch
target extraction in the `ct`/`n` logic to recognize every branch-changing form,
including checkout or switch with an explicit branch target and long create
options such as `--create`, not only `-[bBc]`. Use the parsed target when
validating the branch before push or PR operations, and retain the existing
ambiguous-target rejection when multiple branch targets are detected.
🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift
Do not infer checkout targets from arbitrary command text.
The regex scans the entire command rather than actual git invocations. For example, printf 'checkout -b bob/x'; git push produces ct=bob/x and can authorize a push without performing any checkout. Parse command arguments structurally, or fail closed whenever target extraction is ambiguous.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.claude/settings.json at line 9, The branch-target extraction in the command
guard must not derive checkout targets from arbitrary command text. Replace the
text-wide checkout/switch regex used to populate ct in the command hook with
structural parsing of actual git invocations, or fail closed when parsing is
ambiguous; ensure strings such as printf arguments cannot authorize a subsequent
git push.
🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift
Reject compound writes with multiple git -C targets.
The greedy sed expression assigns only the final git -C path. A command such as git -C /untrusted push; git -C /sanctioned push is judged solely against /sanctioned, allowing the unauthorized first write. Validate each write operation independently or block when multiple repository targets are present.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.claude/settings.json at line 9, The command guard’s repository detection in
the “command” setting only captures the final git -C target, allowing earlier
unauthorized pushes in compound commands. Update the guard to detect all git -C
targets and either evaluate each write operation independently or reject
commands containing multiple repository targets before branch validation;
preserve existing handling for single targets and current-directory commands.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
Fail closed when git -C cannot be resolved.
When git -C "$OTHER_REPO" push runs from a sanctioned current directory, u=1 only emits a warning, then the hook judges the current directory’s sanctioned branch and allows the write to an unknown repository. Exit nonzero when the target is unresolved instead of falling back to an unrelated branch.
Proposed fix
- [ "$u" -eq 1 ] && echo "PWG/#446: the -C target is an unexpanded variable and cannot be resolved; judging against the current-directory branch '$b' instead." >&2;
+ [ "$u" -eq 1 ] && { echo "Write blocked (PWG/#446): the -C target is an unexpanded variable and cannot be resolved." >&2; exit 2; };📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "command": ": pwg-guard-v5; i=$(cat); c=$(printf '%s' \"$i\" | jq -r '.tool_input.command // empty' 2>/dev/null); [ -z \"$c\" ] && c=$i; k=$(printf '%s' \"$c\" | awk 'BEGIN{h=0} h==1{if($0 ~ \"^[[:blank:]]*\" d \"[[:blank:]]*$\"){h=0} next} {if(match($0,/<<-?[[:blank:]]*[\\047\\042]?[A-Za-z_][A-Za-z0-9_]*[\\047\\042]?/)){s=substr($0,RSTART,RLENGTH);sub(/^<<-?[[:blank:]]*/,\"\",s);gsub(/[\\047\\042]/,\"\",s);d=s;h=1} print}'); printf '%s' \"$k\" | grep -Eq '(^|[;&|])[[:space:]]*(git([[:space:]]+(-[Cc][[:space:]]+[^[:space:];&|]+|--[A-Za-z][A-Za-z-]*(=[^[:space:];&|]*)?|-[A-Za-z]+))*[[:space:]]+push|gh[[:space:]]+pr[[:space:]]+(create|merge))([[:space:];&|]|$)' || exit 0; ct=$(printf '%s' \"$k\" | grep -Eo '(checkout|switch)[[:space:]]+(-[qf][[:space:]]+)*-[bBc][[:space:]]+[^[:space:];&|]+' | sed -E 's/.*[[:space:]]//' | grep -v '^$' | sort -u); n=$(printf '%s' \"$ct\" | grep -c .); [ \"$n\" -gt 1 ] && { echo \"Write blocked (PWG/#446): ambiguous branch target — this command checks out more than one branch, so the guard cannot tell which one is pushed. Split the calls.\" >&2; exit 2; }; d=''; u=0; t=$(printf '%s' \"$k\" | sed -n 's/.*git[[:space:]][[:space:]]*-C[[:space:]][[:space:]]*\\([^[:space:];&][^[:space:];&]*\\).*/\\1/p'); [ -n \"$t\" ] && d=$t; if [ -z \"$d\" ]; then p=$(printf '%s' \"$k\" | sed -n 's/^[[:space:]]*cd[[:space:]][[:space:]]*\\([^[:space:];&][^[:space:];&]*\\).*/\\1/p'); [ -n \"$p\" ] && d=$p; fi; case $d in *'$'*) u=1; d='';; esac; case $d in '~') d=$HOME;; '~/'*) d=$HOME/${d#\\~/};; esac; if [ \"$n\" -eq 1 ]; then b=$ct; elif [ -n \"$d\" ]; then b=$(git -C \"$d\" rev-parse --abbrev-ref HEAD 2>/dev/null); else b=$(git rev-parse --abbrev-ref HEAD 2>/dev/null); fi; [ \"$u\" -eq 1 ] && echo \"PWG/#446: the -C target is an unexpanded variable and cannot be resolved; judging against the current-directory branch '$b' instead.\" >&2; printf '%s' \"$b\" | grep -Eqi '^(bob|dia|dr-sommer|engels|gary|hans-cee|jack|james|lenin|linus|lorne-cee|maschin|obi|rumi|sensei|seo-[a-z-]+|steffi|sven|trillian)/' && exit 0; echo \"Write blocked (PWG/#813): branch '$b' is not a sanctioned <persona>/… branch. A no-persona/cloud session must PROPOSE, not push or open PRs.\" >&2; exit 2", | |
| "command": ": pwg-guard-v5; i=$(cat); c=$(printf '%s' \"$i\" | jq -r '.tool_input.command // empty' 2>/dev/null); [ -z \"$c\" ] && c=$i; k=$(printf '%s' \"$c\" | awk 'BEGIN{h=0} h==1{if($0 ~ \"^[[:blank:]]*\" d \"[[:blank:]]*$\"){h=0} next} {if(match($0,/<<-?[[:blank:]]*[\\047\\042]?[A-Za-z_][A-Za-z0-9_]*[\\047\\042]?/)){s=substr($0,RSTART,RLENGTH);sub(/^<<-?[[:blank:]]*/,\"\",s);gsub(/[\\047\\042]/,\"\",s);d=s;h=1} print}'); printf '%s' \"$k\" | grep -Eq '(^|[;&|])[[:space:]]*(git([[:space:]]+(-[Cc][[:space:]]+[^[:space:];&|]+|--[A-Za-z][A-Za-z-]*(=[^[:space:];&|]*)?|-[A-Za-z]+))*[[:space:]]+push|gh[[:space:]]+pr[[:space:]]+(create|merge))([[:space:];&|]|$)' || exit 0; ct=$(printf '%s' \"$k\" | grep -Eo '(checkout|switch)[[:space:]]+(-[qf][[:space:]]+)*-[bBc][[:space:]]+[^[:space:];&|]+' | sed -E 's/.*[[:space:]]//' | grep -v '^$' | sort -u); n=$(printf '%s' \"$ct\" | grep -c .); [ \"$n\" -gt 1 ] && { echo \"Write blocked (PWG/#446): ambiguous branch target — this command checks out more than one branch, so the guard cannot tell which one is pushed. Split the calls.\" >&2; exit 2; }; d=''; u=0; t=$(printf '%s' \"$k\" | sed -n 's/.*git[[:space:]][[:space:]]*-C[[:space:]][[:space:]]*\\([^[:space:];&][^[:space:];&]*\\).*/\\1/p'); [ -n \"$t\" ] && d=$t; if [ -z \"$d\" ]; then p=$(printf '%s' \"$k\" | sed -n 's/^[[:space:]]*cd[[:space:]][[:space:]]*\\([^[:space:];&][^[:space:];&]*\\).*/\\1/p'); [ -n \"$p\" ] && d=$p; fi; case $d in *'$'*) u=1; d='';; esac; case $d in '~') d=$HOME;; '~/'*) d=$HOME/${d#\\~/};; esac; if [ \"$n\" -eq 1 ]; then b=$ct; elif [ -n \"$d\" ]; then b=$(git -C \"$d\" rev-parse --abbrev-ref HEAD 2>/dev/null); else b=$(git rev-parse --abbrev-ref HEAD 2>/dev/null); fi; [ \"$u\" -eq 1 ] && { echo \"Write blocked (PWG/#446): the -C target is an unexpanded variable and cannot be resolved.\" >&2; exit 2; }; printf '%s' \"$b\" | grep -Eqi '^(bob|dia|dr-sommer|engels|gary|hans-cee|jack|james|lenin|linus|lorne-cee|maschin|obi|rumi|sensei|seo-[a-z-]+|steffi|sven|trillian)/' && exit 0; echo \"Write blocked (PWG/#813): branch '$b' is not a sanctioned <persona>/… branch. A no-persona/cloud session must PROPOSE, not push or open PRs.\" >&2; exit 2", |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.claude/settings.json at line 9, Update the `git -C` target resolution and
branch-selection logic in the command hook so an unexpanded or otherwise
unresolved target exits nonzero instead of setting `u=1`, warning, and falling
back to the current directory’s branch. Preserve normal branch validation for
resolvable targets, but fail closed before the sanctioned-branch check when the
repository cannot be identified.
Deploys the Persona-Write-Guard (#813 durable fix, dev-environment #67). Default-denies write verbs unless on a sanctioned
<persona>/…branch. Generated from dev-environmentlib/pwg.sh; merge-not-clobber into.claude/settings.json.Summary by CodeRabbit