Skip to content

Reject pull_request_target in CI, so a sync cannot reintroduce it - #229

Merged
danielhanchen merged 11 commits into
masterfrom
security/reject-pull-request-target
Sep 22, 2026
Merged

danielhanchen merged 11 commits into
masterfrom
security/reject-pull-request-target

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

Follow-up to #228, which deleted bench.yml.disabled. That removed the file but not the hazard, and this closes it.

Why a deleted file still needs a guard

This is a fork. An upstream sync can bring back upstream's bench.yml under its original, active name at any time. unsloth-upstream-sync-guard.yml checks that the recorded sync point is still an ancestor of master and says nothing about what the synced workflows do, so that reintroduction would arrive green. The deleted workflow triggered on pull_request_target, checked out the pull request's head, executed it, and piped a PR-controlled file into $GITHUB_ENV alongside secrets.

scripts/unsloth/check_workflow_triggers.py refuses pull_request_target outright and requires workflow_run to carry a written justification. It matches the rule unslothai/unsloth enforces in scripts/lint_workflow_triggers.py, using the same escape-hatch comment spelling so the two repositories read the same way.

Hosted in unsloth-pr-set-lint.yml because its push and pull_request triggers already include .github/workflows/*.yml, so every workflow edit runs it — including the merge commit of a sync. It reads only .yml and .yaml, which is all GitHub loads: a neighbouring .disabled file is genuinely inert, and the moment someone renames it the check sees it. There is a test for exactly that pair.

The two workflow_run workflows now say why they are safe

Neither changed in behaviour. The check surfaced that their safety was real but undocumented, so it is written down instead of left to be re-derived:

  • unsloth-prebuilt-retry.yml has no actions/checkout step at all, reads no artifact from the triggering run, holds no secrets, and uses actions: write only to POST a rerun in this same repository. The workflow it watches is schedule- and dispatch-only, so the trigger is never fork-controlled.
  • unsloth-repin-bot.yml checks out with no ref:, which on a workflow_run event resolves to the default branch rather than any pull request head, with persist-credentials: false set deliberately so the automatic GITHUB_TOKEN extraheader cannot shadow the step-scoped REPIN_TOKEN, which is scrubbed from every log line it could reach.

Verification

scripts/unsloth/test_check_workflow_triggers.py, in the self-running style of the sibling resolver tests: 11 checks, all passing. It covers the live tree, both file extensions, a reintroduced bench.yml, the .disabled form being correctly ignored, the justified and unjustified workflow_run cases, and an empty or missing workflows directory failing rather than passing vacuously.

14 workflow(s): no pull_request_target, no unjustified workflow_run

Found during the organisation-wide audit against the 2026-09-21 cargo-miri cache disclosure. Not an instance of that class.

Deleting bench.yml.disabled removed the file but not the hazard. This is a fork, and
an upstream sync can bring back upstream's bench.yml under its original, ACTIVE name
at any time. unsloth-upstream-sync-guard.yml checks that the recorded sync point is
still an ancestor of master and says nothing about what the synced workflows do, so
that reintroduction would arrive green.

scripts/unsloth/check_workflow_triggers.py refuses pull_request_target outright and
requires workflow_run to carry a written justification, matching the rule
unslothai/unsloth enforces in scripts/lint_workflow_triggers.py, with the same
escape-hatch comment spelling so the two repositories read the same way.

Hosted in unsloth-pr-set-lint.yml because its push and pull_request triggers both
already include .github/workflows/*.yml, so every workflow edit runs it, including
the merge commit of a sync. It only reads .yml and .yaml, which is all GitHub loads:
a neighbouring .disabled file is genuinely inert, and the moment someone renames it
the check sees it. There is a test for exactly that pair.

The two live workflow_run workflows now state why they are safe rather than leaving
it to be re-derived. Neither was changed in behaviour:

  unsloth-prebuilt-retry.yml has no actions/checkout step at all, reads no artifact
  from the triggering run, holds no secrets, and uses actions: write only to POST a
  rerun in this same repository. The workflow it watches is schedule and dispatch
  only, so the trigger is never fork-controlled.

  unsloth-repin-bot.yml checks out with no ref:, which on a workflow_run event
  resolves to the default branch rather than any pull request head, with
  persist-credentials: false set deliberately so the automatic GITHUB_TOKEN
  extraheader cannot shadow the step-scoped REPIN_TOKEN, which is scrubbed from every
  log line it could reach.

scripts/unsloth/test_check_workflow_triggers.py covers the live tree, both file
extensions, a reintroduced bench.yml, the .disabled form being correctly ignored, the
justified and unjustified workflow_run cases, and an empty or missing workflows
directory failing rather than passing vacuously. 11 checks, all passing.

Found during the organisation-wide audit against the 2026-09-21 cargo-miri cache
disclosure. Not an instance of that class.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-22T14:03:38.574403Z 8ee82e1 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff80f85310

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +191 to +192
- name: No pull_request_target, no unjustified workflow_run
run: python3 scripts/unsloth/check_workflow_triggers.py --root .

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Trigger the lint when its checker changes

When a PR or push modifies only scripts/unsloth/check_workflow_triggers.py, this workflow will not run because neither push.paths nor pull_request.paths includes that file (only test_*.py and the two older workflow checkers are listed). Consequently, a regression or weakening of this security check can merge without executing either the checker or its tests; add the new checker path to both trigger lists.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and it is the same defect class this change exists to close, applied to the security check itself. The lint's filters listed test_*.py and the two older checkers but not scripts/unsloth/check_workflow_triggers.py, so a commit weakening the checker would not have run it.

Added to both trigger lists in 4690af5. Note the test file was already covered by the scripts/unsloth/test_*.py glob, so the gap was the checker alone, which is the more dangerous half of the pair.

)

for t in RESTRICTED:
if t in found and ALLOW_COMMENT not in path.read_text():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require the workflow_run waiver to include a reason

When a workflow contains the marker by itself—or merely embeds it in a quoted run: string—this substring check treats the restricted workflow_run trigger as justified. That lets a workflow bypass the policy without the written safety argument promised by the diagnostic and module documentation; validate an actual comment with a nonempty accompanying justification instead of checking only for the marker anywhere in the file.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right on both readings of the substring test: the marker counted with no argument at all, and it counted inside a quoted run: string. Either way a workflow could claim the exemption without recording the reason, which is the only thing requiring a marker buys.

4690af5 adds waiver_status, which requires the marker on a COMMENT line and a non-empty reason, either on that line or on the comment lines directly beneath it, since a justification usually runs onto the next line. Three cases added to the test: the marker alone fails, the marker inside a run: string fails, a reason on the line beneath passes. The two live workflow_run waivers already carry their reasons, so neither changed.

For the record, unslothai/unsloth's scripts/lint_workflow_triggers.py has the same substring behaviour. That one predates this work and I have left it alone rather than widen this change, but it is worth the same treatment.

…ason

Two review findings, both correct.

The lint's own path filters did not list scripts/unsloth/check_workflow_triggers.py,
so a commit weakening the checker would not have run it. Only test_*.py and the two
older workflow checkers were listed. That is the same defect class this repository's
sibling work has been closing, a filter that omits what the job executes, and it
would have applied to the security check itself. Added to both trigger lists.

The waiver was a bare substring test, so the marker counted anywhere in the file:
alone with no argument at all, or embedded in a quoted run: string. Both let a
workflow claim the exemption without recording the reason, which is the only thing
requiring a marker buys. waiver_status now requires the marker on a COMMENT line and
a non-empty reason, either on that line or on the comment lines directly beneath it,
which is how a justification reads naturally.

Three cases added to the test: the marker alone fails, the marker inside a run:
string fails, a reason on the line beneath passes. 14 checks, all passing, and the
two live workflow_run waivers already carry their reasons so neither changed.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 22, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4690af58fd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

for i, line in enumerate(lines):
stripped = line.strip()
# A comment line, not a marker buried in a string or a run: body.
if not stripped.startswith("#") or ALLOW_COMMENT.lstrip("# ") not in stripped:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject waiver markers inside YAML block scalars

Checking line.strip().startswith("#") still treats shell comments inside a multiline run: | scalar as YAML comments. A workflow_run workflow can therefore put the marker and a purported reason on consecutive comment lines inside its script and waiver_status() returns success even though the file has no actual waiver comment. Fresh evidence beyond the earlier quoted-string case is that the common block-scalar form remains accepted; identify comments using YAML token/source locations rather than stripped raw lines.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and the fresh-evidence framing is right: the round-1 fix closed the quoted single-line case and left the multi-line one, which is how run: is actually written. A block scalar body is opaque text, # opens a comment in shell exactly as in YAML, and a line-by-line reader cannot tell them apart.

_block_scalar_lines now computes the extent of every block scalar and those lines no longer count, for the marker or for the reason lines beneath it. A block scalar runs until the indentation returns to its key level, blank lines included, so the extent is computable without YAML token positions; I stayed with the line reader rather than moving to a parser because the rule is about where a comment sits in the source and the reader is what the sibling checks in this repo use.

Three checks added: the block-scalar marker fails and reports the marker as absent rather than unreasoned, and the same file with a real comment above it passes, so the rule rejects the location and not the justification. Pushed as 7762e2c.

waiver_status() required the marker to be on a comment line, which closed the
quoted single-line case. It did not close the multi-line one, and that is how
run: is actually written. A YAML block scalar body is opaque text, `#` opens a
comment in shell exactly as it does in YAML, and a line-by-line reader cannot
tell the two apart. So

      - run: |
          # lint:workflow_triggers-allow-workflow_run consumes no artifact

satisfied the waiver while saying nothing about the workflow: it is script
content, and script content is the part of a synced upstream workflow nobody
reads closely.

_block_scalar_lines() computes the extent of every block scalar and those lines
no longer count, for the marker or for the reason lines under it. A block scalar
runs until the indentation returns to its key's level, blank lines included, so
the extent needs no YAML parser. The comment form with a reason still passes,
which is the point: the rule rejects the location, not the justification.

Three checks added to test_check_workflow_triggers.py: the block-scalar marker
fails and reports the marker as absent rather than unreasoned, and the same file
with a real comment above it passes.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7762e2c25f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

key's level, blank lines included, so its extent is computable without a YAML parser.
"""
inside: set[int] = set()
opener = re.compile(r"^(\s*)(?:-\s+)?[\w.\"'-]+:\s*[|>][-+]?\d*\s*(?:#.*)?$")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize both valid block-scalar indicator orders

When a workflow uses the valid YAML spelling run: |2- (indentation indicator before chomping indicator), this regex does not recognize the scalar opener, so a marker comment inside that script body is treated as a real waiver and the restricted workflow_run passes. Fresh evidence beyond the previously reported block-scalar case is that the new matcher accepts |-2 but not the equivalent |2-; handle both orders or derive scalar source ranges from the YAML parser.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. YAML fixes no order between the chomping and the indentation indicator, so |2- is as valid as |-2, and my [|>][-+]?\d* matched only the second. The consequence is the one you name: the opener is not recognised, so the entire body of that step goes back into scope as ordinary comment lines, which is the hole the block-scalar fix had just closed reached by a different spelling of the same step.

Now [|>](?:\d+[-+]?|[-+]?\d*), which takes |, >, |-, |2, |-2 and |2-. Test added over the |2- form of the same fixture.

Comment on lines +124 to +129
for k, follow in enumerate(lines[i + 1:], start = i + 1):
f = follow.strip()
if k in in_block or not f.startswith("#"):
break
if f.lstrip("# ").strip():
return True, ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require an explicit justification after a bare marker

When a bare marker is followed by any unrelated comment, such as the repository's standard # SPDX-License-Identifier: ... header, this branch accepts that text as the reason and waives workflow_run without a safety argument. Fresh evidence after the earlier bare-marker fix is that placing the marker immediately above the existing license header still passes; require an explicit continuation such as # Justified: ... rather than treating every adjacent comment as justification.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and it is worse than a generic weakness here: every workflow in this repository opens with # SPDX-License-Identifier and # Copyright, so a bare marker placed directly above that header waived the trigger while saying nothing, and it would read as perfectly ordinary in a diff. The easiest reason in the tree to borrow by accident.

A continuation line must now announce itself with # Justified:, and Justified: with nothing after it is rejected too. This costs nothing on the live files, because both existing waivers already write # Justified: ... on the line under the marker, which is why the introducer is the right shape for the rule rather than a new burden. The failure message names the required form so the documented remedy is the one that works.

Three checks: the license header rejected, the empty Justified: rejected, and the real form still passing. Pushed as 7b268e7.

…an argument

Both indicator orders open a block scalar. YAML fixes no order between the
chomping and the indentation indicator, so `|2-` is as valid as `|-2`, and
matching one order only left the entire body of a `run: |2-` step back in scope
as ordinary comment lines. That is the same hole the block-scalar fix just
closed, reached by a different spelling of the same step.

A continuation line must now announce itself with `# Justified:`. Accepting any
adjacent comment as the reason meant the file's own
`# SPDX-License-Identifier` and `# Copyright` header counted as the safety
argument, so a bare marker placed directly above that header waived the trigger
while saying nothing. Every workflow in this repository opens with that header,
which made it the easiest reason in the tree to borrow by accident, and it would
have read as perfectly ordinary in a diff.

The introducer costs nothing here, because both live waivers already write
`# Justified: ...` on the line under the marker. The failure message names the
required form.

Four checks: the `|2-` body, the license header rejected as a justification,
`Justified:` with nothing after it rejected, and the real form still passing.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7b268e7d56

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# only one order left `run: |2-` unrecognised, which put its whole body back in scope
# as ordinary comment lines.
opener = re.compile(
r"^(\s*)(?:-\s+)?[\w.\"'-]+:\s*[|>](?:\d+[-+]?|[-+]?\d*)\s*(?:#.*)?$"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize spaced block-scalar mappings

When a workflow uses valid YAML such as run : |, PyYAML still parses the value as a block scalar, but this regex does not match because it requires the colon immediately after the key. A marker in that script body is consequently omitted from in_block and accepted as a real waiver, allowing an unjustified workflow_run workflow to pass. Fresh evidence beyond the previously reported block-scalar indicator bypass is that run : | with # lint:workflow_triggers-allow-workflow_run fake in its body makes this checker return success; accept valid key/colon separation or derive scalar ranges from the parsed YAML.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

x

Comment on lines +137 to +139
tail = stripped.split(ALLOW_COMMENT.lstrip("# "), 1)[1].strip(" #:-")
if tail:
return True, ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject an empty inline Justified label

When the waiver is written as # lint:workflow_triggers-allow-workflow_run Justified:, stripping punctuation leaves Justified, so this branch accepts it even though no safety argument follows the label. Fresh evidence after the bare-marker fix is that this exact inline spelling still makes an otherwise unjustified workflow_run pass, while the equivalent separate bare # Justified: line is correctly rejected; handle the optional inline label explicitly and require text after it.

Useful? React with 👍 / 👎.

…ners

Two attempts at recognising a block-scalar opener with a regex both had holes,
and they were holes of the same shape: first only `|-2` and not the equally
valid `|2-`, now only `run:` and not `run :`. Each miss puts an entire script
body back in scope as ordinary comment lines, so each one is a full bypass of
the waiver rule rather than a rough edge, and the supply of valid YAML spellings
is larger than the supply of patience for enumerating them.

`yaml.compose` already knows exactly which lines are scalar content. The ranges
now come from each node's `start_mark` and `end_mark`, which is shorter than
the regex it replaces and closes the class rather than another instance of it.

Separately, the optional inline label no longer stands in for the reason it
introduces. `strip(" #:-")` turned a bare `Justified:` into the non-empty string
`Justified` and accepted it, so the inline spelling passed where the equivalent
separate `# Justified:` line was correctly refused. The label is removed
explicitly and what remains has to be text.

Three checks: the `run : |` body, the empty inline label rejected, and
`Justified: <reason>` inline still passing.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1033c33e4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +102 to +103
if isinstance(node, yaml.ScalarNode):
if node.style in ("|", ">"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude multiline quoted scalars from waiver comments

When a single- or double-quoted YAML scalar spans multiple lines and a continuation line begins with # lint:workflow_triggers-allow-workflow_run reason, PyYAML treats that line as scalar content, but _block_scalar_lines() records only | and > nodes. I verified that an active workflow_run containing this marker inside a multiline quoted run: value returns success. Fresh evidence beyond the earlier single-line quoted-string case is that valid multiline quoted scalars bypass the new startswith("#") protection; exclude every parser-identified quoted or block scalar source range.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and the general form is better than the enumeration I had. I checked it directly: a double-quoted run: spanning three lines reports style " with start (7,13) and end (9,16), and the # line in the middle is scalar content that a |-and->-only version read as a comment.

Every scalar style is now included, which needs no list at all: whatever lies strictly inside a scalars extent is that scalars value, by definition of the marks. Renamed to _scalar_content_lines, since it is no longer about block scalars specifically. test_the_marker_inside_a_multiline_quoted_scalar_does_not_count covers it. Pushed as b2c264c.

Comment on lines +104 to +107
# start_mark.line is the line holding the indicator; the body begins
# after it and runs to end_mark.line.
for i in range(node.start_mark.line + 1, node.end_mark.line + 1):
inside.add(i)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop including the comment after a block scalar

When a real waiver comment is placed immediately after a run: | block, PyYAML reports the scalar's end_mark.line as that following comment line with column zero. Including the endpoint therefore adds the waiver to in_block, causing this otherwise valid workflow_run file to fail with “has no ... comment”; I verified this exact layout returns exit code 1. Use the end column to distinguish content ending on a line from an exclusive line boundary instead of unconditionally including the endpoint.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed against PyYAML before fixing, and it is the worse of the two errors this rule can make: a false failure on a correct file. For a run: | block followed by a dedented line, the scalar reports end_mark as (that line, column 0), so including the endpoint unconditionally put the waiver comment itself into the excluded set and the file failed with "has no comment". The next person to write the waiver at the bottom of a file would have hit it.

The end is now exclusive when end_mark.column is 0. test_a_waiver_comment_right_after_a_run_block_still_counts uses exactly that layout: run: |, echo hi, then the waiver at column 0.

Two defects in the parser-based version, and they fail in opposite directions.

The end mark is exclusive when its column is 0. PyYAML reports a block scalar
followed by a dedented line as ending at (that line, column 0), so including the
endpoint unconditionally swallowed the line AFTER the block. A real waiver
comment placed immediately after a `run: |` step was therefore read as script
content and the file failed with "has no comment". That is a false failure on a
correct file, which is the worse of the two errors this rule can make, and it
would have been hit by the next person who put the waiver at the bottom.

Every scalar style counts, not just `|` and `>`. A double-quoted scalar can span
lines too, and a continuation line of one can begin with `#`; PyYAML reads that
as content while a `|`-and-`>`-only version read it as a comment, so the marker
could be smuggled into a quoted `run:` value. The general rule needs no
enumeration: whatever lies strictly inside a scalar's extent is that scalar's
value, by definition of the marks. Renamed to _scalar_content_lines, since it is
no longer about block scalars specifically.

Both verified against PyYAML directly before fixing: the block in the first case
reports end (6, 0) pointing at the waiver line, and the quoted scalar in the
second spans three lines with the marker on the middle one.

Two checks added, one per direction.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b2c264ccb1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

for i, line in enumerate(lines):
stripped = line.strip()
# A comment line, not a marker buried in a string or a run: body.
if i in in_block or not stripped.startswith("#") or ALLOW_COMMENT.lstrip("# ") not in stripped:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require the exact waiver directive

When a comment contains a near-match such as # lint:workflow_triggers-allow-workflow_runs, this substring test recognizes the canonical marker and then treats the trailing s as a nonempty justification, so an unjustified workflow_run passes. Fresh evidence after the quoted/block-scalar fixes is that this exact spelling makes the checker exit successfully; require the comment directive to end at a token boundary before parsing its reason.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and I checked it before changing anything because the asymmetry is striking: # lint:workflow_triggers-allow-workflow_runs returned (True, "") while the correctly spelled bare marker on the same input was refused. A typo was a more effective waiver than the real directive.

Matched with a (?![\w-]) boundary now, so the directive has to end where it ends. Six checks: the s, -anyway and _now suffixes all refused, and the canonical form followed by a space, a colon or Justified: all still reading their reason. Pushed as the head commit.

A substring test recognised the canonical directive inside
`# lint:workflow_triggers-allow-workflow_runs` and then read the trailing `s`
as the justification, so one mistyped character both claimed the waiver and
supplied its own reason. Checked before fixing: that spelling returned
(True, ''), while the correctly spelled bare marker on the same input was
refused. A typo was a more effective waiver than the real thing.

Matched with a `(?![\w-])` boundary now, so the directive has to end where it
ends. `-anyway` and `_now` suffixes are refused for the same reason, and the
canonical form followed by a space, a colon or ` Justified:` still reads its
reason.

Six checks, three each way.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d15171fcd9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +159 to +161
found = re.search(
re.escape(ALLOW_COMMENT.lstrip("# ")) + r"(?![\w-])", stripped
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Anchor the waiver directive at the comment start

Fresh evidence beyond the previously reported suffix near-match is that this unanchored search accepts the marker anywhere within a comment. For example, # Never use lint:workflow_triggers-allow-workflow_run without review makes a workflow_run file pass because without review is interpreted as its justification, even though the comment explicitly is not a waiver directive. Match the directive at the beginning of the YAML comment body before parsing its reason.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and it returned (True, "") on exactly your example before the fix. A comment saying the opposite of a waiver was read as one, with the rest of the sentence as its justification.

The directive now has to OPEN the comment as well as end at a token boundary, so the comment body must START with it. Three checks, one each for the Never use ..., TODO: drop the ... and see docs for ... shapes.

Comment on lines +193 to +194
- name: No pull_request_target, no unjustified workflow_run
run: python3 scripts/unsloth/check_workflow_triggers.py --root .

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the trigger guard for large sync diffs

When an upstream sync or PR changes more than 300 files, GitHub evaluates paths filters using only the first 300 changed files; if the workflow that reintroduces pull_request_target is outside that subset and none of this workflow's other listed paths are included, unsloth-pr-set-lint.yml never starts and this new step cannot scan the change. That is precisely the large-sync scenario the guard is intended to cover, so host the security check in an unconditionally triggered workflow or otherwise ensure syncs cannot skip it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted, and this is the most useful finding on the PR, because it is the one that makes the check not apply in the scenario it was written for. The module docstring claims the host was chosen so that every workflow edit runs it; the 300-file paths evaluation limit makes that claim conditional exactly when a diff is large, and an upstream sync is routinely far larger than 300 files.

Fixed by giving the check a second home in unsloth-upstream-sync-guard.yml, which has no paths filter and already runs on every push to master, which is how a sync arrives. Keeping it in both places is deliberate: the lint host gives fast feedback on an ordinary pull request, the sync guard gives the guarantee. It is also the natural home, since that workflow already refuses to trust a truncated compare-API file list for the same underlying reason, and now says so in a comment beside the new step.

What this does not cover, stated plainly: a pull request over 300 files can still skip the lint host at PR time. The push-to-master run then catches it, which is the protection level the docstring describes. Pushed as b4ab517.

…kip it

The directive now has to OPEN the comment. An unanchored search accepted it
anywhere in any sentence, so

  # Never use lint:workflow_triggers-allow-workflow_run without review

waived the trigger and offered `without review` as the justification: a comment
saying the opposite of a waiver, read as one. Verified before fixing, it
returned (True, ''). Together with the token boundary added in the previous
commit, the directive now has to start and end where it says it does.

The check also gets a second home in unsloth-upstream-sync-guard.yml, and that
is the one that matters for the case it was written for. Its usual host filters
on `paths`, and GitHub evaluates a `paths` filter against only the FIRST 300
changed files. An upstream sync is routinely far larger, so a sync
reintroducing upstream's bench.yml under its active name could land without the
host ever starting: the check skipped precisely when it applies, which is worse
than not having it, because the module docstring claims that host guarantees
coverage.

The sync guard has no `paths` filter and already runs on every push to master,
which is how a sync arrives. Keeping the check in both places is deliberate:
the lint host gives fast feedback on an ordinary pull request, this one gives
the guarantee. Fitting, since the sync guard already refuses to trust a
truncated answer from the compare API for the same reason.

Three checks for the anchoring, one per mid-comment shape.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b4ab517335

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +94 to +95
- name: No workflow may subscribe to pull_request_target
run: python3 scripts/unsloth/check_workflow_triggers.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Run the sync check before merging

For a sync PR whose workflow file falls outside GitHub's 300-file path-filter window, this new second host still cannot block the unsafe workflow: unsloth-upstream-sync-guard.yml subscribes only to pushes to master, so this step first runs after the sync has merged and the reintroduced pull_request_target workflow is already active. The new push-only placement is fresh evidence beyond the earlier path-filter finding; add an unfiltered pull_request trigger or another required pre-merge check for syncs rather than relying on a post-merge failure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and it is the right escalation of the previous finding rather than a repeat of it: I moved the check to a host with no paths filter but left it push-only, so for the exact case being discussed it ran after the merge, with the reintroduced workflow already active. That is a notification, not a guard.

The sync guard now also subscribes to pull_request, unfiltered, so the trigger check can fail a sync before it lands. The two existing steps ask whether a recorded upstream commit is still an ancestor of master and whether the fork delta stays inside .github/ and scripts/unsloth/; both are questions about master's own history and are meaningless against a pull request head, so they keep their current behaviour behind if: github.event_name != 'pull_request'. Only the trigger check runs on both events.

The second host added in the previous commit was push-only, so for the case it
was meant to cover it ran after the merge, with the reintroduced
`pull_request_target` workflow already active on master. That is a
notification, not a guard, and the finding it was answering was specifically
about a sync pull request whose workflow file falls outside GitHub's 300-file
path-filter window.

The guard now also subscribes to `pull_request`, unfiltered. No `paths:` there
either, for the same reason the filter was the problem in the first place.

The two existing steps ask whether a recorded upstream commit is still an
ancestor of master and whether the fork delta stays inside .github/ and
scripts/unsloth/. Both are questions about master's own history and are
meaningless against a pull request head, so they keep their current behaviour
via `if: github.event_name != 'pull_request'`. Only the trigger check runs on
both events, which is the one that has to be able to fail a sync before it
merges.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d9e231566

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +175 to +176
if tail.strip(" #:-"):
return True, ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject punctuation-only waiver reasons

Fresh evidence after the earlier empty-label fix is that a comment such as # lint:workflow_triggers-allow-workflow_run !!! still passes: the punctuation survives tail.strip(" #:-"), so this branch returns success even though no written safety argument exists. This allows an unjustified workflow_run workflow to bypass the policy; require substantive text after any optional delimiter or label rather than merely a nonempty punctuation string.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, confirmed before fixing: both !!! and *** returned (True, ""). The punctuation survived the strip and stood in for the argument the marker exists to record.

_is_reason now requires three word characters, applied to both the inline form and the # Justified: continuation line. The threshold is arbitrary in the way any threshold here would be, and the docstring says so along with the honest scope of the rule: it rules out the degenerate case only. Whether an argument is a GOOD argument is a question for the reviewer, not for a lint, and I would rather write that down than imply the check is doing more than it is.

Nine checks: four punctuation shapes each way, plus a short real reason (no checkout) still passing, so the threshold does not quietly start demanding an essay.

`strip(" #:-")` left `!!!` and `***` standing as non-empty strings, so a marker
followed by punctuation claimed the waiver while recording nothing. Confirmed
before fixing: both returned (True, '').

`_is_reason` requires three word characters. The threshold is arbitrary in the
way any threshold here would be, and the docstring says so along with the honest
scope of the rule: it rules out the degenerate case only. Whether an argument is
a GOOD argument is a question for the reviewer, not for a lint.

Applied to both the inline form and the `# Justified:` continuation line. Nine
checks: four punctuation shapes each way, plus a short real reason still passing
so the threshold does not demand an essay.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ada5352c13

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

it is the honest scope of this check: it rules out the degenerate case only. Whether
an argument is a GOOD argument is a question for the reviewer, not for a lint.
"""
return len(re.findall(r"\w", text)) >= 3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject underscore-only waiver reasons

Fresh evidence after the punctuation-only fix is that Python's \w includes underscores, so a waiver such as # lint:workflow_triggers-allow-workflow_run ___ satisfies this threshold and lets an unjustified workflow_run pass. Require actual alphanumeric prose (preferably at least one letter) rather than regex word characters, which treat _ as substantive text.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and a good catch on my own fix: Python's \w includes the underscore and digits, so ___ cleared the threshold while saying exactly as much as the !!! the threshold was added to reject.

Counted as letters now, [^\W\d_]. Three checks added: ___, 123 and _ _ _, each rejected inline and on a # Justified: continuation line, with the short real reason still passing so the threshold does not start demanding an essay. The docstring keeps the same framing and is a raw string now so the \w in it is not a syntax warning.

Python's `\w` includes the underscore and digits, so `___` cleared the
three-word-character threshold while saying exactly as much as the `!!!` the
threshold was added to reject. Counted as letters now.

The docstring keeps the same honest framing, and is a raw string so the `\w` in
it does not become a syntax warning. Three checks added: `___`, `123` and
`_ _ _`, each rejected inline and on a continuation line.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 8ee82e1747

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen
danielhanchen merged commit 119fa9d into master Sep 22, 2026
0 of 5 checks passed
@danielhanchen
danielhanchen deleted the security/reject-pull-request-target branch September 22, 2026 14:30
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.

3 participants