Files
ducoterra 144e322391 Add --publish: the one ingress control
Everything else in the file gates egress, which left an everyday request unanswered: the
agent starts a dev server inside the sandbox and a human wants to look at it. This puts a
listener on the host that forwards into the container, so a browser, curl or an API
tester reaches the server the sandboxed process runs.

`--publish [ADDR:]HOST:CONTAINER`, config `publish` and AISB_PUBLISH, repeatable, with the
cost named in the flag: it is ingress, so whatever that server serves is reachable from
the address it binds. The default address is 127.0.0.1 because a bare podman -p binds
every interface (measured) -- a port committed in a repo config must not quietly be a port
on the LAN. publish_addr changes the default machine-wide; a spec with a non-loopback
address warns on every run. The grammar is smaller than podman's on purpose: one port per
entry, no ranges, no /udp, no `*` -- this list is reviewed line by line, and typing
0.0.0.0 is the whole warning. An address means three parts, so `127.0.0.1:8000` is refused
rather than guessed. No -p alias: that flag belongs to the harness (claude -p prints).

Fail closed, as elsewhere: --net none cannot forward a port and podman discards the
mapping while starting the container anyway, so "it ran" would mean "nothing is
listening"; a host port already in use is named before the container starts, not after a
build; pick_port() will not hand the gate a port this run publishes. The container is
labelled with the ports it was given, and doctor reports them.

Inside, publish_rules() accepts tcp on the published ports in the input chain and never
the output one. Measured (docs/networking.md): pasta delivers the connection over
loopback, so that rule counts 0 packets on the reference host; it is there for a host or a
bridge network that delivers on the interface, and so that `nft list table inet aisb`
answers "what can reach this container". The ports are validated again inside the
entrypoint, because a ruleset is not somewhere to splice an unvalidated string. And the
measurement that makes a configured preview look broken: a server bound to 127.0.0.1
inside answers inside and refuses the published port outside, so dev servers must bind
0.0.0.0 -- both skills say so in the change that writes the port.

Tests. Unit: the grammar and every port list that looks right and is not, config/env/flag
precedence and dedup, the canonical spec in --print-policy, the `# ingress:` header, the
flags reaching the invocation before the image, egress enforcement byte-identical with a
port open, which chain the rule lands in and that a non-numeric port never reaches nft as
code. e2e (tests/e2e/13_publish.sh): one real container with enforce=nft where the host
gets its 200 and the same run still refuses a domain outside the allowlist at 403, the
server runs as uid 1000 with no capabilities so it cannot widen its own port, and the host
listener is gone when the container exits.

LLM-Generated: true
2026-10-30 22:53:57 -04:00

9.3 KiB

Settings map

Check this against the installed program before writing a config: ai-sandbox --help, and cfg_read_kv() / resolve_opts() in the script. Where they disagree, the script wins and this file is the bug.

Precedence

built-in default  <  ~/.config/ai-sandbox/{config,allow}  <  ./.ai-sandbox/{config,allow}
                  <  AISB_* environment  <  command-line flag

allow, env, mount, install, label, publish accumulate from every source (deduped); every other key replaces. So a repo file cannot revoke a user-wide allow line — say so if the user expects a repo config to tighten their own machine. It also cannot unpublish a port the user's own config opens.

The container, for orientation

Anything named "container" below is inside the sandbox: uid/gid 1000, the image's own sandbox account, its home the value of HOME in there (--userns=keep-id makes files the agent writes owned by you on the host). So ~/.agents exists on both sides of the wall and they are different directories — the host one is bind-mounted read-only onto the container one at agents_dest. /project is the image's own directory and the fallback working directory, not the normal one.

