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
13 KiB
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 theproxyuser with--cap-drop=ALL, destroyed with the session.gate upis the explicit long-lived exception. - ruleset — the
inet aisbnft table installed inside the container's namespace: outputpolicy dropwith room for loopback, established/related, ICMP, the direct IP entries, the gate's address:port, and DNS only in--no-gatemode. Inputpolicy droptoo, 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 asX="$(helper)"runs in a subshell, so anyPOL[..]it sets is discarded.- fail closed — no allowlist in
allowmode, 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. |