From dc9748b0314d59cbff198f5cf49b6ec8caa85eaf Mon Sep 17 00:00:00 2001 From: znerol2 <6800371+znerol2@users.noreply.github.com> Date: Sat, 7 Nov 2026 06:26:46 +0100 Subject: [PATCH] ci(guard): refuse agent session links in commits and PR descriptions The commitlint step now scans the full message of every commit, merge commits included, and fails when one links to an agent session. The issue-link step checks the PR description for the same link before the no-issue exit, and `edited` re-runs it after a description edit. Failures name the commit by its short SHA and the rule, never the message text, so a public log cannot repeat the link. The match is case-insensitive. The commits API lists at most 250 commits, so the commitlint and DCO steps now fail closed when they checked fewer commits than the PR holds, or when the PR's commit count is missing or not a number. Refs dmfdeploy/dmfdeploy#838 Signed-off-by: znerol2 <6800371+znerol2@users.noreply.github.com> --- .github/workflows/dco.yml | 12 ++++++++++++ .github/workflows/guard.yml | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml index a0ca3a0..f60e3b5 100644 --- a/.github/workflows/dco.yml +++ b/.github/workflows/dco.yml @@ -13,9 +13,14 @@ jobs: env: GH_TOKEN: ${{ github.token }} COMMITS_URL: ${{ github.event.pull_request.commits_url }} + PR_COMMITS: ${{ github.event.pull_request.commits }} run: | set -eu + case "$PR_COMMITS" in + ''|*[!0-9]*) echo "::error::the PR commit count is missing or not a number; cannot verify every commit"; exit 1;; + esac missing=0 + checked=0 # Page through the PR commits and check each message for the trailer. page=1 while :; do @@ -26,6 +31,7 @@ jobs: msg="$(printf '%s' "$row" | base64 -d | \ python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["commit"]["message"]);print(d["sha"])')" sha="$(printf '%s\n' "$msg" | tail -n1)" + checked=$((checked+1)) body="$(printf '%s\n' "$msg" | sed '$d')" if ! printf '%s\n' "$body" | grep -qiE '^Signed-off-by: .+ <.+@.+>'; then echo "::error::commit ${sha} missing Signed-off-by trailer (run: git commit -s)" @@ -34,4 +40,10 @@ jobs: done page=$((page+1)) done + # The commits API lists at most 250 commits. Fail closed when it returned fewer + # than the PR holds, so an unchecked commit cannot pass. + if [ "$checked" -ne "$PR_COMMITS" ]; then + echo "::error::checked ${checked} of ${PR_COMMITS} PR commits; the commits API cannot list them all. Split the PR." + missing=1 + fi [ "$missing" -eq 0 ] || exit 1 diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml index e107e1e..a49e5d3 100644 --- a/.github/workflows/guard.yml +++ b/.github/workflows/guard.yml @@ -2,6 +2,8 @@ name: guard on: pull_request: branches: [main] + # edited: a description edit re-runs the issue-link check (#838). + types: [opened, synchronize, reopened, edited] push: branches: [main] permissions: @@ -56,24 +58,47 @@ jobs: env: GH_TOKEN: ${{ github.token }} COMMITS_URL: ${{ github.event.pull_request.commits_url }} + PR_COMMITS: ${{ github.event.pull_request.commits }} run: | set -eu + case "$PR_COMMITS" in + ''|*[!0-9]*) echo "::error::the PR commit count is missing or not a number; cannot verify every commit"; exit 1;; + esac bad=0 + checked=0 re='^(feat|fix|docs|chore|refactor|test|build|ci|perf|revert|style)(\([a-z0-9._-]+\))?!?: .+' page=1 while :; do batch="$(gh api "${COMMITS_URL}?per_page=100&page=${page}" --jq '.[] | @base64')" [ -z "$batch" ] && break for row in $batch; do - subj="$(printf '%s' "$row" | base64 -d | python3 -c 'import sys,json;print(json.load(sys.stdin)["commit"]["message"].splitlines()[0])')" + msg="$(printf '%s' "$row" | base64 -d | python3 -c 'import sys,json;print(json.load(sys.stdin)["commit"]["message"])')" + sha="$(printf '%s' "$row" | base64 -d | python3 -c 'import sys,json;print(json.load(sys.stdin)["sha"][:7])')" + subj="$(printf '%s\n' "$msg" | head -n 1)" + checked=$((checked+1)) + # Any commit, merge commits included, may carry a session link (#838). + # Name the commit by SHA only: the log is public, so it must not echo + # a message that holds the link. The match is case-insensitive because + # hostnames are; it targets accidental links, not deliberate obfuscation. + if printf '%s\n' "$msg" | grep -qiE 'claude\.ai/code/session'; then + echo "::error::commit ${sha} links to an agent session; remove the link from its message" + bad=1 + continue + fi case "$subj" in "Merge "*) continue;; esac if ! printf '%s' "$subj" | grep -qE "$re"; then - echo "::error::non-conventional commit subject: $subj" + echo "::error::commit ${sha} has a non-conventional subject; use type(scope): summary" bad=1 fi done page=$((page+1)) done + # The commits API lists at most 250 commits. Fail closed when it returned fewer + # than the PR holds, so an unchecked commit cannot pass. + if [ "$checked" -ne "$PR_COMMITS" ]; then + echo "::error::checked ${checked} of ${PR_COMMITS} PR commits; the commits API cannot list them all. Split the PR." + bad=1 + fi [ "$bad" -eq 0 ] || exit 1 trivy: @@ -127,6 +152,14 @@ jobs: PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }} run: | set -eu + # A link to a private agent session does not belong in a public PR (#838). + # This runs before the no-issue exit, so the label cannot skip it. The match + # is case-insensitive because hostnames are; it targets accidental links, + # not deliberate obfuscation. + if printf '%s' "${PR_BODY}" | grep -qiE 'claude\.ai/code/session'; then + echo "FAIL: PR description links to an agent session; remove the link." >&2 + exit 1 + fi case " ${PR_LABELS} " in *" no-issue "*) echo "OK: no-issue label set"; exit 0 ;; esac