Public: anyone can read this; nobody can change it.

On a host the operator names, an origin is refused when its account administers the instance or can push to a protected repository #71

opened this view on origin

Check · Review · Land · Live — No review yet. Next: reviewer, no review yet.

Merges on the origin

**Stacked on #70.** This branch has that pull's commit as its first commit. Only the top commit is new. Land #70 first; the guard uses its scope words.

0 files · +0 −0

waiting checks and the preview wait for a writer to approve this head

Check on trevato-apps/forge: running, as of

Checks and the preview wait for a writer to approve this head

A guest

This pull comes from a guest: its preview runs without the app's variables or a forge token until a person who can write here approves it.

This pull cannot land as it is, whatever its review says, because it changes guarded paths: (the diff) (it could not be read).

opened this view on origin

Stacked on #70. This branch has that pull's commit as its first commit. Only the top commit is new. Land #70 first; the guard uses its scope words.

A forge keeps an origin's credential on the machine where its agents run. So the risk is the account that a person authorized. A token of an instance administrator, or a token that can push to a protected repository (a repository whose main deploys something), gives that power to every agent run. A team that runs many forges against one Forgejo needs to say "not those accounts" once, for every forge.

The operator now names the hosts to guard. A host is guarded when it is in FORGE_ORIGIN_CLIENT_ID, or when a FORGE_ORIGIN_DENY_PUSH rule covers it. On a guarded host, a credential is refused before it is stored when its account administers the instance (is_admin / site_admin), or when it can push to a protected repository (permissions.push).

  • The guard runs on each way in: a pasted token, the code flow and the device flow. On the code flow the grant is revoked where the provider can do it. A refusal stores nothing.
  • A repository named outright is asked about by name. A wildcard walks /repos/search?uid= sorted by id, with no fallback, at most 20 pages. The walk is complete only when the distinct repositories it saw equal X-Total-Count, and that count did not change between pages. Anything less fails closed (guard_unchecked). A confirmed hit always wins (can_push).
  • A stored origin is checked again: on each OAuth refresh, before the new token is used, and by a sweep on the sync tick (an origin never checked at once, the others daily, one origin per tick). A refusal sets needs_reauth with the guard's sentence. A refresh never clears it.
  • On a guarded host, an origin writes only after its account passed. Until then, or while a check cannot run, write-through, push-through, carried branches, forks and contributions answer origin_paused, and the adapter refuses each request that is not a read. Settings → Origins says so and has Check again (POST /settings/origins/:id/check, once a minute). A check that has not run for a day refuses the origin.
  • The walk fills the repository picker's cache at connect.

What is unchanged: a host that is not guarded behaves as before. With neither variable set, no host is guarded, so a person who runs their own Forgejo can still connect it as its administrator. One change to note: a forge that already sets FORGE_ORIGIN_CLIENT_ID (for example through the chart's forge.originClients) guards those hosts from this version on, so an administrator's account is refused there. If you prefer the guard to come only from FORGE_ORIGIN_DENY_PUSH, that is one line in guarded (src/origins/operator.ts).

docs/ORIGINS.md, "The operator guard", says all of this, and README's origin paragraph names the new variable.

Environment

  • FORGE_ORIGIN_DENY_PUSH (new, optional): comma-separated owner/name patterns. matches inside one part, a bare owner means owner/, and host:pattern limits a rule to one host. A rule with no host covers every origin.
  • FORGE_ORIGIN_CLIENT_ID keeps its syntax; its hosts are now guarded too.

Tests

bun 1.3.13, a scrubbed environment (no agent variables):

  • test/origin-guard.test.ts: 23 pass, 0 fail, against the fake origin with a real forge serving git: a pasted token and an OAuth consent, the exact and the wildcard rules, the walk's bound and its count, a refresh and the sweep, paused writes and Check again.
  • With #70's suite and the origin, fork, migrate and cross suites, in one bun test --timeout 60000 call: 489 pass, 0 fail across 35 files.
  • bun scripts/gate.ts and tsc --noEmit: clean.
the 35 files

origin-guard, origin-token-scopes, following, forks, origins, origins-oauth, origins-operator, origins-reconnect, origins-security-auth, origins-security-provider, origins-security-push, origins-sync, origins-sync-settled, web-origins, web-origin-link, mcp-origins, mcp-origin-apps, push-through, trusted-hosts, trusted-hosts-ui, steps-lists, no-polling, refresh-meta, mirror-outbound, mirror-out, carried, migrate, migrate-after, tui-migrate, apps-feedback-mirror, and the five cross files reserved-names, studio, shards, check-list, hollow (each test/<name>.test.ts).

🤖 Generated with Claude Code

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs