Egress policy

Nothing leaves a session unless every layer allows it. This page covers the layers in the order they are checked, who sets each one, the read-only registry cache, the decision log you read with ciresk dev egress, and when a change reaches a session that is already running. The network boundary itself (a pod with no network, one exit through the session's own egress proxy) is described in security.md.

The order of checks

A destination (a host and a port) must pass all of these, in this order:

#LayerWho sets itUnset means
1No model or tool vendorCireskalways on
2The organization's ceiling, egress.sessionyour admins, in the dev policyno ceiling
3The agent ceiling, egress.agent, for a session an agent identity startedyour admins, in the dev policynothing: such a session gets no template row
4The template's egress rows, or locked.egress when your organization sets ityour repository, or your adminsnothing leaves
5The installation's allow-list, egress.yaml, its dev-session rowswhoever operates the installation: Ciresk for the cloud, your operator for a self-hosted onenothing leaves

locked.egress, when set, replaces the template's rows before any check runs, so locked rows pass checks 1 to 5 like any template row.

Checks 1 to 3 run at ciresk dev up on the template's rows and again at every renewal of a running session. Check 5 runs at ciresk dev up for the required rows, and on the host for every connection. The session's egress proxy then decides each connection against what is left.

Template rows

A template asks for each destination with one row under egress:

Code
egress:
  - {host: api.stripe-mock.example.com, ports: [443], purpose: the payments sandbox the api calls, required: true}
  - {host: "*.tiles.example.org", ports: [443], purpose: map tiles in the web app}
FieldTypeDefaultWhat it does
hostDNS namerequiredA host name, or *. and a name for every name below it (*.example.org covers a.example.org, not example.org). No IP address, scheme or port (egress_host_not_name).
portslist of portsrequiredAt least one, none of them 0 (egress_ports).
purposestringrequiredWhy the session needs it.
requiredbooleanfalseA required row that a check refuses fails ciresk dev up; an optional one is dropped (only its refused ports, when some ports pass) and the session starts without it.

The full template reference is templates.md.

When ciresk dev up refuses

A required row that fails a check refuses the session with 422 egress_denied (exit code 4). The answer lists every refused destination as host:port in denied and names the check in reason:

reasonCheckFix
vendor1Remove the row. No row opens a model or tool vendor.
org_policy2Ask an admin to add the host to egress.session, or make the row optional. The answer's next is ciresk dev policy show.
agent_policy3Ask an admin to add the host to egress.agent. The answer's next is ciresk dev policy show.
(none)5The installation's egress.yaml has no dev-session row that allows it: ask its operator, or make the row optional.

Model and tool vendors

Model and inference APIs, AI gateways and tool vendors' hosted MCP servers are refused whatever a row says. So is any row that could reach one: a wildcard over a vendor, a wildcard over a shared cloud domain or one of its regions (for example *.eu-central-1.amazonaws.com), and every IP address row. The check runs three times:

  • at ciresk dev up, where a required row is refused with reason: vendor and an optional one is dropped;
  • on the host, when it narrows the session's rows;
  • on every connection, by the session's egress proxy, which also refuses a name that resolves onto the host, a metadata address or a private network range.

An agent inside a session has no direct model access. The installation's own MCP gateway is not a vendor: when your session's agents are admitted to it, the session gets a row for it that stands outside both ceilings.

The organization's ceilings

Both ceilings are part of the dev policy and are set with ciresk dev policy set (see admin.md). Each row is a host as egress.yaml writes it (a name, *. and a name, or an IP address) and its ports, 443 when you list none:

JSON
"egress": {
  "session": [
    { "host": "registry.npmjs.org" },
    { "host": "*.githubusercontent.com", "ports": [443] }
  ],
  "agent": [
    { "host": "registry.npmjs.org" }
  ],
  "agent_exit": true
}
SettingDefaultWhat it does
egress.sessionnullWhen set, a template row it does not cover is dropped, and a required one fails ciresk dev up with reason: org_policy. null changes nothing.
egress.agentnullOn top of egress.session, for a session an agent identity started, and for the agent's own exit in a person's session. null is empty: an agent can edit .ciresk/dev.yaml itself, so only an admin widens what it reaches. A required row it does not cover fails with reason: agent_policy.
egress.agent_exittrueGives an agent's commands in a person's session their own exit, under egress.agent (see below). false lets them share the person's rows.

A row covers a destination when its host matches (a wildcard covers every name below it, and a narrower wildcard) and its ports include the port. ciresk dev policy set refuses a row whose host the allow-list format does not accept, or that lists port 0, with policy_invalid. A template row is never an IP address, so an address in a ceiling covers no template row.

Who is an agent

A session started with an agent's API key (an agent: identity) is an agent's session: it gets egress.agent for the whole session. A person's key or sign-in used by an agent on a laptop is the person.

The agent's own exit in a person's session

With agent_exit on (the default), a person's session has a second exit for agents. A command an agent identity runs in it, through ciresk dev exec or ciresk dev ssh, goes out through that exit, and the kernel keeps it off the person's exit. The agent's exit reaches:

  • the session's rows that egress.agent covers (unset: none);
  • the installation's MCP gateway, when the session's agents are admitted to it;
  • the read-only registry cache, when the template turns it on.

Secrets are attached only on the person's exit, never on the agent's (see secrets.md). A session with an agent exit is only placed on a host that can enforce it: until one can take it, the session waits with the reason agent_exit_unavailable, and it is never placed where the agent would get the person's set. An agent cannot open an editor connection in such a session.

What it does not cover: everything the agent did not start itself goes out through the person's exit. The session's services, a script the agent leaves for the person to run, and any listener of the person's on the session's loopback can carry the agent's traffic. UDP is not covered, and no exit listens on UDP.

The installation's allow-list

egress.yaml is the ceiling over everything, and empty means nothing leaves. On Ciresk's cloud, Ciresk sets it; on a self-hosted installation, your operator does. Each row names the products it is for, and three of them belong to Ciresk dev:

ProductWhose trafficWhat a row for it opens
dev-sessionthe sessions: everything a session's egress proxy lets outa destination every session of the installation may reach, when its template (and your ceilings) ask for it. ciresk dev up checks a required row against these rows, and the host's proxy decides every connection by the same rows.
devthe Ciresk dev service's own calls, for example to the identity providernothing for a session: a session never goes out under a dev row
dev-pushthe host's push of a session's work to your Git forge, and the pull request callsnothing for a session: the push runs on the host, outside the session (see git.md)

So an operator who opens the forge for pushes, or lets the service reach its identity provider, opens neither to sessions. To let sessions reach a host, the operator adds a row with products: [dev-session]:

egress.yaml
- {host: api.stripe-mock.example.com, purpose: the payments sandbox our templates call, products: [dev-session]}

The host's own rows (image pulls, toolchain downloads) are never inherited by a session. In eu1 the only dev-session row is the cell's MCP gateway, which only a session whose agents are admitted to it lists; no other host is open to sessions today, so a session otherwise reaches only the registry cache.

Dotfiles

When you set a dotfiles repository (ciresk dev dotfiles set), the sessions you start add one optional row for its host on port 443, and the clone goes out through the session's egress proxy. That row passes the same checks as any template row: the vendor rule, egress.session (and egress.agent in an agent's session) and the installation's dev-session rows. When a check refuses it, the session starts without your dotfiles; it is never refused for them.

