Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
78d149d
fix(security): neutralise workflow-command markers in logged project …
don-petry Oct 28, 2026
c01daa9
fix(security): only count same-repo agent PRs as dev-lead activity (#…
don-petry Oct 28, 2026
e1c997f
fix(security): shell-quote the base branch in auto-rebase recovery co…
don-petry Oct 28, 2026
29d9d94
fix(security): run the AgentShield scanner's npm outside the PR check…
don-petry Oct 28, 2026
7583771
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 28, 2026
404afb2
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 28, 2026
44a100a
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 28, 2026
72f0502
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 28, 2026
e2462a7
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 28, 2026
ab9dc15
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 28, 2026
dfb98a9
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 28, 2026
d890e20
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 29, 2026
84e2aae
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 29, 2026
bb22a80
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 29, 2026
d5a5190
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 30, 2026
b7ac1c0
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 30, 2026
080a3aa
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 31, 2026
4176f33
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Oct 31, 2026
3b3b138
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Nov 1, 2026
2b90ca8
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Nov 1, 2026
f4aa3cb
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Nov 2, 2026
b0498c9
Merge branch 'main' into fix/sec-workflow-hardening-batch
don-petry Nov 2, 2026
674f483
fix(security): compare dev-lead logins case-insensitively (#1391)
don-petry Nov 2, 2026
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
14 changes: 9 additions & 5 deletions .github/scripts/add-to-project/classify-initiative.sh
Original file line number Diff line number Diff line change
Expand Up @@ -276,16 +276,20 @@ sweep_project() {
decided=$(decide_for_signature "${sig}")
init="${decided%%$'\t'*}"
theme="${decided#*$'\t'}"
# Titles are contributor-controlled: neutralise runner command markers
# before any log line below embeds them (#1384).
local log_title
log_title=$(atp_log_safe "${title}")

if [ -z "${init}" ]; then
unmatched=$((unmatched + 1))
printf 'UNMATCHED %-20s «%s»\n' "${repo:-draft}" "${title}"
printf 'UNMATCHED %-20s «%s»\n' "${repo:-draft}" "${log_title}"
continue
fi

optid="${CI_INIT_OPT[${init}]:-}"
if [ -z "${optid}" ]; then
printf '::warning::rule matched Initiative %q which is not a live project option; skipping «%s»\n' "${init}" "${title}" >&2
printf '::warning::rule matched Initiative %q which is not a live project option; skipping «%s»\n' "${init}" "${log_title}" >&2
skipped=$((skipped + 1))
continue
fi
Expand All @@ -296,11 +300,11 @@ sweep_project() {
# (this is a fill-blanks-only sweep, so retries are idempotent). Mirrors
# reconcile-backlog.sh's per-item error isolation.
if ! set_item_single_select_value "${item_id}" "${CI_INIT_FIELD_ID}" "${optid}"; then
printf '::warning::failed to set Initiative %q on «%s» (transient?); will retry next sweep\n' "${init}" "${title}" >&2
printf '::warning::failed to set Initiative %q on «%s» (transient?); will retry next sweep\n' "${init}" "${log_title}" >&2
failed=$((failed + 1))
continue
fi
printf 'MATCH %-22s <- %-20s «%s»\n' "${init}" "${repo:-draft}" "${title}"
printf 'MATCH %-22s <- %-20s «%s»\n' "${init}" "${repo:-draft}" "${log_title}"
matched=$((matched + 1))

# Theme is best-effort: co-assign only when the field and matching option
Expand All @@ -309,7 +313,7 @@ sweep_project() {
if [ -n "${theme}" ] && [ -n "${CI_THEME_FIELD_ID}" ]; then
local topt="${CI_THEME_OPT[${theme}]:-}"
if [ -n "${topt}" ] && ! set_item_single_select_value "${item_id}" "${CI_THEME_FIELD_ID}" "${topt}"; then
printf '::warning::failed to set Theme %q on «%s» (transient?); retry requires RECLASSIFY=all (Initiative already set)\n' "${theme}" "${title}" >&2
printf '::warning::failed to set Theme %q on «%s» (transient?); retry requires RECLASSIFY=all (Initiative already set)\n' "${theme}" "${log_title}" >&2
fi
fi
done < <(printf '%s' "${items}" | jq -c '.[]')
Expand Down
22 changes: 21 additions & 1 deletion .github/scripts/add-to-project/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ _atp_require_env() {
fi
}

# atp_log_safe <value> — print an untrusted value (an issue / PR / draft title)
# so it is safe to embed in a runner log line (combsmith/.github#1384). The
# Actions runner treats `::cmd::` at the start of a line, and the legacy
# `##[cmd]` marker anywhere in a line, as workflow commands, so a raw title
# could forge annotations or `stop-commands` the rest of the step. CR/LF become
# spaces (no new line can start inside the value) and each `::` / `##[` gets a
# zero-width space (U+200B) between its characters, which keeps the title
# readable in the log while no longer matching either command syntax.
atp_log_safe() {
local v="${1-}" zwsp=$'\xe2\x80\x8b'
v="${v//$'\r'/ }"
v="${v//$'\n'/ }"
v="${v//'##['/#${zwsp}#[}"
# Loop: one pass over ':::' leaves '::' at the tail.
while [[ "${v}" == *'::'* ]]; do
v="${v//'::'/:${zwsp}:}"
done
printf '%s' "${v}"
}

# Prefetched board-membership cache (optional; batch reconcile only).
# reconcile-backlog.sh scans the whole fleet's open issues/PRs and would
# otherwise hit the API once PER item on both hot paths — a redundant
Expand Down Expand Up @@ -241,7 +261,7 @@ add_draft_item() {
local body="$2"

if [ "${DRY_RUN:-}" = "1" ]; then
printf '[dry-run] would add draft: %s\n' "${title}"
printf '[dry-run] would add draft: %s\n' "$(atp_log_safe "${title}")"
return 0
fi

Expand Down
1 change: 1 addition & 0 deletions .github/scripts/auto-rebase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Source the file, then call:
| Function | Input | Returns |
|----------|-------|---------|
| `auto_rebase_post_comment_best_effort PR_NUMBER REPO BODY` | PR number, `owner/repo`, comment body | always `0` — posts the comment; on failure logs a `::warning::` and swallows the error |
| `auto_rebase_shell_quote VALUE` | any string (e.g. `origin/<base>`) | prints VALUE as one POSIX-shell word: unchanged if only `[A-Za-z0-9_./+-]`, else single-quoted (`'` → `'\''`). Used for the copy-paste `git rebase` / `git merge` commands in comments (issue #1396) |

### Why best-effort (issue #594)

Expand Down
17 changes: 17 additions & 0 deletions .github/scripts/auto-rebase/lib/comments.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,20 @@ auto_rebase_post_comment_best_effort() {
|| echo "::warning::could not post conflict comment on #$pr_number (comment cap / API error) — continuing" >&2
return 0
}

# auto_rebase_shell_quote VALUE
# Prints VALUE as a single POSIX-shell word, for pasting into the manual
# recovery commands the comments publish (`git rebase <ref>`, issue #1396).
# A git branch name may legally contain $, `, ;, |, &, (, ), quotes, so a raw
# base branch could run extra commands when a maintainer copies the snippet.
# Values made only of [A-Za-z0-9_./+-] are printed unchanged so ordinary refs
# stay readable (`origin/main`); anything else is single-quoted, with each
# embedded ' written as '\'' . Works in bash, zsh and sh alike (unlike %q).
auto_rebase_shell_quote() {
local v="$1"
if [[ -n "$v" && "$v" != *[!A-Za-z0-9_./+-]* ]]; then
printf '%s' "$v"
else
printf "'%s'" "${v//\'/\'\\\'\'}"
fi
}
15 changes: 13 additions & 2 deletions .github/workflows/agent-shield-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,24 @@ jobs:
# --- Deep security scan via AgentShield CLI ---
# Uses ecc-agentshield (https://github.com/affaan-m/agentshield)
# 102 rules: secrets, permissions, hooks, MCP servers, agent config
#
# npm reads "project" config from the .npmrc of its local prefix (cwd), so
# running npx inside the PR checkout would let the scanned PR's own .npmrc
# (script-shell, registry, ...) control the scanner (#1405). Run npm from
# an empty prefix under $RUNNER_TEMP instead and pass the checkout as the
# scan target.
- name: AgentShield Security Scan
env:
AS_VERSION: ${{ inputs.agentshield-version }}
AS_SEVERITY: ${{ inputs.min-severity }}
run: |
npx --ignore-scripts "ecc-agentshield@${AS_VERSION}" scan \
--path . \
scan_target="$PWD"
npm_prefix="$RUNNER_TEMP/agentshield-npm"
mkdir -p "$npm_prefix"
printf '{"private":true}\n' > "$npm_prefix/package.json"
cd "$npm_prefix"
npx --prefix="$npm_prefix" --ignore-scripts "ecc-agentshield@${AS_VERSION}" scan \
--path "$scan_target" \
--min-severity "${AS_SEVERITY}" \
--format terminal

Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/auto-rebase-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ jobs:
echo " Branch updated"
else
echo " Update failed: $UPDATE_OUTPUT"
# The base ref goes into copy-paste shell commands below; a branch
# name can contain shell syntax, so quote it (#1396).
MANUAL_BASE_REF=$(auto_rebase_shell_quote "origin/$BASE_BRANCH")

if echo "$UPDATE_OUTPUT" | grep -qF "without \`workflows\` permission"; then
# GitHub blocks update-branch when merging the base would add
Expand All @@ -215,7 +218,7 @@ jobs:
BLOCKED_BODY+=" but the auto-rebase workflow's token does not have that permission."
BLOCKED_BODY+=$'\n\n'"Please rebase this branch manually:"
BLOCKED_BODY+=$'\n'"\`\`\`"$'\n'"git fetch origin"
BLOCKED_BODY+=$'\n'"git rebase origin/$BASE_BRANCH"
BLOCKED_BODY+=$'\n'"git rebase $MANUAL_BASE_REF"
BLOCKED_BODY+=$'\n'"git push --force-with-lease"$'\n'"\`\`\`"
auto_rebase_post_comment_best_effort "$PR_NUMBER" "$REPO" "$BLOCKED_BODY"
fi
Expand Down Expand Up @@ -255,7 +258,7 @@ jobs:
CONFLICT_BODY+=" (automatic resolution requires \`GH_PAT_WORKFLOWS\` to be configured):"
fi
CONFLICT_BODY+=$'\n'"\`\`\`"$'\n'"git fetch origin"
CONFLICT_BODY+=$'\n'"git merge origin/$BASE_BRANCH"
CONFLICT_BODY+=$'\n'"git merge $MANUAL_BASE_REF"
CONFLICT_BODY+=$'\n'"# resolve conflicts, then:"
CONFLICT_BODY+=$'\n'"git add ."$'\n'"git commit"$'\n'"git push"$'\n'"\`\`\`"
# Best-effort: a comment-side failure (e.g. GitHub's 2500-comment
Expand Down
18 changes: 15 additions & 3 deletions scripts/lib/dev-lead-retrigger.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,34 @@

# dl_dev_lead_active <org> <repo> <issue>
# Returns 0 (true) if dev-lead is ALREADY working this issue — either an open PR
# exists on a dev-lead/issue-<number>-* branch, or the issue carries the
# exists on a dev-lead/issue-<number>-* branch of this repo (not a fork) authored
# by the dev-lead identity (DEV_LEAD_LOGINS, default don-petry), or the issue carries the
# `in-progress` label. In that case the caller must NOT cycle the trigger label,
# to avoid interrupting or duplicating active work.
dl_dev_lead_active() {
local org="$1" repo="$2" issue="$3"

# Match the dev-lead branch exactly: "dev-lead/issue-<n>-<timestamp>".
# Require the trailing "-" so issue 1 does not match branch "issue-12-...".
# The branch name alone is NOT proof (#1391): the open-PR list includes fork
# PRs, whose head branch name the contributor picks. Only count a PR whose head
# lives in this repository (not a fork, not a deleted head repo) and whose
# author is the dev-lead agent's runtime identity (persona-standards.md §5.1:
# dev-lead acts as `don-petry`; override with DEV_LEAD_LOGINS, comma-separated).
# GitHub logins are case-insensitive, so both sides compare in lower case.
# The logins are JSON-encoded by jq, never spliced raw into the filter.
# Use --paginate so repos with >100 open PRs are fully scanned.
# Fail closed: if the API call itself fails (transient error, rate limit, token
# scope issue), treat the issue as active so we do not cycle the label and
# accidentally create duplicate work.
local pr_raw pr_count
local pr_raw pr_count logins_json
logins_json=$(printf '%s' "${DEV_LEAD_LOGINS:-don-petry}" \
| jq -Rc 'split(",") | map(gsub("^\\s+|\\s+$"; "") | ascii_downcase) | map(select(length > 0))')
if ! pr_raw=$(gh api "repos/$org/$repo/pulls?state=open&per_page=100" \
--paginate \
--jq "[.[] | select(.head.ref | type == \"string\" and startswith(\"dev-lead/issue-${issue}-\"))] | length" \
--jq "[.[] | select(.head.ref | type == \"string\" and startswith(\"dev-lead/issue-${issue}-\"))
| select(.head.repo != null and .head.repo.full_name == .base.repo.full_name)
| select((.user.login // \"\" | ascii_downcase) as \$l | ${logins_json} | any(.[]; . == \$l))] | length" \
2>/dev/null); then
echo "[warn] PR lookup for $org/$repo#$issue failed — treating as active to avoid duplicate work" >&2
return 0
Expand Down
137 changes: 137 additions & 0 deletions test/scripts/compliance-retrigger/dev-lead-active.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env bats
# Tests for dl_dev_lead_active (scripts/lib/dev-lead-retrigger.sh): only a PR
# opened BY the dev-lead agent FROM the repository itself counts as "dev-lead is
# already working this issue" (combsmith/.github#1391).
#
# The open-PR list includes fork PRs, and a fork contributor chooses its head
# branch name freely, so a `dev-lead/issue-<n>-*` branch name alone must not
# suppress the compliance retrigger. The `gh` stub below evaluates the real
# `--jq` filter against a fixture, so these tests exercise the actual selector.

bats_require_minimum_version 1.5.0

setup() {
TT_REPO_ROOT="$(cd -- "$BATS_TEST_DIRNAME/../../.." && pwd)"
PULLS_FIXTURE="${BATS_TEST_TMPDIR}/pulls.json"
GH_FAIL_PULLS=0
unset DEV_LEAD_LOGINS
# shellcheck source=/dev/null
. "${TT_REPO_ROOT}/scripts/lib/dev-lead-retrigger.sh"
}

# gh stub: apply the caller's --jq filter to the pulls fixture (or to an issue
# with no labels), mimicking `gh api --jq`.
gh() {
local path="" filter="" arg prev=""
for arg in "$@"; do
case "$prev" in --jq) filter="$arg" ;; esac
case "$arg" in repos/*) path="$arg" ;; esac
prev="$arg"
done
case "$path" in
*/pulls\?*)
[ "$GH_FAIL_PULLS" -eq 0 ] || return 1
jq "$filter" "$PULLS_FIXTURE"
;;
*/issues/*/events*) printf '' ;;
*/issues/*) printf '{"labels":[]}' | jq -r "$filter" ;;
*) return 1 ;;
esac
}

# pr_json <head_ref> <head_full_name|null> <author_login>
pr_json() {
local head_repo
if [ "$2" = "null" ]; then head_repo=null; else head_repo="{\"full_name\":\"$2\"}"; fi
printf '{"number":1,"head":{"ref":"%s","repo":%s},"base":{"ref":"main","repo":{"full_name":"combsmith/widget"}},"user":{"login":"%s"}}' \
"$1" "$head_repo" "$3"
}

write_pulls() {
local IFS=,
printf '[%s]' "$*" >"$PULLS_FIXTURE"
}

@test "active: same-repo dev-lead branch PR authored by the agent identity" {
write_pulls "$(pr_json dev-lead/issue-7-20261027-1200 combsmith/widget don-petry)"
run dl_dev_lead_active combsmith widget 7
[ "$status" -eq 0 ]
}

@test "inactive: a FORK PR on a dev-lead/issue-<n>- branch does not suppress the retrigger" {
write_pulls "$(pr_json dev-lead/issue-7-20261027-1200 mallory/widget mallory)"
run dl_dev_lead_active combsmith widget 7
[ "$status" -eq 1 ]
}

@test "inactive: a fork PR is rejected even when its author is the agent login" {
write_pulls "$(pr_json dev-lead/issue-7-20261027-1200 don-petry/widget don-petry)"
run dl_dev_lead_active combsmith widget 7
[ "$status" -eq 1 ]
}

@test "inactive: a same-repo dev-lead branch PR by someone other than the agent" {
write_pulls "$(pr_json dev-lead/issue-7-20261027-1200 combsmith/widget some-contributor)"
run dl_dev_lead_active combsmith widget 7
[ "$status" -eq 1 ]
}

@test "inactive: a PR whose head repo was deleted (head.repo null) does not count" {
write_pulls "$(pr_json dev-lead/issue-7-20261027-1200 null don-petry)"
run dl_dev_lead_active combsmith widget 7
[ "$status" -eq 1 ]
}

@test "active: the agent PR is found among fork decoys" {
write_pulls \
"$(pr_json dev-lead/issue-7-20261027-1100 mallory/widget mallory)" \
"$(pr_json dev-lead/issue-7-20261027-1200 combsmith/widget don-petry)"
run dl_dev_lead_active combsmith widget 7
[ "$status" -eq 0 ]
}

@test "issue-number prefix stays exact: issue 1 does not match an issue-12 branch" {
write_pulls "$(pr_json dev-lead/issue-12-20261027-1200 combsmith/widget don-petry)"
run dl_dev_lead_active combsmith widget 1
[ "$status" -eq 1 ]
}

@test "DEV_LEAD_LOGINS overrides the trusted agent identity (comma-separated)" {
write_pulls "$(pr_json dev-lead/issue-7-20261027-1200 combsmith/widget other-agent)"
DEV_LEAD_LOGINS="don-petry, other-agent" run dl_dev_lead_active combsmith widget 7
[ "$status" -eq 0 ]
DEV_LEAD_LOGINS="don-petry" run dl_dev_lead_active combsmith widget 7
[ "$status" -eq 1 ]
}

@test "logins compare case-insensitively: DEV_LEAD_LOGINS casing differs from the API's" {
write_pulls "$(pr_json dev-lead/issue-7-20260101 combsmith/widget don-petry)"
DEV_LEAD_LOGINS="Don-Petry" run dl_dev_lead_active combsmith widget 7
[ "$status" -eq 0 ]
}

@test "logins compare case-insensitively: the API's casing differs from the default" {
write_pulls "$(pr_json dev-lead/issue-7-20260101 combsmith/widget Don-Petry)"
run dl_dev_lead_active combsmith widget 7
[ "$status" -eq 0 ]
}

@test "case-insensitive compare still rejects a different login" {
write_pulls "$(pr_json dev-lead/issue-7-20260101 combsmith/widget Don-Petry2)"
run dl_dev_lead_active combsmith widget 7
[ "$status" -ne 0 ]
}

@test "DEV_LEAD_LOGINS values cannot inject into the jq filter" {
write_pulls "$(pr_json dev-lead/issue-7-20261027-1200 combsmith/widget mallory)"
DEV_LEAD_LOGINS='x"] or true or ["' run dl_dev_lead_active combsmith widget 7
[ "$status" -eq 1 ]
}

@test "fail-closed preserved: a failing PR lookup still reports active" {
write_pulls
GH_FAIL_PULLS=1
run dl_dev_lead_active combsmith widget 7
[ "$status" -eq 0 ]
[[ "$output" == *"treating as active"* ]]
}
Loading
Loading