Skip to content

chore(probe): cross-repo GITHUB_TOKEN read probe (throwaway, do not merge) - #271

Closed
kyle-sexton wants to merge 1 commit into
mainfrom
probe/cross-repo-token
Closed

chore(probe): cross-repo GITHUB_TOKEN read probe (throwaway, do not merge)#271
kyle-sexton wants to merge 1 commit into
mainfrom
probe/cross-repo-token

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

Throwaway Phase 0 probe for #238. Settles empirically whether a workflow GITHUB_TOKEN can read ANOTHER repository's Actions/Checks data when that repository is public.

GitHub's docs never join the two axes. The installation-token side says the token is "limited to the repository that contains your workflow" and "cannot be granted access to repositories that the installation was not granted access to"; each target endpoint says it "can be used without authentication ... if only public resources are requested". Nothing states the evaluation order, so #238's ratified receiver-side polling architecture rests on an unverified premise. If the answer is 404, the poller needs a GitHub App or PAT, breaking the zero-new-secrets constraint that already excluded event-driven dispatch — which would send #238 back to design rather than to a build tweak.

This probe does not admit #238 for work. That item stays human-gated; this only informs the operator's decision.

Six arms, two permission postures ({} and actions+checks: read). A bare 200 is confounded — public Actions data is world-readable — so the private-repo arm is the discriminator:

  • A: unauthenticated to public claude-code-plugins (baseline)
  • B: GITHUB_TOKEN to public claude-code-plugins (the question)
  • B2: GITHUB_TOKEN to public standards (second target; rate-limit bucket test)
  • C: GITHUB_TOKEN to PRIVATE medley (discriminator)
  • D: GITHUB_TOKEN to own ci-workflows (sanity)
  • E: the full detection chain cross-repo — runs, then jobs, then check-run annotations

Also captures x-ratelimit-limit/remaining/resource and x-accepted-github-permissions on every call, to settle whether the 1,000 req/hr bucket is keyed to the workflow's repo or to each target repo.

Reads only. No writes, no secrets beyond the ambient token, and the token value is never echoed.

Test plan

The workflow IS the test. Read the two jobs' logs and compare arm B against arm C:

Related

Refs #238. Follows the throwaway-probe shape of #270 (pull_request-triggered, permissions: {}, closed unmerged, branch deleted).

Do not merge. This PR is closed and the branch deleted once the logs are captured.

