Files
ai-sandbox/.agents/skills/doc-sandbox/references/source-map.md
T
ducoterra a91744f390 stop the driver hanging on a terminal and on a launcher
ai-sandbox-autophase could sit forever, printing nothing, in two places
that are both before the first unit -- which is why both looked like a
script that "does nothing".

The rc probe ran `timeout 15 bash -ic` in front of the user's terminal.
An interactive bash calls tcsetpgrp() to take that terminal, is in its
background, and is stopped for trying; a stopped process does not take
SIGTERM, so the timeout waited in front of it and the driver waited on
the command substitution behind it. It now runs in its own session
(setsid), with stdin closed, under `timeout -k`, and reads the
definition from a temp file rather than a pipe, because a grandchild an
rc file leaves behind holds a pipe's EOF open long after the shell it
came from has been reaped.

A launcher is called *with* the run, and a function ending at
`--env NAME` -- the shape everyone writes for typing by hand, including
every example this repo printed -- discards the flags and the
in-container command. The container then starts the image's default
harness with no prompt and, with --no-interactive gone, the terminal
attached: an agent waiting for keystrokes behind a pipe nobody reads.
`ap_forwards_args` checks the captured definition, --status and the
preflight name it with the one-line fix, and every child now starts with
stdin on /dev/null so a launcher cannot put the sandbox back on the
keyboard. Aliases and whole command lines need no forwarding: the shell
appends the words to those.

Tests: a launcher fixture that drops its arguments, one that leaves a
process behind, and shape assertions on ap_probe_rc.

LLM-Generated: true
2026-10-29 21:07:39 -04:00

13 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 / 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). 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
"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 policy drop too, so nothing is published in.
  • 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 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.