Saphan StudioDocs
Security/Egress control

Policies

Which policy judges a run, what each backend is allowed by default, and how to write your own.

Every judged run is judged by exactly one policy document. Which one is decided at dispatch:

the runits policyon the run record
says nothing about its networkits backend's baseline, plus the workload additions that name its permission class or grantbaseline:<name>+workload:<name>…
is dispatched with --egress-policyyour document, on its own — no baseline, no additionsnamed:<path>
is dispatched with --egress-open "<reason>"none: the run's network is openopen:<reason>, <actor>

Baselines

A baseline is the smallest set of hosts a backend needs to reach its own model. Port 443 unless noted.

backenddefault hosts
claude-codeapi.anthropic.com
codexapi.openai.com, chatgpt.com
droidapp.factory.ai
qwen-codetoken-plan.maas.qwencloudapi.com, token-plan.ap-southeast-1.maas.aliyuncs.com
cursorapi2.cursor.sh, reached through the guardrail proxy
agent-loop, openai-compatthe model endpoint the run itself declares — its host, on the URL's port (443 for https, 80 for http, or the explicit port). An endpoint on a private address needs egress.allow_unsafe_networks
procnot applicable — no model, no network

⚠ A backend with no shipped baseline is refused as egress-baseline-unknown unless the run is given your own document or an explicit --egress-open.

⚠ A model endpoint on a loopback address can never be named in a policy. A run whose backend points at one is refused by name; the only way to run it is --egress-open.

Workload additions

An agent also runs a toolchain, and a toolchain fetches from its own registry. A silent run therefore gets its baseline plus the shipped addition of each workload that names the run's permission class or worktree grant. The mapping lives in the shipped workload documents, not in the class: for example, the go addition names the shipped classes executor-go and executor-container. All are port 443.

workloadhosts
goproxy.golang.org, sum.golang.org
npmregistry.npmjs.org
android-gradle-mavenrepo1.maven.org
  • A class or grant that declares no workload gets the bare baseline.
  • A workload is honoured only for a shipped grant. A custom grant that merely reuses a shipped grant's file name gets nothing, and the run record carries the note EGRESS-WORKLOAD-GRANT-NOT-SHIPPED.
  • ⚠ Docker image pulls are not covered: the pull is made by the Docker daemon, not by the run, and the door never sees it. A registry not listed above — PyPI, for example — is refused today; use your own document or --egress-open for that run.

Your own document

--egress-policy judges the run by the one policy document stored in the engine's state directory, <state dir>/egress/policy.json. If the file is missing, the run is refused before it starts.

⚠ Your document replaces the baseline; it is not added to it. Include the model hosts your backend needs, or the agent will not reach its model.

You can edit the document in the console, on the Egress page, or write the file directly. The console validates the document before saving and shows its digest.

The format

{
  "schema_version": 1,
  "name": "team-default",
  "rules": [
    {
      "id": "model",
      "hosts": ["api.anthropic.com"],
      "ports": [443],
      "verdict": "allow"
    },
    {
      "id": "go-modules",
      "hosts": ["proxy.golang.org", "sum.golang.org"],
      "ports": [443],
      "verdict": "allow"
    }
  ],
  "limits": {
    "preface_bytes": 16384,
    "request_bytes": 8192,
    "header_count": 64,
    "line_bytes": 8192,
    "handshake_deadline": "20s",
    "idle_deadline": "300s",
    "total_transfer": 536870912
  }
}
fieldmeaning
schema_versionmust be 1
namea label for people; it does not change the policy's identity
rules[].idunique within the document
rules[].hostshost names, exact or with one leading wildcard label (*.example.com, which does not match example.com itself). Addresses are not accepted here
rules[].literalsIP addresses, or ranges written a.b.c.d/n or first-last, that the run may dial directly. A connection to an address that is not listed is refused
rules[].portsthe ports the rule covers, 1–65535
rules[].verdictallow, or deny — a deny is accepted only inside a strictly wider allow, to carve an exception out of it
limitsseven connection ceilings, all required, all positive — see What no policy can open

A document is refused when it loads if it has — among others — a schema_version other than 1, an unknown key, no name, no allow rule, a missing or duplicate rule id, a missing verdict, a rule without hosts, literals or ports, a port outside 1–65535, an address under hosts, a bare * wildcard, a range that touches the hard floor, two rules for the same hosts with conflicting verdicts, a rule already covered by a wider one on the same ports, a deny no wider allow covers, or a missing or non-positive ceiling. The refusal names the rule or field.

Identity

A document is identified by the digest of its compiled form. Reordering rules or keys, changing whitespace or host-name case, or renaming it does not change the digest; changing a host, a port or a ceiling does. The digest of the document that judged a run is written on the run's record.

Private networks

The private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7) can be opened only when both hold:

  1. the installation allows it: saphan config set egress.allow_unsafe_networks true;
  2. the document names a range that lies wholly inside one of them, under literals.

Write a single private address as a range, for example 10.1.2.3/32; a bare private address is refused even with the setting on.

This applies to host names too: an internal host name that resolves to a private address is reached only when its address also falls inside a listed range.

Even then, loopback, link-local and metadata addresses stay closed. The console editor never accepts private ranges; set them in the file.

The policy library: drafts, merge and admit

Besides the one installation document, Saphan Studio keeps a library of policy drafts — network rule sets and permission classes you can write, check and combine before any of them is in force. In the console it is the Rules screen (Policies in the Advanced view); on the command line it is saphan policy:

saphan policy egress list     <dir>
saphan policy egress show     <file>
saphan policy egress validate <path>...
saphan policy egress merge    <file> <file>... --id <new-id>

A draft governs nothing. It is not signed and no run is judged by it, so editing one changes nothing that is in force.

Merging. merge combines several drafts into a new one and never picks a side for you. Where two sources disagree — the same rule id, a ceiling, an environment value, an allow in one source that a deny in another overlaps — it lists each conflict with a key and the sources on each side, and composes nothing until you answer every one:

saphan policy egress merge team.json project.json --id combined \
  --resolve rule:model=team.json

The console offers the same merge, with the conflicts listed by name and your choice taken for each.

Admitting a draft is an owner act. In the editor, Admit shows the policy's title, its address (the SHA-256 of its bytes) and the exact bytes to be signed; Touch your passkey to admit signs those bytes into the record. From then on the policy is permanent and readable by its address; a change is a new policy with a new address. A person who is not an owner is refused before any passkey is asked, and a draft that changed between the screen and the touch is refused.

⚠ Admitted means signed into the record, with the owner's name on the signature. It does not by itself change which policy a run is judged by: runs are judged as described above, by their baseline, the installation document, or an opened network.

Opening one run

saphan run … --egress-open "<reason>"

This runs one agent with an open network. The reason may not be blank, and it cannot be combined with --egress-policy. The reason and the dispatching actor are written on the run record as open:<reason>, <actor>, where they can be counted afterwards.

⚠ Use it for a run that genuinely needs a destination no policy names, or a lane that cannot carry the wall. Do not use it to get past a refusal you have not read: the refusal usually names a cheaper fix.

Not available yet

  • Per-seat or per-machine policies. There is one document per installation; a run either uses it, uses its baseline, or is opened.
  • Additional workload classes, such as PyPI.

On this page