Harden untrusted contributor intake into an executable preflight - #341
Harden untrusted contributor intake into an executable preflight#341justin808 wants to merge 1 commit into
Conversation
Summary - Add skills/untrusted-contributor-intake/bin/untrusted-contributor-intake-preflight: a metadata-only, fail-closed helper that validates the trusted origin policy, classifies PR_REF, performs one gh pr view --json number,url lookup, and validates the server-returned canonical URL. - Deduplicate host/port/DNS-label, repository, and exact-PR-URL validation into one shared implementation used by the trusted origin, PR_REF, and canonical URL paths. - Make SKILL.md invoke the helper as the single source of truth and keep prose for policy, host enforcement, reporting, and disposition guidance. - Add the helper's own test, strengthen contract tests around exact snippet/helper binding and documented marker uniqueness, and register the new test in bin/validate. Why SKILL.md previously required agents to transcribe four near-duplicate shell authority parsers. The duplicated host, port, and DNS-label rules could drift apart, and a transcription slip could silently weaken a security boundary. The executable helper makes those rules testable in one place while keeping the metadata-only intake boundary: no fork checkout or execution, and no PR body, issue, comment, or review text before preflight succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Warning Review limit reached
Next review available in: 28 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Review: Harden untrusted contributor intake into an executable preflightOverall this is a strong, well-executed refactor. It replaces roughly 250 lines of duplicated POSIX shell (repeated host/port/DNS-label and owner/repo validation logic scattered across five separate snippets) with a single, tested, stdlib-only Ruby helper (bin/untrusted-contributor-intake-preflight), and rewires SKILL.md to invoke it instead of transcribing the logic in prose. I traced the new helper's logic against the removed bash line-by-line and it is a faithful (and in several places stricter) port; no security regression found. What I verified
Minor observations (non-blocking)
Risk/SecurityNo injection, TOCTOU, or fail-open paths found. The helper's own network access is limited to exactly one pinned, metadata-only gh pr view --json number,url call, matching the stated boundary, and this is enforced structurally (single Open3.capture3 call site, asserted by test) rather than just by convention. Nice work; this is exactly the kind of change (prose to tested executable) that reduces the risk of a future transcription slip silently weakening a security boundary. |
QA Evidence — independent Batch QA Lane (
|
| Command | Result |
|---|---|
LANG=en_US.UTF-8 bin/validate |
exit 0 — PASS agent-workflows validation; rubocop 1.87.0, 119 files, no offenses; zero failures/errors anywhere in the log |
ruby .../untrusted-contributor-intake-preflight-test.rb |
24 runs, 823 assertions, 0 failures, 0 errors, 0 skips |
ruby .../untrusted-contributor-intake-contract-test.rb |
49 runs, 750 assertions, 0 failures, 0 errors, 0 skips |
main @ 282a87a baseline (contract test) |
61 runs, 1180 assertions — recorded before checkout to compare the split |
Neither known environment issue interfered: the push-downstream-test.rb locale error (#281) did not occur under LANG=en_US.UTF-8, and the agent-coord-bounded-test.rb flake (#338) did not reproduce on this head (also run 2x green on clean main).
Security boundary — metadata-only (read line by line)
skills/untrusted-contributor-intake/bin/untrusted-contributor-intake-preflight is confirmed metadata-only:
- One network call,
gh pr view <number> --repo <repo> --json number,url(line 216-229). No PR body, comments, reviews, diff, or fork content is ever requested. - No execution of untrusted content: grep for
eval|system(|%x|exec(|popen|Kernel|.send|require_relative|File.|Dir.|checkout|clone|worktreereturns only two prose comment hits. No git invocation at all. - No shell:
Open3.capture3is used in array form, so no shell is spawned and no interpolation of attacker-controlled strings occurs. The PR number reachingghis always\A[0-9]+\z. require_printable(line 85) gates every externally supplied value to bytes0x20..0x7Ebefore any regex, so invalid-UTF-8 input fails closed rather than raising — the failure class behind bin/push-downstream-test.rb is locale-dependent and aborts bin/validate when LANG is unset #281 cannot occur on this path.
Coverage split — no rejection vector lost
The contract test went 61/1180 → 49/750 with a new 24/823 preflight test. I did not accept the net assertion increase as proof. I extracted all 110 URL-shaped attack vectors from the main contract test and diffed them against the union of both post-change files, then empirically probed every class that did not survive textually. All still fail closed:
| Dropped vector class | Live behavior |
|---|---|
trusted host containing ?, #, newline, tab, non-ASCII |
BLOCKED, 0 network calls |
canonical host containing ?, CR injection, non-ASCII |
BLOCKED after exactly 1 call |
ftp://, git@host:owner/repo.git, https://user@/… |
BLOCKED, 0 calls |
| negative / leading-zero PR numbers | BLOCKED |
The .git-remote vectors from main are architecturally obsolete, not lost: SKILL.md now sources trust only from trusted-base .agents/agent-workflow.yml and explicitly forbids deriving it from a checkout remote, and test_ambient_intake_state_cannot_authorize_or_redirect_a_run covers the replacement.
One real (non-blocking) coverage gap: the trusted-host rejection list at untrusted-contributor-intake-preflight-test.rb:237-255 covers / ] [ @ and space but drops the ?, #, and embedded-control-character vectors that main exercised. Behavior is correct (verified above by direct probe) — this is lost test coverage, not a live defect.
Mutation check (independently performed)
Commented out the canonical-host trust check (blocked(CANONICAL_UNTRUSTED) unless gh_host == policy.fetch(:host)) and re-ran the contract test: it went red — 2 failures, test_canonical_authority_must_match_the_trusted_host_policy (line 741) and test_requires_fresh_atomic_trusted_policy_inputs_in_a_persistent_shell (line 577). Restored; git diff against the head is empty. SKILL.md is bound to the real executable — run_documented_preflight runs the documented snippet against UNTRUSTED_CONTRIBUTOR_INTAKE_SKILL_DIR, not a stub or a transcribed copy.
Envelope and acceptance criteria
5 files, no deletions or renames, all within the lane's envelope. bin/validate receives exactly one appended line (+1/-0) registering the new test directly, satisfying #168's acceptance criteria. The helper is executable and runs from a trusted base checkout.
Batch handoff — AW F 08-05 08:40 (lane
|
| Gate | Result at head 1ff7828649af81d011c81d56bf5a87344ee7710b |
|---|---|
pr-ci-readiness v2 |
READY (exact head) |
autonomous-merge-eligibility |
human-approval-required — triggered: architectural-product-judgment, changed-lines-limit (2819 > 999), public-compatibility, security-auth-privacy |
merge-assurance |
eligible: false, verdict: BLOCKED — "auto merge authority lacks a qualifying exact-head autonomous verdict" |
| Evidence failures | none (clean policy result, not UNKNOWN) |
| Rollback | code-only-rollback-established |
merge_authority for this batch was auto_merge_when_gates_pass. That authority does not apply here: the repo's own autonomous_merge policy in .agents/agent-workflow.yml triggers the gates above, so the batch terminates at human review rather than merging. Separately, the seam sets merge_submission: mode: merge_queue_only and no merge queue is enabled on main, so pr-merge-submit would refuse before mutation regardless.
QA: independent Batch QA lane (checker-aw-f, distinct from every maker) — qa-evidence v2 marker on this PR, status: satisfied, release_blocking: clear, bound to head 1ff7828649af81d011c81d56bf5a87344ee7710b.
bin/validate in overlapping context. They are independently mergeable, but whichever merges second needs a rebase. #342 is independent of both.
coordination: unavailable — repo seam sets coordination_backend: "n/a" (.agents/agent-workflow.yml:10), single-operator source repo.
Completed-batch audit: not published. completed-batch-publication-preflight cannot run here for two independent reasons — there is no coordination status to supply (backend n/a), and no target is merged, which it refuses on its own.
Local merge-gate artifacts (CI-readiness / eligibility / assurance JSON) were transient and did not survive temp cleanup; regenerate from the helpers in skills/pr-batch/bin/ against base 282a87aaf4bf1dd602723efddc78252f7a138235.
Fixes #168
Why
skills/untrusted-contributor-intake/SKILL.mdcarried roughly 250 lines of POSIXshell that agents were expected to transcribe verbatim: a trusted-origin
producer, a
PR_REFclassifier, a metadata resolver, a canonical-authorityparser, and a canonical PR URL parser. Four of those blocks each contained their
own copy of the same host / port / DNS-label rules, and three contained their own
copy of the
owner/reporules. Duplicated security rules drift, and atranscription slip in a prose snippet silently weakens a boundary rather than
failing a test.
This PR turns that prose into one executable, tested helper and leaves SKILL.md
to state policy, host enforcement, reporting, and disposition guidance.
What changed
skills/untrusted-contributor-intake/bin/untrusted-contributor-intake-preflight.Stdlib-only Ruby, executable, runnable from a trusted base checkout. It
validates the trusted origin policy, classifies
PR_REF, performs exactly onegh pr view --json number,urllookup pinned to the trusted host andrepository, validates the server-returned canonical URL, and prints nine
KEY=valuelines. Any failure prints oneBLOCKED: ...line to stderr andexits 1.
normalize_authority(host, optional port,DNS labels,
:443-only stripping for https),normalize_repository, andparse_pull_request_urleach exist once. The trusted origin, thePR_REFURL,and the canonical URL all flow through the same code, and the helper test
asserts both the call-site counts and behavioral parity across all three
inputs.
documented
# Intake preflight:snippet that resolvesUNTRUSTED_CONTRIBUTOR_INTAKE_SKILL_DIR, runs the helper, and consumes itsoutput through an allow-listed
case(noeval), then asserts key count,scheme, and host/repo agreement.
PR bodies, issue text, comments, review text, or fork content; a test asserts
its code contains no
body/comments/reviews/graphql/pr diff/checkout/ backtick /system(/exec(references and exactly oneOpen3.capture3.pr-security-preflightis still explicitly not reused.expected read-only, no-execution, no-secrets, no-writes, named-override, and
"report BLOCKED if unenforceable" expectations.
bashblocks now opens with a uniquemarker (
# Intake preflight:,# Metadata gathering:,# Actor authority:),and a contract test asserts each appears exactly once and opens a block. The
previous metadata-gathering extractor keyed off a line that appears twice in
the document.
untrusted-contributor-intake-preflight-test.rb(24 tests, 823assertions) carries the Add safe untrusted contributor intake skill #158 behavior matrix against the real helper. The
contract test keeps its markdown-extraction tests and gains exact
snippet/helper binding tests; its end-to-end tests now execute the documented
snippet against the real helper with a stubbed
gh.bin/validategained oneappended line for the new test.
Decision log
says "e.g.
bin/untrusted-contributor-intake-preflight". It lives atskills/untrusted-contributor-intake/bin/untrusted-contributor-intake-preflightinstead, per
AGENTS.md→ Editing Rules: "Keep helper scripts in the skillfolder that invokes them, unless the helper is repo-wide like
bin/agent-workflow-seam-doctor." Only this skill invokes it, and that folderalready holds
untrusted-contributor-intake-contract-test.rb.files. It would have been convenient to have the helper read
.agents/agent-workflow.ymlitself, but that reintroduces exactly thecheckout-derived trust the Add safe untrusted contributor intake skill #158 design removed (a fork or repointed checkout
could supply the file). The seam contract is unchanged: the invoker reads the
three
untrusted_contributor_intake.*keys from the trusted base and exportsthem.
BLOCKED message. With one code path instead of five snippets:
TRUSTED_GH_REPOnow reportstrusted origin is invalid(previously
metadata resolution is invalid, because the repo check thatfired depended on which snippet the caller had pasted);
numberthat disagrees with the raw URL number nowstops at
metadata resolution is invalidinstead of reaching the canonicalparser.
Both are strictly earlier fail-closed stops on the same rejected inputs; the
five documented BLOCKED message strings are otherwise unchanged.
--jq '"\(.number)|\(.url)"') was kept rather thanswitching to JSON parsing, so the Add safe untrusted contributor intake skill #158 malformed-record rejection vectors port
over one-for-one and the documented
ghcall shape is unchanged.eval. The snippet consumes helper output through an explicitallow-listed
caseand a key count, preserving the skill's existing "no eval,no standalone jq" posture.
CHANGELOG.md/README.mduntouched. Both are outside this lane's ownedpaths. The skill is already listed in both from Add safe untrusted contributor intake skill #158, and this PR adds no new
skill. Flagging for the coordinator in case a changelog line is wanted.
Validation
All commands run from the branch head in a clean worktree.
Note on
bin/validate: withLANGunset (the default in a non-interactive agentshell)
bin/push-downstream-test.rberrors withEncoding::CompatibilityError: incompatible character encodings: US-ASCII and UTF-8at
test_policy_apply_compares_validated_non_ascii_policy_content_as_bytes(line 1576). This is the pre-existing locale dependency tracked in #281,
reproducible on clean base 282a87a; the test is untouched by this PR and passes
under
LANG=en_US.UTF-8, which is why every run above sets it.Confidence note: high on the helper and its behavior coverage — the #158
rejection matrix is ported vector-for-vector, exercised against the real
executable, and a deliberate mutation of the canonical-host check fails the
suite. Medium on prose-assertion churn: SKILL.md wording moved, so several
contract-test string assertions were rewritten to match the new text; those
assertions verify documentation, not behavior. The helper has not been exercised
against a live GitHub or GitHub Enterprise host in this branch — every
ghinteraction in the tests is stubbed.