Config keys (key = value, # comments)

key default notes
net allow none | allow | full. allow requires a non-empty allowlist.
harness pi pi | codex | claude | none | a command line, e.g. pi --model turbo
workdir auto auto mirrors the host path; or an absolute path. project_dest is parsed and never read — use workdir.
cpus 4
memory 4g 512m/4g shape
pids 512
tmpfs_size 1g sizes /tmp and, with home_cache = 0, the container home
base_image docker.io/library/ubuntu:24.04 may itself be name@sha256:…
node_version 22.23.4 checksum-verified against nodejs.org
pi_version 1.4.0 npm package version baked into the image
base_ttl_days 7 0 re-resolve every build, -1 never
base_auto_update 1 0 keeps the recorded digest offline
selinux :z when getenforce exists :z, :Z or empty
home_cache 1 0 = throwaway tmpfs home (also --ephemeral-home)
gate_addr 100.64.0.1 the address that means the host's loopback inside the container
gate_port 18081 preferred first port; next free is used
publish_addr 127.0.0.1 the host address a bare publish = 8000 binds. Machine-wide setting: put it in the user config, never in a repo file, and do not widen it for the user's convenience
extra_args empty appended verbatim to podman run, word-split. Escape hatch: do not commit it
pi_config 1 bridge ~/.pi/agent/{models,settings}.json with the credential as $VAR
pi_token_env AI_API_TOKEN the variable the bridged config reads
pi_token_auto 1 pass that variable automatically when set
pi_agent_dir ~/.pi/agent where to read the host config from
thinking xhigh off minimal low medium high xhigh max host; pi only, re-applied every run
agents 1 share ~/.agents read-only when the directory exists
agents_dir ~/.agents which directory; a project's own .agents/ needs nothing
agents_dest the container's ~/.agents keep it under the container $HOME — that is the only place a harness looks for user-level skills
allow — repeatable; same grammar as --allow
env — repeatable; NAME, NAME=VALUE (no secret in a committed file), or @path = an env file
mount — repeatable; SRC[:DEST][:ro|rw], default DEST=/mnt/<basename>
install — repeatable; apt packages, each distinct set is its own image tag
label — repeatable; K=V on the container
publish — repeatable; ingress: [ADDR:]HOST:CONTAINER forwarded into the container. One port per entry, no ranges, no /udp; an address needs three parts. 8000, 18080:3000, 0.0.0.0:18080:3000 (LAN-visible: warned, do not commit)

Anything else warns (unknown key) and is ignored.

Flags

egress    --net none|allow|full  --allow HOST[:PORT]  --allow-file F  --proxy HOST:PORT  --no-gate
ingress   --publish [ADDR:]HOST:CONTAINER        (no `-p`: that flag belongs to the harness)
files     --mount SRC[:DEST][:ro]  --no-cwd  --workdir DIR|auto  --read-only
          --home-cache  --ephemeral-home  --agents | --no-agents  --agents-dir PATH
env       --env NAME  --env NAME=VALUE  --env-file F          (F should be 0600)
image     --base-image REF  --install PKG  --no-build
harness   --harness NAME  --shell  --thinking LEVEL  --pi-token-env NAME  --no-pi-token-env
          --no-pi-config
limits    --cpus N  --memory SIZE  --pids N  --tmpfs-size SIZE
run       --name NAME  --label K=V  --keep  --interactive | --no-interactive  --no-tty
preview   --dry-run|-n  --print-policy  --print-entrypoint  --print-containerfile
hatches   --allow-unenforced  --insecure-keep-root  --strict-args

Parsing stops at the first positional or unknown argument, which becomes an argument to the harness — that is what makes alias pi=ai-sandbox behave like the harness. -- replaces the command entirely. --strict-args makes an unknown flag an error instead (use it in scripts).

Subcommands: build [--rebuild] [--gate] [--refresh-base], update [--check], doctor [--deep], gate up|down|status|logs, ps, clean [--all] [--volumes[=GLOB]], inspect, version.

Exit status: the command's own code; 77 the egress ruleset could not be installed; 78 the policy file was missing in the container. Both refusals are the sandbox working.

Allow-entry grammar

entry effect
example.com gate matches that name and anything below it, at CONNECT time
example.com:8443 same; the port does not create a netfilter rule, and the gate only relays 80/443/8443
10.0.0.5:5432 direct netfilter rule for that host and port — the way to allow non-HTTP
10.20.0.0/16 direct rule for the subnet, default ports 80,443 unless :port
[fd00::5]:8443 direct ipv6 rule
localhost resolves on the host → a direct rule for 127.0.0.1, i.e. the container's loopback. For your machine's services use 100.64.0.1

Per-ecosystem starting points

Add only what the project's own lockfiles and build files prove it contacts. Everything left out fails loudly, which is the feature.

stack entries
npm registry.npmjs.org (+ nodejs.org and, for prebuilds, release-assets.githubusercontent.com — read the git-host warning before adding anything from github.com)
python pypi.org, files.pythonhosted.org (the file CDN is separate; both are needed). A private index: whatever host --index-url, PIP_INDEX_URL, [[tool.uv.index]] or tool.poetry.source names
rust crates.io, static.crates.io, index.crates.io (+ static.rust-lang.org if it installs toolchains)
go proxy.golang.org, sum.golang.org (+ the host of a private module path, allow = <host> and GOPRIVATE in the env)
ruby rubygems.org, index.rubygems.org
php repo.packagist.org, api.github.com (composer metadata; check before allowing)
jvm repo1.maven.org, repo.maven.apache.org, plugins.gradle.org, services.gradle.org, dl.google.com
dotnet api.nuget.org
terraform registry.terraform.io, releases.hashicorp.com
playwright / puppeteer their download hosts (cdn.playwright.dev, storage.googleapis.com) — or commit the browsers and go --net none
container builds inside the sandbox registry-1.docker.io, auth.docker.io, production.cloudflare.docker.com

Gotchas worth a comment in the file

  • --env NAME=VALUE shows up in ps; the name-only and --env-file forms do not.
  • An unset variable named with --env fails the run (in preview it is reported as env_missing= instead) — that fail-closed behaviour is why preview output is worth reading.
  • The image's home is a named volume (aisb-home) that outlives the image: after a rebuild the home may be stale. ai-sandbox clean --volumes reclaims unused aisb-* volumes.
  • A project under /tmp, /usr, /etc, /var, /opt, /run, /home itself or the container's own home cannot mirror its host path and lands in /project, with a line saying so. Do not "fix" that with --workdir /tmp/...; the tmpfs covers it.
  • The host path may not contain : at all — podman cannot parse such a mount spec, and ai-sandbox refuses rather than passing the error along.
  • publish is the only ingress setting there is, and it is not an allowlist entry. allow = 100.64.0.1:5432 lets the sandbox reach a service on your machine; publish = 5432:5432 lets your machine reach a port inside the container. Confusing the two is how a database ends up exposed. Read references/port-and-loopback.md before writing either.
  • Nothing is inherited but TERM LANG LC_ALL LC_CTYPE TZ (plus HOME, set to the container home, AISB=1, AISB_SESSION). A tool that needs PATH, a dotfile or anything else from your real home needs an explicit grant.
  • .env files inside the project are readable by the agent — that is the point of the mount, and it is why the sandbox token should be a narrow one.