Files
ai-sandbox/tests/unit/01_args.sh
T
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

80 lines
5.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# tests/unit/01_args.sh -- argument handling.
source "$TESTS/lib.sh"
t_name "help / version"
sb --help; assert_rc 0 "--help exits 0"; assert_contains "NETWORK" "--help documents the network section"
sb --version; assert_rc 0 "--version exits 0"
sb version; assert_contains "ai-sandbox" "version subcommand prints the name"
t_name "flag -> harness pass-through"
A="--allow example.com" # allow mode is the default and fails closed without this
sb --dry-run $A --model sonnet; assert_rc 0 "unknown flag is accepted"; assert_contains "pi --model sonnet" "unknown flag and the rest are handed to pi"
sb --dry-run $A file.txt --more; assert_contains "pi file.txt --more" "positional starts the command"
sb --dry-run $A -- echo hi; assert_contains "echo hi" "after -- the command runs exactly as given"
assert_absent "pi echo hi" "after -- the harness is replaced, not prefixed"
assert_contains "--network pasta:--map-host-loopback" "and a --net after -- is not mistaken for a sandbox flag"
sb --dry-run $A --shell; assert_contains "bash -li" "--shell runs bash"
sb --dry-run $A --harness codex; assert_contains "codex" "--harness codex selects the command"
sb --dry-run $A --harness none; assert_contains "bash -li" "--harness none drops to a shell"
sb --dry-run --net none -- echo hi; assert_contains "echo hi" "--net= form before the command still parses"
t_name "strict args"
sb --strict-args --model sonnet; assert_rc 1 "strict mode rejects unknown flags"
assert_contains "unknown option" "strict mode says 'unknown option'"
sb --strict-args --al foo; assert_contains "did you mean --allow" "strict mode suggests a near miss"
t_name "value validation"
sb --net bogus --dry-run; assert_rc 1 "bad --net rejected"; assert_contains "--net must be none, allow or full" "names the valid values"
sb --pids abc --dry-run; assert_rc 1 "bad --pids rejected"
sb --cpus x4 --dry-run; assert_rc 1 "bad --cpus rejected"
sb --memory huge --dry-run; assert_rc 1 "bad --memory rejected"
sb --allow; assert_rc 1 "--allow with no value"
sb --allow-file /nope/missing; assert_rc 1 "--allow-file must exist"
sb --mount /nope/missing --dry-run; assert_rc 1 "--mount path must exist"
t_name "publish takes a port, a pair, or an address and a pair"
sb --dry-run $A --publish 8000; assert_rc 0 "a bare port parses"
sb --dry-run $A --publish 18080:8000; assert_rc 0 "host:container parses"
sb --dry-run $A --publish 0.0.0.0:8000:8000; assert_rc 0 "addr:host:container parses"
sb --dry-run $A --publish '[::1]:8000:8000'; assert_rc 0 "bracketed ipv6 parses"
sb --dry-run $A --publish=8000; assert_rc 0 "--publish= form parses"
sb --strict-args --publish; assert_rc 1 "--publish is a known flag that needs a value"
sb --strict-args --pub 8000 --dry-run $A; assert_contains "did you mean --publish" "a near miss suggests --publish"
# Every one of these is a port list that *looks* right and is not. Silently reading one as
# something else is how a config ends up publishing a port nobody wrote.
t_name "publish specs that are refused"
for bad in 0 99999 abc 8000/udp 8000:8000:8000:8000 '::1:8000:8000' '8000:' ':8000' '8000::9000' '0.0.0.0:8000' '*:8000:8000' '0.0.0.0:8000:8000:8000'; do
sb_timeout 15 --dry-run $A --publish "$bad"
assert_rc 1 "refuses '$bad'"
done
# Three fields where the first is not an address: podman would get 'bind on 8000' and fail with an
# error that names none of our words, so the address has to look like an address here.
sb_timeout 15 --dry-run $A --publish 8000:8000:8000
assert_rc 1 "refuses a port written where an address belongs"
assert_contains "is not an address" "and says the three shapes that are valid"
sb_timeout 15 --dry-run $A --publish all:8000:8000
assert_rc 1 "refuses a word for an address"
sb --dry-run $A --publish box.internal:8000:8000
assert_contains "--publish box.internal:8000:8000" "a dotted host is taken as an address (and warned: not loopback)"
assert_contains "anything that can route to this machine" " ... by the same warning an explicit IP gets"
sb_timeout 15 --dry-run $A --publish 18080:8000 --publish 18080:9000
assert_rc 1 "two container ports on one host port is refused"
assert_contains "published twice" "and says which"
sb_timeout 15 --dry-run --net none --publish 8000
assert_rc 1 "--net none refuses to publish"
assert_contains "cannot forward a port" "because podman would drop the mapping and run anyway"
# -p is the harness's flag (claude -p prints), so it must stay a pass-through.
sb --dry-run $A -p 8000
assert_contains "pi -p 8000" "-p belongs to the harness, not to the sandbox"
t_name "fail-closed network policy"
sb --dry-run; assert_rc 1 "allow mode with no allowlist refuses to start"
assert_contains "Refusing to start wide open" "explains that it refuses to be wide open"
sb --dry-run --net none; assert_rc 0 "--net none needs no allowlist"; assert_contains "--network none" "--net none maps to podman --network none"
sb --dry-run --net full; assert_rc 0 "--net full is allowed (explicit opt-in)"
assert_contains "unrestricted internet" "--net full warns"
sb --dry-run --allow example.com; assert_rc 0 "one allow entry is enough"