Skip to content

feat(source-control): trust a size-split sub-issue's task lines in the merge gate - #9945

Merged
kyle-sexton merged 8 commits into
mainfrom
feat/split-child-trust-mirror
Oct 25, 2026
Merged

kyle-sexton merged 8 commits into
mainfrom
feat/split-child-trust-mirror

Conversation

@kyle-sexton

Copy link
Copy Markdown
Member

Refs: #9906

Summary

pr-merge's Python verifier-evidence reader now applies the split-child trust rule that pr-verify's JS applies (#9906, hardened in #9940). An issue the lanes App filed counts as trusted spec only when it is a size split's sub-issue of a native parent written by a listed human, and its lines are that human's task-list lines. The owner confirmed the tightened rule on 2026-10-25, relayed by Issue Triage.

Fix

  • split_child_body (babysit_verify_evidence.py):
    • Every line after the split header must be one of the parent's trusted task-list lines. The prefix is admission's task-list read from admission-rules.json at the base branch. A missing or empty task-list counts nothing.
    • On the edited path (a listed human is the last editor), a line may also be an allowed line with its single box ticked ([x] or [X]). A marker without exactly one box allows exact lines only.
  • _split_child reads the child, its parent and the rules once per reader. Any failed read is verify:unreadable, matching resolve-pr's throw. A cut comment page counts nothing. A reused answer re-notes when each read behind it was taken.
  • The pr-pipeline README describes the rule, and there are changelog fragments for source-control and pr-pipeline.

Verification

  • The shared .github/scripts/intake-triage/split-child.vectors.json matches main byte for byte (blob 06da3284): 21 of 21 vectors pass.
  • python3 -m unittest discover -s tests -t . (babysit-prs scripts): 2895 tests OK.
  • Mutation checks:
    • Dropping the task-line filter fails exactly the prose and heading vectors.
    • Dropping the ticked-form allowance fails the two edited-path vectors.
    • The re-note test fails on the old code.
  • Fresh security review at a4721ce: OK. A differential run of 59 adversarial cases against the JS found 0 divergences (Unicode spaces, header spoofing, editor and comment authors, task-list values). Two shared-design P4 findings went to the JS owner and shipped as fix(pr-pipeline): close two non-listed paths into split-child trust #9940.

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_01LY6PpXaikB8J2tRLeypMmn

kyle-sexton and others added 8 commits October 24, 2026 17:37
…ts them

pr-merge's spec-trust check now mirrors Issue Triage's split-child rule
(admit.mjs splitChildBody): a linked issue the lanes App wrote counts when
its task lines come verbatim from its listed-human parent's text or from
the newest unedited intake refinement its header names. The sub-issue, its
parent and the parent's comments are read in one GraphQL call under the
Actions token, only for an App-authored issue. The shared vectors file is
copied byte for byte so the gate's test runs every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-issue

The gate's split-child rule now matches Issue Triage's tightened
splitChildBody: an unedited sub-issue the lanes App filed counts only when
every line after its header is one of the parent's trusted task-list
lines, those starting with admission's `task-list` prefix. The prefix is
read from admission-rules.json at the base branch, once per reader and
only for an App-authored issue; a missing or empty prefix counts nothing.
The refinement-source path is gone, as in the JS. A failed read of the
sub-issue, its parent or the rules now refuses verify:unreadable, as
resolve-pr.mjs fails on it; a cut comment read stays untrusted. The shared
vectors file is copied byte for byte from the Issue Triage branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LY6PpXaikB8J2tRLeypMmn
A split-child answer the reader reuses for a later queued pull request
now notes again when each read behind it was taken (the task-list rule,
the child and parent, the trusted list), so `_kept_recently` dates that
judgment by its oldest input as for a fresh read. The task-list read
notes its time on every answer, as the trusted-list reads do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LY6PpXaikB8J2tRLeypMmn
An edited sub-issue now counts only when every line after the header is
one of the parent's trusted task-list lines, or that line with its one
`[ ]` ticked to `[x]` or `[X]` (indent and text unchanged), matching
admit.mjs splitChildBody and tickedForms. A task-list prefix without
exactly one `[ ]` takes exact lines only. All 21 shared vectors pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LY6PpXaikB8J2tRLeypMmn
… fix a code span

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LY6PpXaikB8J2tRLeypMmn
@kyle-sexton
kyle-sexton marked this pull request as ready for review October 25, 2026 08:47
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 25, 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-10-25T08:50:19.638353Z 9e9c8be Draft marked ready
ℹ️ 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.

@github-actions

Copy link
Copy Markdown
Contributor

claude-security-review reviewed 9e9c8beb93c22a8fbacb9030d2c9bb1df1128800: 0 finding(s), 0 as inline comments.

No security findings.

critical-findings: 0

I read the change under the diff, pr-context.md, and the existing admit.mjs splitChildBody and tickedForms. No changed file matches a trusted path (trusted-actors, check-trusted-trigger, CODEOWNERS, .gitmodules). The change does touch a merge gate's spec-trust decision, so I read it whole. I did not run the tests.

What the change does. pr-merge's babysit_verify_evidence.py now also counts a linked issue as trusted spec when the lanes App filed it as a size-split sub-issue. That is a widening of the gate, so I checked whether an attacker could reach it.

Checked against admit.mjs, no divergence found:

  • Author: the recorded author must be a Bot whose id equals the lanes App id. The live GraphQL author is then checked again against that id. The app id comes from the base-branch trusted-actor list through merge.agent-identity. With no identity, or an identity the list doesn't name, nothing counts.
  • Parent: the parent must be filed by a listed human, and must be in this repository (nameWithOwner == repo). The check is case-sensitive, so a mismatch fails closed.
  • Edits: if the child was edited, the last editor must be a listed human.
  • Header: the body must start with the exact header rebuilt from the parent number and the part numbers, and each following line must be in the allow-list.
  • Allowed lines: a line is allowed only if it starts with task-list, after leading JS whitespace, and comes from a parent body or comment that a listed human wrote and last edited. The lanes App's refinement comment is excluded, as in JS. The edited path adds only the [x] and [X] ticked forms. The "exactly one [ ]" rule is implemented the same way.
  • JS_SPACE: the set matches String.prototype.trimStart (verified by regex over the source line).
  • Comment page: a totalCount that differs from the node count counts nothing, so a parent with more than 100 comments fails closed.
  • Rules file: admission-rules.json is read at the base branch (self.branch), so a PR cannot change the prefix it is judged by. A missing, empty or non-string task-list counts nothing. Failed reads raise _Unreadable, which refuses with verify:unreadable. They do not fall through to trust.
  • Cache: the new trusted-logins and task-list entries use the same run-scoped cache, key and MAX_AGE as the existing trusted-humans entry. The per-child answer is cached by issue number, but its gate inputs are fixed by the number plus the live read. Each reuse re-notes the read times, so the spec-changed freshness logic still sees them.
  • _trusted_humans refactor: _read_trusted_list returns the same humans set as before. The or frozenset() fallback cannot be reached, because _humans is always set after the read.
  • Token: the GraphQL read uses self._env() (the Actions token when one is set) and passes env_overrides through gh_capture. No credential is logged or placed in an argument.

Docs and changelog fragments: prose only. They describe the same rule and add no standing instruction that removes a control.

Cost: no billed resource or plan change. The gate now makes one extra GraphQL read plus one contents read per lanes-App-filed linked issue, which draws only on the job's Actions token.

The residual risk is the shared design (the JS and Python copies must be kept in step), which the shared split-child.vectors.json test covers. No finding.

@github-actions

Copy link
Copy Markdown
Contributor

claude-review reviewed 9e9c8beb93c22a8fbacb9030d2c9bb1df1128800: 0 finding(s), 0 as inline comments.

Code review: PR #9945 (split-child trust in the pr-merge verifier-evidence reader)

No findings. I read the whole diff (6 files) and the PR context. The PR closes no issue (it only says Refs: #9906), so there are no closing-issue criteria to check. I ran no tests myself.

What I checked

  • Reader construction sites. Reader(...) in babysit_merge.py (both sites) now gets agent_identity. main() in babysit_verify_evidence.py sets it only when --base-sha gives a config, so with no config no sub-issue counts. That fails closed. babysit_merge_skip.py:538 builds a Reader only to read the artifact listing, so it needs no identity.
  • Plumbing.
    • _env() and actions_token already exist (babysit_verify_evidence.py:804 and :638). The new env parameter on _graphql falls back to the plain call when there is no token.
    • ACTOR_FRAGMENT is defined at :253, and SPLIT_CHILD_QUERY uses it.
    • is_json_object and json_object come from babysit_util.
  • Run cache.
    • run_cache.get_dated returns (value, at) for any value stored under the section and key, so a stored None task-list is read back as a hit.
    • A put into a section that isn't a dict is replaced.
    • track() and note() match how _split_child uses them. The first judgment notes every read it depended on, and a reused answer re-notes the same times.
    • A failed read raises _Unreadable before anything is stored in _split_children, so a failure is never cached as "not trusted".
  • JS_SPACE. I grepped the constant (babysit_verify_evidence.py:286). After \t\n\v\f\r it holds exactly 20 characters, all from JavaScript's trimStart whitespace set (space, U+00A0, U+1680, U+2000–200A, U+2028, U+2029, U+202F, U+205F, U+3000, U+FEFF).
  • split_child_body logic.
    • Checks run in this order: lanes-App author, same-repo parent, complete comment page, listed-human parent author, edited-path editor, header match, then the line filter.
    • A trailing newline or an empty body after the header leaves a "" line that is never allowed, so it fails closed.
    • A task-list that is missing, empty or not a string counts nothing.
    • ticked_forms returns nothing unless the prefix has exactly one [ ].
  • Fragments. The source-control minor bump fits a new gate capability, and the pr-pipeline patch bump fits a README-only change.

Author-claimed and not checked by me

The 2895-test pass, the 21 of 21 shared vectors, the mutation checks and the 59-case differential run against the JS are from the PR description. I did not run them. The new test file covers the vectors, the edited-path ticking rule, failed reads, a cut comment page, a missing task-list, one read per sub-issue under the Actions token, and re-noting of read times.

Minor, not counted

  • The diff includes two reformat-only hunks in babysit_verify_evidence.py (_newest signature, the sliced condition) unrelated to the change.
  • In docs/conventions/pr-pipeline/README.md the edited bullet re-wraps unevenly. One line is well over 100 characters and The / model's spec text breaks mid-phrase. Reflowing it would read better.

No cost or critical-security hold. This is not a billing change, and it narrows trust rather than widening it: it trusts a lanes-App-filed sub-issue only when every line is a listed human's task-list line.

@kyle-sexton
kyle-sexton added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit 6819810 Oct 25, 2026
67 checks passed
@kyle-sexton
kyle-sexton deleted the feat/split-child-trust-mirror branch October 25, 2026 09:07
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.

1 participant