A private repository's token is stored as a secret. The session holds only a placeholder, and the egress proxy puts the token in the request on its way out, for that host only; a placeholder sent anywhere else is refused with the rule secret. The token is never attached in a session an agent or a service started, nor to an agent's commands through the agent's own exit. See secrets.md.

The registry cache

registry_cache: true in a template gives the session the host's read-only package registry cache: GET and HEAD only, one request per connection, no body. Package managers install from the public registries it mirrors (crates.io, npm, PyPI, Go modules, Maven Central, the Gradle plugin portal, NuGet and RubyGems) without an egress row. It is not an egress row, needs none, and does not appear in the decision log. A template that names a registry host directly gets it only through the layers above; such a row also carries writes (a npm publish), which the cache never does.

The decision log

Terminal
ciresk dev egress
ciresk dev egress --denied
ciresk dev egress --since 2026-10-24T08:00:00Z --limit 200

ciresk dev egress reads the decision log of the current worktree's session, oldest first. The host reports it about once a minute: one entry per host, port, method, decision and rule in that minute, with the request count and the bytes up and down. Each line reads <window end> <decision> <method> <host>:<port> x<count> <rule>, and a denied line is followed by the change that would let it out. When the page holds a denied entry, the command's suggested next step is that change; otherwise it is the next page or ciresk dev status.

