Skip to content

feat(autofix): replace inline reviewdog with /autofix ChatOps button#1458

Merged
magyargergo merged 8 commits into
mainfrom
feat/autofix-artifact-universal-contract
May 9, 2026
Merged

feat(autofix): replace inline reviewdog with /autofix ChatOps button#1458
magyargergo merged 8 commits into
mainfrom
feat/autofix-artifact-universal-contract

Conversation

@magyargergo

@magyargergo magyargergo commented May 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Pivot the PR autofix UX from per-line reviewdog inline suggestions to a single ChatOps button. Contributors comment /autofix on a PR; a new trusted workflow downloads the existing autofix patch artifact, applies it to the PR head, and pushes a commit back.

Why

The current pipeline has three failure modes that all reduce to "the sticky tells the user to do something but the actionable thing isn't usable":

  • 3K+ diffs hit GitHub's review-comment API 406 limit → sticky says "run locally" with no concrete patch.
  • No-overlap diffs (formatter touched lines outside PR's added range) get filtered by -filter-mode=added → reviewdog posts nothing. PR fix(autofix): verify reviewdog actually posted before claiming "click Apply" #1457 fixed the lying sticky, but the UX dead-end remained.
  • Per-line click-Apply is high-friction for big diffs and easy to apply unevenly.

A single git apply + push works at any size and lands the fixes atomically. This is the standard ChatOps pattern (Dependabot's @dependabot rebase, Mergify's @mergify rebase).

What changed

  • .github/workflows/pr-autofix-publish.yml: removed Install reviewdog and Post inline suggestions steps. Collapsed three sticky states (suggestions-posted, diff-no-overlap, skipped-too-large) into one (fixes-available). Schema bumped v1 → v2 with new apply_command field; all v1 fields preserved.
  • .github/workflows/pr-autofix-apply.yml (new): listens for issue_comment with body matching ^/autofix\s*$, validates commenter has write/admin/maintain OR is PR author, locates latest successful pr-autofix.yml run for PR head SHA, downloads artifact, applies patch, pushes commit. Reacts 👀/✅/👎 on the triggering comment per outcome. Idempotent (git apply --check --reverse detects already-applied state).
  • CONTRIBUTING.md: documented v2 schema and the /autofix flow, including the Allow edits by maintainers requirement for fork PRs.

Trust posture

  • Apply workflow runs from default-branch code only, under issue_comment trigger.
  • Comment body + author login flow through env vars and pattern-matched, never interpolated into shell.
  • Permission gate (write/admin/maintain OR PR author) before any artifact fetch.
  • Fork PRs require "Allow edits by maintainers" (GitHub-native; we don't bypass).
  • No fork-controlled string flows into a URL or shell-injection sink. CodeQL js/server-side-request-forgery and template-injection posture preserved.

Test plan

Post-merge manual matrix on test PRs against main:

  • Small PR (≤50 lines) with prettier violations overlapping added range → sticky shows fixes-available + /autofix instruction. Comment /autofix → bot reacts 👀, then ✅, push lands as chore(autofix): ... commit.
  • Small PR with formatter touching only non-added lines → same flow.
  • Synthetic 3K+ diff (drop paths-ignore for one snapshot temporarily) → no skipped-too-large state; /autofix works.
  • Idempotent re-invoke after success → bot reacts ✅ no push.
  • Comment /autofix --foo (extra args) → silently ignored (no reaction).
  • Comment please don't /autofix this → silently ignored.
  • User without write access (and not PR author) comments /autofix → 👎 + refusal reply.
  • No successful pr-autofix run for current head SHA (e.g., right after force-push) → 👎 + regenerate instruction.
  • Fork PR without Allow edits by maintainers → 👎 + enable-instruction reply.
  • gh pr checks <pr> --json name,conclusion,output | jq '.[] | select(.name == "gitnexus/autofix")' returns the new neutral title.
  • Sticky JSON: jq '.schema' reads gitnexus.pr-autofix/v2, .apply_command == "/autofix".

Notes

🤖 Generated with Claude Code

Supersedes

Closes #1457 — this PR includes that commit (e1f0fe7e) as a base and pivots beyond it. The no-overlap state #1457 introduced becomes dead code with the inline reviewdog removal, but the commit history is preserved.

Codex adversarial review (post-pivot follow-up)

Codex flagged two trust gaps in the pivot. Both addressed in commit a4e1b220:

  • U1 (high): pr-autofix-publish.yml now cross-verifies artifact identity (pr_number, head_sha, head_repo) against github.event.workflow_run authority before any sticky/check-run side effect. Fork-PR fallback uses gh api commits/{sha}/pulls.
  • U2 (medium): pr-autofix-apply.yml now pushes with --force-with-lease=refs/heads/${HEAD_REF}:${HEAD_SHA} against the resolved SHA. Distinct lease-failed result code + retry-message reply (separate from push-failed for fork-maintainer-edit so contributors can diagnose the actual cause).

Residual review findings - all resolved in commit a7dcd4f

All five P2 items from ce-code-review (#6 gh_retry on locate, #7 artifact-expired fallback, #8 re-entrancy loop guard, #10 producer-still-running UX, #12 gh_retry wrapper port) landed in this PR. Every code path in pr-autofix-apply.yml now sets a meaningful result= that maps to a specific user-facing reaction + reply.

… Apply"

The sticky summary comment was stating "Posted formatting suggestions
inline. Click Apply suggestion on each" even when reviewdog landed zero
inline review comments — typical case: the formatter touched lines
outside the PR's added range, so `-filter-mode=added` (correctly)
filtered everything out. The script unconditionally set `posted=true`
after running reviewdog regardless of whether any comments were
actually created, leaving the user staring at a sticky that promised
buttons that didn't exist.

The publish job now snapshots the count of `github-actions[bot]` review
comments before and after reviewdog. If the delta is zero, surface a
new `diff-no-overlap` UI state that tells the user plainly:

  "Formatter found fixable issues, but they're on lines outside this
  PR's added range — there's nothing to click here. Run locally:
  npm run lint:fix && npm run format."

Plus a matching `gitnexus/autofix` Check Run conclusion (still neutral,
distinct title) so agents reading `gh pr checks` see the same signal.

Three states are now machine-distinguishable in the sticky's
gitnexus-autofix JSON block: suggestions-posted (delta > 0),
diff-no-overlap (delta == 0), skipped-too-large (>3k lines).
Pivot the PR autofix UX from per-line reviewdog suggestions to a single
slash-command button. Contributors comment `/autofix` on the PR; a new
trusted workflow downloads the existing autofix patch artifact, applies
it to the PR head, and pushes a commit back.

Why:
- 3K+ diffs hit GitHub's review-comment API 406 limit -> dead end.
- Diffs where the formatter touches lines outside the PR's added range
  ("no-overlap") get filtered by reviewdog's -filter-mode=added -> dead
  end (PR #1457 patched the lying sticky but the underlying UX gap
  remained).
- Per-line click-Apply-suggestion is high-friction for big diffs and
  easy to apply unevenly.
- A single `git apply` + push works at any size and lands fixes
  atomically.

Changes:
- pr-autofix-publish.yml: remove `Install reviewdog` and
  `Post inline suggestions` steps. Collapse three sticky states
  (suggestions-posted, diff-no-overlap, skipped-too-large) into one
  (fixes-available). Bump JSON schema v1 -> v2 with `apply_command`
  field; all v1 fields preserved.
- pr-autofix-apply.yml (new): triggers on issue_comment with body
  `/autofix`, validates body via strict regex, validates commenter
  has write/admin/maintain or is the PR author, locates latest
  successful pr-autofix run for PR head SHA, downloads artifact,
  applies patch, pushes commit. Reacts +1/-1/eyes on triggering
  comment per outcome. Idempotent (`git apply --check --reverse`
  detects already-applied state).
- CONTRIBUTING.md: document v2 schema and the /autofix flow,
  including the maintainer-edit requirement for fork PR pushes.

Trust posture: apply workflow runs from default-branch code only,
under issue_comment trigger. Comment body and author login flow
through env vars and pattern-matched, never interpolated into shell.
Permission gate (write/admin/maintain OR PR author) before any
artifact fetch. Fork PRs require "Allow edits by maintainers"
(GitHub-native; we don't bypass).

Net YAML: -139 lines in publish.yml, +260 in apply.yml. Removes
reviewdog binary pin and the entire review-comment API surface.
@vercel

vercel Bot commented May 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gitnexus Ready Ready Preview, Comment May 9, 2026 3:16pm

Request Review

Comment thread .github/workflows/pr-autofix-apply.yml Fixed
Two findings from the Codex adversarial review of the autofix ChatOps
pivot. Both are localized YAML changes that close trust gaps the pivot
inherited from the original PR #1446 design.

U1 — Cross-verify metadata against workflow_run authority
(.github/workflows/pr-autofix-publish.yml):
  Previously the trusted publisher accepted pr_number, head_sha, and
  head_repo from metadata.json after only an allowlist regex. A
  fork-controlled `npm run lint:fix` could have written a syntactically
  valid metadata.json referencing another PR/SHA, redirecting the
  write-scoped sticky/check-run onto an attacker-chosen target.

  New `Verify metadata against workflow_run authority` step compares
  artifact-claimed identity against:
    - github.event.workflow_run.head_sha
    - github.event.workflow_run.head_repository.full_name
    - workflow_run.pull_requests[].number (within-repo PRs)
    - gh api commits/{sha}/pulls fallback (fork PRs, where
      pull_requests[] is empty)
  Fail closed on mismatch — no sticky, no check-run, no override.

U2 — Lease-protected push in apply workflow
(.github/workflows/pr-autofix-apply.yml):
  Previously the apply step pushed `HEAD:${HEAD_REF}` plain. A force-
  push between resolve (Step 5) and push (Step 9) would silently
  fast-forward an older commit graph over the contributor's newer
  state.

  Push now uses `--force-with-lease=refs/heads/${HEAD_REF}:${HEAD_SHA}`
  against the SHA resolved earlier. Distinct `lease-failed` result code
  + retry-message reply, separated from `push-failed` (fork without
  maintainer-edit) so contributors can diagnose the actual cause.

Plan: docs/plans/2026-05-09-005-fix-autofix-codex-adversarial-findings-plan.md
(local-only per repo convention).

Trust posture preserved: no new permissions, no new workflows, no
contract change. JSON v2 schema unchanged. CodeQL js/server-side-
request-forgery and template-injection posture unchanged — all new
inputs flow via env vars and pattern-matched.
…eckout

actions/checkout's default behavior writes the GITHUB_TOKEN into
.git/config as an extraheader. The token then sits on disk in the
checkout directory — an actions/upload-artifact step on that
directory would leak it. We don't upload, but zizmor's
credential-persistence lint correctly flags the latent risk.

Set persist-credentials: false on the Checkout PR head step. Provide
push auth inline via `git -c http.extraheader="Authorization: Basic
<base64-of-x-access-token:TOKEN>"` so the credential never lands on
disk and never appears in process listings (the URL form
https://x-access-token:TOKEN@… is rejected here because it leaks via
ps and git remote -v).

Push lease semantics from U2 unchanged — same --force-with-lease
against the resolved HEAD_SHA, same lease-failed/push-failed/stale
result codes.
ce-code-review surfaced 15 findings on PR #1458; this commit applies
the 7 with concrete fixes (#1, #2, #3, #4, #5, #9, #13). Five P2
findings (#6, #7, #8, #10, #12) are recorded as residual actionable
work for follow-up; two advisory items (#11, #14) skipped.

#1 — applied_run_id schema drift (CONTRIBUTING.md):
  v2 docs claimed `state: applied` enum value and an `applied_run_id`
  field that no code path emits. Trimmed docs to match what the
  workflow actually writes (state: fixes-available; v1 field set as
  superset). Implementing the apply-side sticky upsert that would
  populate `applied_run_id` is deferred — cleaner than carrying a
  contract claim with no code.

#2 — result= unset between idempotency probe and lease push
(pr-autofix-apply.yml):
  After `git apply --check` passed, an early non-zero exit from
  `git config` / `git apply` / `git add` / `git commit` left
  `result=` unset, sending the user to the `*` "unexpected state
  (`unknown`)" arm. Wrapped the apply/commit phase in a single
  if-test that sets `result=apply-failed` on any failure. New
  React-and-reply branch surfaces an actionable message.

#3 — permission lookup conflated transient API failures with denial
(pr-autofix-apply.yml):
  `gh api … 2>/dev/null || echo "none"` swallowed 5xx, 429 secondary
  rate-limit, and network failures, surfacing them as a public 👎
  refusal to legitimate maintainers. Now distinguishes 404
  (genuine non-collaborator) from other API failures via stderr
  match. New `allowed=api-failed` state triggers a 😕 reaction with
  a "transient API failure, retry" reply instead of a misleading
  refusal.

#4 — lease-failure grep missed git's "remote rejected" / branch-
deleted phrasings (pr-autofix-apply.yml):
  Real lease failures got classified as `push-failed` →
  user told to enable maintainer-edit, which won't help. Expanded
  regex to match `remote rejected` and `! [rejected]`.

#5 — broken bullet continuation in CONTRIBUTING.md release-candidate
section: rejoined the split bullet so it renders correctly.

#9 — base64 GITHUB_TOKEN bypassed GitHub's secret-masker
(pr-autofix-apply.yml):
  Added `::add-mask::${auth_header}` immediately after construction
  so any subsequent log line (set -x, GIT_TRACE) gets *** redacted.

#13 — misleading schema-bump comment in pr-autofix-publish.yml:
  Comment claimed all v1 fields preserved exactly, but the `state`
  enum was redefined v1→v2. Updated to make the migration path
  explicit (v1 readers see unfamiliar schema, fall back to prose).

Residual actionable work (deferred to follow-up):
  #6 locate step gh api retry; #7 artifact-expired graceful fallback;
  #8 re-entrancy comment-spam guard; #10 producer-still-running UX;
  #12 gh_retry wrapper for apply.yml.

Validations: yaml.safe_load OK, check-workflow-concurrency.py OK.
#8, #10, #12)

Pulls the deferred items from the previous review pass into this PR so
the workflow ships with full reliability + UX coverage rather than
follow-up debt.

#6 + #12 — gh_retry wrapper on idempotent GETs in apply.yml:
  Permission lookup, PR metadata fetch, and workflow-run lookup are now
  wrapped in the same gh_retry helper publish.yml uses (3 attempts,
  linear backoff). Reaction/comment POSTs remain unwrapped (retrying
  POST would dupe the resource).

#10 — producer-still-running UX:
  The locate step now distinguishes three cases via `found_status`
  output: success (proceed), in-progress / queued / pending / waiting
  (reply ⏳ "wait for autofix run to finish"), not-found (reply 🤔
  "push a commit"), api-failed (reply ⚠️ "transient API failure"). The
  "no successful autofix run" message no longer fires immediately after
  a fresh push while the producer is still mid-run.

#7 — artifact-expired graceful fallback:
  actions/download-artifact gains `continue-on-error: true`. The apply
  step distinguishes patch-file-missing (artifact expired, 1-day
  retention elapsed) from patch-file-zero-bytes (formatter found
  nothing). New `result=artifact-expired` case + ⏳ "push a new commit
  to regenerate" reply.

#8 — re-entrancy loop guard:
  After checkout but before applying, check if HEAD itself is a
  github-actions[bot] `chore(autofix)` commit. If so, refuse to
  re-apply (`result=loop-prevented`) with a 🔁 reply telling the user
  to push a human-authored commit or revert before retrying. Prevents
  formatter-config-drift loops where an automated agent watching the
  sticky could pump arbitrary apply commits.

Net effect: every code path in apply.yml now sets a meaningful `result=`
that maps to a specific user-facing reaction + reply. The `*` "unexpected
state (unknown)" arm becomes truly unreachable in normal operation.

Validations: yaml.safe_load OK, check-workflow-concurrency.py OK.
@github-actions

github-actions Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @magyargergo's task in 9m 5s —— View job


PR #1458 Security Review — feat(autofix): replace inline reviewdog with /autofix ChatOps button

Tasks

  • Read repo context files (AGENTS.md, GUARDRAILS.md, CONTRIBUTING.md, zizmor.yml)
  • Read changed workflow files (pr-autofix-apply.yml, pr-autofix-publish.yml, CONTRIBUTING.md)
  • Read related workflow (pr-autofix.yml producer)
  • Check CI status
  • Inspect git log / branch hygiene
  • Inspect hidden Unicode / non-ASCII / bidi controls
  • Perform full security analysis across all review dimensions
  • Post complete review

Review bar for this PR

Against GUARDRAILS.md (least-privilege, no secrets, security-sensitive config requires human sign-off) and CONTRIBUTING.md CI/security standards, this PR must clear:

  • Trust split preserved: untrusted producer (pull_request, permissions: {}) → trusted publisher (workflow_run, artifact-as-data) → trusted apply (issue_comment, default-branch code). No fork code executes with write credentials.
  • Command gate strict: ^/autofix\s*$ whole-line regex; must not match embedded mentions or extra arguments.
  • Permission gate sound: write/admin/maintain OR PR author; 404 vs. 5xx distinguished; untrusted login allowlisted before URL interpolation.
  • Artifact identity cross-verified against workflow_run authority before any sticky/check-run side effect.
  • Push safe: --force-with-lease=refs/heads/${HEAD_REF}:${HEAD_SHA} against resolved SHA; lease failure surfaced distinctly.
  • Checkout credentials not persisted: persist-credentials: false + push auth via inline -c http.extraheader (never on disk).
  • All failure states explicit: artifact-expired, in-progress, lease-failed, push-failed, loop-prevented, stale, apply-failed, already-applied, empty-patch, applied, api-failed — each maps to a distinct reaction + reply.
  • CI/security tooling clean: actionlint, zizmor, CodeQL, Gitleaks, Dependency Review all green on latest head.

Current PR state

Field Value
Latest head SHA a7dcd4f
Status Open (not draft)
Commits on branch 7
Changed files (GitHub diff) 3 (.github/workflows/pr-autofix-apply.yml, .github/workflows/pr-autofix-publish.yml, CONTRIBUTING.md)
Net diff +687 / -102
Merge-from-main commit Present (dfdad36)
CI (total) 9/10 passed, 1 in-progress (main CI job)
CodeQL ✅ success
Gitleaks ✅ success
Dependency Review ✅ success
Workflow Lint (actionlint + zizmor) ✅ success
PR Autofix ✅ success
Hidden bidi controls (U+202A–202E, U+2066–2069) None found
Non-ASCII in workflow YAML Emoji only (✅ 👀 👎 ⚠️ etc.) in step names and reply strings — not in shell, expressions, regexes, or permission blocks. Benign.
Vercel preview Deployed; irrelevant to this review
Package/lockfile changes None in PR diff
GHAS finding (line 347) Marked resolved; persist-credentials: false confirmed in place at line 344

Branch hygiene assessment

Verdict: merge-from-main commit present but harmless and merge-safe.

The merge commit dfdad36 pulled 152a050 (shared resilient-fetch), regression tests, and dep bumps from main. Those files appear in git diff HEAD~7 HEAD but not in GitHub's PR diff (which compares against the merge base). GitHub correctly shows 3 changed files (+687/-102), all autofix-related. No lockfile churn, no unrelated workflow changes, no generated artefacts, no release commits introduced by the PR-specific commits. #1457's base commit (e1f0fe7) is intentionally included because this PR supersedes #1457; that commit only touched pr-autofix-publish.yml (now further modified by this PR).


Understanding of the change

Why reviewdog was removed: Reviewdog's github-pr-review reporter hits GitHub's ~3K review-comment API ceiling for large diffs (406 error). The -filter-mode=added flag silently drops fixes to lines not in the PR diff. Per-line "Apply suggestion" is high-friction and can be applied unevenly. PR #1457 made the sticky comment honest about these failures but the UX dead-end remained.

Why /autofix is better: A single git apply + push works at any diff size, applies the full patch atomically, and is the standard ChatOps pattern (Dependabot, Mergify). No GitHub review-comment API surface means no 406 cap and no filter-mode gaps.

Why issue_comment is risky: It runs with access to secrets.GITHUB_TOKEN even on fork PRs, because the workflow always runs from the default branch. This is the whole point — it's the only way to acquire write credentials for a fork PR — but it means unauthorized invocation, command injection, and artifact-metadata redirection are the primary attack surfaces.

Why applying and pushing is more sensitive than suggestions: The old flow could only post cosmetic suggestions; rejecting them was free. This flow writes commits directly to PR branches with contents: write. A botched push, stale-head overwrite, or unauthorized trigger has real consequences.

Why artifact identity validation is mandatory: The artifact's metadata.json is produced by untrusted fork code. Without cross-checking against workflow_run authority, a forged pr_number or head_sha in that file could redirect the sticky comment or check run to a victim PR.

Why --force-with-lease matters: There is a window between the SHA resolve and the push. Without a lease, a concurrent push (human or CI) could be silently overwritten.

Why fork PRs need "Allow edits by maintainers": GitHub's GITHUB_TOKEN push authorization for cross-repo refs requires the PR author to have granted maintainer-edit access. The workflow cannot bypass this; it fails loud with a clear reply when the push is rejected.


Findings

[Low] Stale reviewdog header comments in changed and related workflow files

  • Category: Documentation / code hygiene
  • Files: pr-autofix-publish.yml (changed) lines 7–8, 17–19, 52; pr-autofix.yml (unchanged, pre-existing) lines 9, 17, 111
  • Issue: pr-autofix-publish.yml's header still says "posts inline review-comment suggestions to the PR using reviewdog" and "Reviewdog reporter: github-pr-review reads $REVIEWDOG_GITHUB_API_TOKEN…" — both statements describe the removed behavior. Line 52 (in the permission block comment) still enumerates old check-run outcomes: clean / suggestions-posted / skipped-too-large — only clean and fixes-available exist now. The producer pr-autofix.yml (unchanged file) still says the publisher "posts the inline review-comment suggestions."
  • Why it matters: Future maintainers reading the header to understand trust posture will see a description of a system that no longer exists. This is the primary inline-documentation risk in a security-sensitive workflow. It does not affect runtime behaviour.
  • Recommended fix: Update the pr-autofix-publish.yml header to describe the current behavior (sticky comment + check run, no reviewdog). Update line 52 to say # the outcome (clean / fixes-available) to. Consider updating pr-autofix.yml header as a follow-up (it is not a changed file in this PR, so it is pre-existing tech debt). Fix this →
  • Blocks merge: No

[Low] No patch-path constraint on .github/workflows/ modifications

  • Category: Supply chain / patch trust boundary
  • Files: .github/workflows/pr-autofix-apply.yml (apply step, line 424)
  • Issue: git apply "$patch" applies the full diff without excluding sensitive paths. If a PR includes a prettier/ESLint config that causes the formatter to touch .github/workflows/*.yml files (prettier supports YAML formatting when configured), the artifact patch could contain workflow YAML modifications. When a maintainer runs /autofix, those changes would be pushed with contents: write credentials. The authorized invoker cannot inspect the diff before the bot applies it — there is no "review step" in the apply flow.
  • Why it matters: This is a theoretical but real supply-chain vector: a malicious PR authors a custom prettier config that reformats workflow files → the artifact patch includes those files → a maintainer runs /autofix → bot commits modified workflow code. The attack requires social engineering (tricking a write-access user into /autofixing a PR that doesn't look suspicious), so severity is limited. This is inherent to any ChatOps apply-patch pattern; it is not a regression introduced by this PR vs. the prior design.
  • Recommended fix: Options in increasing strictness: (a) document the risk in CONTRIBUTING.md under the /autofix section; (b) add a step before git apply that warns if the patch touches .github/; (c) reject patches that modify .github/workflows/ paths (hardest; could break legitimate format-on-YAML configs). Option (a) is minimum viable. Fix this →
  • Blocks merge: No

Trust-boundary assessment

Clean. The three-stage split is intact and correctly implemented:

  • Producer (pr-autofix.yml): triggers on pull_request, permissions: {}, checks out fork code, runs npm run lint:fix && npm run format with --ignore-scripts to block lifecycle hooks, captures the diff and metadata via jq (not shell interpolation), uploads artifact with 1-day retention. No token, no API writes.
  • Publisher (pr-autofix-publish.yml): triggers on workflow_run (default-branch code only), treats artifact as hostile data — every metadata.json field is allowlist-validated before $GITHUB_OUTPUT, then cross-verified against github.event.workflow_run.* authority for non-empty patches. Never checks out fork code. Refuses to publish if base_repoGITHUB_REPOSITORY, if head_shaworkflow_run.head_sha, or if pr_number is not in the authoritative PR list.
  • Apply (pr-autofix-apply.yml): triggers on issue_comment (default-branch code only). Does not execute fork code before validation. Does not run npm, eslint, prettier, or any package script. Only git operations and GitHub API calls. Checks out the PR HEAD after permission and metadata validation.

The only fork-controlled string that reaches a git command is HEAD_REPO (in the push URL) and HEAD_REF (in the refspec). Both are validated by the locate step: HEAD_REPO matches ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ and HEAD_REF matches ^[A-Za-z0-9._/-]+$ before export to $GITHUB_OUTPUT and usage in env vars. No interpolation into shell source.


Command / permission-gate assessment

Sound. The layered command guard is correct:

  1. Workflow-level if: startsWith(github.event.comment.body, '/autofix') — coarse pre-filter to avoid spawning runners for unrelated comments. Correctly handled: the strict Step 1 regex rejects everything the coarse filter passes unnecessarily (/autofixing, /autofix-foo).
  2. Step 1 (body check): BODY via env var, regex ^/autofix[[:space:]]*$ — no shell interpolation of comment body. Mismatch exits silently (no reaction), correctly avoiding bot noise on unrelated discussions.
  3. issue_comment types: [created] only — edited comments do not re-trigger.
  4. PR-only guard: github.event.issue.pull_request != null.
  5. Commenter login allowlisted (^[A-Za-z0-9-]{1,39}$) before use in gh api URL path.
  6. Permission lookup distinguishes HTTP 404 (genuine denial → allowed=false) from 5xx/429/network (allowed=api-failed → confused reaction, retry instruction — does NOT silently grant or deny).
  7. PR author bypass is correct: it allows the contributor to apply fixes to their own PR. Fork authors can trigger /autofix, but the push still requires GitHub's native "Allow edits by maintainers" check — the bot cannot bypass this.
  8. Bot loop prevention: the apply bot never posts a comment containing /autofix, so its ✅ Applied reply cannot trigger itself.

Artifact / metadata validation assessment

Sound with one nuance. The publisher's verify step is conditioned on steps.meta.outputs.changed_lines != '0' — it is skipped for empty patches. For the empty-patch case, the check run is still posted using head_sha from the artifact metadata. However, the producer sets head_sha from ${{ github.event.pull_request.head.sha }} (a GitHub-controlled event field, not fork-controlled code), so the value is always the genuine PR head SHA. The lack of cross-verification for the empty-patch check run is defense-in-depth absent but not an exploitable gap given the data source.

The apply workflow's artifact lookup is pinned to the run identified in the locate step (run-id: ${{ steps.locate.outputs.run_id }}). run_id is validated as ^[0-9]+$. The apply step distinguishes artifact-expired (file absent → continue-on-error: true + missing file check) from empty patch (file present, zero bytes).


Checkout / patch / push assessment

Clean. All critical controls in place:

  • Checkout target: repository: ${{ steps.locate.outputs.head_repo }}, ref: ${{ steps.locate.outputs.head_sha }} (pinned to resolved SHA, not floating branch ref). Isolated at path: pr-checkout.
  • persist-credentials: false confirmed at line 344 (GHAS finding at line 347 is resolved). Push auth provided via inline -c http.extraheader (Authorization: Basic base64(x-access-token:TOKEN)), never written to .git/config, and the base64-encoded value is masked with ::add-mask:: before any further logging.
  • git apply --check before apply; --check --reverse for idempotency; no --unsafe-paths flag.
  • git add -A then commit atomically — no partial-apply risk.
  • Force-with-lease: --force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" — both values from validated locate outputs via env vars. Lease-failure is detected via stderr pattern match and reported as lease-failed (distinct from push-failed).
  • HEAD_REF validated as ^[A-Za-z0-9._/-]+$; no shell metacharacters possible. Cannot target tags (tags don't match that regex), cannot contain newlines or colons.
  • Re-entrancy guard checks HEAD commit author email (41898282+github-actions[bot]@users.noreply.github.com) AND subject prefix (chore(autofix)) — prevents unbounded loop where apply commit → new producer run → agent comments /autofix again. A human commit breaks the guard, which is the correct escape hatch.

Sticky comment / check-run assessment

Clean. v2 schema is correctly implemented:

  • Sticky JSON uses "schema":"gitnexus.pr-autofix/v2", "state":"fixes-available", "apply_command":"/autofix". All v1 fields (pr_number, head_sha, changed_lines, run_id) preserved.
  • Only fixes-available state exists. Old states (suggestions-posted, diff-no-overlap, skipped-too-large) are absent from both code and emitted JSON (they appear only in a historical comment explaining v1→v2 migration).
  • Sticky marker (``) filter is combined with .user.login == "github-actions[bot]" — user marker spoofing cannot hijack the bot's PATCH path.
  • Check run: conclusion: neutral for fixes-available, conclusion: success for clean. Title accurately says Autofix available — comment /autofix to apply. No reference to reviewdog in the check run output.
  • CONTRIBUTING.md documents v2 schema accurately: correct fields, correct regex, fork maintainer-edit requirement, idempotency behavior.

Token / permission assessment

Minimal and correct.

Workflow Permissions Justified by
pr-autofix.yml (producer) {} (none) Fork code execution; no API writes needed
pr-autofix-publish.yml pull-requests: write, actions: read, checks: write PR comment upsert, artifact download, check-run creation
pr-autofix-apply.yml pull-requests: write, contents: write, actions: read PR comment + reaction, push commit, artifact download

pr-autofix-apply.yml does not have checks: write (correct — apply creates no check runs). No custom PAT, no id-token, no OIDC, no secrets.* beyond GITHUB_TOKEN. GITHUB_TOKEN is not echoed; set -x is absent from all run: blocks.


Injection / security tooling assessment

Clean on latest head. Every untrusted value flows through env vars:

  • github.event.comment.body$BODY (not interpolated into shell source)
  • github.event.comment.user.login$COMMENTER (allowlisted before URL use)
  • github.event.issue.number$PR (numeric, only used in gh api URL)
  • All metadata.json fields → allowlist-validated before $GITHUB_OUTPUT; cross-verified against workflow_run authority
  • HEAD_REF, HEAD_REPO, HEAD_SHA → validated regex; used via env vars not ${{ }} interpolation in shell

No eval, no unsafe heredocs, no unquoted variable expansions, no gh api paths constructed from untrusted strings without validation. JSON built with jq --arg/--argjson, not string concatenation.

Tooling status: Workflow Lint (which runs actionlint + zizmor) ✅. CodeQL ✅. Gitleaks ✅. Dependency Review ✅. The GHAS credential-persistence finding is resolved (persist-credentials: false at line 344; zizmor passes).


Race / re-entrancy assessment

Well-handled. Concurrency key ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }} serializes apply runs per PR; cancel-in-progress: false queues a second /autofix rather than cancelling the first (correct — the second invocation hits the already-applied idempotency guard).

Producer-still-running: distinguished via in-flight run status check with in_progress/queued/pending/waiting. Instructs user to wait rather than regenerate. Artifact-expired: clearly differentiated from empty patch. The --force-with-lease catches the apply run that raced against a force-push. Re-entrant loop is bounded: HEAD must be a human commit for the loop guard to clear.

The one remaining theoretical loop path — an AI agent watching stickies and auto-commenting /autofix on each new fixes-available — is mitigated by the loop guard and by the sticky including head_sha + run_id so a well-implemented agent can detect staleness. There is no bot auto-comment /autofix, so no self-triggering.


Reviewdog removal assessment

Complete. The Install reviewdog and Post inline suggestions steps are absent from pr-autofix-publish.yml. reviewdog/action-setup is not referenced. No REVIEWDOG_GITHUB_API_TOKEN env var. The 3K review-comment API cap is no longer relevant. suggestions-posted, diff-no-overlap, and skipped-too-large are absent from all runtime code paths. CONTRIBUTING.md describes only /autofix. PR #1457's no-overlap state is superseded.

The stale references at pr-autofix-publish.yml lines 7, 17, 52 are comments only (header documentation and a permission annotation), not executable paths. They do not cause old behavior to run.


Validation / CI assessment

All relevant static checks pass on latest head. issue_comment workflows cannot be exercised in PR checks (GitHub only runs default-branch copies of issue_comment workflows post-merge), so end-to-end validation is necessarily post-merge manual. The PR body documents a complete manual matrix (11 scenarios) covering: small/large/no-overlap diffs, repeated /autofix, unauthorized user, no-run, in-progress, expired artifact, fork with/without maintainer edits, stale head. Rollback is a standard git revert of the apply commit (small blast radius); the workflow itself can be disabled in the GitHub Actions settings page if needed. This is adequate for a privileged ChatOps workflow where the scope is narrow (push commits to PR branches only when an authorized user explicitly requests it).


Hidden Unicode / YAML hygiene assessment

No bidirectional controls (U+202A–U+202E, U+2066–U+2069) found in any changed file. Non-ASCII characters present are emoji (, 👀, 👎, ⚠️, 🚫, 🔁, , ) appearing exclusively in YAML step name strings and gh api -f body="..." user-facing reply messages. None appear in shell code, GitHub expressions (${{ }}), regex patterns, permission blocks, JSON schema markers, or security comments. The CONTRIBUTING.md non-ASCII is emoji in the PR-type table header (🚀, 🐛, 🏎️), which is pre-existing and benign.


Elegance / maintainability assessment

The state machine is clean and exhaustive: every result= value is explicitly handled in the final case with a distinct reaction + reply. The * fallback correctly catches unexpected values rather than silently succeeding. The gh_retry helper is consistently repeated (unavoidable in YAML run: blocks which have no shared scope) with a clear comment explaining the repetition. The auth-via-inline-header approach (-c http.extraheader) is slightly more complex than a token URL but is the correct pattern to avoid token leakage in process listings and remote configs; the accompanying ::add-mask:: call is a good defensive extra step. Comment density is high and targeted at the trust-boundary decisions, not at obvious mechanics. Future maintainers have enough context to understand why each security control is present.


Final verdict

Production-ready with minor follow-ups.

The core trust-boundary architecture is correctly implemented: the untrusted producer never receives write credentials, the trusted publisher treats artifact metadata as hostile data and cross-verifies against workflow_run authority, the trusted apply workflow runs only default-branch code, and every fork-controlled string is allowlist-validated before use in git or GitHub API operations. The two key mechanisms — --force-with-lease with exact SHA and persist-credentials: false with inline auth — are both in place and correctly structured. All static security checks (actionlint, zizmor, CodeQL, Gitleaks, Dependency Review) pass on the latest head.

The two findings above are both [Low] severity with no merge-blocking impact: (1) stale reviewdog documentation in workflow headers is misleading for future maintainers but has zero runtime effect; (2) the absence of patch-path constraints on .github/workflows/ is inherent to the ChatOps apply pattern, not a regression, and requires social engineering of a write-access user to exploit. Both can be addressed as post-merge follow-ups.

The branch hygiene is merge-safe: the merge-from-main commit is harmless (GitHub's diff shows exactly 3 autofix-related files) and PR #1457's supersession is intentional and clean.

@github-actions

github-actions Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

CI Report

All checks passed

Pipeline Status

Stage Status Details
✅ Typecheck success tsc --noEmit
✅ Tests success unit tests, 3 platforms
✅ E2E success gitnexus-web changes only

Test Results

Tests Passed Failed Skipped Duration
8489 8488 0 1 392s

✅ All 8488 tests passed

1 test(s) skipped — expand for details
  • buildTypeEnv > known limitations (documented skip tests) > Ruby block parameter: users.each { |user| } — closure param inference, different feature

Code Coverage

Tests

Metric Coverage Covered Base Delta Status
Statements 77.91% 25461/32677 N/A% 🟢 ███████████████░░░░░
Branches 66.38% 16077/24217 N/A% 🟢 █████████████░░░░░░░
Functions 83.13% 2553/3071 N/A% 🟢 ████████████████░░░░
Lines 80.95% 23014/28429 N/A% 🟢 ████████████████░░░░

📋 View full run · Generated by CI

…ing .github/

Two follow-up findings on PR #1458:

#1 — Stale reviewdog references in workflow header comments:
  pr-autofix-publish.yml's header still described the removed inline-
  suggestion path ("posts inline review-comment suggestions to the PR
  using `reviewdog`", "Reviewdog reporter: github-pr-review reads
  $REVIEWDOG_GITHUB_API_TOKEN…"). The Check Run permissions comment
  enumerated the old outcomes (clean / suggestions-posted /
  skipped-too-large) instead of the current set (clean / fixes-
  available). pr-autofix.yml's header described the trusted job as
  posting "inline review-comment suggestions" and the changed_lines
  comment referenced the dead 3000-line cap. Refreshed all three to
  describe the actual sticky + Check Run + /autofix flow.

#2 — Reject patches touching .github/ (sensitive-paths guard):
  Theoretical supply-chain vector: a malicious PR could ship a custom
  prettier/ESLint config that reformats workflow YAML, dependabot.yml,
  or CODEOWNERS. The producer would capture those edits in
  autofix.patch; a maintainer running `/autofix` would push them under
  `contents: write` without human review. The default GITHUB_TOKEN
  lacks the `workflows` scope so workflow-file pushes would fail at
  the platform layer anyway, but as a generic `push-failed` (which
  misleads users into enabling maintainer-edit). Reject early with
  a specific reason.

  Match runs against the patch with grep on `^(diff --git|---|+++)
  [ab]?/?\.github/`. New `result=sensitive-paths` case + 🛑 reply
  telling the user to apply .github/ formatter changes manually.

  Documented the constraint in CONTRIBUTING.md under the /autofix
  section so contributors aren't surprised when the workflow refuses
  a patch that includes formatter changes to workflow files.

Validations: yaml.safe_load OK, check-workflow-concurrency.py OK.
@magyargergo magyargergo merged commit 6906be3 into main May 9, 2026
31 checks passed
@magyargergo magyargergo deleted the feat/autofix-artifact-universal-contract branch May 9, 2026 15:32
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.

2 participants