Saphan StudioDocs
Security/Egress control

Writing a policy

⚠ The policy document is BUILT AND LIVE (§2's three states, §8's row) — a run that passes --egress-policy is judged by the file this section teaches you to write. This paragraph said "the policy document does not…

The policy document is BUILT AND LIVE (§2's three states, §8's row) — a run that passes --egress-policy is judged by the file this section teaches you to write. This paragraph said "the policy document does not exist yet" and "there is no file to write today", and both were false — a security reviewer who read that did not open a 650-line parser of operator-supplied input. What is true is narrower:

  • the format exists and is a schema-versioned JSON document — the product, Document / DocumentRule / DocumentLimits, with a closed schema-version set and an unknown-key refusal;
  • the parser exists: LoadDocument reads bytes, LoadDocumentFile reads a named file — so there is a file shape to write, and §4.3 below is the list of refusals that loader really performs, not a plan for one;
  • the identity exists: the loader digests the exact bytes and the compiled policy, and the digest is what a run would be bound against (§4.5);
  • a run is given this document by asking for it: saphan run --egress-policy makes the ONE canonical file that run's policy (§4.7 is the ceremony, §8's assignment row is the code);
  • what is still absent is a policy STORE and any DERIVATION: one document per workspace, no versions, no history, and nothing that composes a machine document with a seat document (§4.5, and §8's effective policy row). ⛔ A policy you author has effect on the runs that ask for it, and on no others.

THE THIRD BULLET READ "no store, no console field, and nothing anywhere that assigns a policy to a run — that absence is why no policy you author today has any effect" UNTIL 2026-09-02. Two of its three clauses were false: the assignment shipped 2026-08-30 and the console field shipped before that. It survived every round of this arc's guard because the prose scan matches a closed list of absence phrases, "nothing anywhere that assigns" is not on it, and the mechanism it named — assigns — is not the word §8.1 registers for that row (assignment). Two near-misses on one sentence; §8.1's blind spot 10 is where that is priced.

⇒ Read §4 as the model you will be working in and as code you can audit now, not as a task that has not started.

The legibility target is firewall rules in the OPNsense idiom, and the console editor at /console/egress renders exactly that: a table of rules, each with an action, a destination, ports and a description, read top to bottom by a human even though the engine does not read them in order. ⚠ That page edits THIS fileconsoleEgressPolicyPath delegates to egress.DocumentPath, so there is one artifact and not two (§8's rule editor row).

A rule

fieldwhat it holds
actionallow or deny. There is no third value, and a rule that fails to say is refused rather than read as allow.
destinationone hostname, one hostname pattern, or one IP address. The shape is inferred from what you wrote.
portsone or more ports. Required. A rule with no port matches nothing, and a rule that matches nothing is refused rather than carried.
descriptionfree text for the human reading the table later.

The three destination shapes:

you writeit matches
api.example.comthat exact name
*.example.comany name below the suffix — api.example.com, a.b.example.com. ⚠ Not example.com itself. If you mean the bare domain, write the bare domain.
93.184.216.34only a request that itself asked for that address — see §4.4

Order does not matter, and ambiguity is refused

Rules are not read top to bottom. The engine does not consult a rule's position, which means moving a row cannot change a verdict. What resolves an apparent conflict instead:

  • the narrower rule governs its own subset. *.example.com allowed on 443, plus *.internal.example.com denied, is an exception and works the way you would expect it to, in either order.
  • two rules naming the same destinations on overlapping ports with opposite actions are a conflict, and the whole policy is refused. It is not resolved by position, because every way of resolving it is an order, and order is the property this policy deliberately does not have. If you meant an exception, write the narrower rule.

What is refused when you load a policy

A policy is compiled, not merely read. Load-time refusals are loud and diagnostic — unlike a connection refusal, which tells the client nothing (§7). Each refusal below is a way a policy can look like a decision while being none:

what you wrotewhy it is refused
a malformed rule — no action, no ports, a port outside 1–65535, a destination that is not a host, a pattern over an IP addressfix the row; the refusal names the row and the reason
* on its owna rule naming every host is an allow-all, and this grammar has no such rule. Write the hosts, or do not open the door.
a rule that can never match — including a deny with no wider allow above ita deny exists only to re-close part of a hole an allow opened. deny-all is the floor, not a rule you can write (§3.1), so a deny that closes nothing already-open changes no outcome and is refused rather than carried as decoration.
a rule fully shadowed by a wider rule with the same actionit changes no outcome. Delete it, or make it an exception by giving it the opposite action.
a rule naming an address inside the hard floor (§5)no policy can open the floor, so the rule is decoration.
a policy with no allow rule at allthat document is the floor written out at length. Loading it would let you believe an empty allowlist had been reviewed rather than never written.
a policy with no rules at allnamed separately from the row above, because the mistake is different: a file that failed to load, versus a file that loaded and says nothing.

Writing an IP address instead of a hostname

A rule may name an IP address, and it then matches only a client that itself asked for that address. Two things to know before you write one:

  1. an address rule is always an allow. A deny naming a bare address has nothing wider above it to narrow, so it is refused as a rule that can never match;
  2. recording an address accepts every TLS site behind it. An address destination is not hostname-checked (§3.5) — there is no name to compare. On a shared-IP CDN, one address rule is a far wider grant than the hostname it came from. This is the single most consequential thing in this section.

Per machine and per seat

Policies are administrator-authored and assignable per machine and per seat (a seat being one named user of the fleet).

  • the effective policy is the intersection of the machine's policy and the seat's policy;
  • a seat can only ever narrow its machine, never widen it. Granting a seat a permissive policy on a restrictive machine grants nothing;
  • an empty intersection refuses the run. It is not read as "no restrictions" — the run does not start.

§4.5 is BUILT, NOT WIRED — except the assignment, which is BUILT AND LIVE in its narrow form since 2026-08-30 (a run names the one document) and absent in its general one (nothing derives a policy for a seat or a machine). This paragraph said "None of §4.5 is built. Nothing binds a policy to a run, nothing computes an intersection", and of the four things it declared absent, three are present. Measured in the product (697 lines, non-test, in the build):

⇒ The design is ratified and three quarters of it is code you can read today (§8). ⚠ What changed on 2026-08-30, stated precisely because the two are easy to conflate: a run can now BE GIVEN a policy, so the first three clauses above are reachable in production for that run — but they are reached with a SINGLE DOCUMENT, not with an intersection. Effective still has no caller outside its own package, and the wired opener hands the loader an EMPTY Claim, so verifyIdentity compares nothing. A correct intersection that is never asked for a particular run still governs nothing; what ships is the narrower thing that does.

The ceilings you write

**This section exists because three cells of §6 said the opposite ** They read "There is no setting to raise it (§4 — nothing is configurable yet)" and sent an operator to a §4 that did not mention the block at all — the tenth sentence in this arc to tell a reader that a built thing does not exist, and the first aimed at an operator rather than an auditor. Document.Limits is required: a policy that omits the block is refused at load, and every value below is one an operator writes.

BUILT AND LIVE FOR A RUN THAT ASKS, since 2026-08-30 — and this paragraph said the opposite until today. The retired wording read "nothing in production hands your document to a run, so what you write here has no effect yet", and on this tip that is false: saphan run --egress-policy hands the door this exact document, egressdoor.Open passes loaded.Limits into egress.NewCore, and five of the seven ceilings below are then enforced on that run's connections. The parser reads these keys and the digest covers them, as before — what changed is that a run can now be judged by what you wrote.

AND IT IS OPT-IN AND SO FAR UNUSED, which is the other half and belongs in the same breath: no run in this fleet has passed the flag (§8), and a run that does not pass it is handed no document, so these ceilings bound nothing for it. ⇒ Read the table as a control you can tune for a run you opt in — not as a format waiting for a wire, and not as something already bounding your fleet.

the key you writewhat it boundsthe refusal it produces (§6)
preface_byteshow much a client may send before the destination has been judgedegress-limit-preface-bytes
request_bytesthe whole protocol request, on either dooregress-limit-line-length
line_bytesone line of an HTTP-style CONNECT handshake — that door onlyegress-limit-line-length
header_counthow many header lines that handshake may carry — that door onlyegress-limit-header-count
handshake_deadlinehow long the judgement itself may takeegress-limit-handshake-deadline
idle_deadlinehow long an established tunnel may sit silentegress-limit-idle-deadline
total_transferthe whole volume of one connection (§3.6)egress-limit-total-transfer

The seven ceilings above are not a hand-kept list. the product reads the field names out of DocumentLimits and turns RED when one of them is not named in this document. So a ceiling added to the format cannot arrive without a row here — and this is the alarm the tenth sentence had none of, because a sentence saying "nothing is configurable" is invisible to every phrase-based instrument in this tree. ⚠ Its limit, named: it checks that each key is mentioned in this section — not that what the table says about it is true, and not that §6 still points here.


Turning it on — the ceremony, and it is entirely an act of your hand

Nothing in this engine turns egress control on for you. No default, no derivation, no machine row and no seat setting: a run is governed because a human asked, in that dispatch, or it is not governed at all. This section is the numbered form of that act. It is written for the person holding the terminal, and every step names what a wrong answer looks like.

Read §4.6 and §5 first if you have not. Step 2 will refuse a document that omits a required ceiling, and §5 is the list of destinations no rule you write can open.

1. Find out where the document goes. One file per workspace, and its path is arithmetic on the state directory:

<state-dir>/egress/policy.json

There is no second location and no --egress-policy <path>. The flag is a boolean opt-in precisely so that an operator and the console cannot end up editing two files, one of which governs nothing. The state directory defaults to <workspace root>/.saphan/state and is moved with the global --state-dir flag; if you set that flag for your runs, set it for your editing too, or you will author a document nothing reads.

2. Author the document — by hand, or in the console. The console page is /console/egress (GET to read the table, POST to save; you must be signed in to the console — see the console for that sign-in). It renders the same file as a firewall-legible table and validates a save with the production loader, so a document the console accepts is a document a run will accept. Editing the file directly is equally legitimate; §4.1–§4.6 is the format, and §4.3 is the complete list of load-time refusals. ⚠ A fresh workspace has no document at all, and the page says not provisioned rather than refusing — that is a state, not an error.

3. Check the document loads BEFORE you spend a run on it. Save it in the console and read the result: a refusal names the field and the reason, in the loader's own words. ⚠ There is no saphan egress validate verb in this build — measured 2026-09-02: the only egress verb this binary registers is egress-door, which serves a door and is not a checker. If you find a validate verb, it arrived after this line was written. The console save is the check.

4. Ask for it, on the dispatch that should be governed:

saphan run --egress-policy --workspace <workspace root> <the rest of your run>

That flag, and nothing else, is what mints the run a socket, opens a door on it under this document, composes the network cut around it, and hands the agent ALL_PROXY. A run without it is byte-for-byte the run it was before this control existed.

5. Read what the refusal says, because most first attempts refuse — and that is the control working. Three families, and each has its own next act:

what you are holdingwhat it meansgo to
egress-unavailable with a rung in itthe host cannot be walled, so the run was not started§6.6.1, which reads the rung and gives you one of four acts
egress-unavailable naming no rung, or naming an operating systeman engine-wiring fault, not a host act — there is nothing for you to install§6.6's preamble
a refusal naming the document (absent, a directory, unparseable)step 1 or step 2 is not finished — nothing was spawned and the run cost $0§4.3, then step 2 above

6. Confirm it actually took, rather than assuming. The run's record carries the compiled policy's digest, and a run that was never doored carries an empty one:

SELECT run_id, ts, status, egress_policy_digest FROM run_record WHERE egress_policy_digest != '';

A run that ASKED and was refused never gets a digest either, because the digest is written after the door opens — §8 carries the second query that finds those, and the reasons both queries are blind to.

7. Know what you have NOT bought, before you write it into a report.

  • Per-decision auditing does not follow. The wall holds; nothing writes down what it admitted or refused, because nothing constructs the fact store (§7, §8). You cannot answer "what did this run connect to" afterwards.
  • The wall covers the runs that asked, and only those. There is no fleet-wide switch in this build; turning egress on for everything is an owner's act that has not been taken.
  • On a far Linux host you get a refusal, not a wall (§9B.3). That is the honest outcome and it is not a fallback — this engine emits no network lever there.
  • Your own toolchain may ignore the door even when it is open. Go's HTTP client does not read ALL_PROXY, and a stock macOS pip cannot speak SOCKS5 at all — both measured, both in Security §11A.6. The wall stops them from leaving another way; it does not teach them to use the door.

And what is NOT on this list, in either direction: there is no flag that starts a run anyway. No --allow-open-network, no fallback rung, no "warn instead of refuse" (§6.6.5).


Two complete policies

Both are whole documents: paste either one, change the hosts, and it is a policy. Every field below is required except the two host lists, of which a rule needs exactly one.

Allow: a build that may reach two registries and nothing else

{
  "schema_version": 1,
  "name": "build-runners: package registries only",
  "rules": [
    {
      "id": "registries",
      "hosts": ["registry.npmjs.org", "*.githubusercontent.com"],
      "ports": [443],
      "verdict": "allow"
    }
  ],
  "limits": {
    "preface_bytes": 8192,
    "request_bytes": 65536,
    "header_count": 64,
    "line_bytes": 8192,
    "handshake_deadline": "10s",
    "idle_deadline": "60s",
    "total_transfer": 268435456
  }
}

A run given this policy reaches those two names on 443 and nothing else — the absence of a rule is a refusal, not permission. There is no default verdict and no catch-all.

Deny: one address closed inside a wider allowance

{
  "schema_version": 1,
  "name": "internal metadata service is closed",
  "rules": [
    {
      "id": "metadata-endpoint",
      "literals": ["169.254.169.254"],
      "ports": [80, 443],
      "verdict": "deny"
    },
    {
      "id": "corporate-proxy",
      "hosts": ["proxy.example.com"],
      "ports": [3128],
      "verdict": "allow"
    }
  ],
  "limits": {
    "preface_bytes": 8192,
    "request_bytes": 65536,
    "header_count": 64,
    "line_bytes": 8192,
    "handshake_deadline": "10s",
    "idle_deadline": "60s",
    "total_transfer": 268435456
  }
}

Two things this second document shows. A name goes under hosts, an address goes under literals — a host that resolves to an address is refused where it is written, so the two lists are not interchangeable. And deny is worth writing even where nothing allows the destination, because the row is the record of a decision somebody made on purpose.

schema_version is checked first, against a closed set, before anything else in the document is read. A document naming a version this installation does not parse is refused whole — it is never partially applied.