Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ec64acd06 | |||
| 4d5033b001 | |||
| f93f12cfe6 | |||
| 2f4c5fa6c2 |
4 changed files with 87 additions and 43 deletions
71
README.md
71
README.md
|
|
@ -1,49 +1,50 @@
|
||||||
# unsafe-cell
|
# Brief
|
||||||
|
|
||||||
A `jail-nix` container that shares user namespace and grants all capabilities (the "unsafe" part), packaging a command into a self-contained executable ("cell"). It provides nix daemon passthrough, configurable networking, and filesystem isolation — enough to run agentic workloads without the friction of full containerization.
|
`unsafe-cell` is designed with following workloads in mind:
|
||||||
|
- share a cache/binary store with the host
|
||||||
|
- share a configuration with the host
|
||||||
|
- allow limited internet access (to a set list of domains)
|
||||||
|
- jailed program might misbehave -- host must be protected from _accidental_ damage
|
||||||
|
|
||||||
## Capabilities
|
So this cell aims to prevent malfunction, not malice; for example: current design shares a nix daemon with the host -- a malicious code could easily exploit the build pipeline to dos, mine crypto or circumvent the web proxy. However, this kind of malfunction is extremely unlikely happen _accidentally_, while benefits of sharing the nix store with host are self-explanatory.
|
||||||
|
|
||||||
Shares user/id mappings 1:1 with the host. All kernel capabilities granted. No PID namespace isolation — host PID 1 visible inside. New mount namespace with only explicitly bound paths.
|
Example use-cases:
|
||||||
|
- trusted CI job container
|
||||||
Nix daemon access via read-only bind of host store (`/nix/store`) and daemon socket (`/run/nix`). Nix config and experimental features (`nix-command`, `flakes`) set inside the jail. Binary substitution enabled.
|
- agentic runtime (like `opencode`, for instance)
|
||||||
|
|
||||||
Network access is always proxied through tinyproxy with an isolated network namespace (no network routes inside the jail). A socat pair forwards TCP through a Unix socket onto tinyproxy. An optional tinyproxy filter file controls allowed domains; defaults to allow-all (`.*`).
|
|
||||||
|
|
||||||
Working directory bind-mounted at a configurable host path. Optional log directory bind.
|
|
||||||
|
|
||||||
Entrypoint runs the command derivation supplied via the `command` parameter. `PATH` includes `nix`, `coreutils`.
|
|
||||||
|
|
||||||
## Parameters
|
## Parameters
|
||||||
|
|
||||||
`command` (`pkgs -> derivation`): Builds the jailed entrypoint. Receives nixpkgs.
|
- `command` (`pkgs -> derivation`): a function to build the cell entrypoint from `nixpkgs` instantiation
|
||||||
|
- `worktreePath` (string): host path to bind as `/home/cell/worktree` inside the jail. Use this parameter to expose jailed processes primary worktree (like a repo you work on)
|
||||||
|
- `extraCombinators` (`jail-nix -> list`, default `_: []`): a function to produce additional [`jail.nix` combinators](https://alexdav.id/projects/jail-nix/combinators) you want the jail to have.
|
||||||
|
- `tinyproxyFilter` (string or null): Path to a tinyproxy filter file. `null` means "allow all", non-`null` should contain a regex list of the allowed domains. Uses [`tinyproxy`](https://tinyproxy.github.io/) (with your filter as a white-list!).
|
||||||
|
- `logDir` (string or null): directory to bind for log output. `null` means using the jail tempdir (wherever `mktemp` creates it).
|
||||||
|
- `nixDaemonSocket` (string, default `"/var/nix/daemon-socket"`): host path to nix daemon socket directory (contains `socket`).
|
||||||
|
|
||||||
`worktreePath` (string): Host path to bind as `/home/cell/worktree` inside the jail. Required, no default.
|
NOTE: if you want to pass some truly special arguments to `bwrap`, see `unsafe-add-raw-args` jail.nix combinator -- an escape hatch for adding bare os args for the `bwrap` process.
|
||||||
|
NOTE: for now, the host is expected to have a nix daemon socket. To make the cell work, at the bare minimum, you should make up a socket and point `nixDaemonSocket` to it -- cell will connect `socat` to it, but won't actually communicate with the other side, for as long as you don't use `nix` inside the jail.
|
||||||
|
|
||||||
`extraCombinators` (`jail-nix -> list`, default `_: []`): jail-nix combinators appended to the default set.
|
## Capabilities
|
||||||
|
|
||||||
`tinyproxyFilter` (string or null): Path to a tinyproxy filter file. `null` = default allow-all filter (`.*`); non-null = use the specified filter file.
|
- nix daemon access:
|
||||||
|
- read-only bind of host store (`/nix/store`)
|
||||||
|
- bind of the daemon socket (`/run/nix`)
|
||||||
|
- nix client config to silence experimental feature warnings (`nix-command`, `flakes`)
|
||||||
|
- nix client substitutions enabled to allow binary cache usage (client controls that, for some reason?)
|
||||||
|
- network:
|
||||||
|
- http proxied through `tinyproxy`, with optional filter
|
||||||
|
- isolated network namespace with no interfaces
|
||||||
|
- workdir bind-mounted at a configurable host path
|
||||||
|
- optional log directory bind
|
||||||
|
|
||||||
`logDir` (string or null): Directory to bind for log output. `null` = use jail tempdir (`$JD`).
|
planned:
|
||||||
|
- spawning a personal dockerd (blocked by some annoying stuff inside `bwrap`)
|
||||||
|
- support for MITM docker image cache (could be implemented now, but is useless without a personal dockerd)
|
||||||
|
|
||||||
`nixDaemonSocket` (string, default `"/var/nix/daemon-socket"`): Host path to nix daemon socket directory (contains `socket`).
|
NOTE: unfortunately, outward communication with protocols other than HTTP(S) is not supported. Any celled app failing to interpret `{HTTP{,S},NO}_PROXY` envvars, is also likely to misbehave.
|
||||||
|
|
||||||
## Implicit assumptions
|
## Usage example
|
||||||
|
|
||||||
`mkCell` builds its output for every system in `systems.flakeExposed`. Requires `nixpkgs` and `jail-nix` as flake inputs.
|
See `tests.nix`.
|
||||||
|
|
||||||
The nix daemon socket path is assumed to be a **directory** containing a `socket` entry — the `NIX_REMOTE` env var always resolves `/run/nix/socket` regardless of `nixDaemonSocket`.
|
Will probably add my `opencode` run script, once it's complete.
|
||||||
|
|
||||||
`/nix/store` is assumed present and readable on the host. `NIX_PATH` hardcoded to `/home/cell/.nix-defexpr/channels`.
|
|
||||||
|
|
||||||
`NIX_CONFIG` unconditionally overrides host nix config — forces `nix-command`, `flakes`, and `substitute = true`.
|
|
||||||
|
|
||||||
Host-side packages required at nixpkgs evaluation time: `socat`, `coreutils`, `nix`, `iproute2`, `gawk`, `tinyproxy`.
|
|
||||||
|
|
||||||
`worktreePath` is resolved relative to the host-script's CWD at runtime (shell expansion applies).
|
|
||||||
|
|
||||||
The default combinator set does not include SSL certificates, app config/state directories, nix registry mapping, nix config files, or secret tokens — these must be supplied by the consumer via `extraCombinators`.
|
|
||||||
|
|
||||||
The host script always creates `$JD` (temp dir) with tinyproxy config, SSL overlay dirs, and Unix socket — all cleaned up on exit. The SSL overlay dirs are created but unused (dead code from original hardcoded version).
|
|
||||||
|
|
||||||
No IPC namespace isolation — inherits from host.
|
|
||||||
|
|
|
||||||
41
flake.lock
generated
Normal file
41
flake.lock
generated
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"jail-nix": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1772137954,
|
||||||
|
"narHash": "sha256-h4MGNbOo7L3RHi4uNFmsg5g17/DHXEfnv/xiG6BrNFQ=",
|
||||||
|
"owner": "~alexdavid",
|
||||||
|
"repo": "jail.nix",
|
||||||
|
"rev": "42b355c38ca63dab4904acc5c0d95f17954a8c9b",
|
||||||
|
"type": "sourcehut"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "~alexdavid",
|
||||||
|
"repo": "jail.nix",
|
||||||
|
"type": "sourcehut"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778869304,
|
||||||
|
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"id": "nixpkgs",
|
||||||
|
"type": "indirect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"jail-nix": "jail-nix",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
13
flake.nix
13
flake.nix
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
description = "unsafe-cell - isolated environment with trusted nix store access";
|
description = "unsafe-cell - a reasonably-isolated environment";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "nixpkgs";
|
nixpkgs.url = "nixpkgs";
|
||||||
|
|
@ -146,7 +146,12 @@
|
||||||
SOCAT_PID=$!
|
SOCAT_PID=$!
|
||||||
|
|
||||||
trap "${pkgs.coreutils}/bin/rm -rf $JD; ${pkgs.coreutils}/bin/kill $PROXY_PID $SOCAT_PID" EXIT
|
trap "${pkgs.coreutils}/bin/rm -rf $JD; ${pkgs.coreutils}/bin/kill $PROXY_PID $SOCAT_PID" EXIT
|
||||||
export NIX_CONFIG="''${NIX_CONFIG:+$NIX_CONFIG$'\n'}extra-experimental-features = nix-command flakes"$'\n'"substitute = true"
|
|
||||||
|
export NIX_CONFIG="
|
||||||
|
''${NIX_CONFIG:-}
|
||||||
|
extra-experimental-features = nix-command flakes
|
||||||
|
substitute = true"
|
||||||
|
|
||||||
${jailed}/bin/unsafe-cell
|
${jailed}/bin/unsafe-cell
|
||||||
'';
|
'';
|
||||||
in
|
in
|
||||||
|
|
@ -163,7 +168,7 @@
|
||||||
system:
|
system:
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
test-names = builtins.attrNames self.checks.${system}.test;
|
test-names = builtins.attrNames (builtins.removeAttrs self.checks.${system} [ "all" ]);
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
test-logs = {
|
test-logs = {
|
||||||
|
|
@ -173,7 +178,7 @@
|
||||||
failed=0
|
failed=0
|
||||||
for test in ${builtins.concatStringsSep " " test-names}; do
|
for test in ${builtins.concatStringsSep " " test-names}; do
|
||||||
echo "=== $test ==="
|
echo "=== $test ==="
|
||||||
if nix build ".#checks.${system}.test.$test" -L --no-link 2>&1; then
|
if nix build ".#checks.${system}.$test" -L --no-link 2>&1; then
|
||||||
echo "=== PASS: $test ==="
|
echo "=== PASS: $test ==="
|
||||||
else
|
else
|
||||||
echo "=== FAIL: $test ==="
|
echo "=== FAIL: $test ==="
|
||||||
|
|
|
||||||
|
|
@ -382,8 +382,5 @@ nixpkgs.lib.genAttrs
|
||||||
'';
|
'';
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
tests // { inherit all; }
|
||||||
inherit all;
|
|
||||||
test = tests;
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue