Skip to content

feat(pr-pipeline): lane-arm the merge gate's own code on a verified pr-merge attestation - #10966

Closed
kyle-sexton wants to merge 24 commits into
mainfrom
feat/lane-arm-gate-workflows
Closed

kyle-sexton wants to merge 24 commits into
mainfrom
feat/lane-arm-gate-workflows

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Refs: #10743

Lifted from a person by this PR

merge.lane-arm-gate is true, so these 20 paths ship lane-armed (generated LANE_ARMED). They are the merge gate's own code: babysit_risk.py and every .github/scripts/pr-merge/ script on the floor, floor-ast.py included.

.github/scripts/pr-merge/merge-holds.mjs
.github/scripts/pr-merge/enable-auto-merge.mjs
.github/scripts/pr-merge/superseded.mjs
.github/scripts/pr-merge/clear-escalations.mjs
.github/scripts/pr-merge/carry-run-cache.mjs
.github/scripts/pr-merge/gate-check.mjs
.github/scripts/pr-merge/head-ref-map.mjs
.github/scripts/pr-merge/lane-records.mjs
.github/scripts/pr-merge/last-report.mjs
.github/scripts/pr-merge/rate-stop.bash
.github/scripts/pr-merge/refire-refused.mjs
.github/scripts/pr-merge/rotation.mjs
.github/scripts/pr-merge/sweep-summary.mjs
.github/scripts/pr-merge/who.mjs
.github/scripts/pr-merge/post-unarmed-status.mjs
.github/scripts/pr-merge/verify-answers.mjs
.github/scripts/pr-merge/listing-carry.mjs
.github/scripts/pr-merge/lane-armed.mjs
.github/scripts/pr-merge/floor-ast.py
plugins/source-control/skills/babysit-prs/scripts/babysit_risk.py

Residual, accepted by owner decision Q5: a change that keeps the hold floor whole but weakens the logic of these files merges on the two bound AI reviews plus the pr-merge/lane-armed attestation, with no person. No check reads what that logic does. The hub audits each such merge afterwards.

Only the lanes App's own pull requests lane-arm. A pull request from any other author keeps these paths with a person.

Summary

