feat(guard): scan PR/issue/comment bodies, not just the published tree - #33
feat(guard): scan PR/issue/comment bodies, not just the published tree#33yakimoto wants to merge 1 commit into
Conversation
Vendored verbatim from wave-moq-edge@main, where this is merged and proven live. The actions/checkout pin already in this repo is preserved. Refs wave-av/claude-workstation#481 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_0ac37abf-a142-451b-bff8-4e0be8e7982d) |
📝 WalkthroughWalkthroughThe pull request adds a Bash body-policy scanner, fixture tests, and GitHub Actions integration for scanning pull request, issue, and comment text. The workflow also refines event triggers and concurrency handling for the existing tree guard. ChangesPublic repository body guard
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant GitHub
participant body-guard
participant EventPayloadFile
participant body-policy.sh
GitHub->>body-guard: emit PR, issue, or comment event
body-guard->>EventPayloadFile: validate and write title/body
body-guard->>body-policy.sh: scan materialized text
body-policy.sh-->>body-guard: return policy status
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
Warning Review ran into problems🔥 ProblemsThese MCP integrations need to be re-authenticated in the Integrations settings: Sentry 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 @.github/workflows/public-repo-guard.yml:
- Around line 128-133: Update the body-guard checkout step using
actions/checkout to set persist-credentials to false in its with configuration,
while preserving the existing sparse-checkout settings.
In `@scripts/public-repo-guard/body-policy.sh`:
- Around line 81-85: Remove the stale shellcheck disable comments immediately
above the cf-account-id and abs-user-path checks, since their regexes and
messages no longer reference shell variables or trigger SC2016. Keep both BLOCK
rules and their existing detection patterns and messages unchanged.
- Around line 38-78: Update check() to accept an optional parameter controlling
whether the guard:allow filter applies, defaulting to the current bypass
behavior for judgment-call rules. Pass the override-disabled value when invoking
the hard credential-format rules stripe-live-key, stripe-account, anthropic-key,
github-pat, supabase-pat, aws-akid, and private-key, so guard:allow cannot
suppress those matches; add or update body-policy.test.sh fixtures to verify
this behavior.
- Around line 87-100: Update the internal-marker regular expression in the BLOCK
check to include left and right smart single quotes in both lookaround
quote-exemption classes, while preserving the existing straight, smart double,
and backtick quote handling.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 0b59fb84-9d8c-45a5-806d-d3ea14e3025b
📒 Files selected for processing (3)
.github/workflows/public-repo-guard.ymlscripts/public-repo-guard/body-policy.shscripts/public-repo-guard/tests/body-policy.test.sh
📜 Review details
🧰 Additional context used
🪛 Shellcheck (0.11.0)
scripts/public-repo-guard/tests/body-policy.test.sh
[info] 83-83: Expressions don't expand in single quotes, use double quotes for that.
(SC2016)
🪛 zizmor (1.26.1)
.github/workflows/public-repo-guard.yml
[warning] 128-133: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
🔍 Remote MCP Linear
Additional context for PR review
- I found no exact Linear matches for
public-repo-guard,body-policy.sh, orbody-guardin issues or documents. - The closest related tracked item is WAVE-3524 — “Phase 7: GitHub Actions modernization”; it is a canceled backlog issue created 2026-01-02 and canceled 2026-07-03, with no attachments, documents, or relations listed.
- Other nearby workflow/security-hardening issues are:
- WAVE-3194 — “Create smart-enforcement.sh workflow” (canceled backlog)
- WAVE-3212 — “Add missing GitHub secrets from .env.local” (canceled backlog)
- WAVE-3523 — “Phase 5.2: Pin third-party actions to SHA” (canceled backlog)
- These records suggest the PR fits into a broader GitHub Actions / workflow-security effort, but I did not find any Linear item with details specific to this exact PR.
🔇 Additional comments (3)
scripts/public-repo-guard/body-policy.sh (1)
1-36: LGTM!Also applies to: 102-140
scripts/public-repo-guard/tests/body-policy.test.sh (1)
1-109: LGTM!.github/workflows/public-repo-guard.yml (1)
16-20: LGTM!Also applies to: 29-63, 95-127, 135-163
| - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | ||
| with: | ||
| # Only the gate's own scripts are needed — no reason to pay for the whole | ||
| # tree on every comment. | ||
| sparse-checkout: scripts/public-repo-guard | ||
| sparse-checkout-cone-mode: false |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win
Security Misconfiguration (CWE-522): Insufficiently Protected Credentials
Reachability: Internal
Set persist-credentials: false on the body-guard checkout.
This checkout doesn't need to push anything, but by default actions/checkout persists the job's token in .git/config, making it readable by any later step (or a compromised dependency) in this job. Since permissions: contents: read already limits scope, this is defense-in-depth, but it's a one-line, zero-downside hardening on a job that scans untrusted text.
🔒 Suggested fix
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
+ persist-credentials: false
sparse-checkout: scripts/public-repo-guard
sparse-checkout-cone-mode: false📝 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.
| - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| # Only the gate's own scripts are needed — no reason to pay for the whole | |
| # tree on every comment. | |
| sparse-checkout: scripts/public-repo-guard | |
| sparse-checkout-cone-mode: false | |
| - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| # Only the gate's own scripts are needed — no reason to pay for the whole | |
| # tree on every comment. | |
| sparse-checkout: scripts/public-repo-guard | |
| sparse-checkout-cone-mode: false |
🧰 Tools
🪛 zizmor (1.26.1)
[warning] 128-133: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
🤖 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 @.github/workflows/public-repo-guard.yml around lines 128 - 133, Update the
body-guard checkout step using actions/checkout to set persist-credentials to
false in its with configuration, while preserving the existing sparse-checkout
settings.
Source: Linters/SAST tools
| check() { | ||
| local sev="$1" name="$2" re="$3" why="$4" | ||
| [[ -z "$re" ]] && { echo "::error::body-policy: internal bug — empty regex for rule '$name'"; exit 2; } | ||
| # rg exit: 0=match, 1=no match, >=2=real error → FAIL CLOSED. A gate that passes | ||
| # because its scanner broke is worse than no gate: it reports success. | ||
| local raw rc | ||
| raw="$(rg -nP --no-filename -- "$re" "$FILE" 2>/dev/null)"; rc=$? | ||
| if (( rc >= 2 )); then | ||
| echo "::error title=public-repo-guard ($name)::ripgrep failed (exit $rc) scanning rule '$name' — failing closed." | ||
| exit 2 | ||
| fi | ||
| # Filter with rg, not grep: BSD/macOS grep has no -P, so a `grep -P` allowlist | ||
| # silently errors out locally while working on GNU/CI — the gate would then | ||
| # disagree with itself depending on where it ran. rg is already required above. | ||
| local matches | ||
| matches="$(printf '%s' "$raw" \ | ||
| | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]' \ | ||
| | rg -vNiP -- "$ABOUT_THE_CONTROL" || true)" | ||
| [[ -z "$matches" ]] && return 0 | ||
| local count; count="$(printf '%s\n' "$matches" | grep -c '')" | ||
| # Print the LINE NUMBER only — never the matched text. This annotation is itself | ||
| # world-readable, so echoing the hit would re-publish the very thing we caught. | ||
| echo "::group::[$sev] $name — $why" | ||
| printf '%s\n' "$matches" | sed -E 's/^([0-9]+):.*/ line \1: «match redacted — view the body to see it»/' | ||
| echo "::endgroup::" | ||
| if [[ "$sev" == "BLOCK" ]]; then | ||
| echo "::error title=public-repo-guard ($name)::$why — $count occurrence(s) in the title/body. Edit the body to remove it, then re-run." | ||
| VIOLATIONS=$((VIOLATIONS+1)) | ||
| else | ||
| echo "::warning title=public-repo-guard ($name)::$why — $count occurrence(s) (non-blocking; review)." | ||
| fi | ||
| } | ||
|
|
||
| # --- Credential formats — never legitimate in prose -------------------------- | ||
| check BLOCK stripe-live-key '(sk|rk)_live_[A-Za-z0-9]{16,}' 'Live Stripe secret/restricted key' | ||
| check BLOCK stripe-account 'acct_[A-Za-z0-9]{16,}' 'Live Stripe account ID — financial infra, never publish' | ||
| check BLOCK anthropic-key 'sk-ant-(api|admin)[0-9]{2}-[A-Za-z0-9_-]{20,}' 'Real Anthropic API/admin key' | ||
| check BLOCK github-pat 'github_pat_[A-Za-z0-9_]{30,}' 'GitHub fine-grained PAT' | ||
| check BLOCK supabase-pat 'sbp_[a-f0-9]{40}' 'Supabase personal access token' | ||
| check BLOCK aws-akid 'AKIA[0-9A-Z]{16}' 'AWS access key ID' | ||
| check BLOCK private-key '-----BEGIN [A-Z ]*PRIVATE KEY-----' 'Embedded private key material' |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Other (CWE-807)
Reachability: External
guard:allow bypasses hard credential-format BLOCKs too — not just the judgment-call rules.
The guard:allow <reason> filter at Line 54 runs unconditionally inside check(), before any rule-specific logic, so it exempts a whole line from every rule that scans it — including the format-specific credential checks at Lines 72-78 (stripe-live-key, stripe-account, anthropic-key, github-pat, supabase-pat, aws-akid, private-key). A line like sk_live_XXXXXXXXXXXXXXXX guard:allow debug fully defeats the live-Stripe-key block.
The header (Lines 20-22) and the internal-marker section (Lines 97-99) explicitly justify this tradeoff for context-dependent judgment calls ("a deliberate one is visible in a public diff"), but that rationale doesn't hold for hard credential formats: there is no legitimate reason a real live key/PAT/private-key match should ever be allowed through with a comment, and unlike the internal-marker case, exposure already happens the instant the text is posted regardless of whether the check blocks the PR. This scenario also isn't covered by any fixture in body-policy.test.sh.
🔒 Suggested fix: opt specific rules out of the guard:allow filter
-check() {
- local sev="$1" name="$2" re="$3" why="$4"
+check() {
+ local sev="$1" name="$2" re="$3" why="$4" allow_override="${5:-yes}"
...
local matches
- matches="$(printf '%s' "$raw" \
- | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]' \
- | rg -vNiP -- "$ABOUT_THE_CONTROL" || true)"
+ matches="$raw"
+ if [[ "$allow_override" == "yes" ]]; then
+ matches="$(printf '%s' "$matches" | rg -vN -- 'guard:allow[[:space:]]+[^[:space:]]' || true)"
+ fi
+ matches="$(printf '%s' "$matches" | rg -vNiP -- "$ABOUT_THE_CONTROL" || true)"Then call the hard-format rules with the override disabled, e.g. check BLOCK stripe-live-key '...' '...' no.
🤖 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 `@scripts/public-repo-guard/body-policy.sh` around lines 38 - 78, Update
check() to accept an optional parameter controlling whether the guard:allow
filter applies, defaulting to the current bypass behavior for judgment-call
rules. Pass the override-disabled value when invoking the hard credential-format
rules stripe-live-key, stripe-account, anthropic-key, github-pat, supabase-pat,
aws-akid, and private-key, so guard:allow cannot suppress those matches; add or
update body-policy.test.sh fixtures to verify this behavior.
| # shellcheck disable=SC2016 # $CLOUDFLARE_ACCOUNT_ID is literal guidance text | ||
| check BLOCK cf-account-id 'account_id\s*[:=]\s*["'"'"']?[0-9a-f]{32}' 'Hardcoded Cloudflare account_id — reference the env var instead' | ||
| check BLOCK internal-ip '100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.[0-9]{1,3}\.[0-9]{1,3}' 'Internal Tailscale-CGNAT IP (100.64.0.0/10) — internal fleet address' | ||
| # shellcheck disable=SC2016 # $HOME is literal guidance text | ||
| check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' 'Operator absolute home path — leaks identity and local layout' |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Stale shellcheck disable=SC2016 comments don't match the code they annotate.
Both disables claim a literal $CLOUDFLARE_ACCOUNT_ID/$HOME reference justifies suppressing SC2016, but neither the regex nor the "why" text on Lines 82/85 actually contains those variable names — nothing here would trigger SC2016 in the first place. This looks like the "why" messages once told the operator which env var to use and that guidance was dropped, leaving a dangling disable comment and a less actionable annotation ("reference the env var instead" without naming it).
✏️ Suggested fix
-# shellcheck disable=SC2016 # $CLOUDFLARE_ACCOUNT_ID is literal guidance text
check BLOCK cf-account-id 'account_id\s*[:=]\s*["'"'"']?[0-9a-f]{32}' 'Hardcoded Cloudflare account_id — reference the env var instead'-# shellcheck disable=SC2016 # $HOME is literal guidance text
check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' 'Operator absolute home path — leaks identity and local layout'Or, if the intent was to actually name the env var in the message, restore that instead of removing the disable.
📝 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.
| # shellcheck disable=SC2016 # $CLOUDFLARE_ACCOUNT_ID is literal guidance text | |
| check BLOCK cf-account-id 'account_id\s*[:=]\s*["'"'"']?[0-9a-f]{32}' 'Hardcoded Cloudflare account_id — reference the env var instead' | |
| check BLOCK internal-ip '100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.[0-9]{1,3}\.[0-9]{1,3}' 'Internal Tailscale-CGNAT IP (100.64.0.0/10) — internal fleet address' | |
| # shellcheck disable=SC2016 # $HOME is literal guidance text | |
| check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' 'Operator absolute home path — leaks identity and local layout' | |
| check BLOCK cf-account-id 'account_id\s*[:=]\s*["'"'"']?[0-9a-f]{32}' 'Hardcoded Cloudflare account_id — reference the env var instead' | |
| check BLOCK internal-ip '100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.[0-9]{1,3}\.[0-9]{1,3}' 'Internal Tailscale-CGNAT IP (100.64.0.0/10) — internal fleet address' | |
| check BLOCK abs-user-path '/(Users|home)/(?!runner/)[a-z][a-z0-9._-]+/' 'Operator absolute home path — leaks identity and local layout' |
🤖 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 `@scripts/public-repo-guard/body-policy.sh` around lines 81 - 85, Remove the
stale shellcheck disable comments immediately above the cf-account-id and
abs-user-path checks, since their regexes and messages no longer reference shell
variables or trigger SC2016. Keep both BLOCK rules and their existing detection
patterns and messages unchanged.
| # --- Self-identified internal material --------------------------------------- | ||
| # USE vs MENTION. A body that SAYS "internal-only" is leaking; a body that QUOTES | ||
| # the phrase is describing a policy — including this one. The lookarounds exempt a | ||
| # marker wrapped in straight, smart, or backtick quotes. | ||
| # | ||
| # Not hypothetical: the first run of this job failed on its own pull request, | ||
| # because a review bot had edited the PR body to summarize the change and its | ||
| # summary quoted the phrase verbatim. The line-level allowlist could not help — | ||
| # that line named no gate. Only use-vs-mention separates the two. | ||
| # | ||
| # A quoted marker is also a trivial bypass, and that is an accepted trade. The | ||
| # threat here is the ACCIDENTAL paste; a deliberate evader has easier routes, and | ||
| # `guard:allow <reason>` already exists as the honest, visible one. | ||
| check BLOCK internal-marker '(?<![“"'"'"'`])\b(internal[- ]only|do\s+not\s+(share|publish|distribute)|for\s+internal\s+use)\b(?![”"'"'"'`])' 'Text self-identifies as not-for-public' |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Extract only the internal-marker check line and replay it through a stub
# check() to see the literal regex bash produces (no repo file is executed).
LN=$(grep -n "check BLOCK internal-marker" scripts/public-repo-guard/body-policy.sh | cut -d: -f1)
LINE=$(sed -n "${LN}p" scripts/public-repo-guard/body-policy.sh)
echo "extracted line:"; echo "$LINE"
check() { printf 'REGEX=%s\n' "$3" > /tmp/marker_regex.txt; }
eval "$LINE"
echo "---"; cat /tmp/marker_regex.txt
if command -v rg >/dev/null && rg --pcre2-version >/dev/null 2>&1; then
RE=$(sed -n 's/^REGEX=//p' /tmp/marker_regex.txt)
for s in "This is 'internal-only' text." "This is ‘internal-only’ text." "This is internal-only text."; do
printf '%s\n' "$s" > /tmp/t.txt
echo -n "[$s] -> "
rg -nP -- "$RE" /tmp/t.txt || echo "no match (exempt)"
done
fiRepository: wave-av/wave-profiles
Length of output: 654
Add ‘/’ to the quote-exemption class. The pattern already exempts straight single quotes, but ‘internal-only’ still matches. If smart single quotes should count as quoted markers too, include them in the lookaround class.
🤖 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 `@scripts/public-repo-guard/body-policy.sh` around lines 87 - 100, Update the
internal-marker regular expression in the BLOCK check to include left and right
smart single quotes in both lookaround quote-exemption classes, while preserving
the existing straight, smart double, and backtick quote handling.
Brings this repo in line with wave-moq-edge, where this is already merged and running.
public-repo-guardhas always scanned the published working tree. It has never scanned a pull request title or body, an issue, or a comment — and those are just as world-readable. The gap was measured, not theorised: a PR was correctly blocked for naming a private repo in a config file, while that same name, carrying more operational detail with it, sat unchallenged in the PR body and merged.What this adds
A
body-guardjob onpull_request,issues, andissue_comment— including theeditedtypes, because a body can be made to leak long after it is first opened — plus a vendoredbody-policy.shbeside the existingcontent-policy.sh.The body profile is deliberately not the file profile
content-policy.shblocks a bare private-repo name outright, which is right for a checked-in file. Applying that rule to bodies would be unusable: an earlier sweep found 134 legitimate cross-repo references in public issue text, of the ordinary "companion to #260" kind. A gate that fires on all of those gets switched off, and then it protects nothing.So a bare mention stays silent. What fires is a private repo name within ~140 characters of internal operational detail — a screaming-case credential name, a secret-binding verb, a service binding, or a secret count.
Safety properties
pull_request, notpull_request_target— a fork PR gets no write token and no secrets.Honest about the limits
On a pull request this prevents the merge. On an issue or comment the text is public the instant it posts, so this is detection. Only a client-side pre-write hook stops that class before publication.
Verification
21 fixtures, run in CI. The negatives are the load-bearing half — bare cross-references, a credential name with no repo nearby, the CI runner path, and text quoting the policy all stay silent. Proven live in wave-moq-edge on a real
issue_commentevent.Files are copied verbatim from wave-moq-edge@main; the workflow's
actions/checkoutpin is preserved as it was in this repo.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Note
Medium Risk
Changes merge-blocking CI security policy and how PR/issue text is evaluated; design mitigates fork PR token exposure and re-leak via annotations, but false positives or bypasses on body text could block merges or miss leaks.
Overview
Extends
public-repo-guardso world-readable PR/issue/comment text is gated, not only the checked-in tree. A newbody-guardjob runs onpull_request,issues, andissue_comment(includingedited), materializes title/body from the event payload into a temp file, and runs vendoredbody-policy.sh.The existing
guardtree job is narrowed: it skips issue/comment events and PRedited(body edits do not change the tree). Concurrency is per job—tree scans can cancel in progress; body scans do not, so rapid edits do not leave stale failed checks on the commit.body-policy.shmirrors many file-tree rules (credentials, Cloudflareaccount_id, Tailscale IPs, operator paths, internal markers) but does not block bare private-repo names in prose. It only blocks a configured private repo when it appears within ~140 characters of operational detail (credential names, secret binding language, service bindings, secret counts), withguard:allowand “about the control” allowlists; hit annotations redact matched text.The
guardjob also runsbody-policy.test.shfixture self-tests in CI.Reviewed by Cursor Bugbot for commit 0178ffe. Configure here.
Summary by cubic
Extend
public-repo-guardto scan PR titles/bodies, issues, and comments, not just the working tree. Blocks merges on PRs with leaked internal info; detects issue/comment leaks for fast redaction.New Features
body-guardjob forpull_request(opened/edited/reopened/synchronize),issues(opened/edited), andissue_comment(created/edited), usingscripts/public-repo-guard/body-policy.sh.GUARD_PRIVATE_REPOSappears within ~140 chars of operational detail (SECRET/TOKEN names,wrangler secretbinding, service binding, secret counts).account_id, Tailscale CGNAT IPs, operator home paths; unquoted “internal-only” markers.pull_request(notpull_request_target).edited; body job is keyed per PR/issue/comment with cancel-in-progress=false. Adds fixture tests viascripts/public-repo-guard/tests/body-policy.test.sh. Installsripgrep; keepsgitleaksandactions/checkoutpins.Migration
GUARD_PRIVATE_REPOSwith a comma-separated list of private repo names..github/workflows/public-repo-guard.yml,.gitleaks.toml,scripts/public-repo-guard/content-policy.sh,scripts/public-repo-guard/body-policy.sh.Written for commit 0178ffe. Summary will update on new commits.