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:
| # | Layer | Who sets it | Unset means |
|---|---|---|---|
| 1 | No model or tool vendor | Ciresk | always on |
| 2 | The organization's ceiling, egress.session | your admins, in the dev policy | no ceiling |
| 3 | The agent ceiling, egress.agent, for a session an agent identity started | your admins, in the dev policy | nothing: such a session gets no template row |
| 4 | The template's egress rows, or locked.egress when your organization sets it | your repository, or your admins | nothing leaves |
| 5 | The installation's allow-list, egress.yaml, its dev-session rows | whoever operates the installation: Ciresk for the cloud, your operator for a self-hosted one | nothing 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:
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}| Field | Type | Default | What it does |
|---|---|---|---|
host | DNS name | required | A 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). |
ports | list of ports | required | At least one, none of them 0 (egress_ports). |
purpose | string | required | Why the session needs it. |
required | boolean | false | A 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:
reason | Check | Fix |
|---|---|---|
vendor | 1 | Remove the row. No row opens a model or tool vendor. |
org_policy | 2 | Ask an admin to add the host to egress.session, or make the row optional. The answer's next is ciresk dev policy show. |
agent_policy | 3 | Ask an admin to add the host to egress.agent. The answer's next is ciresk dev policy show. |
| (none) | 5 | The 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 withreason: vendorand 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:
"egress": {
"session": [
{ "host": "registry.npmjs.org" },
{ "host": "*.githubusercontent.com", "ports": [443] }
],
"agent": [
{ "host": "registry.npmjs.org" }
],
"agent_exit": true
}| Setting | Default | What it does |
|---|---|---|
egress.session | null | When 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.agent | null | On 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_exit | true | Gives 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.agentcovers (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:
| Product | Whose traffic | What a row for it opens |
|---|---|---|
dev-session | the sessions: everything a session's egress proxy lets out | a 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. |
dev | the Ciresk dev service's own calls, for example to the identity provider | nothing for a session: a session never goes out under a dev row |
dev-push | the host's push of a session's work to your Git forge, and the pull request calls | nothing 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]:
- {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
ciresk dev egress
ciresk dev egress --denied
ciresk dev egress --since 2026-10-24T08:00:00Z --limit 200ciresk 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.
| Flag | Default | What it does |
|---|---|---|
--denied | off | Only refused requests. The same as --decision denied. |
--decision <DECISION> | both | Only one decision, allowed or denied. Cannot be combined with --denied. |
--since <TIME> | none | Windows that end at or after this moment (RFC 3339). |
--until <TIME> | none | Windows that start before this moment (RFC 3339). |
--limit <N> | 50 | Entries per page, 1 to 200. |
--cursor <CURSOR> | none | The 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).
methodisCONNECTfor a tunnel (TLS stays opaque) and the request's method for plainhttp://. 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
| Rule | Decision | Meaning | What lets it out |
|---|---|---|---|
session:<host pattern> | allowed | the session's row that let it out (the request may still fail upstream) | |
agent:<host pattern> | allowed | the same, through the agent's own exit | |
vendor:<name> | denied | a model or tool vendor | nothing |
no_session_row | denied | no row of the session names the destination: the template, locked.egress or a ceiling left it out | in 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_row | denied | an agent's command in a person's session, and egress.agent does not cover the destination | an admin adds it to egress.agent; the next renewal applies it |
egress.yaml | denied | the session lists it, the installation's allow-list has no dev-session row for it | the installation's operator adds the row |
address | denied | the name resolves onto the host, a metadata address or a private network range | nothing |
secret | denied | a request carried a secret placeholder to a destination that secret is not for | nothing: 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
| Change | Reaches a running session |
|---|---|
egress.session or egress.agent narrowed | at 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 widened | at 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 off | at its next renewal: the agent's commands share the person's rows |
egress.agent_exit turned on | ciresk dev up --recreate |
locked.egress | ciresk dev up --recreate |
The template's egress | ciresk 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.