Owner decisions, verbatim:

  • Q5 (how a PR that changes the merge gate's own code gets through): "GO WITH WHATEVER THIS SUGGESTS". Decision: option 1. Build a pr-merge/lane-armed check-run attestation that ci-status verifies, bound to a pr-merge run on main, and lane-arm the gate code when both bound review lanes are clean, with zero findings, zero threads and no hold-floor shrink. Workflows, credentials and the review criteria are not lane-armed. Owner arms continue for gate-code PRs until this PR merges.
  • Q-C (the review criteria pr-review-main.yml copies from main): "Lets go with whatever aligns with: [standing direction]". Decision: enforcement.

Still person-held

Every other trust and gate row (generated PERSON_HELD):

.github/actions/check-trusted-trigger/**
**/CODEOWNERS
**/.gitmodules
.github/actions/resolve-config/**
.github/actions/check-kill-switch/**
.github/actions/request-lane-token/**
.github/actions/log-api-budget/rate-guard.mjs
.github/actions/log-api-budget/rate-limits.json
.github/actions/report-check-run/posting-run.mjs
scripts/sync-hold-floor.py
plugins/source-control/skills/babysit-prs/scripts/babysit_merge.py
plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py
plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py
docs/conventions/pr-pipeline.yaml
.github/scripts/pr-review-main/**
**/.claude/**
**/.claude-lane/**
docs/conventions/instruction-exception-register/**
docs/conventions/untrusted-content/**
**/.mcp.json
**/CLAUDE.md
**/CLAUDE.local.md
**/AGENTS.md
REVIEW.md
docs/standards/**
every gated workflow (23, from babysit_risk.gated_workflow)

The trusted-actor list still holds through trust-gate:trusted-actors.

New floor rows

  • .github/scripts/pr-merge/lane-armed.mjs and .github/scripts/pr-merge/floor-ast.py: lane-armed.
  • plugins/review/skills/** and plugins/review/reference/**: enforcement (Q-C). A change to them waits for a clean bound security review, with no person.

Through the synced lists, these rows also hold the same pull requests every existing floor row holds:

  • a pull request from an author no review lane reviews (the exempt-author hold_patterns in pr-require-checks.yml);
  • a pull request an agent App touched (.github/trust-path-hold.json).

Fix

  • Lane-armed set. scripts/sync-hold-floor.py generates LANE_ARMED and PERSON_HELD into babysit_risk.py and merge-holds.mjs.

    • LANE_ARMED is babysit_risk.py plus every .github/scripts/pr-merge/ floor row (lane_armable), trust rows included, less merge.person-held.
    • PERSON_HELD is every other trust and gate row.
    • The script refuses to run when a gate row outside the merge gate's code is missing from merge.person-held.
  • The gate.

    • babysit_risk.lane_armed drops the high-path: hold on those paths only for a lanes App PR whose two lanes are bound to a main-run review with no open finding, and which has no open review thread.
    • Those paths are lane sources (babysit_review_evidence.lane_source), so only a main-run review binds them.
    • lane_arm_record records the paths, the lane counts, the thread count and the first failed condition in the gate's laneArmed row.
  • Attestation. pr-merge's lane-armed job runs only on schedule or workflow_dispatch on main. It attests at most one head per run, in three steps:

    • post pr-merge/lane-armed at the head, with the run id in external_id and details_url;
    • upload the run-scoped artifact pr-merge-lane-armed-<pr>-<head>;
    • re-run the head's red ci-status job.
  • The floor check, judged on the change. The attester refuses a change that could weaken the hold floor (lane-armed.mjs floorChange). It never parses the head's text, because a decoy marker pair in a docstring or block comment, or a row inside an expression that drops it (.slice(0, 0), [:0]), fools any text parse.

    • What it reads. The PR's patch to each floor-definition file (babysit_risk.py, merge-holds.mjs, lane-armed.mjs), judged against the base's marked regions. The regions are floor-rows FLOOR_CLASSES, hold-floor HOLD_PATH_CLASSES and hold-floor LANE_ARM. The base is main's copy, read in the exact generated shape: every line between the markers is a comment or a generated row.

    • What refuses. A changed line that names a floor marker. Outside the regions, a line that names a floor table or a set derived from one (FLOOR_CLASSES, HOLD_PATH_CLASSES, HOLD_PATH_FLOOR, HIGH_FLOOR, REVIEW_FLOOR, PERSON_HELD, LANE_ARM* and the rest). Inside them, anything but an added row of the generated shape or an added person-held entry. In any floor file, an added line with a non-ASCII character (Python folds a fullwidth FLOOR_CLASSES to ASCII). A removed row is a shrink.

    • Reflection and rebinding in babysit_risk.py, judged on the parsed trees. The base checkout's floor-ast.py runs as python3 -I <base>/.github/scripts/pr-merge/floor-ast.py <base-copy> <head-copy>. It parses both copies with ast and never imports or runs either; a syntax error refuses. It compares counts between the base and the head, over every Name, attribute and imported name, so an alias (g = globals) or a split line still counts. It refuses a head with more of any of these:

      • Reflective names: globals, locals, vars, setattr, delattr, getattr, exec, eval, compile (the builtin, not re.compile), __import__, importlib, __builtins__, builtins, __dict__, __setattr__, __delattr__, modules, __class__ and __getattribute__.
      • Frame and introspection names: f_globals, f_locals, f_back, _getframe, currentframe, gi_frame, cr_frame, ctypes, gc and inspect. A frame's globals are the module's.
      • Dunder names: any name or attribute that starts and ends with __, such as a function's __globals__.
      • Imports: any import or from statement.

      A logic edit that adds none of these is accepted. These are counts: a dunder, import, frame or reflective reference refuses only when its count rises above the base's. Trading an existing reference for a new one passes. For example, a head that removes one existing getattr and adds getattr(lambda: 0, "__glob" "als__")[...] keeps every count. The JS count has the same limit: [].constructor.constructor(...) and a prototype patch such as Array.prototype.some = ... pass. All of these are the Q5 residual: a logic edit merges on the two bound reviews plus the attestation, and only a check that judges logic closes this class.

      It also refuses a head where a floor table (FLOOR_CLASSES, LANE_ARM_GATE, LANE_ARMED, PERSON_HELD, COST_PATHS) or a set derived from one (FLOOR, HIGH_FLOOR, REVIEW_FLOOR and the rest) is bound other than exactly once, at module level, by one plain or annotated assignment. A table must be bound to a literal ast.literal_eval accepts, and a derived set to the base's own expression. Any other binding refuses: augmented, del, a for, with, except or match target, an import alias, global or nonlocal, a walrus, an argument, a def or a class. The base's tables use annotated assignments, so those are allowed too.

    • The JS floor. merge-holds.mjs binds its tables with const and Object.freeze, which no later line can rebind by name. Its head copy, and the head copy of lane-armed.mjs, also refuses when it holds more of these than the base copy: eval(, Function(, new Function, globalThis, Reflect., Object.defineProperty, __proto__, import(, require(, vm. and process.binding (jsRefusal). The tokens are counted over the whole text, so splitting a string around a table name does not help. Each token is call-shaped or not a path segment, so a floor row naming an eval-* workflow counts as nothing. lane-armed.mjs's own token list sits in a js-dynamic marker block: the block is not counted, and a patch line inside it or naming its markers refuses, so a literal cannot be traded for a real call. Any other JS logic edit stays the Q5 residual.

    • Judge code. floor-ast.py holds no floor table. Its patch is judged for floor markers and hold names only, and its logic edits are the Q5 residual.

    • Where the base is read. The base copy and the patches come from one compare of the base branch with the head (GET /compare/<base>...<head>), and the base copy is read at its merge_base_commit, the commit GitHub measures the patch from. A merge base that cannot be read refuses.

    • Unreadable input. Only a floor file with status modified is judged; a renamed, added or deleted one refuses. A missing patch (GitHub omits one for a large or binary change) refuses. A compare file list shorter than the pull request's changed_files refuses; the compare lists at most 300 files. A line inside a region with a CRLF ending is not the generated shape, so it refuses.

    • What still passes. Pure row additions and logic edits that name no hold.

  • ci-status. merge-holds.mjs laneArmedHold, loaded from the base commit and keyed on the PR head (so merge_group runs check the same thing), passes a lane-armed path only when all of these hold:

    • the check run at the head is from github-actions, succeeded, and carries a record of this PR and head that covers every lane-armed path changed;
    • the run it names is .github/workflows/pr-merge.yml on the default branch, in this repository, started by schedule or workflow_dispatch (never workflow_run), and running or succeeded;
    • the run's commit is the default branch tip, read from /branches/<default>, or an ancestor of it: the compare API returns identical or ahead. head_branch is only a ref name, and a tag can share the branch's name;
    • that run uploaded the unexpired artifact, which only its own steps can.

    Any workflow with checks: write posts as github-actions and can copy a real run id, so the run and its artifact are the proof, not the check run. Anything missing or unreadable holds. A trusted approval of the head still lifts the hold.

  • Cycle. pr-review-main.yml also runs after a red pr-require-checks run, for a PR whose full file list matches main's lane-armed globs. A list of 3000 or more files reviews nothing. Neither the red status nor needs-human blocks the review, so the same-head clear in clear-escalations stays.

  • Measurement (§9). Each lane-armed merge is preceded by exactly one countable attestation record, the line lane-armed {"v":1,"pr":N,"head":"<sha>","paths":[...],"lanes":{"claude-review":0,"claude-security-review":0},"threads":0,"run":<id>}. It is recorded in two places:

    • in the pr-merge/lane-armed check run's summary at the head;
    • as a ::notice:: in the pr-merge run.

    The same record is uploaded as the run's pr-merge-lane-armed-<pr>-<head> artifact. Each head the job declines logs ::notice::lane-armed refused {...,"reason":"..."}.

  • Not done. Showing the person hold as neutral in ci-status is skipped: pr-review-main reads the workflow conclusion, so a neutral-looking annotation changes nothing it decides.

Verification

  • babysit-prs Python suites: 3569 passed.
  • node --test .github/scripts/pr-merge/*.test.mjs .github/scripts/pr-review-main/*.test.mjs: 708 passed. These include:
    • Attestation forgery. Another app; a copied genuine pr-merge run with no artifact for this head; an expired artifact, or an artifact of another run, name or PR; a run of another workflow, from a PR branch, from pull_request_target, pull_request or workflow_run, failed, queued, in a fork, or with another id; a run whose commit is not on main, which covers a tag named main; a record for another head, run, version or PR, or one missing a path; each read failing. Mutating away the branch, app, event, artifact or on-branch check fails them.

    • The floor judge (floorChange and floorRefusal, .github/scripts/pr-merge/lane-armed.mjs). Patches come from git diff. Each of these refuses: a Python docstring decoy; a JS block-comment decoy; a LANE_ARM decoy; .slice(0, 0); [:0]; a removed or weakened row; a moved marker; a HOLD_PATH_CLASSES = [] re-definition; a rebound FLOOR; a newly lane-armed glob; a dropped person-held entry; a fullwidth identifier; a removed last row when main has moved past the merge base; a missing patch; a renamed file; a cut-short list; an unreadable merge base. Each of these is accepted: a pure row addition, a person-held entry, a logic edit, and a RE.exec( method call.

    • floor-ast.py, against the real babysit_risk.py. Each of these refuses:

      • reflective references: locals()[...], an alias g = globals, (globals\n)() split over lines, builtins.setattr, object.__setattr__, sys.modules[__name__].__setattr__, __import__(...).exec, importlib, getattr, the compile builtin, __builtins__;
      • frames, dunders and imports: (lambda: 0).__globals__[...], sys._getframe().f_globals[...], inspect.currentframe().f_globals[...], an added import helper;
      • rebindings: a second, augmented, loop, with, import-alias, walrus, def, argument, del or global binding of a floor name;
      • a re-derived FLOOR, a non-literal LANE_ARM_GATE, and a syntax error.

      A logic edit that uses re.compile, a pure logic edit inside an existing function, and a row addition are accepted.

    • The JS count, and floor-ast.py. An added eval, globalThis, Function(, Reflect., Object.defineProperty, __proto__, import( or require( refuses, and so does an eval added to merge-holds.mjs end to end. A logic edit is accepted, and so is a name like evaluate. A logic edit to floor-ast.py is not refused for an unreadable base. The previous checker accepts the __globals__, _getframe and import helper cases. End to end, a locals() rebinding that the patch rules accept is refused through floor-ast.py. The previous text-parsing floor check accepts the block-comment decoy.

  • scripts/ci-lanes-pr-hold.test.sh: PASS=243 FAIL=0. scripts/ci-review-lanes.test.sh: PASS=140 FAIL=0.
  • scripts/sync-hold-floor.py --check exits 0 at this head, which shows only that the generated copies match. It is not a control on a lane-armed PR: it runs the head's own test_merge_holds_sync.py (follow-up 1). actionlint is clean.

Known follow-ups (MEDIUM, defense in depth over the Q5 residual)

These ship in a follow-up PR right after this one merges.

  1. The sync check can be skipped. sync-hold-floor.py --check runs through the head's test_merge_holds_sync.py in test-python, and the tests directory has no floor row, so a PR can skip or weaken it.
  2. #11048: judge reflective references by position, not count. Add constructor, prototype, getPrototypeOf and setPrototypeOf to JS_DYNAMIC. Refuse a getattr or setattr with a non-literal name, and any string concatenation that joins into a dunder or a table name.
  3. Floor readers outside FLOOR_FILES. clear-escalations.mjs and enable-auto-merge.mjs read the floor tables outside FLOOR_FILES. A logic change there is the accepted Q5 residual.

Related

🤖 Generated with Claude Code

kyle-sexton and others added 3 commits October 29, 2026 10:49
…main-run reviews

Owner decision Q7 (#10743): merge.lane-arm-gate (on) lets a pull request
held only by `gate` floor paths merge with no person once both review
lanes are bound to the main-run review of its head with no finding and no
review thread is open. merge.person-held is the merge-enforcement set that
stays with a person: the pr-merge scripts that apply the holds,
pr-merge.yml, ci-status, the review workflows, the main-run review's
scripts and pr-verify.yml. Every `trust` path stays with a person too.

- sync-hold-floor.py copies both keys into babysit_risk.py and
  merge-holds.mjs and refuses a person-held entry that is not a trust or
  gate floor row; only a literal `true` turns the switch on.
- babysit_risk drops the `high-path:` hold on a lane-armed glob only when
  both lanes are main-run bound (LaneEvidence.main_run) with no open
  finding and open_threads reads 0; the gate reads the thread count only
  when it could lane-arm.
- merge-holds.mjs holds a lane-armed path neither in ci-status nor in
  enable-auto-merge, and an approval never carries over a push touching one.
- clear-escalations removes the human-gated label at the same head when the
  refusal names lane-armed paths alone and the gate's reader refuses
  nothing there.

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

- sync-hold-floor's lane_arm: only a literal true turns the switch on; a
  person-held entry off the floor, an enforcement row or a repeat stops it.
- enable-auto-merge: a hold-path refusal the switch lane-armed does not
  relabel once the gate passes the head.
- The README's gate bullet, babysit_risk's floor comment, merge-holds.mjs's
  header and the diff-check comment name the switch where they said a gate
  path always waits for a person.

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

sameNetChange and babysit_merge_policy.same_net_change compared patch text
with each hunk header's line numbers, so a base merge editing the same file
above the pull request's hunk refused to carry an approval and cost the
owner a re-approval. Both now drop the header numbers before comparing; the
hunk bodies, context lines, section heading, hunk count, status and
previous_filename still compare exactly. The #10224 exact-carry fixtures
gain a context change so they still exercise exactBaseCarry.

Also: ci-lanes-pr-hold.test.sh drops a one-item loop shellcheck flags
(SC2043), which blocked the pre-push check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 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-29T15:12:32.083692Z e6696af PR opened
ℹ️ 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

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

claude-security-review reviewed 081662b44e6440d70e6f5d572bebbad0489878f4: 1 finding(s), 1 as inline comments.

Incremental security review of lane-armed.mjs, its test and the README line, against the previous review at 9563725.

critical-findings: 0

What I checked

  • Locked block patch rule (floorChange, locked argument): the boundary arithmetic is right. A removed line is refused strictly between the markers. An added line is refused from the BEGIN line through the line before END. patchLines sets at to the base line an added line follows, so the block cannot be edited or extended through a patch.
  • Marker handling: MARKER now includes js-dynamic, so any added or removed marker line is refused. A head copy therefore cannot gain a second block, and cannot move or drop the existing one. The jsRefusal fallback also fails closed. A head copy whose bounds is not exactly one block is counted whole, literals included, which trips the count.
  • Block exclusion in jsRefusal: the base and head blocks are byte-identical because the patch rule forbids changing them. Excluding them only drops the self-referential literals. I found no way to make block text execute as code.
  • floorRefusal: it fails closed when the base copy of lane-armed.mjs has no single block. That includes this PR's own merge base, so a person lands it. This is a fail-closed behaviour and not a finding.
  • Test and README changes: the test world() fallback reads the checkout's own file and has no security effect. The README matches the code.

Finding (1, IMPORTANT): the eval token was narrowed from \beval\b to \beval\s*\(, which stops counting indirect-eval and aliased forms. See the inline comment.

Spend: none.

No manifest fields were touched, so there are no manifest <field> lines.

@github-actions github-actions Bot added the needs-human Human-in-the-loop required; autonomous sessions must not resolve items carrying this. label Oct 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

claude-security-review holds this pull request for a person (critical-security) at e6696af5bc92616c480efeba8996723fbb270816.

The PR turns on merge.lane-arm-gate in docs/conventions/pr-pipeline.yaml, which weakens a merge hold. Credentialed workflows (release-publish-plugins.yml, work-resolve-issue.yml) and agent settings and instruction files (.claude/, CLAUDE.md, AGENTS.md, .mcp.json, docs/standards/) then merge on a model-only verdict, with no person. The remaining control is a model review of attacker-influenced diff text, and the repository rulesets are outside the diff. Whether that suffices is unsettled, so it is rated CRITICAL under the fail-closed rule.

claude-lane: claude-security-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: e6696af5bc

ℹ️ 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 thread .github/scripts/pr-merge/clear-escalations.mjs
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

claude-review reviewed 081662b44e6440d70e6f5d572bebbad0489878f4: 1 finding(s), 1 as inline comments.

Incremental review of lane-armed.mjs, its test and the pr-pipeline README since 9563725. I read the diff and the head copy of lane-armed.mjs. I did not run the tests.

What I checked

  • Patch-line lock. The locked check in floorChange uses the right base-line arithmetic. A removed line is refused strictly between BEGIN and END. An added line is refused when it follows BEGIN through the line before END. An added line after END is allowed.
  • Block counting. jsRefusal strips the block from the base and head copies and counts what is left. A head with a missing or duplicated block keeps its literals in the head count and refuses, because the base count excludes them. The js-dynamic marker is also refused at the patch level.
  • Base copy without a block. floorRefusal fails closed when the base copy of lane-armed.mjs has no single block. This PR's own base lacks the block, so the PR itself cannot attest. That fits a person-held floor file.
  • Tests. The new test lane-armed.mjs cannot trade a JS_DYNAMIC literal for a real call exercises both layers: the count alone, then the patch rule. The eval-foo.yml floor-row case covers the false positive the narrowing fixes.

Finding (1, doc drift)

  • The README describes the lane-armed check but not the new js-dynamic marker block or the patch-line lock. See the inline comment.

Observation, not counted as a finding

  • Narrowing eval to eval( means a head that only aliases it no longer trips jsRefusal. Examples are const run = eval; and (0, eval)(x). An indirect eval runs in global scope and cannot reach the module-level floor tables. Function and Reflect aliases were already outside the call-shaped patterns, so this is consistent with the existing design. It is worth knowing about if the guard's intent is "no dynamic code at all".

No cost or critical-security hold applies. No closing issues were checked against criteria in this pass.

kyle-sexton and others added 7 commits October 29, 2026 11:44
…unk-offset carry

An independent security review of #10966 found the lane-armed set too wide.
Owner decision Q11 stays intact:

- merge.person-held now lists the pr-merge scripts with every module and
  script they import or run, the rate guard and its limits,
  .github/actions/report-check-run/** (widened from posting-run.mjs), the
  main-run review's scripts, and the review criteria and agent settings and
  instruction files. sync-hold-floor.py adds every gated workflow
  (gated_workflow), so a workflow that gains a credential is person-held at
  once.
- plugins/review/skills/** and plugins/review/reference/**, which
  pr-review-main.yml copies from main as the model's criteria, are new
  `gate` floor rows outside the trusted-author lift, and are person-held.
- With the switch on, sync-hold-floor.py refuses to leave any `gate` row
  lane-armed. No ci-status attestation exists yet, and the closure test
  only proves no person-held file loads a lane-armed path. The lane-armed
  set is empty.
- The rule, the same-head clear and the approval-carry refusal take an
  injectable floor, so their tests run against a sample set.
- sameNetChange and same_net_change compare patches exactly again. An evil
  merge could relocate an approved hunk.

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

# Conflicts:
#	scripts/sync-hold-floor.py
…on the floor

Remove the merge.lane-arm-gate switch, the merge.person-held list, the
lane-arm scoring, the clear-escalations same-head clear and their tests:
the lane-armed set was empty, so all of it was dead code. Drop the new
floor rows the revision added, so the pull request adds no person hold.

test_merge_holds_sync.py now checks that what every trust row and the
merge-enforcement gate scripts import or run sits on a trust or gate
row. The paths it reaches today that do not are pinned in
OFF_FLOOR_ALLOWED for the owner's decision.

Refs: #10743

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Restores the tree of 07751e0 after owner decision Q5 turned this pull
request into the gate-attestation PR.

Refs: #10743

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kyle-sexton and others added 4 commits October 29, 2026 13:49
…r-merge attestation

Owner decision Q5 option 1: babysit_risk.py and the pr-merge scripts
merge with no person once both review lanes are bound to a clean
main-run review of the head, no review thread is open, the hold floor
does not shrink against the base, and pr-merge has attested the head.

- sync-hold-floor.py generates LANE_ARMED (the merge gate's own code,
  trust rows included) and PERSON_HELD (every other trust and gate row);
  it refuses a gate row outside the merge gate's code left unlisted.
- pr-merge's lane-armed job posts pr-merge/lane-armed at the head, uploads
  the run-scoped artifact pr-merge-lane-armed-<pr>-<head>, and re-runs the
  head's red ci-status job. Each attestation and refusal is a countable
  record line.
- ci-status (merge-holds.mjs laneArmedHold) passes a lane-armed path only
  on that check run naming a pr-merge.yml run on the default branch that
  uploaded the artifact; a trusted approval still lifts it.
- pr-review-main.yml reviews a lane-armed pull request after a red
  ci-status too, which breaks the label and status cycle the same-head
  clear needed.
- Only the lanes App's own pull requests lane-arm; the 8413b31 floor
  rows are dropped.

Refs: #10743

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-review: 2 inline finding(s) on 60af038043ca12628cd7f97f2938e2c900d3caca; the summary is in this lane's pull request comment.

claude-lane: claude-review

Comment thread .github/scripts/pr-merge/merge-holds.mjs
Comment thread .github/scripts/pr-merge/merge-holds.mjs Outdated
@github-actions

Copy link
Copy Markdown
Contributor

claude-review holds this pull request for a person (critical-security) at 60af038043ca12628cd7f97f2938e2c900d3caca.

The head ships merge.lane-arm-gate: true with 19 lane-armed paths (babysit_risk.py and 18 .github/scripts/pr-merge/ scripts, including merge-holds.mjs and lane-armed.mjs). A pull request that rewrites the merge gate's own logic can merge with no person once the two bound AI reviews are clean and pr-merge/lane-armed is posted. The PR description says the opposite: lane-armed set ∅, LANE_ARMED_FLOOR = [], merge-holds.mjs person-held, and sync refusing any lane-armed gate row. A person must confirm the shipped scope is the intended one before this merges.

claude-lane: claude-review

@kyle-sexton kyle-sexton changed the title feat(pr-pipeline): lane-arm gate workflow and hold-path PRs on bound main-run reviews feat(pr-pipeline): lane-arm the merge gate's own code on a verified pr-merge attestation Oct 29, 2026
kyle-sexton and others added 2 commits October 29, 2026 14:11
…ed or hand-started run

The lane-armed job runs only on schedule and workflow_dispatch, so a
workflow_run run of pr-merge.yml can never have attested a head.
laneArmedHold now refuses one. The README states that a lane-armed
merge also needs the attestation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd the floor markers

- laneArmedHold requires the attesting run's commit to be the default
  branch tip or an ancestor of it (compare API against the tip from the
  branches API), since head_branch is only a ref name a tag can share.
- lane-armed.mjs reads floor rows only between each file's markers and
  refuses missing, repeated or out-of-order markers, and block comments
  or multi-line strings inside a region.
- plugins/review/skills/** and plugins/review/reference/**, the criteria
  pr-review-main copies from main, become enforcement floor rows.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on ffdde4431a5e573d68dd804dcf4c18d0f488920b; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread .github/scripts/pr-merge/lane-armed.mjs Outdated
kyle-sexton and others added 2 commits October 29, 2026 14:37
…e head's text

A decoy marker pair in a docstring or block comment, or a row inside an
expression that drops it, fools any text parse of the head. lane-armed.mjs
now reads the patch to each floor-definition file against the base's
marked regions, read in the exact generated shape: a line naming a floor
marker, a hold identifier outside the regions, or anything but an added
row or person-held entry inside them refuses, as does a removed row, a
missing patch, a renamed floor file or a cut-short file list.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on e4812b19d5a810cead29f591d79ea95bc61d41ee; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread .github/scripts/pr-merge/lane-armed.mjs

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-review: 1 inline finding(s) on e4812b19d5a810cead29f591d79ea95bc61d41ee; the summary is in this lane's pull request comment.

claude-lane: claude-review

Comment thread .github/scripts/pr-merge/lane-armed.mjs Outdated
…ebinding lines

GitHub measures a pull request's patch from the merge base, so a base
copy read at the moved base branch tip could place a removed row outside
its region. lane-armed.mjs now takes the base copy and the patches from
one compare of the base branch with the head, reading the base at its
merge_base_commit. An added line in a floor file that holds a non-ASCII
character (Python folds a fullwidth identifier to ASCII) or a reflective
call (globals, setattr, vars, exec, eval, __dict__) refuses.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-review: 2 inline finding(s) on 6daeb478cc76c4b12d305d704b53ea4d64325134; the summary is in this lane's pull request comment.

claude-lane: claude-review

Comment thread .github/scripts/pr-merge/lane-armed.mjs Outdated
Comment thread .github/scripts/pr-merge/lane-armed.mjs

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on 6daeb478cc76c4b12d305d704b53ea4d64325134; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread .github/scripts/pr-merge/lane-armed.mjs Outdated
…t lines

A line denylist misses locals(), an alias of globals, a call split over
lines, object.__setattr__, sys.modules and the rest. floor-ast.py, run
from the base checkout with python3 -I, parses the base and head copies
of babysit_risk.py and runs neither. It refuses a head that references a
reflective name more often than the base, or that binds a floor table or
a derived set other than once at module level (a table to a literal, a
derived set to the base's expression). The line rule keeps only its
non-ASCII refusal; floor-ast.py joins the floor as a lane-armed row.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-review: 2 inline finding(s) on 860916aef19187a58d70688aa222ea584894d043; the summary is in this lane's pull request comment.

claude-lane: claude-review

Comment thread .github/scripts/pr-merge/floor-ast.py
Comment thread .github/scripts/pr-merge/lane-armed.mjs Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 2 inline finding(s) on 860916aef19187a58d70688aa222ea584894d043; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread .github/scripts/pr-merge/floor-ast.py
Comment thread .github/scripts/pr-merge/lane-armed.mjs
kyle-sexton and others added 3 commits October 29, 2026 15:22
… AST check

A function's __globals__ or a frame's f_globals is the module namespace,
so (lambda: 0).__globals__[...] and sys._getframe().f_globals[...] rebind
a floor table past the reflective-name count. floor-ast.py now also
refuses a head with more dunder names, more imports, or more frame and
introspection names than the base copy.

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

floor-ast.py holds no floor table, so reading it as one refused every pull
request that touched it; it is now judged for markers and hold names only.
merge-holds.mjs and lane-armed.mjs regain a reflective check: the head copy
may hold no more eval, Function(, globalThis, Reflect., defineProperty,
__proto__, import(, require(, vm. or process.binding than the base copy.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-review: 2 inline finding(s) on 95637252837f47b48fadde8923dfcd14701e7ca3; the summary is in this lane's pull request comment.

claude-lane: claude-review

Comment thread .github/scripts/pr-merge/lane-armed.mjs
Comment thread .github/scripts/pr-merge/lane-armed.mjs Outdated
lane-armed.mjs holds the tokens it counts as literals, so a head could
drop one literal and add one real call. The list now sits in a js-dynamic
marker block that is not counted and that no patch line may touch. eval
counts only when called, so a floor row naming an eval-* workflow is no
longer refused.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-review: 1 inline finding(s) on 081662b44e6440d70e6f5d572bebbad0489878f4; the summary is in this lane's pull request comment.

claude-lane: claude-review

Comment on lines +2511 to +2514
`Object.freeze`, which no later line can rebind by name. Its head copy, and `lane-armed.mjs`'s,
also refuses when it holds more `eval(`, `Function(`, `globalThis`, `Reflect.`,
`Object.defineProperty`, `__proto__`, `import(`, `require(`, `vm.` or `process.binding` than the
base copy. Any other logic edit there stays the residual below. `floor-ast.py` holds no table:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This paragraph covers the new eval( wording, but not the rest of this change. lane-armed.mjs now holds its token list in a js-dynamic JS_DYNAMIC marker block. The block's literals are excluded from the dynamic-token count. Any patch line inside the block refuses, and so does a changed line that names the js-dynamic marker. The step-4 text above lists only the floor-rows and hold-floor markers. A reader of this page will not learn that editing the token list in lane-armed.mjs always refuses attestation, or why the count skips that block. Add a sentence here and name the new marker alongside the others at lines 2494-2496.

claude-lane: claude-review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on 081662b44e6440d70e6f5d572bebbad0489878f4; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

// room for a real call.
const JS_DYNAMIC = Object.freeze([
// BEGIN js-dynamic JS_DYNAMIC
["eval(", /\beval\s*\(/g],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The eval token is narrowed from /\beval\b/g to /\beval\s*\(/g, so jsRefusal no longer counts forms where eval is not directly followed by (. Examples: eval?.(src), (0, eval)(src), and const e = eval; e(src). All of these were counted before and now count as zero.

The path is a pull request that edits merge-holds.mjs or lane-armed.mjs, the files this check guards. It can add one of these forms and jsRefusal returns null. The text of an eval call can be assembled from split strings, which is the case the comment above this list describes. These forms are indirect eval, which runs in global scope. It cannot rebind the module's frozen const tables, and plain logic edits to these files are already the documented residual. That is why this is IMPORTANT and not CRITICAL.

The narrowing was made so a floor row such as .github/workflows/eval-browser-dispatch.yml counts as nothing. A pattern that keeps the alias forms and still skips eval- filenames is /\beval\b(?!-)/g.

severity: IMPORTANT

claude-lane: claude-security-review

@kyle-sexton kyle-sexton added the do-not-merge Hard merge gate: do not merge while applied. label Oct 29, 2026
@kyle-sexton

Copy link
Copy Markdown
Member Author

Superseded by owner decision Q15 (no path-based merge holds; harden later). Path holds go off behind one config switch, merge.path-holds, on branch feat/merge-path-holds-switch.

@kyle-sexton kyle-sexton removed the needs-human Human-in-the-loop required; autonomous sessions must not resolve items carrying this. label Oct 29, 2026
@kyle-sexton

Copy link
Copy Markdown
Member Author

Replacement for owner decision Q15: #11058, which turns path-based merge holds off behind merge.diff-check.path-holds (default false). Hardening means setting it to true once open issues and open PRs both reach 0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do-not-merge Hard merge gate: do not merge while applied.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant