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

15 KiB

Source map

Question topic → the function, doc and test that hold the answer. Line numbers move; names do not — grep for these in the installed script (readlink -f "$(command -v ai-sandbox)").

Flags, keys, defaults

question read
"what does --x do / does it exist" usage() (grouped by NETWORK / PORTS (INGRESS) / FILESYSTEM / ENVIRONMENT / IMAGE / PI CONFIG / LIMITS / RUN / ESCAPE HATCHES / SUBCOMMANDS), parse_args(), and KNOWN_OPTS + suggest() for the accepted set
"which config keys are legal" cfg_read_kv() — the case lists them, the accumulate list is cfg_accum_keys (allow env mount install label publish). Anything else warns unknown key and is ignored
"what wins, my flag or the file" resolve_opts() — every line is ${OPT_x:-${AISB_X:-$(cfgv x default)}}: CLI, then env, then config, then default. load_config() shows the file order
"AISB_* variables" the comment block above usage() and --help's ENVIRONMENT section
"why did arg parsing eat my harness flag" parse_args() stops at the first positional/unknown; -- sets COMMAND; --strict-args; default_command()/harness_argv() decide what actually runs
subcommand behaviour main() dispatch, then cmd_build / cmd_update / cmd_doctor / cmd_gate / cmd_ps / cmd_clean / cmd_inspect

Networking and enforcement

question read prove with
how --net allow holds at all entrypoint_script() → gen_rules(), plus build_argv()'s --cap-add=NET_ADMIN + the setpriv line ai-sandbox --print-entrypoint; docs/networking.md
can the sandboxed process lift the policy the capabilities the entrypoint drops before exec (--inh-caps=-all --bounding-set=-all --no-new-privs) tests/e2e/11_net.sh (flush attempt, unshare -n, raw socket, DNS to 8.8.8.8)
why an entry did nothing split_target() then classify_allows(): domains → the gate (CONNECT-time, subdomain match); IP/CIDR/ipv6 → direct netfilter rules, ports default 80,443 ai-sandbox --print-policy → gate_domains=, direct=, direct6=
which ports the gate relays write_squid_conf(): Safe_ports 80 443 8443, SSL_ports 443 8443, http_access deny all after the allowlist ai-sandbox gate logs
gate lifecycle / ports / reuse pick_port(), gate_up(), teardown(), cmd_gate(); AISB_GATE_KEEP=1; gate_port/gate_addr ai-sandbox gate status
--no-gate vs --proxy vs the gate resolve_proxy_endpoints() — --proxy uses your proxy (default port 3128); --no-gate resolves domains once on the host, opens DNS and warns; default is the built-in gate --dry-run (the HTTP(S)_PROXY args)
host-loopback / 100.64.0.1 build_argv()'s --network=pasta:--map-host-loopback,${gate_addr} and the NO_PROXY defaults docs/networking.md's measurement table
"can I see the server the sandbox is running" / what publish means resolve_publish() (the grammar, the loopback default publish_addr, the refusals), build_argv()'s --publish + aisb.publish label, write_policy_files()'s AISB_PUBLISH_PORTS, and publish_rules() in the entrypoint. pick_port() skips published host ports --dry-run's # ingress: line; docs/networking.md "Publishing a port"; tests/e2e/13_publish.sh (host reaches it, egress still refused, loopback-only server inside does not answer, busy port refuses the run)
"does publishing weaken the sandbox?" It is ingress only: the output chain, the gate and the allowlist are untouched, and the mapping is decided on the host before the container starts — the command has no CAP_NET_ADMIN and cannot add or widen a port tests/unit/06_entrypoint.sh (the rule lands in input, never output), tests/e2e/13_publish.sh, threat model #14
"is my machine able to do this" cmd_doctor(), tests/spike_network.sh ai-sandbox doctor --deep

Filesystem, mounts, the working directory

question read
why the project is at /project resolve_workdir() and mirror_block_of() — the deny list is what the image or the sandbox owns, and /tmp is a measured case (the tmpfs covers a bind of the same path). workdir_note= in --print-policy gives the reason
what is mounted, rw or ro build_argv(): the cwd, /run/aisb:ro (session policy), the home volume or tmpfs, the .agents share, then user --mounts
--mount grammar and defaults mount_dest() — SRC[:DEST][:ro|rw], default DEST=/mnt/<basename>; the option must be ro or rw; the source must exist
skills sharing agents_src_of(), agents_dest_taken(), the .agents block in build_argv(). Read-only, no override; a hand-written --mount on the same destination wins and the share is skipped with a log line
file ownership written by the agent --userns keep-id:uid=…,gid=… in build_argv() (files land owned by you), checked by doctor
SELinux labels set_selinux(), POL[selinux], AISB_SELINUX

Environment and secrets

question read
what is inherited AISB_IMPLICIT_ENV (TERM LANG LC_ALL LC_CTYPE TZ) and the explicit HOME/AISB/AISB_SESSION in build_argv()
how a value reaches the container build_env_file() — a 0600 file, NAME, NAME=VALUE and @file forms; unset --env NAME dies on a real run and reports env_missing= in preview
can a secret leak through output print_argv() (redacts the env file), print_policy() (names only), log_session() (net, counts, rc, command). tests/unit/04_env.sh, tests/e2e/12_sandbox.sh
what lands on disk per session session_init() / teardown(): $AISB_STATE_DIR/sessions/<id> (chmod 700), removed unless --keep

The pi bridge and thinking policy

question read
what is copied vs rewritten pi_config_bridge() — apiKey → "$VAR", !command values dropped and reported, host paths dropped and reported, auth.json and models-store.json never touched
why settings.json behaves oddly the per-key merge in the entrypoint's seeding block, and .aisb-settings.json.wrote — a key you edit in the sandbox stays yours; keys you never touched follow the host
when the run is refused for a credential pi_config_bridge() + cmd_doctor(): unset or empty token variable → refused. --pi-token-env NAME, --no-pi-token-env, --no-pi-config
"the model endpoint is not in the allowlist" warning pi_egress_hint(), pi_provider_hosts_of()
thinking level resolve_opts() (validation of off…max|host) and the seeding block: AISB_PI_THINKING is written over the merged settings every run; pi-only; --no-pi-config declines the policy too

Image, pinning, supply chain

question read
what is in the image containerfile() (git curl jq build tools python3 Node pi), gate_containerfile() (squid from the same base)
why the tag is what it is image_hash() (the policy, including the base digest, Node and pi versions) and image_ref(); --install changes it, so variants coexist
digest pinning and freshness base_pin_digest(), base_digest_online(), base_age_days(), base_ref_of(), cmd_update(); base_ttl_days, base_auto_update, AISB_BASE_PULL_FALLBACK, AISB_SKOPEO. Preview modes resolve cache-only — that is why --dry-run never touches the network
"what version of squid filtered that" the aisb.* image labels and /etc/aisb/manifest.txt inside the image (image_hash()/containerfile() write them)
rollback cmd_update() keeps the previous image and prints the podman rmi for it
stale home ensure_home_volume(), cmd_clean()'s --volumes[=GLOB]

The pipeline driver (bin/ai-sandbox-autophase)

question read
where does my launcher come from ap_resolve_launcher(), ap_probe_rc() (sources an rc file in a throwaway bash -ic and returns the definition; it never calls it), ap_rc_list(), AISB_AP_RC_FILES
a launcher that ignores what it was asked to run ap_forwards_args() / ap_warn_forwarding(): a function must end in "$@", because every child is NAME --no-tty --no-interactive -- <command>. Aliases and whole command lines need nothing (ap_build_run()). Named in --status, refused by ap_preflight()
"it just sits there and does nothing" two causes, both at the very start of a run: the launcher dropped the command so the container is running an interactive harness with no prompt (ap_forwards_args()), or the rc probe is waiting on the terminal (ap_probe_rc(): setsid, </dev/null, timeout -k 5 15, transcript into a file, because an interactive bash takes the terminal, gets stopped, and a stopped process ignores SIGTERM). Every child starts with stdin on /dev/null
how the child command is built ap_build_runner() / ap_build_run() — printf %q per argument; --sandbox-args defaults --no-tty --no-interactive; the prompt is one argument that may contain quotes, newlines and $
what it shadows from the phase library ap_install_overrides(): it first requires the library to provide find_root next_unit phase_entries phase_next_unit unit_phase is_phase_end count_units sweep_finished_phases execute_unit unit_report notify_task commit_phase push_phase (plus first_prompt/fix_prompt, kept as ap_lib_*), then shadows pi, first_prompt, fix_prompt, run_validation, push_phase — and nothing else. Order matters: the launcher resolves before pi() exists, or a launcher named pi would resolve to the shim
why a push was refused ap_git_fingerprint(), ap_git_guard(), ap_git_env(), ap_push_pending(); --trust-git-changes is the (loud) override
redaction ap_redact() — literal --env NAME=value and credentials inside remote URLs
what runs where ap_preflight(), ap_gate_preview() (--validate-in sandbox|host), ap_run_pipeline(), ap_status()
the contract without the skill installed tests/unit/10_autophase.sh and tests/fixtures/phase-lib/

Vocabulary

  • gate — the per-session squid container, --network=host, bound to the host loopback on a free port ≥ gate_port, run as the proxy user with --cap-drop=ALL, destroyed with the session. gate up is the explicit long-lived exception.
  • ruleset — the inet aisb nft table installed inside the container's namespace: output policy drop with room for loopback, established/related, ICMP, the direct IP entries, the gate's address:port, and DNS only in --no-gate mode. Input is policy drop as well, accepting loopback, established/related, a few icmpv6 types and — since publish exists — the tcp ports the host forwards in (aisb-publish). Measured on the reference host pasta delivers those over loopback, so that rule carries 0 packets there and does the work on a host that delivers on the interface.
  • POL[...] / P_* / OPT_* / CFG_* — resolved policy / resolved lists / raw CLI flags / config-file values. Reading raw env vars elsewhere is the bug pattern; a helper called as X="$(helper)" runs in a subshell, so any POL[..] it sets is discarded.
  • fail closed — no allowlist in allow mode, missing policy file (78), uninstallable ruleset (77), unset --env NAME, unset-or-empty bridged token: all refusals, never degradations.
  • escape hatch — --allow-unenforced, --insecure-keep-root, --net full, --mount, extra_args, AISB_EXTRA_ARGS. Named to be uncomfortable; nothing uses them implicitly.

FAQ (verify the version before quoting)

question answer
--print-policy says "could not create the home cache volume"? There is no usable podman (missing, or not rootless, or no /etc/subuid range) — --print-policy ensures the aisb-home volume exists, unlike --dry-run, which is the fully side-effect-free preview. Run ai-sandbox --dry-run to see the policy and ai-sandbox doctor for the host.
Why exit 77? The ruleset could not be installed, so the sandbox refused to run unfiltered. ai-sandbox doctor; check podman info | grep -i rootless and /etc/subuid. Debug with --allow-unenforced only to see the error, not to ship it.
Why "no egress allowlist configured"? allow mode with nothing in it is a hard error. --allow, .ai-sandbox/allow, or --net none/--net full if you mean it.
Can it read ~/.ssh? No — that path is not in the mount namespace unless you mounted it. Threat model #1 is what you do mount and allow.
Can it rewrite my skills? No. ~/.agents is read-only and there is no flag; but its contents are readable, so treat the shared tree as published to the agent.
Why is my project at /project? Mirroring refused because the host path would bury a path the image or sandbox owns; workdir_note= says which, --workdir chooses.
npm ci works but a package fails That ecosystem needs another host (python: files.pythonhosted.org; rust: static.crates.io/index.crates.io; go: sum.golang.org). Or the package fetched from an allowlisted host — allowing a domain is not a security review.
How do I let it push? You don't, from inside. --allow github.com + --env GH_TOKEN or --mount ~/.ssh:ro does it, and the threat model explains the price. For pipelines, run ai-sandbox-autophase, which pushes on the host.
How do I see what the gate allowed? ai-sandbox gate logs (and gate status, gate down). Don't put secrets in URLs: the gate sees what flows through it.
A stale container home after a rebuild? It is the aisb-home volume. ai-sandbox clean --volumes[=GLOB], or --ephemeral-home per run.
How do I see the dev server the sandbox runs? --publish 8000 (config publish = 8000): host 127.0.0.1:8000 → container 8000, warned if you name a non-loopback address. The server inside must bind 0.0.0.0 — a 127.0.0.1-only server answers inside and refuses the published port (measured). --net none refuses to publish, because podman drops the mapping there and starts anyway.
Is a published port a security hole? It is ingress you asked for: whatever that server serves is reachable from the address it is bound to, for as long as the run lasts. Loopback by default (podman's bare -p binds every interface), no ranges, the agent cannot open or widen one, egress enforcement is untouched. Threat model #14.
How does the sandbox reach a database on my machine? That is egress, not publish: allow = 100.64.0.1:<port> (and a NO_PROXY entry when it is HTTP on an unusual port). publish points the other way.
How do I refresh the base? ai-sandbox update --check then ai-sandbox update (or build --refresh-base). TTL re-resolves a digest, it never downloads on a preview.
Do --dry-run/--print-* touch the network? No — that determinism is deliberate. inspect is the one that asks the machine now.
Is the container root? It starts root with CAP_NET_ADMIN just long enough to install the ruleset, then setpriv to uid 1000 with no capabilities and no-new-privileges. --insecure-keep-root is the opposite.
What is NOT protected? docs/threat-model.md, and answer with it: allowlisted destinations, kernel/runtime escapes, anything you mounted, the cached home volume, DNS in --no-gate mode, the fact that a host-side push pushes what the agent wrote.