Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ $ echo $?

## Quick install

One needs an up to date Go compiler.
`snug` must be built with `CGO_ENABLED=0`.

```bash
# build the binary
Expand Down
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- [Configuration files](configuration.md)
- [Profiles](profiles.md)
- [Variables](variables.md)
- [Filesystem grants](filesystem.md)
- [Environment](environment.md)
- [Network](network.md)
Expand Down
48 changes: 44 additions & 4 deletions docs/src/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,42 @@ it has already expired. Run `claude` on the host to refresh it, or log in again
inside with `/login` (see [`login`](#login)). A login made inside is not saved
on the host; the next run starts from the host token again.

## `settings.json`

The host's `~/.claude/settings.json` and the target's `.claude/settings.json` and
`.claude/settings.local.json` are filtered and most of their content is ignored.
The following keys are passed; the rest is ignored.

- `model`
- `theme`
- `editorMode`
- `verbose`
- `alwaysThinkingEnabled`
- `autoCompactEnabled`
- `includeCoAuthoredBy`
- `prefersReducedMotion`
- `spinnerTipsEnabled`
- `skipWorkflowUsageWarning`

Additionally:

- A value that does not look like a plain name (a `model` containing
a path, for example) is dropped.
- The file is read as strict JSON, up to 1 MiB. So comments, a trailing comma or
a larger file carry nothing.
- `snug -v` and `snug --dry-run` say what is carried and what is not.
- `~/.claude/settings.json` inside the sandbox is writable, so `claude` inside
can write there. The host file remains untouched, of course. The target's copies
are read-only.
- `/etc/claude-code/managed-settings.json` is not visible inside, so an
organisation's managed policy does not apply.
- A target settings file that is a symlink, a directory or anything else but
a plain file refuses the run.
- A profile granting `~/.claude/settings.json` still gets the filtered file on
top. A `.claude/settings.json` in any other
directory a profile grants is not filtered, and Claude Code reads it when
started there.

## The target opens without the trust dialog

Claude Code normally asks "Quick safety check" before it opens a directory
Expand Down Expand Up @@ -94,16 +130,20 @@ login = ["claude"]
- snug opens the page on the host with `xdg-open`, found on your `PATH`, and
needs `DISPLAY` or `WAYLAND_DISPLAY` set. `xdg-open` would fall
back to a text browser otherwise.
- Inside, `BROWSER` is `/snug/bin/snug-browser`, which hands the URL to snug.
snug opens only Claude's own login page, for Claude Code's own client, at
most five times per run and at most once every 30 seconds. Anything else is
refused, and nothing opens:
- `BROWSER` inside the sandbox points to `/snug/lib/snug/browser`, a link to snug's own
executable (see [HTTP doors](network.md#http-doors-listen_names)). It passes the
URL out to snug on the host, which opens it in the host's default browser.
- snug opens only Claude's own login page, for Claude Code's own client, at
most five times per run. Anything else is refused, and nothing opens:

```text
$ $BROWSER https://example.com/
snug: login bridge: refused an open request from the sandbox — not the Claude login URL snug pins (scheme, host or path): https://example.com/. Nothing was opened.
```

- snug sets `BROWSER` itself, so a run where another profile also sets
`BROWSER` in `environ` is refused.
- The browser `xdg-open` starts is yours and may outlive the sandbox itself.
- snug answers the browser's callback itself and passes the sandbox only the
code and state, never a cookie.

Expand Down
33 changes: 33 additions & 0 deletions docs/src/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,39 @@ proxy forwards only a part: `podman build`, `podman run -d`, `podman wait`,
`podman logs` and `podman rm` work, but `podman run` without `-d` is refused,
because it attaches to the container and the proxy does not forward attach.

- `docker cp` is refused. The engine runs beside the sandbox, not in it (see
[the diagram](img/topology-engine.svg)). It can give access to more files
than the configuration allows.

- A host `podman` that is a shim into the host (`host-spawn`,
`flatpak-spawn`, ...) is replaced by snug's own `podman`, which calls the
`docker` client if the sandbox has one. `snug` only filters container
requests and ships no client of its own.

## `docker build`

snug sets `DOCKER_BUILDKIT=0`, so `docker build` uses the classic builder,
whose options the proxy filters.

This is a default, not a bound: a process inside can set
`DOCKER_BUILDKIT=1`. What BuildKit can then reach is not yet measured, see
[#690](https://github.com/gomoni/snug/issues/690).

## Image signatures

The engine checks images against a signature policy built from the host's
`policy.json`:

- `insecureAcceptAnything`, `reject` and `signedBy` carry over. Key files a
`signedBy` names are copied for the engine.
- Any other requirement, such as `sigstoreSigned` or `signedBaseLayer`,
refuses the run and names the requirement and the file.
- A host with no `policy.json` gets a policy that accepts any image. A plain
podman on that host pulls nothing, so here the sandbox is more permissive
than the host; `--dry-run` says so.
- The policy applies to pulls. An image already in the target's storage keeps
running under a stricter policy.

## Storage per target directory

Every target directory gets its own container storage. Images, containers
Expand Down
11 changes: 9 additions & 2 deletions docs/src/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ A variable's type decides which verbs (operations) it accepts. Unsupported verbs
|---|---|---|---|---|---|
| `set` | string | sets a scalar | profiles must agree on the value | scalar | yes |
| `merge` | string or array of strings | adds elements to a list | union, sorted, duplicates removed | path-list, if its row allows it | only if the profile declares it `path-list` |
| `prepend` | string or array of strings | puts elements at the front of a list, in the order written | one profile per variable | path-list, if its row allows it | only if the profile declares it `path-list` |
| `prepend` | string or array of strings | puts elements at the front of a list, in the order written | one value per variable; profiles writing the identical list agree, different lists are an error | path-list, if its row allows it | only if the profile declares it `path-list` |
| `inherit` | `true` | copies the host's value, if the host has one | union | scalar | yes |
| `sanitise` | `true` | copies the host's list, keeping only elements that are granted inside and hold the host's content | union | path-list, if its row allows it | no |

Expand All @@ -75,10 +75,17 @@ A variable's type decides which verbs (operations) it accepts. Unsupported verbs
the list, so a declared `path-list` never takes it.
- A string in `merge` or `prepend` is **one** element. `snug` never splits a
value. So always write `["/a", "/b"]`, not `"/a:/b"`.
- A variable name must match `[A-Za-z_][A-Za-z0-9_]*` and anything else is refused
when the profile loads.
- `sanitise` can keep variables empty, but never unset them.
- `← not granted` in `snug --dry-run` or `snug profile show` means the
path is not granted by any profile.
- `← writable from inside` marks a `PATH` directory the sandboxed program can
write to.
- `inherit` and `sanitise` take `true`. `false` is an error: nothing was
inherited to begin with, so there is nothing to turn off.
- `set`, `merge` and `prepend` values expand `{target}`, `{home}` and the
other [path variables](filesystem.md#variables).
other [variables](variables.md).

## Declaring a type

Expand Down
129 changes: 103 additions & 26 deletions docs/src/filesystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ symlink = [{ at = "/usr/local/bin/python", target = "/usr/bin/python3" }]
| `rw` | list of path specs | a writable bind of a host path. Be aware writes reaches the host, there's no middle layer |
| `tmpfs` | list of guest paths | an empty writable directory in memory, at most [`tmpfs_size`](#tmpfs) (default 1 GiB) |
| `symlink` | list of `{ at, target }` | a symlink at guest path `at` pointing to `target` |
| `file` | list of `{ path, content }` tables | a read-only file (mode `0444`) at `path` holding the text `content` |
| `optional` | list of paths | nothing by itself; an `ro` or `rw` host path must exist, and listing it here skips it instead when it is absent |

## Path specs

A path grant is read-only (`ro`) or read-write (`rw`). Host paths, which are
not granted remain not accessible inside the sandbox. The spec is split at the
first `:`. After variable expansion (for example `{target}`) both sides must be absolute.
first `:`. After [variable](variables.md) expansion (for example `{target}`) both sides must be absolute.

- `HOST` — bind host `HOST` at the same path inside, or
- `HOST:GUEST` — bind host `HOST` at `GUEST` inside.
Expand Down Expand Up @@ -64,31 +65,8 @@ The examples below assume `{home}` (or `~`) is `/home/me` and the `{target}` is

### Variables

Those are NOT environment variables. The home directory is spelled `{home}` or
`~`. `$HOME` has no meaning and is taken as it. `/opt/$HOME` is the literal
path `/opt/$HOME`, and `$HOME/bin` is refused because it is not absolute.

| variable | value |
|---|---|
| `{target}` | the directory given on the command line, symlinks resolved, see [Host symlinks](#host-symlinks) |
| `{target_parent}` | the parent of `{target}` |
| `{home}` | your account's home directory (`getent passwd`), symlinks resolved, see [Host symlinks](#host-symlinks) and [The home directory](#the-home-directory) |
| `{lbrace}` | a literal `{` |
| `{rbrace}` | a literal `}` |
| `{colon}` | a literal `:` |
| `~/` | `{home}/`, only as the first two characters |

Variables expand in `ro`, `rw`, `tmpfs`, `symlink`, `optional`. A path
containing `:` or `{` cannot be written in a spec. A `:` or `{` that comes from
a variable's value is part of the path:

| path to grant | spec | result |
|---|---|---|
| `/mnt/a:b` | `/mnt/a:b` | refused: `both sides must be absolute paths`; split into host `/mnt/a`, guest `b` |
| `/tmp/{foo}` | `/tmp/{foo}` | refused: `unknown variable {foo}` |
| `/mnt/a:b` | `/mnt` | works; grants the whole parent |
| `/w/x:y`, snug run on it | `{target}` | works; `/w/x:y` on both sides |
| `/tmp/{foo}`, snug run on it | `{target}` | works; `/tmp/{foo}` on both sides |
`{target}`, `{home}`, `~/` and the other variables are described in
[Variables](variables.md).

## Host symlinks

Expand Down Expand Up @@ -180,6 +158,104 @@ snug: grant /opt/lnk/x (from p) resolves through the symlink /opt/lnk -> /proc,
be made there. Remove the grant, or point the symlink somewhere else
```

## `file`

```toml
[[profile.work.file]]
path = "/etc/gitconfig"
content = """
[init]
defaultBranch = main
"""
```

`path` is a guest path, absolute after expansion; `~/` and `{home}` work in
it. `content` is the whole text of the file, written verbatim: snug adds no
newline and removes none. `content = ""` is an empty file, and a `file` with no
`content` is refused. Nothing is read from the host and nothing is created on
it. The file is read-only, mode `0444`, and is gone when the sandbox exits.
There is no `rw` form and no `mode` key.

The double brackets are TOML's array of tables: each `[[profile.NAME.file]]`
block adds one file, so a profile writes as many as it has blocks.

```toml
[[profile.work.file]]
path = "/etc/gitconfig"
content = "..."

[[profile.work.file]]
path = "~/.config/pip/pip.conf"
content = "..."
```

`file = [{ path = "…", content = "…" }, …]` is the same list written inline;
the block form reads better when `content` spans lines.

**What a hostile process inside the sandbox can do with it:** read the text,
and nothing more. It cannot write, replace or unlink the file. The power is the
author's: whatever program reads `path` obeys the text. `/etc/gitconfig` is a
command table git executes (`core.sshCommand`, `credential.helper`,
`alias.x = "!cmd"`, `core.pager`). It is exactly the power of
`ro = ["<stub>:/etc/gitconfig"]`, with no host file opened. Write only what you
would write in that stub.

**Never put a credential in `content`.** `snug --dry-run` and
`snug profile show` print the full text and its SHA-256.

The text must be valid UTF-8 with no control character except newline and tab,
and at most 64 KiB, before and after expansion. A longer text belongs in a file
you bind with `ro = ["<file>:<path>"]`. Two profiles writing the same `path`
combine only when the text is identical; different text is refused, naming both.

A `file` is a grant like any other. It may sit inside a `tmpfs` (`{home}` is
one), and is refused inside a `ro` or `rw` bind, because that would hide what
the bind shows there. Nothing can be granted beneath it.

### Placeholders

`content` expands the [variables](variables.md) and `{profile.NAME.KEY}`: a
value another profile pins. `KEY` is
`identity.` followed by a leaf that is not a host path, for example
`{profile.work-id.identity.ssh.host}`. `identity.ssh.key`,
`identity.git.signing_key` and `identity.kubectl.kubeconfig` are host paths snug
reads, and are refused. `~/` is not expanded in `content`.

```toml
[profile.work]
include = ["work-id"]

[[profile.work.file]]
path = "/etc/gitconfig"
content = """
[url "git@{profile.work-id.identity.ssh.host}:"]
pushInsteadOf = https://{profile.work-id.identity.ssh.host}/
"""
```

`NAME` must be the profile that writes the file or one it includes, so the text
never depends on what else a run selects. A placeholder whose key is unset is
refused, and is never expanded to nothing. Expansion is one pass: a value that
contains `{` is data, not another placeholder. A value that contains a control
character, a newline in `{target}` for one, is refused.

### Where git reads it

snug generates `~/.gitconfig` for an `identity.git` and pins user, signing,
`insteadOf` and `safe.directory` there. Git reads `/etc/gitconfig` at system
scope, below the global file, so a `file` at `/etc/gitconfig` adds to the
generated one and cannot override those pins. `--show-scope` reports `system`
for it.

### Generated paths

A `file` at a path snug generates for the same selection is refused, naming the
generated file and what to do instead: `~/.gitconfig`, `~/.ssh/config`,
`~/.ssh/known_hosts`, `/etc/resolv.conf`, `/etc/passwd`, `/etc/group`,
`/etc/nsswitch.conf` and the account files an `identity` stages. snug has no
layer for ssh configuration, so `{home}/.ssh/config` is refused with that
wording. Builtin profiles carry no `file`.

## `optional`

```toml
Expand Down Expand Up @@ -256,6 +332,7 @@ A grant inside another grant must not hide what the outer one shows:
| outer grant | a grant inside it is |
|---|---|
| `tmpfs` | allowed: there is nothing to hide |
| a [`file`](#file) | refused: nothing can be mounted inside a file |
| `ro`/`rw` of host `H` | allowed only when it binds the same tree, `H/sub` at `…/sub`; anything else is refused |

## Paths snug owns
Expand Down
8 changes: 5 additions & 3 deletions docs/src/network.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ listen_names = ["web"]
prints a URL carrying a one-time token.
- A door name is lowercase letters, digits and single interior dashes, at most
32 characters.
- snug starts the command through a two-line `/bin/sh` script that sets
`LISTEN_PID` to the command's own pid, which only the sandbox knows. The
selection must grant `/bin/sh` (`@sys` does).
- snug starts the command through its own wrapper, which sets `LISTEN_PID`
and runs the command in its place. This feature does not need networking
enabled or a shell.
- `/snug/lib/snug/wrapper` is snug's own executable, bound read-only and not
on `PATH`. `--dry-run` shows it as `bind /proc/self/exe` with a note.
2 changes: 1 addition & 1 deletion docs/src/reference/profile-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Every key a `[profile.NAME]` table accepts. Any other key is an error.
| `rw` | array of strings | path specs | union; `rw` wins at one path | [Filesystem](../filesystem.md) |
| `tmpfs` | array of strings | guest paths | union | [Filesystem](../filesystem.md#tmpfs) |
| `symlink` | array of tables | `{ at = "…", target = "…" }` | union; one target per `at` | [Filesystem](../filesystem.md#symlink) |
| `file` | array of tables | `{ path = "…", content = "…" }` | union; identical text at one `path` | [Filesystem](../filesystem.md) |
| `file` | array of tables | `{ path = "…", content = "…" }` | union; identical text at one `path` | [Filesystem](../filesystem.md#file) |
| `optional` | array of strings | paths | this profile only | [Filesystem](../filesystem.md#optional) |
| `nss` | boolean | `true`, `false` | any `true` | [Filesystem](../filesystem.md#generated-account-files-nss) |
| `environ.set` | table of strings | `NAME = "value"` | must agree | [Environment](../environment.md) |
Expand Down
45 changes: 45 additions & 0 deletions docs/src/variables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Variables

As usual with computers, the concept of variables, symbols or special
characters appears sooner or later. snug supports special placeholders such as
`{home}` and `{target}`, which are called _variables_. All in all this is only
simple string _interpolation_: each placeholder is replaced by its actual
value.

Note that despite `{home}`, environment variables such as `$HOME` are not
expanded.

| variable | value |
|---|---|
| `{target}` | the directory given on the command line, symlinks resolved, see [Host symlinks](filesystem.md#host-symlinks) |
| `{target_parent}` | the parent of `{target}` because `@parent-ro` existed forever in `snug` |
| `{home}` | your account's home directory (read by `getent passwd`), symlinks resolved, see [Host symlinks](filesystem.md#host-symlinks) and [The home directory](filesystem.md#the-home-directory) |
| `~/` | `{home}/`, only as the first two characters |
| `{lbrace}` | a literal `{` |
| `{rbrace}` | a literal `}` |
| `{colon}` | a literal `:` |

Variables expand in `ro`, `rw`, `tmpfs`, `symlink`, `optional`, the `path` of a
[`file`](filesystem.md#file) and in `environ` values. A `{` always opens a name, and an unknown
name is refused. There is no escaping; use the special variables shown above.

## The colon

> One does not use colon in his paths!

If you must (Open Build Service uses `:` both in URL identifiers and in
physical file names), be aware that `{colon}` makes a grant for a path
containing `:` possible: `ro = ["/home/u/home{colon}obs:/opt/obs"]`.

You can't use the `{colon}` hack for `merge` or `prepend`. It is refused, as
this is simply not possible and would break most tools.

| path to grant | spec | result |
|---|---|---|
| `/mnt/a:b` | `/mnt/a:b` | refused: `both sides must be absolute paths`; split into host `/mnt/a`, guest `b` |
| `/tmp/{foo}` | `/tmp/{foo}` | refused: `unknown variable {foo}` |
| `/mnt/a:b` | `/mnt/a{colon}b` | works; `/mnt/a:b` on both sides |
| `/tmp/{foo}` | `/tmp/{lbrace}foo}` | works; `/tmp/{foo}` on both sides |
| `/mnt/a:b` | `/mnt` | works; grants the whole parent |
| `/w/x:y`, snug run on it | `{target}` | works; `/w/x:y` on both sides |
| `/tmp/{foo}`, snug run on it | `{target}` | works; `/tmp/{foo}` on both sides |
Loading
Loading