FlagDefaultWhat it does
--deniedoffOnly refused requests. The same as --decision denied.
--decision <DECISION>bothOnly one decision, allowed or denied. Cannot be combined with --denied.
--since <TIME>noneWindows that end at or after this moment (RFC 3339).
--until <TIME>noneWindows that start before this moment (RFC 3339).
--limit <N>50Entries per page, 1 to 200.
--cursor <CURSOR>noneThe next page, as the previous page named it.

--json returns {session_id, egress, next_cursor, has_more}, where each entry of egress has seq, session_id, from, to, host, port, method, decision, rule, count, bytes_up, bytes_down and next (null when allowed).

  • method is CONNECT for a tunnel (TLS stays opaque) and the request's method for plain http://. On a plain HTTP keep-alive connection only the first request's method is logged.
  • A request without a whole, valid request head is not logged.
  • Each minute holds at most 100 allowed and 100 denied entries, counted apart, so allowed traffic never crowds out a denial.

The log needs dev:sessions.read and is readable by the session's owner and members, people and agents alike. The MCP tool is dev_session_egress, and the API route is GET /v1/dev/sessions/{session_id}/egress (see the API reference). The first time a session is refused a host, port and rule, the audit log records dev.session.egress; that record holds only the host's last two labels, while the full host stays in the session's own log.

Rules and what lets each out

RuleDecisionMeaningWhat lets it out
session:<host pattern>allowedthe session's row that let it out (the request may still fail upstream)
agent:<host pattern>allowedthe same, through the agent's own exit
vendor:<name>denieda model or tool vendornothing
no_session_rowdeniedno row of the session names the destination: the template, locked.egress or a ceiling left it outin an agent's session, an admin adds it to egress.agent; otherwise, when egress.session does not cover it, an admin adds it there; when the template does not list it, add it to the template's egress and run ciresk dev up --recreate
no_agent_rowdeniedan agent's command in a person's session, and egress.agent does not cover the destinationan admin adds it to egress.agent; the next renewal applies it
egress.yamldeniedthe session lists it, the installation's allow-list has no dev-session row for itthe installation's operator adds the row
addressdeniedthe name resolves onto the host, a metadata address or a private network rangenothing
secretdenieda request carried a secret placeholder to a destination that secret is not fornothing: send the placeholder only to the secret's own hosts (see secrets.md)

A retry is refused the same way: every fix is a policy or template change.

When a change reaches a running session

ChangeReaches a running session
egress.session or egress.agent narrowedat its next renewal, within about 20 seconds; connections opened under the old rows are closed, and clients reconnect under the new ones
egress.session or egress.agent widenedat its next renewal, but never wider than the rows the session started with: a row a ceiling dropped while the session ran comes back, a row it dropped at ciresk dev up needs ciresk dev up --recreate
egress.agent_exit turned offat its next renewal: the agent's commands share the person's rows
egress.agent_exit turned onciresk dev up --recreate
locked.egressciresk dev up --recreate
The template's egressciresk dev up --recreate

A session that moves to another host starts with the ceilings as they are at that moment. A ceiling never stops a running session: a required row that a narrowed ceiling no longer covers is dropped, not refused.