Skip to content

Untrusted pull requests

manni docevals runs code that content files ask it to run. On a pull request from a fork, the person supplying those content files is a stranger.

Read this before enabling the gate on a public repository.

One path from a content file to code on your runner

Section titled “One path from a content file to code on your runner”

A page can declare a command eval in its frontmatter:

evals:
- id: check
grader: command
command: [node, scripts/whatever.mjs, "{file}"]

That command runs only under execution.allow: [frontmatter-commands] (config) or --allow-execution frontmatter-commands (CLI). It is denied unless you grant it, and a denied command eval reports as skipped.

A command eval defined in manni.config.yaml needs no grant, because the config is yours, not the content’s. On a pull request, though, the config is part of the change too.

Flags are not sufficient. Restrict the job that can execute content-driven code to same-repo pull requests:

jobs:
verify-docs:
runs-on: ubuntu-latest
# SECURITY GATE. Do not remove.
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v7
# ... full run, with secrets available

Then give forks a separate job that runs no page-declared command and needs no secret:

verify-docs-fork:
runs-on: ubuntu-latest
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name != github.repository
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npx @hawkeyexl/manni docevals run --deterministic-only --no-execution --format github

Fork contributors still get every tool:regex check with no credential in reach. A command eval in the fork’s manni.config.yaml still runs there, as anything in the fork’s workflow would. That job holds no secret and gets a read-only token, so it has nothing worth stealing.

Frontmatter and structure are checked by their own domains. Add manni meta validate and manni lint structure as steps in the same job. Neither runs content-authored code.

It runs with repository secrets and can be made to check out the fork’s code. That combination is how tokens leak. If you are reaching for it to give forks a full run, stop: the deterministic job above is the safe version of what you want.

Secrets are unavailable on forks by design

Section titled “Secrets are unavailable on forks by design”

GitHub does not expose secrets to fork pull requests. That is a feature. It also means the fork path must work without a provider, which --deterministic-only does.

If your gate is unusable without a key, fork contributors get no signal at all, and you will be tempted toward the unsafe workaround above.

  • The job that can execute content code is gated to same-repo pull requests.
  • Forks run a separate --deterministic-only job with no secrets.
  • execution.allow grants only what the corpus needs, and nothing on a fork-facing job. That is defence in depth, not the defence.
  • Third-party actions pinned to a full commit SHA, not a tag.
  • permissions: set to least privilege (contents: read for a check job).
  • A turn budget set (judge.maxTurns), so a malicious or accidental change cannot run up a bill.

A pull request that edits the workflow is asking for a change to what runs with your credentials. Review those with the same care as a change to authentication code. Remember too that a contributor who can add a command eval to manni.config.yaml, or a grant to execution.allow, has changed what the whole job runs. That holds even if the workflow file is untouched.