This PR was opened by an AI agent (adversarial-verification lane, on the operator's behalf).

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 41s —— View job


Code Review

  • Read the changed file
  • Review against CLAUDE.md security rules
  • Analyze probe design and script correctness
  • Compile findings

Security & CLAUDE.md Alignment

The probe is well-aligned with the repo's security ground rules:

Rule Status
pull_request only (no pull_request_target/workflow_run with secrets)
No untrusted checkout before action step ✅ (no checkout at all)
Token never echoed to logs ✅ (env var only, -sS suppresses curl request headers)
Minimal permissions ({} / actions+checks: read)
No ACTIONS_STEP_DEBUG
Token not passed as CLI arg (would appear in process listings) ✅ (-H "Authorization: Bearer ${PROBE_TOKEN}" reads from env)

One subtle positive: for fork PRs, GitHub provides a read-only, secrets-stripped GITHUB_TOKEN — so the probe is naturally sandboxed even if triggered by an external contributor.


Probe Design

The discriminator logic is sound. ARM C (private repo) is the correct falsifiability test — a 200 on ARM B alone is confounded by world-readable public data, and the PR description correctly identifies this. The x-accepted-github-permissions + x-ratelimit-resource header captures are the right diagnostic observables for both questions the probe is asking.


Findings

1. ARM E intermediate calls don't capture rate-limit headers

Lines 80–97 (and mirror at 141–157): the two intermediate curl calls that resolve RUN_ID and CR_URL use bare curl -sS without -D "$hdrs", so their response headers — including x-ratelimit-resource and x-accepted-github-permissions — are never printed. If the rate-limit bucket question is the secondary goal, these calls are hits that go unaccounted.

If it matters, wrapping them with a lightweight version of probe() (or adding a separate probe_silent that captures headers without printing the body) would cover it. For a throwaway probe it's a minor observability gap, not a blocker.

2. set -uo pipefail without -e

Line 34 (and L96): omitting -e is intentional here — you want all arms to run even if one fails — but it means a silent error in the probe() setup (e.g., a failed mktemp) won't abort and could produce misleading output. Since || code=curl-error handles curl failures and the || true guards on grep/echo are present, the practical risk is low. Worth a note in case someone reads this later and wonders.

3. Code duplication between jobs

The two jobs are identical except for the permissions: block. A matrix strategy (matrix: [no-permissions, actions-checks-read]) with conditional permissions would eliminate the duplication, but for a short-lived probe the verbosity is arguably clearer. Not a blocker.

4. jq error handling in ARM E

Line 82: if the API returns an error body (e.g., a 404 JSON), jq -r '.workflow_runs[0].id // empty' will attempt to index null, producing a jq type error to stderr. The || true suppresses the non-zero exit and ${RUN_ID:-} in the null-check handles the empty result gracefully — so the behavior is correct. Just worth knowing when reading the logs: a jq error line on stderr doesn't mean the arm misfired.


Summary

Probe design is solid and all security-relevant constraints from CLAUDE.md are respected. The only actionable item is finding #1 (missing header capture for ARM E's intermediate calls) if the rate-limit bucket question is load-bearing. Everything else is cosmetic. Ready to run as-is for the Phase 0 empirical check.

@kyle-sexton kyle-sexton added the do-not-merge Hard merge gate: do not merge while applied. label Jul 26, 2026
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 4s —— View job


Security Review — probe-cross-repo-token.yml

  • Read changed file and analyze data flows
  • Check trigger, permissions, token handling, and injection surfaces
  • Report findings

Overall: This probe is well-constructed from a security standpoint. Safe trigger, no checkout, token in env rather than interpolated inline, no set -x, no github context piped into shell. One data-flow finding; everything else checks out.


SUGGESTION — ARM E forwards the Bearer token to an API-response-derived URL without host validation

Confidence: MEDIUM

Lines 85–92 (identical pattern at lines 154–161):

CR_URL="$(curl ... | jq -r '.jobs[0].check_run_url // empty')" || true
...
probe "ARM E ..." yes "${CR_URL}/annotations"   # PROBE_TOKEN forwarded here

CR_URL comes from an external API response body. It is used, without host validation, as the destination of a subsequent curl call that attaches Authorization: Bearer ${PROBE_TOKEN}. The probe() function's auth=yes branch sends the token to whatever URL is in that variable.

The immediate risk is low: the source is GitHub's own API (extremely high trust), curl does not follow redirects by default (no -L), and the token carries only {} or actions+checks: read for a short lifetime. But the pattern — API-response value → credential-bearing curl destination — is worth noting because it skips the defense-in-depth step of pinning the target host before attaching credentials. A host prefix check ([[ $CR_URL == https://api.github.com/* ]]) would close the gap for negligible cost.


Everything else is clean:

Check Result
Trigger pull_request only — no pull_request_target or workflow_run
github context in run: None — no script-injection surface ✓
Token handling Passed as env var; never echoed; no set -x
Permissions {} top-level; per-job {} and actions+checks: read only ✓
No checkout No actions/checkout, no untrusted code in workspace ✓
No external uses: No action pins to audit — pure run: shell ✓
Temp file cleanup rm -f "$hdrs" "$body" in every probe call ✓
ARM C (private repo) Expected 404 — no exfiltration path ✓
Log hygiene Error bodies capped at 200 bytes; no token echo ✓

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Probe complete; results recorded on #238 (#238 (comment)).

Verdict: DESIGN LIVES. GITHUB_TOKEN -> public cross-repo = 200 (even with permissions: {}), private = 404, full runs -> jobs -> annotations chain = 200. Rate-limit bucket is shared across target repos; observed ceiling 5000/hr against a documented 1000/hr.

Closing unmerged and deleting the branch as planned.

This comment was written by an AI agent (adversarial-verification lane, on the operator's behalf).

@kyle-sexton
kyle-sexton deleted the probe/cross-repo-token branch July 26, 2026 23:04
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