# Networking and egress policy A sandbox runs under one of three network postures. You can bind an egress policy to a profile, or to one session, on top of that. An egress policy is enforced only on hull's `hvi` backend, which needs macOS 15 or newer and a hull newer than 0.1.0-rc21. Every other runtime, including every Linux runtime, refuses to boot a policy it cannot enforce. The one exception is `--network offline`: it reaches no network at all, so it satisfies any egress rule and is never refused. See [Where a policy is enforced, and where it is not](#where-a-policy-is-enforced-and-where-it-is-not). ## Network postures Every sandbox runs under one of three postures: `shared`, `isolated` or `offline`. Set one with `--network`, with `BRIG_NETWORK`, or with a profile's own `network:` field. A flag beats the setting, and the setting beats the profile. Leave all three unset and you get `shared`. ```bash brig run claude --network isolated ``` | posture | what it permits | | --- | --- | | `shared` | one network for every sandbox on the host. The default | | `isolated` | a network of this sandbox's own | | `offline` | no route out. The agent runs, the guest home is mounted, nothing leaves | `shared` is a shared network in name, not in reach. Sandboxes on it cannot reach each other on either macOS backend, but they can reach each other on Linux. See the per-backend table in [security.md](security.md#things-brig-does-not-claim) for how that was measured. `brig info` prints the posture as one of these three lines: ``` NETWORK shared (one network for every sandbox on this host) NETWORK isolated (a network of this sandbox's own) NETWORK offline (no egress) ``` `isolated` needs the `hvi` backend on macOS. `vz` and `qemu` take their network from vmnet, which Brig does not own, so Brig refuses `--network isolated` there. On Linux, nerdctl creates a network per sandbox for `isolated`, so the posture works on any Linux host. Binding an egress policy to a sandbox forces the `isolated` posture, whether or not `--network` asked for it. See [Where a policy is enforced, and where it is not](#where-a-policy-is-enforced-and-where-it-is-not). An unrecognized value refuses the run rather than picking a posture nobody asked for, and names where the value came from: ```console $ BRIG_NETWORK=bogus brig info claude-code brig: BRIG_NETWORK "bogus" is not a posture: use shared, isolated or offline ``` ## Writing a policy A policy is a named YAML (or JSON) document declaring what an agent can reach outbound. It sets a default of `allow` or `deny`, plus `host` or `cidr` exceptions on either side. `brig policy create` writes a starter and opens it in your editor, the same way `brig agent edit` does: ```bash brig policy create locked-down # writes ~/.config/brig/policies/locked-down.yaml brig policy edit locked-down # change the rules ``` See [Where a policy is enforced, and where it is not](#where-a-policy-is-enforced-and-where-it-is-not) for what a bound policy actually gets you. ## Where policies live One file per policy in `$XDG_CONFIG_HOME/brig/policies`, default `~/.config/brig/policies`, flat: `~/.config/brig/policies/locked-down.yaml`. `BRIG_POLICY_DIR` overrides the location outright, taken as given. Unlike `$XDG_CONFIG_HOME`, an explicit override is not second-guessed for absoluteness. This follows the [XDG Base Directory Specification, version 0.8](https://specifications.freedesktop.org/basedir/latest/): an empty or relative `$XDG_CONFIG_HOME` counts as unset. The directory starts empty, and Brig never writes there unless you ask it to. `brig policy create` and `brig policy edit` are the only commands that write to it. `name:` inside the file wins over the filename, the same rule a profile already follows. A file need not be named after the policy it declares, though `create` always names them the same way. A directory can hold any number of policies. One file that fails to parse does not stop the others loading: `brig policy ls` reports it on stderr, and lists everything that did load. Two files declaring the same name is a mistake with no winner worth having, and is reported the same way. ## The document A complete example: ```yaml apiVersion: brig.sh/v1alpha1 name: locked-down desc: only Anthropic's API and one internal range egress: default: deny allow: - host: api.anthropic.com - cidr: 10.0.0.0/8 ``` | field | required | what it is | | --- | --- | --- | | `apiVersion` | yes | Pins the document shape. `brig.sh/v1alpha1` is the only value this build knows. Anything else is refused rather than guessed at | | `name` | yes | The policy's identifier. Wins over the filename, and follows the same character rule as a profile name. See [Naming a policy](#naming-a-policy) | | `desc` | no | One line, shown by `brig policy ls` | | `egress.default` | yes | `allow` or `deny`, applied to any traffic neither list below names | | `egress.allow` | no | Exceptions to a `deny` default | | `egress.deny` | no | A host or range to refuse regardless. At the gateway that enforces the rules, `deny` takes priority over `allow` and over `default`. Brig applies no priority of its own: it passes every rule through as a flag. See [Where a policy is enforced](#where-a-policy-is-enforced-and-where-it-is-not) | Each entry in `allow` or `deny` names exactly one of `host:` or `cidr:`. Both, or neither, is refused. `host:` is a domain, or a glob such as `"*.githubusercontent.com"`. `cidr:` is a network range such as `10.0.0.0/8`, checked with Go's own `net.ParseCIDR`. A typo like `10.0.0/8` (an octet short) is refused rather than accepted and silently doing nothing at the gateway that enforces it. `host:` is not held to a pinned glob grammar here. Which wildcard forms an enforcer honors is that enforcer's business, and this document format is deliberately independent of it. A host is refused only for what is unambiguously wrong however it ends up read: whitespace or a control character. The gateway that enforces it today matches the glob against the name the guest asks its resolver for. Parsing is strict throughout. A field this format does not recognize, such as `engine:`, `mode:`, or a plain typo like `dsc:`, fails to parse rather than being silently dropped. The format carries no field naming how a rule gets applied. That is a deliberate limit, and it stays that way as this feature grows. ## Naming a policy The same character rule a profile name already follows: lowercase letters, digits, dot, dash and underscore, starting with a letter or digit. It is checked before a path is built from it, so a bad name never gets as far as touching disk. One rule is particular to policies. A bare word like `no`, `true` or `123` is inside that character set. But YAML reads an *unquoted* one of those as a boolean or a number rather than as the string you typed. A policy named `no` is actually named `false`, unreachable by the name you gave it. `brig policy create` checks a name by writing it the way the starter template writes it, then reading the result back. It refuses one that does not come back as itself: ```console $ brig policy create no brig: name "no" reads as false when written unquoted in YAML, not as itself; pick a different name ``` ## The verbs | verb | what it does | | --- | --- | | `brig policy ls` | every policy that parses, by name and description, and, for one bound to anything, what binds it | | `brig policy create ` | write a starter document, then open it: `$VISUAL`, then `$EDITOR`, then `vi` | | `brig policy edit [--force]` | open an existing one, and only replace it if the save still parses and validates. Refuses a rename that orphans anything bound to it, inline or attached, unless `--force` | | `brig policy show [--json]` | print the parsed document | | `brig policy rm [--force]` | delete it. Refuses one that is bound to anything, inline or attached, unless `--force` | | `brig policy attach [-n NAME]` | bind it to every run of a profile, or, with `-n`, to one session by name instead | | `brig policy detach [-n NAME]` | reverse an attach | | `brig policy check [-n NAME]` | list what is effectively bound to a run of the profile (or `-n` session), and report whether Brig can enforce anything against it at all | Complete command lines for all eight: ```bash brig policy ls brig policy create locked-down brig policy edit locked-down brig policy show locked-down --json brig policy attach locked-down claude-code brig policy detach locked-down claude-code brig policy check claude-code brig policy rm locked-down --force ``` `create` refuses to overwrite a file that is already at the target path, unless you pass `--force`. It refuses a name already taken by some *other* file regardless of `--force`. Forcing leaves two files declaring the same name, which is the thing this check exists to prevent. `attach` and `detach` write to `attachments.yaml` in the same directory, not to the policy or the profile. `attach` refuses, and writes nothing, in three cases. Either name does not exist. The profile is `kind: shell` or `kind: gui`, which has no agent to hook an egress rule into. Or the profile already declares the policy inline in its own `policy:` list. Attaching it again adds an entry `detach` can never remove: ```console $ brig policy attach locked-down claude-code attached locked-down to claude-code note: enforced on the hvi backend, which gives the sandbox a network of its own; a run on any other backend is refused rather than left unenforced $ brig policy attach locked-down claude-code -n refactor attached locked-down to claude-code -n refactor note: enforced on the hvi backend, which gives the sandbox a network of its own; a run on any other backend is refused rather than left unenforced $ brig policy attach locked-down ubuntu brig: cannot attach locked-down to ubuntu: ubuntu is kind: shell, which has no agent to hook an egress rule into. Nothing was written ``` Both `attach` and `check` print that last line. "attached" and a `check` that prints a policy name can both read as a rule already in force. See [Where a policy is enforced, and where it is not](#where-a-policy-is-enforced-and-where-it-is-not). It goes to stderr, where this CLI puts every advisory, so stdout stays the command's answer. Both commands print the same constant from `internal/policy`, so the two cannot drift into saying different things. `detach` reverses `attach`: ```console $ brig policy detach locked-down claude-code -n refactor detached locked-down from claude-code -n refactor ``` `detach` refuses a policy the profile declares inline, the same way: it was never `attach`'s to add, so it is not `detach`'s to remove. Edit the profile's `policy:` list directly instead. A `-n` detach is unaffected. Inline binds every run, `-n` narrows to one session, and the two do not name the same binding. `check` resolves the same union `attach`/`detach` write to (inline, profile-level, session-level) for one profile, or, with `-n`, one of its sessions. It lists what applies, and runs the same `CheckCoverage` refusal `attach` does: ```console $ brig policy check claude-code locked-down note: enforced on the hvi backend, which gives the sandbox a network of its own; a run on any other backend is refused rather than left unenforced $ brig policy check ubuntu no policy applies to ubuntu brig: cannot enforce any policy on ubuntu: ubuntu is kind: shell, which has no agent to hook an egress rule into ``` "Whether Brig can enforce it" means exactly two structural checks. The first is whether the profile is `kind: shell` or `kind: gui`. Neither of those can ever enforce a policy. The second is whether every bound name still resolves to a policy that loaded. `check` does not resolve the current runtime, the current hypervisor, or the runtime's version. It cannot tell you whether the host you are on right now will boot the run or refuse it. See [Where a policy is enforced, and where it is not](#where-a-policy-is-enforced-and-where-it-is-not) for the checks that answer that. `--force` on `rm`, or on a rename, can leave a binding pointing at a name nothing loads under any more: ```console $ brig policy rm locked-down --force removed /home/you/.config/brig/policies/locked-down.yaml $ brig policy check claude-code locked-down (not loaded) brig: claude-code is bound to locked-down, which no policy loads under -- nothing can enforce what did not load ``` `brig policy ls` prints what binds a policy right under it, when anything does. That is an inline `policy:` entry, a profile-level attach, or ` -n ` for a session-level one: ```console $ brig policy ls locked-down only Anthropic's API and one internal range bound to: claude-code, claude-code -n refactor ``` "not loaded" rather than "no such policy", because there are two ways to get there, and Brig cannot always tell them apart. Either nothing declares that name, or the file that declares it did not parse. In that second case the file and its parse error are named separately on stderr. `rm` refuses a policy that is bound to anything (an inline `policy:` entry, a profile-level attach, or a session-level one) unless you pass `--force`. The file is gone either way, but whatever named it is still pointing at nothing: ```console $ brig policy rm locked-down brig: locked-down is bound to claude-code. Detach it first, or pass --force to remove it anyway $ brig policy rm locked-down --force removed /home/you/.config/brig/policies/locked-down.yaml ``` The instruction fits what is actually bound. It says "detach it" for an attach, "edit the profile's `policy:` list" for an inline entry, or both when a policy is bound both ways. `detach` explicitly refuses to touch an inline entry, so telling you to detach one is a dead end. `edit` never touches the real file until the new content is known to be good. It opens a scratch copy, and only replaces the original if that copy still parses and validates. The replace goes through a temp file and a rename in the same directory. A crash or a full disk mid-write cannot leave the real file half written: ```console $ brig policy edit locked-down brig: not saved, /home/you/.config/brig/policies/locked-down.yaml is unchanged: cidr "10.0.0/8" is not a valid CIDR: invalid CIDR address: 10.0.0/8 your edit is still at /tmp/brig-policy-edit-2427992151.yaml ``` Renaming it (changing `name:` to something else) is refused the same way if the old name is bound to anything. The binding then points at a name nothing declares: ```console $ brig policy edit locked-down brig: not saved, /home/you/.config/brig/policies/locked-down.yaml is unchanged: renaming locked-down to totally-new would leave claude-code pointing at a name nothing declares. Detach it first, or pass --force to rename it anyway your edit is still at /tmp/brig-policy-edit-2427992151.yaml ``` A save that keeps the same name never triggers this check: the file a binding points at is still right there either way. ## Binding one session, not every run `policy attach` and `policy detach` take `-n NAME` to bind or unbind one session instead of every run of a profile. `policy check` takes it to ask about that one session instead of the profile as a whole. `-n NAME` must already be the slug form of the name: lowercase letters, digits, dot, dash and underscore. `attach -n Refactor` is refused outright, naming the slug it must become. This is stricter than the retired `brig run --name`, which sanitizes a name and reports the directory it landed on. The difference is in what you are allowed to type, not in what you get. `ParseRef` applies the same slug rule to the `@