---
title: Trust model
description: Who Foreman trusts, how that decision is made, and why every write is gated the way it is.
type: overview
summary: Caller classes, approval policies, and git safety, with the reasoning behind each gate.
related:
  - /docs/intake
  - /docs/tools
  - /docs/glossary
---

# Trust model



Foreman reads third-party issue bodies, comments, and diffs, and its stations execute repository code inside sandboxes. The trust model keeps that content from escalating into a write it should not have.

One rule carries most of the weight: trust is decided once, at dispatch, from the signed webhook, and stamped into session auth. Nothing downstream re-derives trust from model-readable content, because model-readable content is exactly what an attacker controls. `agent/lib/trust.ts` is the single authority that reads those stamps, and a new capability gates on its predicates rather than inventing its own caller check.

## Caller classes

Every session runs as a principal, the identity stamped into session auth at dispatch. Four classes matter.

Trusted callers carry the `trusted` attribute. The GitHub channel applies it only to commenters whose `author_association` is `OWNER`, `MEMBER`, or `COLLABORATOR`. The Linear channel applies it to every Agent Session, because workspace membership is the gate there. The stamp is written next to the authorization decision itself, so the two cannot drift apart.

Autonomous runs are unattended, with no person watching. They execute under a constructed principal, `github:foreman-factory` with `principalType: "service"`. Factory-label intake and red-CI fix runs work this way. The webhook sender's identity is replaced, since the turn must never run as the labeler, and the intake issue or pull request number is stamped in so comment writes can be scoped to that one thread. The fixed login can never collide with a real actor, because real GitHub actors project as numeric `github:<id>` principals.

Schedule app turns are recognized by `isScheduleAppAuth`. No schedule ships in the template, but the policies already recognize the principal, so a sweep schedule added later inherits sensible write behavior.

Everyone else is untrusted: the local dev TUI and the pull-request summary session. Their writes park on an approval card and wait for a person, which is also how the approval flow stays demoable locally.

## Policy outcomes

The predicates in `agent/lib/github/approval.ts` are the whole authorization policy. Each returns `not-applicable` so the call runs, `user-approval` so the call parks, or `denied` so the call is refused server-side.

| Policy                    | Autonomous                                                      | Trusted / schedule | Other callers |
| ------------------------- | --------------------------------------------------------------- | ------------------ | ------------- |
| `writePolicy`             | denied                                                          | runs               | parks         |
| `commentPolicy`           | runs only on the stamped intake issue; denied elsewhere         | runs               | parks         |
| `labelPolicy`             | runs                                                            | runs               | parks         |
| `factoryBrainPolicy`      | denied (reads stay open)                                        | runs               | parks         |
| `shipPolicy`              | denied                                                          | parks              | parks         |
| `closeIssuePolicy`        | runs                                                            | runs               | runs          |
| `createPullRequestPolicy` | runs when `draft: true`, otherwise follows `shipPolicy`         | same               | same          |
| `updateIssuePolicy`       | `state` set follows `closeIssuePolicy`, otherwise `writePolicy` | same               | same          |

The shape in one sentence: autonomous runs get labels, comments on their own intake issue, close or reopen, and draft pull requests, and are denied everything else; trusted callers run every write except shipping, which parks; everyone else parks on every write.

## Why the outcomes are shaped this way

<Accordions type="single">
  <Accordion title="Unattended runs are denied, never parked">
    Nobody is watching an autonomous turn, so an approval card would strand the session forever. A server-side denial resolves in one step, and the run reports what it could not do.

    The denials leave exactly the writes an unattended run needs: labels to mark the item picked up, progress comments on its own intake issue, close or reopen for triage, and a draft pull request to deliver the work.
  </Accordion>

  <Accordion title="Draft PRs are the unattended ceiling">
    `createPullRequest` with `draft: true` runs for every caller, because a draft cannot merge.

    Anything that can ship, whether that is marking a pull request ready or opening a non-draft one, follows `shipPolicy` instead: denied unattended, and parked for every human caller, trusted or not. Shipping is the factory's human gate.
  </Accordion>

  <Accordion title="Merge tools are not mounted at all">
    The GitHub extension uses an explicit allowlist, and every merge tool is absent from it.

    There is no policy to bypass because there is no tool to call. Merging stays a thing people do.
  </Accordion>

  <Accordion title="Close and reopen run ungated for everyone">
    Closing a duplicate or stale issue is everyday triage, and a reopen undoes it, so gating it as a ship action added friction without protecting much.

    Only trusted mentions, autonomous label runs, and the dev TUI ever reach the issue-lifecycle tools in the first place, since an untrusted public commenter never gets a session. Unlike `commentPolicy`, this one is deliberately not scoped to a single issue, because deduplication closes the duplicates rather than the intake issue. `updateIssue` with `state` set follows the same policy, so the two paths to the same action always behave alike.
  </Accordion>

  <Accordion title="Comment scope is the containment">
    An unattended run's progress comment is as reversible as the label writes already allowed, so denying it bought no safety, only a silent-until-done run.

    Instead, `commentPolicy` reads the intake issue number stamped into session auth at dispatch, from the signed webhook and never from model input, and runs only comments targeting that number. Instructions injected through an issue body cannot make the run comment anywhere else in the repository.
  </Accordion>
</Accordions>

## Git safety

Station git safety is structural rather than policy-based. It lives in `agent/lib/github/repo-sandbox.ts` and `agent/lib/github/git-remote.ts`.

Every clone, fetch, and push targets the literal `https://github.com/<FACTORY_REPO>.git` URL, never `origin`. Git remote config inside a sandbox is model-writable, so using `origin` would let a session redirect the credential.

The installation token never enters the sandbox. `brokerPolicy` injects it as an `Authorization` header transform at the sandbox firewall, on egress to `github.com` only, and drops the policy again in a `finally`. General egress stays open so installs and test runs keep working.

Everything interpolated into a git command passes `validateBranch`, which accepts only a conservative character set so shell metacharacters can never reach the command line. It refuses `refs/*` and `HEAD`, which would reach a protected branch under another name, and refuses `main` and `master` outright.

This is also why the Implementer's `push_branch` can run ungated inside a task-mode station. It is inert by construction, and a validated feature branch alone ships nothing.

## Label intake cannot be forged

GitHub fires the `labeled` action even for labels attached at issue creation, which issue templates let unauthenticated reporters do. So the `onIssue` hook does not trust the event.

`isTrustedLabeler` checks the sender's collaborator permission against the GitHub API and dispatches only for `admin`, `maintain`, `write`, or `triage`. Triage is the floor, since it is the permission normally required to apply a label by hand. Bot senders are skipped.

<Callout type="info" title="Fails closed">
  A non-ok response or any API error from the permission check means no session.
</Callout>

## Approvals over GitHub comments

When an attended session parks, the channel posts the pending request as a comment with a mention-based reply instruction. The answer re-enters through `onComment`, which applies the same `OWNER`, `MEMBER`, or `COLLABORATOR` association gate as any other mention.

That gate makes a comment reply an authorization signal rather than a race anyone on a public repository can win. An untrusted account's "approve" never reaches the waiting session.

## Next steps

<Cards>
  <Card href="/docs/intake" title="How work arrives" description="Where each caller class enters: labels, mentions, Linear sessions, red CI." />

  <Card href="/docs/tools" title="Tool surface" description="Every GitHub write tool mapped onto its policy." />

  <Card href="/docs/memory" title="Factory memory" description="The one non-GitHub write these policies gate." />
</Cards>


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)