Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee535f6ae | ||
|
|
0242ab5b5e |
@@ -13,24 +13,36 @@ allowlist that the sandboxed process cannot lift.
|
|||||||
## Non-negotiables
|
## Non-negotiables
|
||||||
|
|
||||||
1. **Single script.** The Containerfile, the container entrypoint, the squid
|
1. **Single script.** The Containerfile, the container entrypoint, the squid
|
||||||
config and the nftables rules are all generated from `bin/ai-sandbox`
|
config, the nftables rules and the bridged pi config are all generated from
|
||||||
(`containerfile()`, `entrypoint_script()`, `write_squid_conf()`, `gen_rules()`
|
`bin/ai-sandbox` (`containerfile()`, `entrypoint_script()`, `write_squid_conf()`,
|
||||||
inside the entrypoint). Keep it that way; do not split policy into files that
|
`gen_rules()` inside the entrypoint, `pi_config_bridge()`). Keep it that way; do
|
||||||
can drift from the script.
|
not split policy into files that can drift from the script.
|
||||||
2. **Fail closed.** Any path where the sandbox cannot guarantee its policy must
|
2. **Fail closed.** Any path where the sandbox cannot guarantee its policy must
|
||||||
refuse to start rather than degrade: no allowlist in `allow` mode, missing
|
refuse to start rather than degrade: no allowlist in `allow` mode, missing
|
||||||
policy file in the entrypoint (exit 78), ruleset that cannot be installed
|
policy file in the entrypoint (exit 78), ruleset that cannot be installed
|
||||||
(exit 77), `--env NAME` where NAME is unset. Escape hatches exist but must be
|
(exit 77), `--env NAME` where NAME is unset, a bridged pi config whose credential
|
||||||
explicitly named (`--allow-unenforced`, `--insecure-keep-root`, `--net full`).
|
variable is unset *or empty*. Escape hatches exist but must be explicitly named
|
||||||
|
(`--allow-unenforced`, `--insecure-keep-root`, `--net full`, `--no-pi-config`).
|
||||||
3. **Never leak a secret.** Values go through a `0600` env file, never argv.
|
3. **Never leak a secret.** Values go through a `0600` env file, never argv.
|
||||||
Nothing that prints (`--dry-run`, `--print-policy`, the session log, error
|
Nothing that prints (`--dry-run`, `--print-policy`, the session log, error
|
||||||
messages) may echo a value. If you add output, add a test that a canary value
|
messages) may echo a value. If you add output, add a test that a canary value
|
||||||
does not appear in it.
|
does not appear in it. The pi bridge is the case that tests this hardest: it reads
|
||||||
|
a file that may hold a live key, so it rewrites `apiKey` to `"$VAR"`, drops
|
||||||
|
`!command` values instead of running them, never touches `auth.json`, and
|
||||||
|
`tests/unit/08_pi_config.sh` asserts a canary secret from the host agent directory
|
||||||
|
appears nowhere in ai-sandbox state.
|
||||||
4. **No hardcoded machine paths, usernames or ports.** Everything is overridable
|
4. **No hardcoded machine paths, usernames or ports.** Everything is overridable
|
||||||
via `AISB_*` or a config file; defaults must work for any user on any host.
|
via `AISB_*` or a config file; defaults must work for any user on any host.
|
||||||
5. **Measure, don't assume, about podman.** Rootless networking varies by version
|
5. **Measure, don't assume, about podman.** Rootless networking varies by version
|
||||||
and distro. Run `tests/spike_network.sh` on a new host and update
|
and distro. Run `tests/spike_network.sh` on a new host and update
|
||||||
`docs/networking.md` when reality disagrees with the design.
|
`docs/networking.md` when reality disagrees with the design.
|
||||||
|
6. **Nothing about a build may be silently mutable.** The base enters the
|
||||||
|
Containerfile as a digest, and that digest is part of `image_hash()`; Node is
|
||||||
|
version-pinned and checksum-verified; `pi` is version-pinned. A refresh is an
|
||||||
|
explicit, dated event (`update`); a build with no digest is announced as `UNPINNED`
|
||||||
|
and labelled that way; preview modes must never reach the network. If you add a
|
||||||
|
dependency, decide which of those three it is and cover it in
|
||||||
|
`tests/unit/07_base.sh` or `tests/e2e/10_image.sh`.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -68,9 +80,15 @@ networking or the image, `make e2e` green too.
|
|||||||
variables that have no config-file counterpart. Breaking this is the single
|
variables that have no config-file counterpart. Breaking this is the single
|
||||||
easiest way to introduce a precedence bug.
|
easiest way to introduce a precedence bug.
|
||||||
* `POL[...]` holds resolved policy, `P_*` arrays resolved lists, `OPT_*` raw flags,
|
* `POL[...]` holds resolved policy, `P_*` arrays resolved lists, `OPT_*` raw flags,
|
||||||
`CFG_*` config-file values. Do not read raw env vars deep in the code.
|
`CFG_*` config-file values. Do not read raw env vars deep in the code. A helper
|
||||||
|
called as `X="$(helper)"` runs in a subshell: it can return text, but any `POL[..]`
|
||||||
|
it assigns is discarded — return a value and assign it in the caller (see
|
||||||
|
`base_pin_digest()` + `base_ref_of()`).
|
||||||
* Preview paths (`--dry-run`, `--print-policy`, `--print-entrypoint`,
|
* Preview paths (`--dry-run`, `--print-policy`, `--print-entrypoint`,
|
||||||
`--print-containerfile`) must not build images, start containers or write state.
|
`--print-containerfile`) must not build images, start containers, write state or
|
||||||
|
reach the network. That is why digest resolution has a `cache_only` mode, and why
|
||||||
|
`inspect` (asks the machine now) is a separate surface from `--print-*` (which stays
|
||||||
|
deterministic and scriptable).
|
||||||
* Tests use `t_name` + `assert_*` from `tests/lib.sh`; each file gets its own
|
* Tests use `t_name` + `assert_*` from `tests/lib.sh`; each file gets its own
|
||||||
`AISB_STATE_DIR`/`AISB_CACHE_DIR`/`AISB_CONFIG_DIR`. Never let a test touch the
|
`AISB_STATE_DIR`/`AISB_CACHE_DIR`/`AISB_CONFIG_DIR`. Never let a test touch the
|
||||||
real `~/.config`, `~/.local/state` or `~/.cache`, and never override `HOME`
|
real `~/.config`, `~/.local/state` or `~/.cache`, and never override `HOME`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Execution plan for `ai-sandbox`, with the state each phase leaves behind.
|
Execution plan for `ai-sandbox`, with the state each phase leaves behind.
|
||||||
Every phase ends with a gate that must pass before the next one starts.
|
Every phase ends with a gate that must pass before the next one starts.
|
||||||
Status as of this writing: **phases 1-7 complete**, follow-ups listed at the end.
|
Status as of this writing: **phases 1-9 complete**, follow-ups listed at the end.
|
||||||
|
|
||||||
| # | Phase | Gate | Status |
|
| # | Phase | Gate | Status |
|
||||||
|---|-------|------|--------|
|
|---|-------|------|--------|
|
||||||
@@ -14,6 +14,8 @@ Status as of this writing: **phases 1-7 complete**, follow-ups listed at the end
|
|||||||
| 5 | Environment passing | deny-by-default, `0600` delivery, redaction, fail-closed on unset | ✅ done (`tests/unit/04_env.sh`, `tests/e2e/12_sandbox.sh`) |
|
| 5 | Environment passing | deny-by-default, `0600` delivery, redaction, fail-closed on unset | ✅ done (`tests/unit/04_env.sh`, `tests/e2e/12_sandbox.sh`) |
|
||||||
| 6 | Limits, lifecycle, observability | exit codes, cleanup, caps, session log, `--keep` | ✅ done (`tests/e2e/12_sandbox.sh`) |
|
| 6 | Limits, lifecycle, observability | exit codes, cleanup, caps, session log, `--keep` | ✅ done (`tests/e2e/12_sandbox.sh`) |
|
||||||
| 7 | Harness defaults, docs, portability sweep | alias UX, README/threat model, shellcheck clean, no personal paths | ✅ done |
|
| 7 | Harness defaults, docs, portability sweep | alias UX, README/threat model, shellcheck clean, no personal paths | ✅ done |
|
||||||
|
| 8 | Supply chain: pinned base, TTL refresh, audit trail | digest in `FROM` and in the content hash; `update`/`--check`; labels + in-image package manifest | ✅ done (`tests/unit/07_base.sh`, `tests/e2e/10_image.sh`) |
|
||||||
|
| 9 | Pi config bridge: same provider inside, credential by env | host `models.json`/`settings.json` rewritten not copied; canary secret nowhere in state; seeding, refresh and edit-presence asserted in a real container | ✅ done (`tests/unit/08_pi_config.sh`, `tests/e2e/12_sandbox.sh`) |
|
||||||
|
|
||||||
## Phase detail
|
## Phase detail
|
||||||
|
|
||||||
@@ -66,6 +68,48 @@ change the image's home layout and existing volumes keep the old files, so
|
|||||||
`ensure_home_volume()` creates it labelled and `clean --volumes` is the documented way
|
`ensure_home_volume()` creates it labelled and `clean --volumes` is the documented way
|
||||||
to drop it.
|
to drop it.
|
||||||
|
|
||||||
|
### 8 — Supply chain
|
||||||
|
The hash made *our* changes traceable but said nothing about the base: `FROM
|
||||||
|
ubuntu:24.04` is a moving pointer, `podman build`'s pull policy is *missing*, and
|
||||||
|
`ensure_image()` returns early when the tag exists — so even `build --rebuild` reused
|
||||||
|
the cached base. Now the tag is resolved to a digest (`skopeo`, falling back to
|
||||||
|
`podman pull` unless `AISB_BASE_PULL_FALLBACK=0`), recorded per-ref under
|
||||||
|
`$AISB_CACHE_DIR/base/`, and used in `FROM` *and* in `image_hash()`: a refreshed base
|
||||||
|
yields a new image rather than mutating one that may be running.
|
||||||
|
|
||||||
|
Two rules make it survivable rather than merely strict. **A stale pin beats a mutable
|
||||||
|
tag** — offline, or with `AISB_BASE_AUTO_UPDATE=0`, the recorded digest is used, and
|
||||||
|
only a build with no digest at all is allowed, loudly marked `UNPINNED` in the output,
|
||||||
|
in the image labels and in `doctor`. **Freshness is a TTL** (`AISB_BASE_TTL_DAYS=7`,
|
||||||
|
`0` every build, `-1` never) rather than a background job, so nothing surprises you
|
||||||
|
mid-session and `update` stays an explicit, dated event. Both images carry
|
||||||
|
`aisb.base-digest`/`aisb.node`/`aisb.pi-version` labels and
|
||||||
|
`/etc/aisb/manifest.txt` with resolved apt versions, which is how you answer "which
|
||||||
|
squid was enforcing this?" a month later. `--print-*` stays cache-only and
|
||||||
|
deterministic; `inspect` is the surface that asks the machine now.
|
||||||
|
|
||||||
|
One bug worth remembering: `POL[base_digest]="$(base_pin …)"` looked right and
|
||||||
|
silently produced `unpinned` labels, because the subshell's assignment was discarded.
|
||||||
|
Helpers return text; callers assign.
|
||||||
|
|
||||||
|
### 9 — Pi configuration bridge
|
||||||
|
The image ships `pi` with no provider and no preferences, so the first run asked you to
|
||||||
|
configure the model twice. It now reads `~/.pi/agent/{models,settings}.json` and
|
||||||
|
rewrites them: every provider `apiKey` becomes `"$AI_API_TOKEN"` (pi resolves an env
|
||||||
|
reference itself, so no value is ever written to disk), `!command` header values are
|
||||||
|
dropped rather than executed, settings pointing into `$HOME` are dropped because that
|
||||||
|
path is not mounted, and `auth.json`/`models-store.json`/sessions/extensions are never
|
||||||
|
read. The pair lands in `/home/sandbox/.pi/agent` at container start, owned by uid 1000,
|
||||||
|
mode 600, and is a managed file: a host change is re-seeded, a sandbox edit is kept and
|
||||||
|
reported, a deletion takes the host version again.
|
||||||
|
|
||||||
|
Two decisions worth keeping. The token follows the existing env rules — one variable,
|
||||||
|
passed by name through the `0600` file, announced when auto-passed — and a bridged run
|
||||||
|
where that variable is unset *or empty* is refused instead of starting a pi that cannot
|
||||||
|
reach its model, because "sandbox is broken" is a worse bug report than an explicit
|
||||||
|
refusal. And the bridge never widens the allowlist; it warns with the exact `--allow`
|
||||||
|
to add, since an agent silently unable to answer looks like a network failure.
|
||||||
|
|
||||||
## Follow-ups (not yet done)
|
## Follow-ups (not yet done)
|
||||||
|
|
||||||
* **`--net unix` mode**: `--network=none` plus a unix-socket proxy and an in-container
|
* **`--net unix` mode**: `--network=none` plus a unix-socket proxy and an in-container
|
||||||
@@ -81,3 +125,17 @@ to drop it.
|
|||||||
* **`--record`**: optional pcap/CONNECT-log capture of a session for review.
|
* **`--record`**: optional pcap/CONNECT-log capture of a session for review.
|
||||||
* **`--mount` for agent skills/plugins**: first-class flag instead of a generic mount.
|
* **`--mount` for agent skills/plugins**: first-class flag instead of a generic mount.
|
||||||
* Packaging: a `quadlet`/systemd unit for a long-lived named gate.
|
* Packaging: a `quadlet`/systemd unit for a long-lived named gate.
|
||||||
|
* Signature verification for the base where the registry signs it (cosign/sigstore),
|
||||||
|
so a pin is authenticated rather than merely immutable; and an optional local
|
||||||
|
mirror/`AISB_SKOPEO`-compatible offline source for air-gapped hosts.
|
||||||
|
* `update --auto` as a timer unit, if TTL-on-next-run turns out to be too lazy for
|
||||||
|
security response — deliberately not done yet, since a background rebuild that
|
||||||
|
surprises a running session is its own hazard.
|
||||||
|
* **Scoped sandbox tokens**: if the gateway can mint a short-lived, per-sandbox key,
|
||||||
|
`--pi-token-env` should ask for one rather than forwarding the long-lived host
|
||||||
|
credential. The bridge deliberately does not guess a minting endpoint.
|
||||||
|
* **Bridging extensions/skills on request**: `--pi-mount-ext` or similar for the
|
||||||
|
extension paths named in `enabledExtensionPaths`, which are host paths and therefore
|
||||||
|
currently dropped; needs the same explicit-grant shape as `--mount`.
|
||||||
|
* **`models.json` review surface**: `inspect` could print the exact diff between host
|
||||||
|
config and bridged config, so a change is reviewable before a run rather than after.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ language.
|
|||||||
|
|
||||||
```
|
```
|
||||||
$ cd ~/Projects/billing-api
|
$ cd ~/Projects/billing-api
|
||||||
$ pi --allow api.anthropic.com --env ANTHROPIC_API_KEY
|
$ pi --allow aipi.reeseapps.com --env AI_API_TOKEN
|
||||||
[aisb] egress gate aisb-gate-7f3c1a -> 127.0.0.1:18084 (1 domains, 0 ip/cidr entries)
|
[aisb] egress gate aisb-gate-7f3c1a -> 127.0.0.1:18084 (1 domains, 0 ip/cidr entries)
|
||||||
[aisb] egress net=allow enforce=nft proxy=[100.64.0.1:18084] direct=0 dns=0
|
[aisb] egress net=allow enforce=nft proxy=[100.64.0.1:18084] direct=0 dns=0
|
||||||
pi
|
pi
|
||||||
@@ -27,7 +27,7 @@ git clone <this repo> && cd ai-sandbox
|
|||||||
./install.sh --alias pi # symlinks to ~/.local/bin and adds the alias to ~/.bashrc
|
./install.sh --alias pi # symlinks to ~/.local/bin and adds the alias to ~/.bashrc
|
||||||
ai-sandbox doctor # checks podman, rootless, subuid, pasta, SELinux, uid mapping
|
ai-sandbox doctor # checks podman, rootless, subuid, pasta, SELinux, uid mapping
|
||||||
ai-sandbox build # optional: build the image now instead of on first run
|
ai-sandbox build # optional: build the image now instead of on first run
|
||||||
make test # 160+ assertions, no containers needed
|
make test # 390+ assertions (unit needs no containers; --e2e builds and runs)
|
||||||
```
|
```
|
||||||
|
|
||||||
Requirements: podman ≥ 5 running rootless (with a `/etc/subuid` range for your
|
Requirements: podman ≥ 5 running rootless (with a `/etc/subuid` range for your
|
||||||
@@ -43,8 +43,8 @@ those become **arguments to the harness**:
|
|||||||
alias pi='ai-sandbox'
|
alias pi='ai-sandbox'
|
||||||
|
|
||||||
pi # runs pi inside the sandbox
|
pi # runs pi inside the sandbox
|
||||||
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --model sonnet
|
pi --allow aipi.reeseapps.com --env AI_API_TOKEN --model turbo
|
||||||
# ^^^^^ sandbox ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^ for pi
|
# ^^^^^ sandbox ^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^ for pi
|
||||||
pi --shell # a bash shell in the same sandbox
|
pi --shell # a bash shell in the same sandbox
|
||||||
pi --net none -- make test # -- replaces the command entirely
|
pi --net none -- make test # -- replaces the command entirely
|
||||||
```
|
```
|
||||||
@@ -61,7 +61,7 @@ which is what you want in scripts.
|
|||||||
| `--net full` | Nothing restricted. Prints a warning. Never the default. |
|
| `--net full` | Nothing restricted. Prints a warning. Never the default. |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ai-sandbox --allow api.anthropic.com --allow registry.npmjs.org --env ANTHROPIC_API_KEY
|
ai-sandbox --allow aipi.reeseapps.com --allow registry.npmjs.org --env AI_API_TOKEN
|
||||||
ai-sandbox --allow 10.20.0.5:5432 # a database: pinned IP and port
|
ai-sandbox --allow 10.20.0.5:5432 # a database: pinned IP and port
|
||||||
ai-sandbox --allow files.pythonhosted.org --allow pypi.org
|
ai-sandbox --allow files.pythonhosted.org --allow pypi.org
|
||||||
ai-sandbox --no-gate --allow example.com # no proxy container: pin resolved IPs instead
|
ai-sandbox --no-gate --allow example.com # no proxy container: pin resolved IPs instead
|
||||||
@@ -77,16 +77,20 @@ a silent free-for-all. Keep it in the repo so the policy is reviewable:
|
|||||||
|
|
||||||
```
|
```
|
||||||
# .ai-sandbox/allow (one entry per line, # comments fine)
|
# .ai-sandbox/allow (one entry per line, # comments fine)
|
||||||
api.anthropic.com
|
aipi.reeseapps.com
|
||||||
registry.npmjs.org
|
registry.npmjs.org
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you bridge your pi config (below), ai-sandbox tells you when the model endpoint
|
||||||
|
your own `models.json` points at is not in the allowlist -- pi would otherwise start
|
||||||
|
and sit there unable to answer, which reads like a broken sandbox.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
Nothing is inherited except `TERM LANG LC_ALL LC_CTYPE TZ`.
|
Nothing is inherited except `TERM LANG LC_ALL LC_CTYPE TZ`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
--env ANTHROPIC_API_KEY # from your environment
|
--env AI_API_TOKEN # from your environment
|
||||||
--env FOO=bar # literal (shows up in ps; prefer the two below)
|
--env FOO=bar # literal (shows up in ps; prefer the two below)
|
||||||
--env-file ~/.config/secrets # a 0600 NAME=VALUE file
|
--env-file ~/.config/secrets # a 0600 NAME=VALUE file
|
||||||
```
|
```
|
||||||
@@ -111,12 +115,79 @@ default. Everything else is opt-in:
|
|||||||
Files the agent writes land on your disk owned by **your** uid, not root
|
Files the agent writes land on your disk owned by **your** uid, not root
|
||||||
(`--userns=keep-id`, verified by `doctor`).
|
(`--userns=keep-id`, verified by `doctor`).
|
||||||
|
|
||||||
|
## Pi inside the sandbox
|
||||||
|
|
||||||
|
The image has `pi` baked in, and its home is a volume, so out of the box the sandboxed
|
||||||
|
pi has no provider and no preferences. Rather than making you configure it twice,
|
||||||
|
`ai-sandbox` **bridges** your host `~/.pi/agent/{models,settings}.json` -- rewriting
|
||||||
|
them, because copying them would ship your credential into a container whose entire
|
||||||
|
purpose is to be unable to use it:
|
||||||
|
|
||||||
|
| your host config | what the sandbox gets |
|
||||||
|
|---|---|
|
||||||
|
| `apiKey: "sk-..."` or `apiKey: "!pass show ..."` | `apiKey: "$AI_API_TOKEN"` -- pi resolves it itself, no value is written to disk |
|
||||||
|
| a header like `X-Trace: "!date +%s"` | dropped, and reported: a host command is not run in the sandbox |
|
||||||
|
| `baseUrl: "https://aipi.reeseapps.com/v1"` | unchanged, along with every model, limit and thinking level |
|
||||||
|
| settings: provider, model, theme, thinking budgets, telemetry | unchanged |
|
||||||
|
| a settings value pointing into `~` | dropped, and reported: that path is not mounted |
|
||||||
|
| `auth.json`, `models-store.json`, sessions, extensions | never touched |
|
||||||
|
|
||||||
|
The rewritten pair is seeded into `/home/sandbox/.pi/agent` at container start, owned by
|
||||||
|
your uid, mode `600`. It is a managed file: a change on the host is picked up on the
|
||||||
|
next run, an edit made *inside* the sandbox is left alone, and deleting it inside
|
||||||
|
re-seeds from the host.
|
||||||
|
|
||||||
|
The token itself follows the normal rule -- it is the one variable the bridged config
|
||||||
|
names, so it is passed by name (never by value, and never into `ps`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi --allow aipi.reeseapps.com # AI_API_TOKEN is picked up and announced
|
||||||
|
pi --no-pi-token-env ... # don't pass it; the run then fails closed
|
||||||
|
pi --pi-token-env GW_TOKEN ... # a different variable name
|
||||||
|
pi --no-pi-config --shell # no bridge at all
|
||||||
|
```
|
||||||
|
|
||||||
|
Refusing rather than launching a pi with no credential is deliberate -- the alternative
|
||||||
|
is an agent that silently cannot reach its model.
|
||||||
|
|
||||||
## Image
|
## Image
|
||||||
|
|
||||||
Built once per distinct policy from `ubuntu:24.04`, tagged by content hash, reused
|
Both images come from one base, `docker.io/library/ubuntu:24.04` — including the gate,
|
||||||
afterwards: git, curl, jq, build tools, python3, Node 22 (checksum-verified
|
which is squid from Ubuntu's apt rather than a third-party image. Built once per
|
||||||
against nodejs.org) and the `pi` CLI. `--install PKG` adds packages and produces a
|
distinct policy and tagged by content hash: git, curl, jq, build tools, python3, Node
|
||||||
different tag, so variants coexist. `--base-image` swaps the base.
|
22 (tarball verified against nodejs.org `SHASUMS256.txt`) and the `pi` CLI. `--install
|
||||||
|
PKG` adds packages and produces a different tag, so variants coexist, and
|
||||||
|
`--base-image` swaps the base.
|
||||||
|
|
||||||
|
### The base is pinned by digest
|
||||||
|
|
||||||
|
A tag is a moving pointer: `FROM ubuntu:24.04` means "whatever Canonical points at
|
||||||
|
this week", which makes a build unreproducible and a security patch invisible. So the
|
||||||
|
tag is resolved to a digest, recorded under `~/.cache/ai-sandbox/base/`, and the
|
||||||
|
digest goes into the `FROM` line *and* into the content hash — refreshing the base
|
||||||
|
produces a new image instead of mutating one a sandbox may be running right now.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ai-sandbox update --check # what upstream has, what you are pinned to, nothing built
|
||||||
|
ai-sandbox update # re-resolve, rebuild sandbox + gate, keep the old image
|
||||||
|
ai-sandbox build --refresh-base
|
||||||
|
```
|
||||||
|
|
||||||
|
Freshness is a TTL: every `AISB_BASE_TTL_DAYS` (default 7) the digest is re-resolved,
|
||||||
|
which is a manifest read (`skopeo`), not a download. Offline, or with
|
||||||
|
`AISB_BASE_AUTO_UPDATE=0`, the recorded digest is kept — a stale pin beats a mutable
|
||||||
|
one, and `AISB_BASE_TTL_DAYS=-1` pins until you say otherwise. A build with no digest
|
||||||
|
is announced as `UNPINNED`, labelled that way, and warned about by `doctor`. Preview
|
||||||
|
modes (`--dry-run`, `--print-*`) never touch the network, so they stay scriptable and
|
||||||
|
report the pin you already have; `inspect` is the one that asks the machine now.
|
||||||
|
|
||||||
|
### What an image admits to being
|
||||||
|
|
||||||
|
`podman image inspect` on any ai-sandbox image shows `aisb.base-digest`, `aisb.node`,
|
||||||
|
`aisb.pi-package`, `aisb.pi-version` (plus `aisb.role=gate` for the gate), and
|
||||||
|
`/etc/aisb/manifest.txt` inside it lists every resolved apt package version — the
|
||||||
|
answer to "which squid was doing the filtering" once the build log is long gone. The
|
||||||
|
previous image is always kept after an update, so rollback is a tag away.
|
||||||
|
|
||||||
## Inspecting a run
|
## Inspecting a run
|
||||||
|
|
||||||
|
|||||||
+546
-23
@@ -11,7 +11,7 @@
|
|||||||
# Meant to be aliased to an agent harness:
|
# Meant to be aliased to an agent harness:
|
||||||
#
|
#
|
||||||
# alias pi='ai-sandbox'
|
# alias pi='ai-sandbox'
|
||||||
# cd ~/Projects/foo && pi --allow api.anthropic.com --env ANTHROPIC_API_KEY
|
# cd ~/Projects/foo && pi --allow aipi.reeseapps.com --env AI_API_TOKEN
|
||||||
#
|
#
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ AISB_PI_PACKAGE="${AISB_PI_PACKAGE:-@earendil-works/pi-coding-agent}"
|
|||||||
# environment variables stay unset unless the user really set them.
|
# environment variables stay unset unless the user really set them.
|
||||||
# AISB_NET AISB_HARNESS AISB_PROJECT_DEST AISB_CPUS AISB_MEMORY AISB_PIDS
|
# AISB_NET AISB_HARNESS AISB_PROJECT_DEST AISB_CPUS AISB_MEMORY AISB_PIDS
|
||||||
# AISB_TMPFS_SIZE AISB_BASE_IMAGE AISB_NODE_VERSION AISB_PI_VERSION
|
# AISB_TMPFS_SIZE AISB_BASE_IMAGE AISB_NODE_VERSION AISB_PI_VERSION
|
||||||
|
# AISB_BASE_TTL_DAYS AISB_BASE_AUTO_UPDATE AISB_BASE_PULL_FALLBACK AISB_SKOPEO
|
||||||
# AISB_SELINUX AISB_GATE_ADDR AISB_GATE_PORT AISB_HOME_CACHE AISB_EXTRA_ARGS
|
# AISB_SELINUX AISB_GATE_ADDR AISB_GATE_PORT AISB_HOME_CACHE AISB_EXTRA_ARGS
|
||||||
AISB_UID="${AISB_UID:-1000}"
|
AISB_UID="${AISB_UID:-1000}"
|
||||||
AISB_GID="${AISB_GID:-1000}"
|
AISB_GID="${AISB_GID:-1000}"
|
||||||
@@ -135,15 +136,16 @@ $PROG $VERSION -- rootless podman sandbox for autonomous agentic development.
|
|||||||
|
|
||||||
USAGE
|
USAGE
|
||||||
$PROG [POLICY ...] [--] [COMMAND ...]
|
$PROG [POLICY ...] [--] [COMMAND ...]
|
||||||
$PROG build [--rebuild] | doctor [--deep] | gate <up|down|status|logs> | ps
|
$PROG build [--rebuild] [--refresh-base] [--gate]
|
||||||
$PROG clean [--all] [--volumes] | inspect | version
|
$PROG doctor [--deep] | gate <up|down|status|logs> | ps | inspect | version
|
||||||
|
$PROG clean [--all] [--volumes[=GLOB]] | update [--check]
|
||||||
|
|
||||||
Sandbox flag parsing stops at the first positional argument or unknown flag.
|
Sandbox flag parsing stops at the first positional argument or unknown flag.
|
||||||
Those arguments become arguments *to the harness*, which is what makes an alias
|
Those arguments become arguments *to the harness*, which is what makes an alias
|
||||||
behave like the harness itself:
|
behave like the harness itself:
|
||||||
|
|
||||||
alias pi='$PROG'
|
alias pi='$PROG'
|
||||||
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --model sonnet
|
pi --allow aipi.reeseapps.com --env AI_API_TOKEN --model turbo
|
||||||
# ^ sandbox flags ^ arguments for pi
|
# ^ sandbox flags ^ arguments for pi
|
||||||
|
|
||||||
pi src/main.py # runs: pi src/main.py
|
pi src/main.py # runs: pi src/main.py
|
||||||
@@ -184,11 +186,24 @@ ENVIRONMENT
|
|||||||
through the command line, and never into the logs.
|
through the command line, and never into the logs.
|
||||||
|
|
||||||
IMAGE
|
IMAGE
|
||||||
--base-image REF Default docker.io/library/ubuntu:24.04
|
--base-image REF Default docker.io/library/ubuntu:24.04. Pinned to the
|
||||||
|
registry digest at build time; REF may itself already be
|
||||||
|
a digest (name@sha256:...) to take that decision away.
|
||||||
--install PKG Extra apt package in the image. Repeatable.
|
--install PKG Extra apt package in the image. Repeatable.
|
||||||
Each distinct set gets its own cached image tag.
|
Each distinct set gets its own cached image tag.
|
||||||
--harness NAME Default in-container command: pi | codex | claude | none.
|
--harness NAME Default in-container command: pi | codex | claude | none.
|
||||||
|
|
||||||
|
PI CONFIG
|
||||||
|
Your ~/.pi/agent/{models,settings}.json are rewritten and seeded into the sandbox:
|
||||||
|
every provider apiKey becomes an environment reference, so no credential is written
|
||||||
|
into the container, and host commands ('!...') and host paths are dropped instead of
|
||||||
|
followed. auth.json and the credential store are never bridged.
|
||||||
|
--pi-token-env NAME Variable the bridged config should read the key from
|
||||||
|
(default AI_API_TOKEN). Passed by name if it is set.
|
||||||
|
--no-pi-token-env Do not pass it automatically; then pass it with --env or
|
||||||
|
the run is refused rather than starting a pi with no key.
|
||||||
|
--no-pi-config Do not bridge at all; pi starts with its own defaults.
|
||||||
|
|
||||||
LIMITS
|
LIMITS
|
||||||
--cpus N (4) --memory SIZE (4g) --pids N (512) --tmpfs-size SIZE (1g)
|
--cpus N (4) --memory SIZE (4g) --pids N (512) --tmpfs-size SIZE (1g)
|
||||||
|
|
||||||
@@ -211,7 +226,10 @@ ESCAPE HATCHES (they weaken the sandbox, which is why they are named this way)
|
|||||||
--strict-args Unknown flags are an error instead of a pass-through.
|
--strict-args Unknown flags are an error instead of a pass-through.
|
||||||
|
|
||||||
SUBCOMMANDS
|
SUBCOMMANDS
|
||||||
build Build or reuse the sandbox image (--rebuild, --gate for the gate image).
|
build Build or reuse the sandbox image (--rebuild, --gate for the gate
|
||||||
|
image, --refresh-base to re-resolve the base digest now).
|
||||||
|
update Re-resolve the base digest and rebuild what that implies. The old
|
||||||
|
image stays put as a rollback. --check only reports.
|
||||||
doctor Check the host: podman, rootless, subuid, helpers, SELinux, uid mapping.
|
doctor Check the host: podman, rootless, subuid, helpers, SELinux, uid mapping.
|
||||||
gate up | down | status | logs -- manage a named long-lived egress gate.
|
gate up | down | status | logs -- manage a named long-lived egress gate.
|
||||||
ps List sandbox containers.
|
ps List sandbox containers.
|
||||||
@@ -227,6 +245,10 @@ CONFIG
|
|||||||
Every default can also be set as an environment variable:
|
Every default can also be set as an environment variable:
|
||||||
AISB_NET, AISB_ALLOW (comma list), AISB_ENV (comma list), AISB_CPUS, AISB_MEMORY,
|
AISB_NET, AISB_ALLOW (comma list), AISB_ENV (comma list), AISB_CPUS, AISB_MEMORY,
|
||||||
AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE, ...
|
AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE, ...
|
||||||
|
Base images: AISB_BASE_IMAGE, AISB_BASE_TTL_DAYS (7; 0 every build, -1 never),
|
||||||
|
AISB_BASE_AUTO_UPDATE (0 keeps the recorded digest), AISB_BASE_PULL_FALLBACK
|
||||||
|
(0 forbids the 'podman pull' used to learn a digest when skopeo is absent),
|
||||||
|
AISB_SKOPEO.
|
||||||
|
|
||||||
EXIT STATUS
|
EXIT STATUS
|
||||||
The exit code of the in-container command. 77/78: the sandbox refused to start
|
The exit code of the in-container command. 77/78: the sandbox refused to start
|
||||||
@@ -253,13 +275,16 @@ cfg_read_kv() { # `key = value` file
|
|||||||
key="$(trim "${line%%=*}")"; val="$(trim "${line#*=}")"
|
key="$(trim "${line%%=*}")"; val="$(trim "${line#*=}")"
|
||||||
val="${val%\"}"; val="${val#\"}"
|
val="${val%\"}"; val="${val#\"}"
|
||||||
if [[ " $cfg_accum_keys " == *" $key "* ]]; then
|
if [[ " $cfg_accum_keys " == *" $key "* ]]; then
|
||||||
local -n ref="CFG_$key"
|
# A nameref with a name nothing else uses: bash resolves namerefs by name, so a
|
||||||
ref+=("$val")
|
# local called `ref` anywhere nearby is a way to corrupt config reading.
|
||||||
|
local -n cfg_target="CFG_$key"
|
||||||
|
cfg_target+=("$val")
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
case "$key" in
|
case "$key" in
|
||||||
net|harness|workdir|cpus|memory|pids|tmpfs_size|base_image|node_version|pi_version|\
|
net|harness|workdir|cpus|memory|pids|tmpfs_size|base_image|node_version|pi_version|\
|
||||||
project_dest|selinux|home_cache|gate_addr|gate_port|extra_args) CFG["$key"]="$val" ;;
|
project_dest|selinux|home_cache|gate_addr|gate_port|extra_args|base_ttl_days|base_auto_update|\
|
||||||
|
pi_config|pi_token_env|pi_token_auto|pi_agent_dir) CFG["$key"]="$val" ;;
|
||||||
*) warn "$file: unknown key: $key" ;;
|
*) warn "$file: unknown key: $key" ;;
|
||||||
esac
|
esac
|
||||||
done < "$file"
|
done < "$file"
|
||||||
@@ -297,6 +322,7 @@ OPT_NAME="" OPT_PROXY="" OPT_GATE="" OPT_BASE_IMAGE=""
|
|||||||
OPT_NO_CWD=0 OPT_SHELL=0 OPT_DRY=0 OPT_PRINT=0 OPT_NO_BUILD=0 OPT_KEEP=0 OPT_READONLY=0
|
OPT_NO_CWD=0 OPT_SHELL=0 OPT_DRY=0 OPT_PRINT=0 OPT_NO_BUILD=0 OPT_KEEP=0 OPT_READONLY=0
|
||||||
OPT_HOME_CACHE="" OPT_STRICT="${AISB_STRICT_ARGS:-0}" OPT_UNENFORCED=0 OPT_KEEP_ROOT=0
|
OPT_HOME_CACHE="" OPT_STRICT="${AISB_STRICT_ARGS:-0}" OPT_UNENFORCED=0 OPT_KEEP_ROOT=0
|
||||||
OPT_TTY="" OPT_INTERACTIVE="" OPT_PRINT_EP=0 OPT_PRINT_CF=0 OPT_PASSTHROUGH=0
|
OPT_TTY="" OPT_INTERACTIVE="" OPT_PRINT_EP=0 OPT_PRINT_CF=0 OPT_PASSTHROUGH=0
|
||||||
|
OPT_PI_CONFIG="" OPT_PI_TOKEN_AUTO="" OPT_PI_TOKEN_ENV=""
|
||||||
declare -a OPT_allow=() OPT_env=() OPT_mount=() OPT_install=() OPT_label=() OPT_allow_files=()
|
declare -a OPT_allow=() OPT_env=() OPT_mount=() OPT_install=() OPT_label=() OPT_allow_files=()
|
||||||
COMMAND=()
|
COMMAND=()
|
||||||
|
|
||||||
@@ -306,7 +332,7 @@ KNOWN_OPTS=(
|
|||||||
net allow allow-file proxy no-gate env env-file mount no-cwd workdir home-cache ephemeral-home
|
net allow allow-file proxy no-gate env env-file mount no-cwd workdir home-cache ephemeral-home
|
||||||
harness install base-image cpus memory pids tmpfs-size name label keep read-only shell
|
harness install base-image cpus memory pids tmpfs-size name label keep read-only shell
|
||||||
interactive no-tty dry-run print-policy no-build allow-unenforced strict-args insecure-keep-root
|
interactive no-tty dry-run print-policy no-build allow-unenforced strict-args insecure-keep-root
|
||||||
print-entrypoint print-containerfile help version
|
print-entrypoint print-containerfile pi-token-env no-pi-config no-pi-token-env help version
|
||||||
)
|
)
|
||||||
suggest() {
|
suggest() {
|
||||||
local given="$1" o hit=0 hits=""
|
local given="$1" o hit=0 hits=""
|
||||||
@@ -326,6 +352,10 @@ parse_args() {
|
|||||||
--net) need "$1" "${2:-}"; OPT_NET="$2"; shift 2 ;;
|
--net) need "$1" "${2:-}"; OPT_NET="$2"; shift 2 ;;
|
||||||
--net=*) OPT_NET="${1#*=}"; shift ;;
|
--net=*) OPT_NET="${1#*=}"; shift ;;
|
||||||
--harness) need "$1" "${2:-}"; OPT_HARNESS="$2"; shift 2 ;;
|
--harness) need "$1" "${2:-}"; OPT_HARNESS="$2"; shift 2 ;;
|
||||||
|
--no-pi-config) OPT_PI_CONFIG=0; shift ;;
|
||||||
|
--no-pi-token-env) OPT_PI_TOKEN_AUTO=0; shift ;;
|
||||||
|
--pi-token-env) need "$1" "${2:-}"; OPT_PI_TOKEN_ENV="$2"; shift 2 ;;
|
||||||
|
--pi-token-env=*) OPT_PI_TOKEN_ENV="${1#*=}"; shift ;;
|
||||||
--harness=*) OPT_HARNESS="${1#*=}"; shift ;;
|
--harness=*) OPT_HARNESS="${1#*=}"; shift ;;
|
||||||
--allow) need "$1" "${2:-}"; OPT_allow+=("$2"); shift 2 ;;
|
--allow) need "$1" "${2:-}"; OPT_allow+=("$2"); shift 2 ;;
|
||||||
--allow=*) OPT_allow+=("${1#*=}"); shift ;;
|
--allow=*) OPT_allow+=("${1#*=}"); shift ;;
|
||||||
@@ -403,6 +433,28 @@ resolve_opts() {
|
|||||||
POL[pids]="${OPT_PIDS:-${AISB_PIDS:-$(cfgv pids 512)}}"
|
POL[pids]="${OPT_PIDS:-${AISB_PIDS:-$(cfgv pids 512)}}"
|
||||||
POL[tmpfs]="${OPT_TMPFS:-${AISB_TMPFS_SIZE:-$(cfgv tmpfs_size 1g)}}"
|
POL[tmpfs]="${OPT_TMPFS:-${AISB_TMPFS_SIZE:-$(cfgv tmpfs_size 1g)}}"
|
||||||
POL[base_image]="${OPT_BASE_IMAGE:-${AISB_BASE_IMAGE:-$(cfgv base_image docker.io/library/ubuntu:24.04)}}"
|
POL[base_image]="${OPT_BASE_IMAGE:-${AISB_BASE_IMAGE:-$(cfgv base_image docker.io/library/ubuntu:24.04)}}"
|
||||||
|
# --- pi configuration bridge (see pi_config_bridge) -------------------------
|
||||||
|
POL[pi_agent_dir]="${AISB_PI_AGENT_DIR:-$(cfgv pi_agent_dir "${HOME%/}/.pi/agent")}"
|
||||||
|
POL[pi_config]="${OPT_PI_CONFIG:-${AISB_PI_CONFIG:-$(cfgv pi_config 1)}}"
|
||||||
|
case "${POL[pi_config]}" in 0|false|no) POL[pi_config]=0 ;; *) POL[pi_config]=1 ;; esac
|
||||||
|
POL[pi_token_env]="${OPT_PI_TOKEN_ENV:-${AISB_PI_TOKEN_ENV:-$(cfgv pi_token_env AI_API_TOKEN)}}"
|
||||||
|
[[ "${POL[pi_token_env]}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \
|
||||||
|
|| die "pi token variable name is not a valid identifier: '${POL[pi_token_env]}'"
|
||||||
|
POL[pi_token_auto]="${OPT_PI_TOKEN_AUTO:-${AISB_PI_TOKEN_AUTO:-$(cfgv pi_token_auto 1)}}"
|
||||||
|
case "${POL[pi_token_auto]}" in 0|false|no) POL[pi_token_auto]=0 ;; *) POL[pi_token_auto]=1 ;; esac
|
||||||
|
|
||||||
|
POL[base_ttl]="${AISB_BASE_TTL_DAYS:-$(cfgv base_ttl_days 7)}"
|
||||||
|
[[ "${POL[base_ttl]}" =~ ^-?[0-9]+$ ]] || { warn "base_ttl_days must be an integer, using 7"; POL[base_ttl]=7; }
|
||||||
|
POL[base_auto]="${AISB_BASE_AUTO_UPDATE:-$(cfgv base_auto_update 1)}"
|
||||||
|
case "${POL[base_auto]}" in 0|false|no) POL[base_auto]=0 ;; *) POL[base_auto]=1 ;; esac
|
||||||
|
# Preview modes must never reach the network: they report the pin that is already
|
||||||
|
# recorded, which is also what makes the generated Containerfile testable.
|
||||||
|
if (( OPT_DRY == 0 && OPT_PRINT == 0 && OPT_PRINT_EP == 0 && OPT_PRINT_CF == 0 )); then
|
||||||
|
POL[base_digest]="$(base_pin_digest "${POL[base_image]}")"
|
||||||
|
else
|
||||||
|
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 0 1)"
|
||||||
|
fi
|
||||||
|
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
|
||||||
POL[node_version]="${AISB_NODE_VERSION:-$(cfgv node_version 22.23.4)}"
|
POL[node_version]="${AISB_NODE_VERSION:-$(cfgv node_version 22.23.4)}"
|
||||||
POL[pi_version]="${AISB_PI_VERSION:-$(cfgv pi_version 1.4.0)}"
|
POL[pi_version]="${AISB_PI_VERSION:-$(cfgv pi_version 1.4.0)}"
|
||||||
POL[selinux]="${AISB_SELINUX-$(cfgv selinux "$AISB_SELINUX_FALLBACK")}"
|
POL[selinux]="${AISB_SELINUX-$(cfgv selinux "$AISB_SELINUX_FALLBACK")}"
|
||||||
@@ -452,7 +504,7 @@ validate_opts() {
|
|||||||
fi
|
fi
|
||||||
if [[ "${POL[net]}" == allow && ${#P_allow[@]} -eq 0 && -z "${POL[proxy]}" ]]; then
|
if [[ "${POL[net]}" == allow && ${#P_allow[@]} -eq 0 && -z "${POL[proxy]}" ]]; then
|
||||||
die "no egress allowlist configured. Refusing to start wide open.
|
die "no egress allowlist configured. Refusing to start wide open.
|
||||||
add one: $PROG --allow api.openai.com --env OPENAI_API_KEY
|
add one: $PROG --allow aipi.reeseapps.com --env AI_API_TOKEN
|
||||||
or go dark: $PROG --net none
|
or go dark: $PROG --net none
|
||||||
or opt out: $PROG --net full"
|
or opt out: $PROG --net full"
|
||||||
fi
|
fi
|
||||||
@@ -715,6 +767,43 @@ if [[ "$KEEP_ROOT" == 1 ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
command -v setpriv >/dev/null || fail "setpriv is missing from the image; rebuild it" 70
|
command -v setpriv >/dev/null || fail "setpriv is missing from the image; rebuild it" 70
|
||||||
|
|
||||||
|
# --- pi configuration bridge -------------------------------------------------
|
||||||
|
# ai-sandbox may have generated a models.json / settings.json for pi from the host's
|
||||||
|
# agent directory (with the credential replaced by an environment reference). They are
|
||||||
|
# seeded into the sandbox user's home, because pi needs that directory writable: it
|
||||||
|
# keeps sessions, extensions and /settings edits there.
|
||||||
|
#
|
||||||
|
# Seeding runs as the sandbox user, not as container root: HOME belongs to that uid and
|
||||||
|
# the entrypoint has no CAP_DAC_OVERRIDE, so root could not write there anyway. It is
|
||||||
|
# also the honest identity -- these are the user's own files.
|
||||||
|
#
|
||||||
|
# A file is only replaced when it is missing or still identical to what we seeded last
|
||||||
|
# time, so an edit made inside the sandbox survives; a change on the host wins next run.
|
||||||
|
if [[ -d "$POLICY_DIR/pi" ]]; then
|
||||||
|
setpriv --reuid="$UID_TARGET" --regid="$GID_TARGET" --clear-groups -- /bin/bash -c '
|
||||||
|
dst="$HOME/.pi/agent"
|
||||||
|
mkdir -p "$dst" || { echo "[aisb] pi config: cannot create $dst" >&2; exit 0; }
|
||||||
|
for f in models.json settings.json; do
|
||||||
|
src="$1/pi/$f"
|
||||||
|
[ -f "$src" ] || continue
|
||||||
|
tgt="$dst/$f"; mark="$dst/.aisb-$f.seeded"
|
||||||
|
want="$(sha256sum "$src" | cut -c1-16)"
|
||||||
|
cur=""; [ -f "$tgt" ] && cur="$(sha256sum "$tgt" | cut -c1-16)"
|
||||||
|
prev=""; [ -f "$mark" ] && prev="$(cat "$mark" 2>/dev/null)"
|
||||||
|
if [ -z "$cur" ]; then
|
||||||
|
install -m 600 "$src" "$tgt" && printf %s "$want" > "$mark"
|
||||||
|
echo "[aisb] pi config: seeded $f"
|
||||||
|
elif [ "$cur" = "$prev" ]; then
|
||||||
|
install -m 600 "$src" "$tgt" && printf %s "$want" > "$mark"
|
||||||
|
echo "[aisb] pi config: refreshed $f from the host config"
|
||||||
|
else
|
||||||
|
echo "[aisb] pi config: left $f alone (changed inside the sandbox; delete it to take the host version again)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
' aisb-seed "$POLICY_DIR" || log "pi config: seeding failed"
|
||||||
|
fi
|
||||||
|
|
||||||
exec setpriv --reuid="$UID_TARGET" --regid="$GID_TARGET" --clear-groups \
|
exec setpriv --reuid="$UID_TARGET" --regid="$GID_TARGET" --clear-groups \
|
||||||
--inh-caps=-all --bounding-set=-all --no-new-privs -- "$@"
|
--inh-caps=-all --bounding-set=-all --no-new-privs -- "$@"
|
||||||
AISB_ENTRY
|
AISB_ENTRY
|
||||||
@@ -733,10 +822,120 @@ exec squid -N -f "$CONF"
|
|||||||
AISB_GATE
|
AISB_GATE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# base image pinning
|
||||||
|
# ===========================================================================
|
||||||
|
# A tag is a moving pointer: "FROM ubuntu:24.04" does not name an image, it names
|
||||||
|
# whatever Canonical points at that week. That makes a build unreproducible and, more
|
||||||
|
# importantly, makes a security patch invisible -- you cannot tell an old image from a
|
||||||
|
# patched one. So the tag is resolved to a digest once, recorded under
|
||||||
|
# $AISB_CACHE_DIR/base/, and the digest is what goes into the Containerfile *and* into
|
||||||
|
# the content hash: refreshing the base produces a new image rather than quietly
|
||||||
|
# mutating one a sandbox may already be running.
|
||||||
|
#
|
||||||
|
# Freshness is a TTL, not a promise. Every AISB_BASE_TTL_DAYS days (default 7) the
|
||||||
|
# digest is re-resolved, which is a manifest read rather than a download. Offline, or
|
||||||
|
# with AISB_BASE_AUTO_UPDATE=0, the recorded digest is kept -- a stale pin is safer
|
||||||
|
# than a mutable one. AISB_BASE_TTL_DAYS=0 re-resolves on every build; -1 never does.
|
||||||
|
# `ai-sandbox update` does it on demand, `update --check` only reports.
|
||||||
|
#
|
||||||
|
# One deliberate asymmetry: an unpinned build is allowed, but it is announced as
|
||||||
|
# UNPINNED, labelled into the image as such, and warned about by doctor. Silent
|
||||||
|
# non-reproducibility is the failure mode this section exists to prevent.
|
||||||
|
|
||||||
|
base_state_path() { printf '%s/base/%s.env' "$AISB_CACHE_DIR" "$(sha12 "$1")"; }
|
||||||
|
base_state_field() { local f; f="$(base_state_path "$1")"
|
||||||
|
[[ -r "$f" ]] || return 0
|
||||||
|
sed -n "s/^$2=//p" "$f" | head -1; }
|
||||||
|
base_digest_recorded() { base_state_field "$1" digest; }
|
||||||
|
|
||||||
|
base_state_write() { # $1=ref $2=digest
|
||||||
|
local f; f="$(base_state_path "$1")"
|
||||||
|
mkdir -p "${f%/*}" || return 1
|
||||||
|
( umask 077; printf 'ref=%s\ndigest=%s\nresolved_at=%s\n' "$1" "$2" "$(date +%s)" > "$f" )
|
||||||
|
}
|
||||||
|
|
||||||
|
base_age_days() { # $1=ref -> whole days since the pin was recorded, empty if unknown
|
||||||
|
local at; at="$(base_state_field "$1" resolved_at)"
|
||||||
|
[[ -n "$at" ]] || return 0
|
||||||
|
printf '%s' $(( ( $(date +%s) - at ) / 86400 ))
|
||||||
|
}
|
||||||
|
|
||||||
|
base_digest_fresh() { # $1=ref -> the recorded digest while it is inside the TTL
|
||||||
|
local d age ttl="${POL[base_ttl]:-7}"
|
||||||
|
d="$(base_digest_recorded "$1")"; [[ -n "$d" ]] || return 0
|
||||||
|
(( ttl < 0 )) && { printf '%s' "$d"; return 0; } # -1: pinned until told otherwise
|
||||||
|
age="$(base_age_days "$1")"; age="${age:-$ttl}"
|
||||||
|
(( age <= ttl )) && printf '%s' "$d"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
base_digest_online() { # $1=ref -> digest from the registry, empty when unreachable
|
||||||
|
local ref="$1" d="" sk="${AISB_SKOPEO:-skopeo}"
|
||||||
|
if command -v "$sk" > /dev/null 2>&1; then
|
||||||
|
d="$("$sk" inspect --format '{{.Digest}}' "docker://$ref" 2>/dev/null | head -1)" || d=""
|
||||||
|
fi
|
||||||
|
# Without skopeo a pull is the only way to learn a digest, and it costs a download.
|
||||||
|
# That is fine for an update and rude for an ordinary run, so it can be turned off
|
||||||
|
# with AISB_BASE_PULL_FALLBACK=0 (the tests do exactly that, to stay offline).
|
||||||
|
if [[ -z "$d" && "${AISB_BASE_PULL_FALLBACK:-1}" == 1 ]]; then
|
||||||
|
if podman pull -q "$ref" > /dev/null 2>&1; then
|
||||||
|
d="$(podman image inspect "$ref" --format '{{.Digest}}' 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
printf '%s' "$d"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Echoes the digest to pin to -- empty means unpinned. It is *not* written into POL
|
||||||
|
# directly because callers use command substitution, and a global assigned in a
|
||||||
|
# subshell is simply lost; combine it with base_ref_of() instead.
|
||||||
|
# $1=ref $2=force (re-resolve now) $3=cache_only (never touch the network)
|
||||||
|
base_pin_digest() {
|
||||||
|
local ref="$1" force="${2:-0}" cache_only="${3:-0}" auto d=""
|
||||||
|
if [[ "$ref" == *@* ]]; then printf '%s' "${ref#*@}"; return 0; fi # user pinned it
|
||||||
|
auto="${POL[base_auto]:-1}"
|
||||||
|
d="$(base_digest_fresh "$ref")"
|
||||||
|
if [[ -z "$d" ]] && (( cache_only == 0 )); then
|
||||||
|
if (( auto )) || (( force )); then
|
||||||
|
log "resolving the current digest of $ref"
|
||||||
|
d="$(base_digest_online "$ref")"
|
||||||
|
if [[ -n "$d" ]]; then
|
||||||
|
base_state_write "$ref" "$d" \
|
||||||
|
|| log "warning: could not record the digest under $AISB_CACHE_DIR/base"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[[ -n "$d" ]] || d="$(base_digest_recorded "$ref")"
|
||||||
|
if [[ -z "$d" ]]; then
|
||||||
|
(( cache_only )) || log "warning: $ref is UNPINNED -- no recorded digest and the registry was unreachable; the build is not reproducible"
|
||||||
|
fi
|
||||||
|
printf '%s' "$d"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A ref is name + tag, plus the digest when there is one. A ref the user pinned
|
||||||
|
# themselves is passed through untouched rather than rebuilt from its parts.
|
||||||
|
base_ref_of() {
|
||||||
|
local ref="$1" digest="$2"
|
||||||
|
[[ "$ref" == *@* || -z "$digest" ]] && { printf '%s' "$ref"; return 0; }
|
||||||
|
printf '%s@%s' "$ref" "$digest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Image tags are named after the base so a non-Ubuntu base does not lie about itself.
|
||||||
|
image_tag_prefix() { local b="${POL[base_image]##*/}"; b="${b%%[:@]*}"; printf '%s' "${b:-base}"; }
|
||||||
|
|
||||||
containerfile() {
|
containerfile() {
|
||||||
cat <<AISB_CF
|
cat <<AISB_CF
|
||||||
# Generated by $PROG $VERSION -- edit bin/$PROG, not this file.
|
# Generated by $PROG $VERSION -- edit bin/$PROG, not this file.
|
||||||
FROM ${POL[base_image]}
|
FROM ${POL[base_ref]:-${POL[base_image]}}
|
||||||
|
|
||||||
|
# Auditable by one 'podman image inspect' on a container found running weeks later.
|
||||||
|
LABEL ai-sandbox=1 \\
|
||||||
|
aisb.version="$VERSION" \\
|
||||||
|
aisb.base="${POL[base_image]}" \\
|
||||||
|
aisb.base-digest="${POL[base_digest]:-unpinned}" \\
|
||||||
|
aisb.node="${POL[node_version]}" \\
|
||||||
|
aisb.pi-package="${AISB_PI_PACKAGE}" \\
|
||||||
|
aisb.pi-version="${POL[pi_version]}"
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \\
|
ENV DEBIAN_FRONTEND=noninteractive \\
|
||||||
LANG=C.UTF-8
|
LANG=C.UTF-8
|
||||||
@@ -800,6 +999,17 @@ RUN set -eux; \\
|
|||||||
|
|
||||||
RUN git config --system --add safe.directory '*' || true
|
RUN git config --system --add safe.directory '*' || true
|
||||||
|
|
||||||
|
# A tag answers "which build"; this answers "which squid, which nftables, which pi"
|
||||||
|
# long after the build log has rotated. Readable from inside as \$AISB_MANIFEST.
|
||||||
|
RUN mkdir -p /etc/aisb \\
|
||||||
|
&& { echo "# $PROG $VERSION package manifest"; \\
|
||||||
|
dpkg-query -W -f='\${Package}=\${Version}\n'; \\
|
||||||
|
echo "node=\$(node -v 2>/dev/null || echo unknown)"; \\
|
||||||
|
npm ls -g --depth=0 --json 2>/dev/null \\
|
||||||
|
| jq -r '.dependencies // {} | to_entries[] | "npm.\\(.key)=\\(.value.version // "?")"' \\
|
||||||
|
|| echo 'npm=unavailable'; \\
|
||||||
|
} > /etc/aisb/manifest.txt
|
||||||
|
|
||||||
COPY aisb-entrypoint /usr/local/lib/aisb/entrypoint.sh
|
COPY aisb-entrypoint /usr/local/lib/aisb/entrypoint.sh
|
||||||
RUN chmod 0755 /usr/local/lib/aisb/entrypoint.sh
|
RUN chmod 0755 /usr/local/lib/aisb/entrypoint.sh
|
||||||
|
|
||||||
@@ -809,7 +1019,8 @@ ENV HOME=/home/sandbox \\
|
|||||||
npm_config_update_notifier=false \\
|
npm_config_update_notifier=false \\
|
||||||
GIT_TERMINAL_PROMPT=0 \\
|
GIT_TERMINAL_PROMPT=0 \\
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \\
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \\
|
||||||
AISB=1
|
AISB=1 \\
|
||||||
|
AISB_MANIFEST=/etc/aisb/manifest.txt
|
||||||
|
|
||||||
WORKDIR ${POL[workdir]}
|
WORKDIR ${POL[workdir]}
|
||||||
ENTRYPOINT ["/usr/local/lib/aisb/entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/lib/aisb/entrypoint.sh"]
|
||||||
@@ -820,11 +1031,20 @@ AISB_CF
|
|||||||
gate_containerfile() {
|
gate_containerfile() {
|
||||||
cat <<AISB_GCF
|
cat <<AISB_GCF
|
||||||
# Generated by $PROG $VERSION -- egress gate image.
|
# Generated by $PROG $VERSION -- egress gate image.
|
||||||
FROM ${POL[base_image]}
|
FROM ${POL[base_ref]:-${POL[base_image]}}
|
||||||
|
LABEL ai-sandbox=1 \\
|
||||||
|
aisb.role=gate \\
|
||||||
|
aisb.version="$VERSION" \\
|
||||||
|
aisb.base="${POL[base_image]}" \\
|
||||||
|
aisb.base-digest="${POL[base_digest]:-unpinned}"
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
RUN apt-get update \\
|
RUN apt-get update \\
|
||||||
&& apt-get install -y --no-install-recommends squid ca-certificates curl iproute2 \\
|
&& apt-get install -y --no-install-recommends squid ca-certificates curl iproute2 jq \\
|
||||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
&& apt-get clean && rm -rf /var/lib/apt/lists/* \\
|
||||||
|
&& mkdir -p /etc/aisb \\
|
||||||
|
&& { echo "# $PROG $VERSION gate package manifest"; \\
|
||||||
|
dpkg-query -W -f='\${Package}=\${Version}\n'; \\
|
||||||
|
} > /etc/aisb/manifest.txt
|
||||||
COPY aisb-gate-entrypoint /usr/local/lib/aisb/gate-entrypoint.sh
|
COPY aisb-gate-entrypoint /usr/local/lib/aisb/gate-entrypoint.sh
|
||||||
RUN chmod 0755 /usr/local/lib/aisb/gate-entrypoint.sh
|
RUN chmod 0755 /usr/local/lib/aisb/gate-entrypoint.sh
|
||||||
ENTRYPOINT ["/usr/local/lib/aisb/gate-entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/lib/aisb/gate-entrypoint.sh"]
|
||||||
@@ -833,16 +1053,18 @@ AISB_GCF
|
|||||||
|
|
||||||
image_hash() {
|
image_hash() {
|
||||||
local h
|
local h
|
||||||
h="${POL[base_image]:-$AISB_BASE_IMAGE}|${POL[node_version]}|${POL[pi_version]}|${AISB_PI_PACKAGE}|v${VERSION}"
|
# base_ref, not base_image: a refreshed base digest has to change the tag, and a
|
||||||
|
# bare tag change (24.04 -> 24.10) changes it too because the name is in the hash.
|
||||||
|
h="${POL[base_ref]:-${POL[base_image]}}|${POL[node_version]}|${POL[pi_version]}|${AISB_PI_PACKAGE}|v${VERSION}"
|
||||||
h+="|$(printf '%s ' ${P_install[@]+"${P_install[@]}"})"
|
h+="|$(printf '%s ' ${P_install[@]+"${P_install[@]}"})"
|
||||||
# any change to the generated image definition produces a new tag
|
# any change to the generated image definition produces a new tag
|
||||||
h+="|$(entrypoint_script | sha256sum | cut -d' ' -f1)"
|
h+="|$(entrypoint_script | sha256sum | cut -d' ' -f1)"
|
||||||
h+="|$(containerfile | sha256sum | cut -d' ' -f1)"
|
h+="|$(containerfile | sha256sum | cut -d' ' -f1)"
|
||||||
sha12 "$h"
|
sha12 "$h"
|
||||||
}
|
}
|
||||||
image_ref() { printf '%s:u24-%s\n' "$AISB_IMAGE_REPO" "$(image_hash)"; }
|
image_ref() { printf '%s:%s-%s\n' "$AISB_IMAGE_REPO" "$(image_tag_prefix)" "$(image_hash)"; }
|
||||||
gate_image_ref() { printf '%s:u24-%s\n' "$AISB_GATE_IMAGE_REPO" \
|
gate_image_ref() { printf '%s:%s-%s\n' "$AISB_GATE_IMAGE_REPO" "$(image_tag_prefix)" \
|
||||||
"$(sha12 "${POL[base_image]:-$AISB_BASE_IMAGE}|v${VERSION}|squid")"; }
|
"$(sha12 "${POL[base_ref]:-${POL[base_image]}}|v${VERSION}|squid")"; }
|
||||||
|
|
||||||
node_sha256() {
|
node_sha256() {
|
||||||
[[ -n "${AISB_NODE_SHA256:-}" ]] && { printf '%s' "${AISB_NODE_SHA256}"; return 0; }
|
[[ -n "${AISB_NODE_SHA256:-}" ]] && { printf '%s' "${AISB_NODE_SHA256}"; return 0; }
|
||||||
@@ -866,6 +1088,7 @@ ensure_image() { # $1=sandbox|gate
|
|||||||
entrypoint_script > "$ctx/aisb-entrypoint"
|
entrypoint_script > "$ctx/aisb-entrypoint"
|
||||||
chmod 755 "$ctx/aisb-entrypoint"
|
chmod 755 "$ctx/aisb-entrypoint"
|
||||||
local sha; sha="$(node_sha256)"
|
local sha; sha="$(node_sha256)"
|
||||||
|
[[ -n "$sha" ]] || log "warning: no SHA256 for Node ${POL[node_version]} (nodejs.org unreachable?) -- the tarball will NOT be verified. Set AISB_NODE_SHA256 to pin it yourself."
|
||||||
build_in "$ctx" "$ref" \
|
build_in "$ctx" "$ref" \
|
||||||
--build-arg "AISB_NODE_SHA256=${sha}" \
|
--build-arg "AISB_NODE_SHA256=${sha}" \
|
||||||
--build-arg "AISB_EXTRA_PACKAGES=$(printf '%s ' ${P_install[@]+"${P_install[@]}"})"
|
--build-arg "AISB_EXTRA_PACKAGES=$(printf '%s ' ${P_install[@]+"${P_install[@]}"})"
|
||||||
@@ -898,15 +1121,20 @@ build_in() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd_build() {
|
cmd_build() {
|
||||||
local force=0 want_gate=0
|
local force=0 want_gate=0 refresh_base=0
|
||||||
while (($#)); do
|
while (($#)); do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--rebuild|-f) force=1; shift ;;
|
--rebuild|-f) force=1; shift ;;
|
||||||
--gate) want_gate=1; shift ;;
|
--gate) want_gate=1; shift ;;
|
||||||
|
--refresh-base|-B) refresh_base=1; shift ;;
|
||||||
*) die "build: unknown argument $1" ;;
|
*) die "build: unknown argument $1" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR"
|
mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR"
|
||||||
|
if (( refresh_base )); then
|
||||||
|
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 1)"
|
||||||
|
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
|
||||||
|
fi
|
||||||
if (( force )); then
|
if (( force )); then
|
||||||
podman rmi -f "$(image_ref)" >/dev/null 2>&1 || true
|
podman rmi -f "$(image_ref)" >/dev/null 2>&1 || true
|
||||||
[[ $want_gate == 1 ]] && podman rmi -f "$(gate_image_ref)" >/dev/null 2>&1 || true
|
[[ $want_gate == 1 ]] && podman rmi -f "$(gate_image_ref)" >/dev/null 2>&1 || true
|
||||||
@@ -916,6 +1144,165 @@ cmd_build() {
|
|||||||
if (( want_gate )); then gref="$(ensure_image gate)"; log "gate image: $gref"; fi
|
if (( want_gate )); then gref="$(ensure_image gate)"; log "gate image: $gref"; fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# pi configuration bridge
|
||||||
|
# ===========================================================================
|
||||||
|
# pi reads <agent-dir>/models.json and <agent-dir>/settings.json, the agent directory
|
||||||
|
# defaulting to ~/.pi/agent. Inside the sandbox that home is a volume, so without a
|
||||||
|
# bridge you get a virgin pi with no provider and no preferences.
|
||||||
|
#
|
||||||
|
# Copying the host files would be worse than useless: models.json may hold a literal
|
||||||
|
# key, or a "!pass show ..." command that can only run on the host and would leak the
|
||||||
|
# secret into a container that is meant to be able to talk to nobody but the gate. So
|
||||||
|
# the bridge rewrites instead of copying:
|
||||||
|
#
|
||||||
|
# * every provider apiKey becomes "$AI_API_TOKEN" (name settable) -- pi itself
|
||||||
|
# resolves that interpolation at request time, so no value is ever written to disk
|
||||||
|
# * header values that were "!command" are dropped, never executed
|
||||||
|
# * settings keys pointing at the host home are dropped rather than left dangling
|
||||||
|
# * auth.json, models-store.json, sessions, extensions and everything else stay on
|
||||||
|
# the host -- they are credentials or host-side executables, not preferences
|
||||||
|
#
|
||||||
|
# The rewritten pair lands in the session dir (which is mounted at /run/aisb) and the
|
||||||
|
# entrypoint seeds it into the sandbox user's home. Only the variable *name* crosses
|
||||||
|
# the boundary in the container's environment; the value travels through the 0600
|
||||||
|
# env file like any other --env.
|
||||||
|
|
||||||
|
declare -a PI_PROVIDER_HOSTS=()
|
||||||
|
PI_GEN_DIR=""
|
||||||
|
|
||||||
|
pi_agent_dir() { printf '%s' "${POL[pi_agent_dir]:-${AISB_PI_AGENT_DIR:-${HOME%/}/.pi/agent}}"; }
|
||||||
|
|
||||||
|
# Is pi itself the thing we are about to launch? Only then is a missing credential a
|
||||||
|
# reason to refuse: --shell, --harness make, or a command after -- all run without it.
|
||||||
|
# codex/claude are excluded because models.json is pi's file, not theirs.
|
||||||
|
pi_harness_in_use() {
|
||||||
|
(( OPT_SHELL )) && return 1
|
||||||
|
(( ${#COMMAND[@]} )) && return 1
|
||||||
|
case "${POL[harness]:-pi}" in
|
||||||
|
pi|pi\ *) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# baseUrl hostnames the bridged config points at, for the egress hint below.
|
||||||
|
pi_provider_hosts_of() { # $1=models.json
|
||||||
|
jq -r '[(.providers // {})[]? | (.baseUrl? // empty),
|
||||||
|
((.models // [])[]? | .baseUrl? // empty)] | .[]' "$1" 2>/dev/null \
|
||||||
|
| sed -E 's#^[A-Za-z][A-Za-z0-9+.-]*://##; s#[/?].*$##; s#:[0-9]+$##' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
pi_config_bridge() {
|
||||||
|
PI_GEN_DIR=""
|
||||||
|
[[ "${POL[pi_config]}" == 1 ]] || return 0
|
||||||
|
local src dst tok="${POL[pi_token_env]}"
|
||||||
|
src="$(pi_agent_dir)"
|
||||||
|
if [[ ! -d "$src" ]]; then
|
||||||
|
(( OPT_PRINT || OPT_DRY )) || log "pi config: no agent directory at $src -- pi starts with its own defaults"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ! command -v jq > /dev/null 2>&1; then
|
||||||
|
warn "pi config: jq is not installed, so the pi config was not bridged (or use --no-pi-config to say so deliberately)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
dst="$SESSION_MOUNT/pi"
|
||||||
|
mkdir -p "$dst"; chmod 755 "$dst"
|
||||||
|
PI_GEN_DIR="$dst"
|
||||||
|
|
||||||
|
# --- models.json: same providers and models, credential from the environment
|
||||||
|
if [[ -r "$src/models.json" ]]; then
|
||||||
|
if ! jq -e . "$src/models.json" > /dev/null 2>&1; then
|
||||||
|
warn "pi config: $src/models.json is not valid JSON -- pi starts without it"
|
||||||
|
else
|
||||||
|
jq --arg env "$tok" '
|
||||||
|
(.providers // {}) |= with_entries(
|
||||||
|
if (.value | type) == "object" and (.value | has("apiKey"))
|
||||||
|
then .value.apiKey = ("$" + $env) else . end)
|
||||||
|
| (.providers // {}) |= with_entries(
|
||||||
|
if (.value | type) == "object" and (.value | has("headers"))
|
||||||
|
then .value.headers = (.value.headers | with_entries(
|
||||||
|
select(((.value | type) == "string" and (.value | test("^!"))) | not)))
|
||||||
|
else . end)
|
||||||
|
' "$src/models.json" > "$dst/models.json" || die "pi config: could not rewrite $src/models.json"
|
||||||
|
chmod 644 "$dst/models.json"
|
||||||
|
|
||||||
|
local rewrote dropped
|
||||||
|
rewrote="$(jq -r '[(.providers // {}) | to_entries[] | select(.value | has("apiKey")) | .key] | join(", ")' "$src/models.json")"
|
||||||
|
dropped="$(jq -r '[(.providers // {})[]? | .headers? // {} | to_entries[]
|
||||||
|
| select((.value | type) == "string" and (.value | test("^!"))) | .key] | join(", ")' "$src/models.json")"
|
||||||
|
[[ -n "$rewrote" ]] && log "pi config: $rewrote -> apiKey \$$tok"
|
||||||
|
[[ -n "$dropped" ]] && warn "pi config: dropped header(s) $dropped -- a '!command' runs on the host, not in here"
|
||||||
|
|
||||||
|
mapfile -t PI_PROVIDER_HOSTS < <(pi_provider_hosts_of "$src/models.json")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- settings.json: preferences travel, host paths do not
|
||||||
|
if [[ -r "$src/settings.json" ]]; then
|
||||||
|
if ! jq -e . "$src/settings.json" > /dev/null 2>&1; then
|
||||||
|
warn "pi config: $src/settings.json is not valid JSON -- pi starts without it"
|
||||||
|
else
|
||||||
|
local homep="${HOME%/}" dropped
|
||||||
|
jq --arg home "$homep" '
|
||||||
|
with_entries(select(((.value | type) == "string" and (.value | contains($home))) | not))
|
||||||
|
' "$src/settings.json" > "$dst/settings.json" || die "pi config: could not rewrite $src/settings.json"
|
||||||
|
chmod 644 "$dst/settings.json"
|
||||||
|
dropped="$(jq -r --arg home "$homep" '[to_entries[]
|
||||||
|
| select((.value | type) == "string" and (.value | contains($home))) | .key] | join(", ")' "$src/settings.json")"
|
||||||
|
[[ -n "$dropped" ]] && warn "pi config: dropped settings $dropped -- they point inside the host home, which is not mounted"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -f "$dst/models.json" || -f "$dst/settings.json" ]] || { rmdir "$dst" 2>/dev/null || true; PI_GEN_DIR=""; return 0; }
|
||||||
|
|
||||||
|
# --- the credential: exactly one variable, named by the config we just wrote
|
||||||
|
local spec in_list=0
|
||||||
|
for spec in ${P_env[@]+"${P_env[@]}"}; do
|
||||||
|
[[ "$spec" == "$tok" || "$spec" == "$tok="* ]] && in_list=1
|
||||||
|
done
|
||||||
|
if (( in_list == 0 )) && (( POL[pi_token_auto] )) && [[ -n "${!tok:-}" ]]; then
|
||||||
|
P_env+=("$tok")
|
||||||
|
log "pi config: passing \$$tok from the host because the bridged config asks for it (--no-pi-token-env to stop)"
|
||||||
|
in_list=1
|
||||||
|
fi
|
||||||
|
# A bridged provider with no credential in reach is a run where pi starts and then
|
||||||
|
# says nothing, so it is refused rather than attempted -- except in preview, which
|
||||||
|
# reports and carries on so a policy can be inspected before the secret exists.
|
||||||
|
if (( in_list == 0 )) && pi_harness_in_use && [[ -f "$dst/models.json" ]]; then # unset or empty: both useless to pi
|
||||||
|
if (( OPT_PRINT || OPT_DRY )); then
|
||||||
|
warn "pi config: \$$tok will not reach the container -- pi would have no credential"
|
||||||
|
else
|
||||||
|
die "pi config asks for \$$tok, but it is not going to reach the container$([[ -n "${!tok:-}" ]] && printf ' (it is set on the host and auto-passing is off)' || printf ' (it is unset or empty in the host environment)').
|
||||||
|
export $tok=... and re-run
|
||||||
|
or add to $(cfg_or_home): env = $tok
|
||||||
|
or pass it explicitly: --env $tok
|
||||||
|
or stop bridging: --no-pi-config"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg_or_home() { printf '%s/config' "$AISB_CONFIG_DIR"; }
|
||||||
|
|
||||||
|
# The one failure mode that looks like a broken sandbox rather than a missing
|
||||||
|
# allowlist entry: pi starts, says nothing, and the user blames the model.
|
||||||
|
pi_egress_hint() {
|
||||||
|
[[ "${POL[net]}" == allow ]] || return 0
|
||||||
|
pi_harness_in_use || return 0
|
||||||
|
((${#PI_PROVIDER_HOSTS[@]})) || return 0
|
||||||
|
local h a not_allowed=""
|
||||||
|
for h in "${PI_PROVIDER_HOSTS[@]}"; do
|
||||||
|
local hit=0
|
||||||
|
for a in ${P_allow[@]+"${P_allow[@]}"}; do
|
||||||
|
[[ "${a%%:*}" == "$h" || "${a%%:*}" == "*.$h" || "${a%%:*}" == "*" ]] && hit=1
|
||||||
|
done
|
||||||
|
(( hit )) || not_allowed="${not_allowed:+$not_allowed }$h"
|
||||||
|
done
|
||||||
|
[[ -n "$not_allowed" ]] && warn "pi config: the model endpoint is not in the allowlist ($not_allowed). pi will start and be unable to answer -- add --allow ${not_allowed%% *} or put it in .ai-sandbox/allow"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# egress gate
|
# egress gate
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@@ -1057,7 +1444,7 @@ default_command() {
|
|||||||
# nothing given at all: just start the harness (or a shell)
|
# nothing given at all: just start the harness (or a shell)
|
||||||
if ((${#hb[@]})); then COMMAND=("${hb[@]}"); else COMMAND=(bash -li); fi
|
if ((${#hb[@]})); then COMMAND=("${hb[@]}"); else COMMAND=(bash -li); fi
|
||||||
elif [[ "${OPT_PASSTHROUGH:-0}" == 1 && ${#hb[@]} -gt 0 ]]; then
|
elif [[ "${OPT_PASSTHROUGH:-0}" == 1 && ${#hb[@]} -gt 0 ]]; then
|
||||||
# arguments meant for the harness, e.g. `pi --model sonnet`
|
# arguments meant for the harness, e.g. `pi --model turbo`
|
||||||
COMMAND=("${hb[@]}" "${COMMAND[@]}")
|
COMMAND=("${hb[@]}" "${COMMAND[@]}")
|
||||||
fi
|
fi
|
||||||
# otherwise -- was used: the command replaces the harness
|
# otherwise -- was used: the command replaces the harness
|
||||||
@@ -1123,6 +1510,10 @@ build_argv() {
|
|||||||
fi
|
fi
|
||||||
[[ -s "$ENV_FILE" ]] && ARGV+=(--env-file "$ENV_FILE")
|
[[ -s "$ENV_FILE" ]] && ARGV+=(--env-file "$ENV_FILE")
|
||||||
|
|
||||||
|
# The pi bridge rides the policy mount: $SESSION/pi shows up as /run/aisb/pi, where
|
||||||
|
# the entrypoint picks it up. No extra mount, and it inherits the read-only policy.
|
||||||
|
[[ -n "${PI_GEN_DIR:-}" && -d "${PI_GEN_DIR}" ]] && ARGV+=(--label aisb.pi-config=bridged)
|
||||||
|
|
||||||
[[ "${OPT_NO_CWD:-0}" == 1 ]] || ARGV+=(-v "${POL[cwd]}:${POL[workdir]}${SELOPT}")
|
[[ "${OPT_NO_CWD:-0}" == 1 ]] || ARGV+=(-v "${POL[cwd]}:${POL[workdir]}${SELOPT}")
|
||||||
ARGV+=(-v "${SESSION_MOUNT}:/run/aisb:ro${SELOPT_SEP}")
|
ARGV+=(-v "${SESSION_MOUNT}:/run/aisb:ro${SELOPT_SEP}")
|
||||||
if [[ "${POL[home_cache]}" == 1 ]]; then
|
if [[ "${POL[home_cache]}" == 1 ]]; then
|
||||||
@@ -1176,6 +1567,16 @@ print_argv() {
|
|||||||
print_policy() {
|
print_policy() {
|
||||||
printf 'image=%s\n' "${POL[image_ref]}"
|
printf 'image=%s\n' "${POL[image_ref]}"
|
||||||
printf 'gate_image=%s\n' "$(gate_image_ref)"
|
printf 'gate_image=%s\n' "$(gate_image_ref)"
|
||||||
|
if [[ -n "${PI_GEN_DIR:-}" ]]; then
|
||||||
|
printf 'pi_config=%s\n' "bridged from $(pi_agent_dir)"
|
||||||
|
local _ptv="${POL[pi_token_env]}"
|
||||||
|
printf 'pi_token_env=%s (%s)\n' "$_ptv" \
|
||||||
|
"$([[ -n "${!_ptv:-}" ]] && printf set || { [[ -v "$_ptv" ]] && printf 'set but empty' || printf 'NOT SET'; })"
|
||||||
|
((${#PI_PROVIDER_HOSTS[@]})) && printf 'pi_endpoints=%s\n' "${PI_PROVIDER_HOSTS[*]}"
|
||||||
|
fi
|
||||||
|
printf 'base=%s\n' "${POL[base_image]}"
|
||||||
|
printf 'base_digest=%s\n' "${POL[base_digest]:-unpinned}"
|
||||||
|
printf 'base_age_days=%s\n' "$(base_age_days "${POL[base_image]}")"
|
||||||
printf 'container=%s\n' "${POL[name]}"
|
printf 'container=%s\n' "${POL[name]}"
|
||||||
printf 'net=%s\n' "${POL[net]}"
|
printf 'net=%s\n' "${POL[net]}"
|
||||||
printf 'proxy=%s\n' "${PROXY_IPS[*]-none}:${PROXY_PORTS[*]-none}"
|
printf 'proxy=%s\n' "${PROXY_IPS[*]-none}:${PROXY_PORTS[*]-none}"
|
||||||
@@ -1228,8 +1629,10 @@ cmd_run() {
|
|||||||
fi
|
fi
|
||||||
resolve_proxy_endpoints
|
resolve_proxy_endpoints
|
||||||
|
|
||||||
|
pi_config_bridge
|
||||||
build_env_file
|
build_env_file
|
||||||
write_policy_files
|
write_policy_files
|
||||||
|
pi_egress_hint
|
||||||
build_argv
|
build_argv
|
||||||
|
|
||||||
if (( OPT_PRINT )); then print_policy; exit 0; fi
|
if (( OPT_PRINT )); then print_policy; exit 0; fi
|
||||||
@@ -1305,11 +1708,53 @@ cmd_doctor() {
|
|||||||
local ref; ref="$(image_ref)"
|
local ref; ref="$(image_ref)"
|
||||||
if podman image exists "$ref"; then chk PASS "sandbox image present: $ref"
|
if podman image exists "$ref"; then chk PASS "sandbox image present: $ref"
|
||||||
else chk WARN "sandbox image not built: $ref (run: $PROG build)"; fi
|
else chk WARN "sandbox image not built: $ref (run: $PROG build)"; fi
|
||||||
|
|
||||||
|
# Base pinning: an image built against a *different* digest than the one recorded now
|
||||||
|
# is an image of the previous base -- worth saying out loud, that is how a patched
|
||||||
|
# base quietly fails to arrive.
|
||||||
|
local pdig; pdig="${POL[base_digest]:-unpinned}"
|
||||||
|
if [[ "$pdig" == unpinned ]]; then
|
||||||
|
chk WARN "base image ${POL[base_image]} is unpinned (no digest recorded, registry unreachable?)"
|
||||||
|
else
|
||||||
|
chk INFO "base pin: ${POL[base_image]} @ ${pdig:0:19}..., $(base_age_days "${POL[base_image]}") day(s) old (ttl ${POL[base_ttl]})"
|
||||||
|
fi
|
||||||
|
if command -v "${AISB_SKOPEO:-skopeo}" > /dev/null 2>&1; then
|
||||||
|
chk OK "digest lookups available: $("${AISB_SKOPEO:-skopeo}" --version | cut -d' ' -f1-3)"
|
||||||
|
else
|
||||||
|
chk WARN "skopeo is missing: refreshing a base digest would need a full 'podman pull'"
|
||||||
|
fi
|
||||||
|
if podman image exists "$ref"; then
|
||||||
|
local built; built="$(podman image inspect "$ref" --format '{{ index .Config.Labels "aisb.base-digest" }}' 2>/dev/null)"
|
||||||
|
case "$built" in
|
||||||
|
"") chk INFO "image $ref predates label tracking" ;;
|
||||||
|
"$pdig") chk OK "image $ref was built on the pinned base" ;;
|
||||||
|
*) chk WARN "image $ref was built on ${built:0:19}... but the pin is now ${pdig:0:19}... (run: $PROG update)" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
if podman image exists "$(gate_image_ref)"; then chk PASS "gate image present: $(gate_image_ref)"
|
if podman image exists "$(gate_image_ref)"; then chk PASS "gate image present: $(gate_image_ref)"
|
||||||
else chk WARN "gate image not built: $(gate_image_ref) (built on first use)"; fi
|
else chk WARN "gate image not built: $(gate_image_ref) (built on first use)"; fi
|
||||||
|
|
||||||
chk INFO "policy: net=${POL[net]} harness=${POL[harness]} limits=cpus:${POL[cpus]},memory:${POL[memory]},pids:${POL[pids]}"
|
chk INFO "policy: net=${POL[net]} harness=${POL[harness]} limits=cpus:${POL[cpus]},memory:${POL[memory]},pids:${POL[pids]}"
|
||||||
chk INFO "dirs: config=$AISB_CONFIG_DIR state=$AISB_STATE_DIR cache=$AISB_CACHE_DIR"
|
chk INFO "dirs: config=$AISB_CONFIG_DIR state=$AISB_STATE_DIR cache=$AISB_CACHE_DIR"
|
||||||
|
|
||||||
|
# pi bridge, reported read-only: doctor must not generate a session to say this.
|
||||||
|
local padir="${POL[pi_agent_dir]}" ptok="${POL[pi_token_env]}"
|
||||||
|
if [[ "${POL[pi_config]}" != 1 ]]; then
|
||||||
|
chk INFO "pi config: bridging is off (pi_config=0 or --no-pi-config)"
|
||||||
|
elif [[ ! -d "$padir" ]]; then
|
||||||
|
chk INFO "pi config: no agent directory at $padir -- pi starts with its own defaults"
|
||||||
|
else
|
||||||
|
local -a ph=()
|
||||||
|
if command -v jq > /dev/null 2>&1 && [[ -r "$padir/models.json" ]]; then
|
||||||
|
mapfile -t ph < <(pi_provider_hosts_of "$padir/models.json")
|
||||||
|
fi
|
||||||
|
chk INFO "pi config: bridged from $padir (endpoints: ${ph[*]:-none})"
|
||||||
|
if [[ -n "${!ptok:-}" ]]; then
|
||||||
|
chk OK "pi credential: \$$ptok is set and will be passed by name"
|
||||||
|
else
|
||||||
|
chk WARN "pi credential: \$$ptok is unset or empty -- a bridged pi run is refused (export it, add it to $(cfg_or_home), or use --no-pi-config)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
if ((${#P_allow[@]})); then
|
if ((${#P_allow[@]})); then
|
||||||
chk INFO "allowlist from config/env: ${P_allow[*]}"
|
chk INFO "allowlist from config/env: ${P_allow[*]}"
|
||||||
else
|
else
|
||||||
@@ -1347,6 +1792,24 @@ cmd_doctor() {
|
|||||||
rm -rf "$d"
|
rm -rf "$d"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# With --deep, ask the registry whether the pin is still current. Deliberately
|
||||||
|
# skopeo-only here: the podman fallback would download the whole base inside a
|
||||||
|
# diagnostic, which is not what someone typing --deep signed up for.
|
||||||
|
if (( deep )); then
|
||||||
|
if command -v "${AISB_SKOPEO:-skopeo}" > /dev/null 2>&1; then
|
||||||
|
local remote; remote="$(base_digest_online "${POL[base_image]}")"
|
||||||
|
if [[ -z "$remote" ]]; then
|
||||||
|
chk WARN "deep check: the registry is unreachable, cannot confirm the base is current"
|
||||||
|
elif [[ "$remote" == "${POL[base_digest]:-}" ]]; then
|
||||||
|
chk PASS "deep check: the base pin matches upstream"
|
||||||
|
else
|
||||||
|
chk WARN "deep check: upstream is ${remote:0:19}..., the pin is ${POL[base_digest]:-none} (run: $PROG update)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
chk INFO "deep check: skipped the upstream digest comparison (needs skopeo)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if (( deep )) && (($DOC_FAIL == 0)); then
|
if (( deep )) && (($DOC_FAIL == 0)); then
|
||||||
chk INFO "deep check: does the sandbox actually block non-allowlisted egress?"
|
chk INFO "deep check: does the sandbox actually block non-allowlisted egress?"
|
||||||
local out rc=0
|
local out rc=0
|
||||||
@@ -1422,17 +1885,76 @@ cmd_clean() {
|
|||||||
log "clean done ($(podman ps -a --filter label=ai-sandbox=1 -q | wc -l) sandbox containers left)"
|
log "clean done ($(podman ps -a --filter label=ai-sandbox=1 -q | wc -l) sandbox containers left)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Re-resolve the base digest on demand, and build whatever that implies. The old
|
||||||
|
# image is deliberately left in place: if the new base misbehaves, the previous tag is
|
||||||
|
# still there to fall back to, and pruning it is a decision for after the check.
|
||||||
|
cmd_update() {
|
||||||
|
local check=0 want_gate=1
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--check) check=1; shift ;;
|
||||||
|
--no-gate) want_gate=0; shift ;;
|
||||||
|
*) die "update: unknown argument $1 (try --check)" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR"
|
||||||
|
local old_ref old_gref old_digest new_ref new_gref
|
||||||
|
old_ref="$(image_ref)"; old_gref="$(gate_image_ref)"; old_digest="${POL[base_digest]:-none}"
|
||||||
|
|
||||||
|
if (( check )); then
|
||||||
|
local remote; remote="$(base_digest_online "${POL[base_image]}")"
|
||||||
|
printf 'base=%s\n' "${POL[base_image]}"
|
||||||
|
printf 'recorded=%s\n' "${POL[base_digest]:-unpinned}"
|
||||||
|
printf 'upstream=%s\n' "${remote:-unreachable}"
|
||||||
|
if [[ -z "$remote" ]]; then
|
||||||
|
printf 'status=could not reach the registry\n'; return 0
|
||||||
|
elif [[ "$remote" == "${POL[base_digest]}" ]]; then
|
||||||
|
printf 'status=up to date\nimage=%s\n' "$old_ref"; return 0
|
||||||
|
else
|
||||||
|
# Show the tag the update would produce without recording anything, so the
|
||||||
|
# decision to rebuild is at least an informed one.
|
||||||
|
POL[base_ref]="${POL[base_image]}@$remote"
|
||||||
|
printf 'status=an update is available (a rebuild would produce a new tag)\n'
|
||||||
|
printf 'rebuilt_image=%s\n' "$(image_ref)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 1)"
|
||||||
|
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
|
||||||
|
new_ref="$(image_ref)"; new_gref="$(gate_image_ref)"
|
||||||
|
printf 'base %s\n was %s\n now %s\n' "${POL[base_image]}" "$old_digest" "${POL[base_digest]:-unpinned}"
|
||||||
|
if [[ "$new_ref" == "$old_ref" ]]; then
|
||||||
|
printf 'sandbox image unchanged: %s\n' "$new_ref"
|
||||||
|
else
|
||||||
|
printf 'sandbox image %s -> %s\n' "$old_ref" "$new_ref"
|
||||||
|
fi
|
||||||
|
if (( want_gate )); then
|
||||||
|
if [[ "$new_gref" == "$old_gref" ]]; then
|
||||||
|
printf 'gate image unchanged: %s\n' "$new_gref"
|
||||||
|
else
|
||||||
|
printf 'gate image %s -> %s\n' "$old_gref" "$new_gref"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
local ref gref
|
||||||
|
ref="$(ensure_image sandbox)"; log "built $ref"
|
||||||
|
if (( want_gate )); then gref="$(ensure_image gate)"; log "built $gref"; fi
|
||||||
|
printf 'the previous image is kept for rollback; remove it with: podman rmi %s\n' "$old_ref"
|
||||||
|
}
|
||||||
|
|
||||||
cmd_inspect() {
|
cmd_inspect() {
|
||||||
mkdir -p "$AISB_STATE_DIR"
|
mkdir -p "$AISB_STATE_DIR" "$AISB_CACHE_DIR"
|
||||||
load_config
|
load_config
|
||||||
parse_args "$@"
|
parse_args "$@"
|
||||||
session_init
|
session_init
|
||||||
|
trap teardown EXIT
|
||||||
resolve_opts
|
resolve_opts
|
||||||
set_selinux "${POL[selinux]}"
|
set_selinux "${POL[selinux]}"
|
||||||
POL[image_ref]="$(image_ref)"
|
POL[image_ref]="$(image_ref)"
|
||||||
classify_allows
|
classify_allows
|
||||||
GATE_PORT="${POL[gate_port]}"
|
GATE_PORT="${POL[gate_port]}"
|
||||||
resolve_proxy_endpoints
|
resolve_proxy_endpoints
|
||||||
|
pi_config_bridge
|
||||||
build_env_file
|
build_env_file
|
||||||
print_policy
|
print_policy
|
||||||
}
|
}
|
||||||
@@ -1447,6 +1969,7 @@ main() {
|
|||||||
gate) shift; cmd_gate "$@" ;;
|
gate) shift; cmd_gate "$@" ;;
|
||||||
ps) shift; cmd_ps ;;
|
ps) shift; cmd_ps ;;
|
||||||
clean) shift; cmd_clean "$@" ;;
|
clean) shift; cmd_clean "$@" ;;
|
||||||
|
update) shift; load_config; session_init; resolve_opts; cmd_update "$@" ;;
|
||||||
inspect) shift; cmd_inspect "$@" ;;
|
inspect) shift; cmd_inspect "$@" ;;
|
||||||
version|-V) printf '%s %s\n' "$PROG" "$VERSION" ;;
|
version|-V) printf '%s %s\n' "$PROG" "$VERSION" ;;
|
||||||
help|-h|--help) usage ;;
|
help|-h|--help) usage ;;
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
# 10.20.0.0/24 pinned subnet (default ports 80,443 unless :port given)
|
# 10.20.0.0/24 pinned subnet (default ports 80,443 unless :port given)
|
||||||
# [fd00::5]:8443 pinned ipv6
|
# [fd00::5]:8443 pinned ipv6
|
||||||
|
|
||||||
api.anthropic.com
|
aipi.reeseapps.com # your model gateway
|
||||||
registry.npmjs.org
|
registry.npmjs.org
|
||||||
|
|
||||||
# typical additions, by kind:
|
# typical additions, by kind:
|
||||||
# api.openai.com
|
# another gateway or a LAN inference host:
|
||||||
|
# gateway.internal:8443
|
||||||
# github.com # git push, if you also mount ~/.ssh
|
# github.com # git push, if you also mount ~/.ssh
|
||||||
# pypi.org
|
# pypi.org
|
||||||
# files.pythonhosted.org
|
# files.pythonhosted.org
|
||||||
|
|||||||
+30
-2
@@ -33,9 +33,37 @@ net = allow
|
|||||||
# tmpfs_size = 1g
|
# tmpfs_size = 1g
|
||||||
|
|
||||||
# --- image -----------------------------------------------------------------
|
# --- image -----------------------------------------------------------------
|
||||||
|
# One base for both images (the gate is squid from this distro's apt, not a
|
||||||
|
# third-party image). base_image may itself be pinned: name@sha256:...
|
||||||
# base_image = docker.io/library/ubuntu:24.04
|
# base_image = docker.io/library/ubuntu:24.04
|
||||||
# node_version = 22.23.4
|
# node_version = 22.23.4
|
||||||
# pi_version = 1.4.0
|
# pi_version = 1.4.0
|
||||||
|
#
|
||||||
|
# The tag is resolved to a registry digest and that digest becomes part of the
|
||||||
|
# image tag, so builds are reproducible and a base patch is visible instead of
|
||||||
|
# quietly folding into an existing image. Every base_ttl_days the digest is
|
||||||
|
# re-resolved -- a manifest read, not a download. 0 re-resolves on every build,
|
||||||
|
# -1 never does.
|
||||||
|
# base_ttl_days = 7
|
||||||
|
#
|
||||||
|
# 0 keeps the recorded digest even past its TTL: nothing reaches the registry
|
||||||
|
# unless you say so. An explicit refresh is `ai-sandbox update [--check]`.
|
||||||
|
# base_auto_update = 1
|
||||||
|
|
||||||
|
# --- pi --------------------------------------------------------------------
|
||||||
|
# Bridge ~/.pi/agent/{models,settings}.json into the sandbox, with every provider
|
||||||
|
# apiKey replaced by an environment reference (nothing secret is written into the
|
||||||
|
# container). 0 = pi starts with its own defaults.
|
||||||
|
# pi_config = 1
|
||||||
|
#
|
||||||
|
# The variable the bridged config reads the credential from, and whether ai-sandbox
|
||||||
|
# passes that one variable automatically when it is set. If it reaches neither, the
|
||||||
|
# run is refused instead of launching a pi that cannot talk to its model.
|
||||||
|
# pi_token_env = AI_API_TOKEN
|
||||||
|
# pi_token_auto = 1
|
||||||
|
#
|
||||||
|
# Where to read the host config from (default ~/.pi/agent).
|
||||||
|
# pi_agent_dir = ~/.pi/agent
|
||||||
|
|
||||||
# --- behaviour -------------------------------------------------------------
|
# --- behaviour -------------------------------------------------------------
|
||||||
# SELinux bind-mount option: ':z' (shared), ':Z' (private) or empty to disable.
|
# SELinux bind-mount option: ':z' (shared), ':Z' (private) or empty to disable.
|
||||||
@@ -52,11 +80,11 @@ net = allow
|
|||||||
# --- repeatable keys -------------------------------------------------------
|
# --- repeatable keys -------------------------------------------------------
|
||||||
# These may appear more than once, and are merged with --allow / --env / etc.
|
# These may appear more than once, and are merged with --allow / --env / etc.
|
||||||
#
|
#
|
||||||
# allow = api.anthropic.com
|
# allow = aipi.reeseapps.com
|
||||||
# allow = registry.npmjs.org:443
|
# allow = registry.npmjs.org:443
|
||||||
# allow = 10.20.0.5:5432
|
# allow = 10.20.0.5:5432
|
||||||
#
|
#
|
||||||
# env = ANTHROPIC_API_KEY
|
# env = AI_API_TOKEN
|
||||||
# env = CI_JOB_TOKEN
|
# env = CI_JOB_TOKEN
|
||||||
#
|
#
|
||||||
# mount = ~/.ssh:ro
|
# mount = ~/.ssh:ro
|
||||||
|
|||||||
+3
-3
@@ -6,6 +6,6 @@
|
|||||||
# Nothing here is passed to the container unless you ask for it with --env or
|
# Nothing here is passed to the container unless you ask for it with --env or
|
||||||
# --env-file, so listing a variable is not the same as granting it.
|
# --env-file, so listing a variable is not the same as granting it.
|
||||||
#
|
#
|
||||||
# ANTHROPIC_API_KEY=sk-ant-...
|
# AI_API_TOKEN=... your gateway's token
|
||||||
# OPENAI_API_KEY=sk-...
|
# AI_API_TOKEN_FILE=... if a tool prefers a file over a variable
|
||||||
# ANTHROPIC_MODEL=sonnet
|
# AI_MODEL=turbo model id your gateway serves
|
||||||
|
|||||||
+59
-19
@@ -7,7 +7,14 @@ Concrete invocations. `pi` below is whatever you aliased to `ai-sandbox`.
|
|||||||
```bash
|
```bash
|
||||||
cd ~/Projects/myapp
|
cd ~/Projects/myapp
|
||||||
ai-sandbox doctor # is this machine ready?
|
ai-sandbox doctor # is this machine ready?
|
||||||
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY
|
pi --allow aipi.reeseapps.com --env AI_API_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
With your pi config bridged, the token is found on its own, so the allowlist is the
|
||||||
|
only thing you have to say:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi --allow aipi.reeseapps.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Same thing, but remembered by the repo (so teammates review the policy, not just
|
Same thing, but remembered by the repo (so teammates review the policy, not just
|
||||||
@@ -15,25 +22,58 @@ your shell history):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p .ai-sandbox
|
mkdir -p .ai-sandbox
|
||||||
printf 'api.anthropic.com\n' > .ai-sandbox/allow
|
printf 'aipi.reeseapps.com\n' > .ai-sandbox/allow
|
||||||
pi --env ANTHROPIC_API_KEY
|
pi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Pi's own configuration
|
||||||
|
|
||||||
|
The bridge rewrites your host `~/.pi/agent/{models,settings}.json` instead of copying
|
||||||
|
it: providers, models, thinking budgets and preferences come along, the credential
|
||||||
|
becomes `"$AI_API_TOKEN"`, and anything that only works on the host (a `!command`
|
||||||
|
header, a path into `~`) is dropped with a warning. `auth.json` is never bridged.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# the everyday case: same provider as the host, token from the environment
|
||||||
|
pi --allow aipi.reeseapps.com
|
||||||
|
|
||||||
|
# a model your gateway serves, chosen per run
|
||||||
|
pi --allow aipi.reeseapps.com --model juggernaut
|
||||||
|
|
||||||
|
# keep the model's endpoint pinned by IP as well, on a LAN inference host
|
||||||
|
pi --allow aipi.reeseapps.com --allow 10.20.0.7:11434
|
||||||
|
|
||||||
|
# a different variable name for the credential
|
||||||
|
pi --allow aipi.reeseapps.com --pi-token-env GW_TOKEN
|
||||||
|
|
||||||
|
# do not hand the token over automatically: the run then fails closed unless you
|
||||||
|
# pass it yourself, which is what you want in shared scripts
|
||||||
|
pi --allow aipi.reeseapps.com --env AI_API_TOKEN --no-pi-token-env
|
||||||
|
|
||||||
|
# no bridge: pi starts with its own defaults inside the volume
|
||||||
|
pi --no-pi-config --allow aipi.reeseapps.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Edits made inside the sandbox are kept; changes to the host config are picked up on
|
||||||
|
the next run; deleting `~/.pi/agent/models.json` inside the sandbox re-seeds it.
|
||||||
|
|
||||||
## Per harness
|
## Per harness
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# pi (baked into the image)
|
# pi (baked into the image, config bridged)
|
||||||
|
pi --allow aipi.reeseapps.com
|
||||||
|
|
||||||
|
# pi with a model chosen on the command line
|
||||||
|
pi --harness 'pi --model turbo' --allow aipi.reeseapps.com
|
||||||
|
|
||||||
|
# a gateway that speaks a different protocol still needs only its host
|
||||||
|
pi --allow gateway.internal:8443 --env AI_API_TOKEN
|
||||||
|
|
||||||
|
# OpenAI Codex CLI / Claude Code: they read their own config files, so mount or
|
||||||
|
# generate what they need and allow their endpoints; the pi bridge does not apply
|
||||||
|
pi --allow chatgpt.com --env CODEX_API_KEY
|
||||||
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY
|
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY
|
||||||
|
|
||||||
# pi with a proxy/harness you pass explicitly
|
|
||||||
pi --harness 'pi --model sonnet' --allow api.anthropic.com --env ANTHROPIC_API_KEY
|
|
||||||
|
|
||||||
# OpenAI Codex CLI (install it into the image with --install or use a custom base)
|
|
||||||
pi --allow chatgpt.com --allow api.openai.com --env OPENAI_API_KEY
|
|
||||||
|
|
||||||
# Claude Code
|
|
||||||
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --env CLAUDE_CODE_OAUTH_TOKEN
|
|
||||||
|
|
||||||
# just a shell in the sandbox
|
# just a shell in the sandbox
|
||||||
pi --shell
|
pi --shell
|
||||||
|
|
||||||
@@ -64,7 +104,7 @@ pi --allow proxy.golang.org --allow sum.golang.org
|
|||||||
pi --allow registry-1.docker.io --allow auth.docker.io --allow production.cloudflare.docker.com
|
pi --allow registry-1.docker.io --allow auth.docker.io --allow production.cloudflare.docker.com
|
||||||
|
|
||||||
# a project that also needs its dev database (non-HTTP: pinned IP + port)
|
# a project that also needs its dev database (non-HTTP: pinned IP + port)
|
||||||
pi --allow api.anthropic.com --allow 10.20.0.5:5432 --env ANTHROPIC_API_KEY
|
pi --allow aipi.reeseapps.com --allow 10.20.0.5:5432
|
||||||
```
|
```
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
@@ -77,7 +117,7 @@ pi --allow github.com --env GH_TOKEN -- git push origin main
|
|||||||
pi --allow github.com --mount ~/.ssh:ro -- git push origin main
|
pi --allow github.com --mount ~/.ssh:ro -- git push origin main
|
||||||
|
|
||||||
# read-only view of a sibling repo for context
|
# read-only view of a sibling repo for context
|
||||||
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --mount ../docs:/docs:ro
|
pi --allow aipi.reeseapps.com --mount ../docs:/docs:ro
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tightening a run
|
## Tightening a run
|
||||||
@@ -96,12 +136,12 @@ pi --strict-args --net allow --allow-file .ai-sandbox/allow --env-file .ci/env -
|
|||||||
## Long-lived gate shared by several sandboxes
|
## Long-lived gate shared by several sandboxes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ai-sandbox gate up --allow api.anthropic.com --allow registry.npmjs.org
|
ai-sandbox gate up --allow aipi.reeseapps.com --allow registry.npmjs.org
|
||||||
# name=aisb-gate
|
# name=aisb-gate
|
||||||
# port=18081
|
# port=18081
|
||||||
# proxy_env=export HTTPS_PROXY=http://100.64.0.1:18081
|
# proxy_env=export HTTPS_PROXY=http://100.64.0.1:18081
|
||||||
|
|
||||||
ai-sandbox --proxy 100.64.0.1:18081 --allow api.anthropic.com --shell
|
ai-sandbox --proxy 100.64.0.1:18081 --allow aipi.reeseapps.com --shell
|
||||||
ai-sandbox gate logs # what did it allow/deny?
|
ai-sandbox gate logs # what did it allow/deny?
|
||||||
ai-sandbox gate down
|
ai-sandbox gate down
|
||||||
```
|
```
|
||||||
@@ -132,13 +172,13 @@ harness = pi
|
|||||||
cpus = 8
|
cpus = 8
|
||||||
memory = 8g
|
memory = 8g
|
||||||
mount = ../specs:/specs:ro
|
mount = ../specs:/specs:ro
|
||||||
env = ANTHROPIC_API_KEY
|
env = AI_API_TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
`.ai-sandbox/allow`:
|
`.ai-sandbox/allow`:
|
||||||
|
|
||||||
```
|
```
|
||||||
api.anthropic.com
|
aipi.reeseapps.com
|
||||||
registry.npmjs.org
|
registry.npmjs.org
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ Be honest about these, and design your work accordingly.
|
|||||||
10. **Rootless podman still means shared kernel and shared UID namespace.** Files
|
10. **Rootless podman still means shared kernel and shared UID namespace.** Files
|
||||||
the container writes are owned by *you*. There is no uid boundary between
|
the container writes are owned by *you*. There is no uid boundary between
|
||||||
"agent's file" and "your file" beyond ownership metadata and your own discipline.
|
"agent's file" and "your file" beyond ownership metadata and your own discipline.
|
||||||
|
11. **The pi bridge reads a file that may hold a live key.** It rewrites `apiKey` to
|
||||||
|
an environment reference and never copies `auth.json`, but the *endpoint* in your
|
||||||
|
`models.json` becomes an endpoint the sandbox can be pointed at, and the token it
|
||||||
|
uses is your real one. So the bridged config is a convenience, not a narrower
|
||||||
|
credential: prefer a per-sandbox token from your gateway if it can mint one, and
|
||||||
|
note that a bridged run whose token variable is unset or empty is refused rather
|
||||||
|
than started. Sandboxed edits to `~/.pi/agent` persist in the home volume and are
|
||||||
|
kept over later host changes (see the README), which is convenience with a
|
||||||
|
consequence: a hostile run could rewrite the sandboxed pi config for later runs of
|
||||||
|
that volume. `--ephemeral-home` removes that.
|
||||||
|
|
||||||
## Residual risk rating
|
## Residual risk rating
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ else
|
|||||||
echo
|
echo
|
||||||
echo "To use it as a harness wrapper, add this to $rcfile:"
|
echo "To use it as a harness wrapper, add this to $rcfile:"
|
||||||
echo " alias pi='$target'"
|
echo " alias pi='$target'"
|
||||||
echo "then: source $rcfile && cd your/project && pi --allow api.anthropic.com --env ANTHROPIC_API_KEY"
|
echo "then: source $rcfile && cd your/project && pi --allow aipi.reeseapps.com --env AI_API_TOKEN"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -23,6 +23,31 @@ sb --print-policy --net none --install hexd > /dev/null
|
|||||||
IMG3="$("$AISB" --print-policy --net none --install hexd | grep '^image=' | cut -d= -f2)"
|
IMG3="$("$AISB" --print-policy --net none --install hexd | grep '^image=' | cut -d= -f2)"
|
||||||
[[ "$IMG3" != "$IMG" ]] && _ok "extra packages produce a different tag" || _bad "extra packages change the tag"
|
[[ "$IMG3" != "$IMG" ]] && _ok "extra packages produce a different tag" || _bad "extra packages change the tag"
|
||||||
|
|
||||||
|
t_name "the image says what it was built from"
|
||||||
|
BASE_DIGEST="$("$AISB" --print-policy --net none 2>/dev/null | grep '^base_digest=' | cut -d= -f2)"
|
||||||
|
if curl -m8 -sS -o /dev/null http://example.com 2>/dev/null && [[ "$BASE_DIGEST" == sha256:* ]]; then
|
||||||
|
_ok "the base was pinned to $BASE_DIGEST"
|
||||||
|
LABEL_DIGEST="$(podman image inspect "$IMG" --format '{{ index .Config.Labels "aisb.base-digest" }}' 2>/dev/null)"
|
||||||
|
assert_eq "$BASE_DIGEST" "$LABEL_DIGEST" "the image label carries the same digest"
|
||||||
|
else
|
||||||
|
echo " (skipped the digest assertions: registry unreachable or base unpinned)"
|
||||||
|
fi
|
||||||
|
OUT="$(podman image inspect "$IMG" --format '{{ range $k, $v := .Config.Labels }}{{$k}}={{$v}} {{end}}' 2>&1)"
|
||||||
|
assert_contains "aisb.node=" "the Node version is recorded on the image"
|
||||||
|
assert_contains "aisb.pi-version=" "and the harness version"
|
||||||
|
OUT="$(podman run --rm --network none --entrypoint cat "$IMG" /etc/aisb/manifest.txt 2>&1)"
|
||||||
|
assert_contains "nftables=" "the manifest records the resolved nftables version"
|
||||||
|
assert_contains "node=v" "and the Node version actually installed"
|
||||||
|
assert_contains "pi-coding-agent" "and the harness package version"
|
||||||
|
|
||||||
|
sb build --gate
|
||||||
|
GIMG="$("$AISB" --print-policy --net none 2>/dev/null | grep '^gate_image=' | cut -d= -f2)"
|
||||||
|
podman image exists "$GIMG" && _ok "the gate image exists: $GIMG" || _bad "the gate image exists"
|
||||||
|
OUT="$(podman image inspect "$GIMG" --format '{{ index .Config.Labels "aisb.role" }}' 2>&1)"
|
||||||
|
assert_eq "gate" "$(printf '%s' "$OUT" | tail -1)" "the gate image is labelled as the gate"
|
||||||
|
OUT="$(podman run --rm --network none --entrypoint cat "$GIMG" /etc/aisb/manifest.txt 2>&1)"
|
||||||
|
assert_contains "squid=" "and its manifest records the squid version doing the filtering"
|
||||||
|
|
||||||
t_name "the entrypoint fails closed without a policy"
|
t_name "the entrypoint fails closed without a policy"
|
||||||
podman run --rm --network none --cap-drop=ALL "$IMG" true > "$WORK/fc.out" 2>&1
|
podman run --rm --network none --cap-drop=ALL "$IMG" true > "$WORK/fc.out" 2>&1
|
||||||
assert_eq 78 "$?" "no /run/aisb policy -> exit 78, nothing ran"
|
assert_eq 78 "$?" "no /run/aisb policy -> exit 78, nothing ran"
|
||||||
|
|||||||
@@ -147,8 +147,69 @@ LOG="$(cat "$AISB_STATE_DIR/log" 2>/dev/null || true)"
|
|||||||
printf '%s' "$LOG" | grep -q 'net=none' && _ok "the log records the run" || _bad "the log records the run"
|
printf '%s' "$LOG" | grep -q 'net=none' && _ok "the log records the run" || _bad "the log records the run"
|
||||||
printf '%s' "$LOG" | grep -q 'canary-value-42' && _bad "secrets must never reach the log" || _ok "no secrets in the log"
|
printf '%s' "$LOG" | grep -q 'canary-value-42' && _bad "secrets must never reach the log" || _ok "no secrets in the log"
|
||||||
|
|
||||||
|
t_name "the pi config bridge lands inside the container"
|
||||||
|
AGENT="$WORK/agent"; mkdir -p "$AGENT"
|
||||||
|
cat > "$AGENT/models.json" <<EOF
|
||||||
|
{ "providers": { "gateway": { "baseUrl": "https://aipi.reeseapps.com/v1",
|
||||||
|
"api": "openai-completions", "apiKey": "!pass show gateway",
|
||||||
|
"models": [ { "id": "turbo", "contextWindow": 262144 } ] } } }
|
||||||
|
EOF
|
||||||
|
printf '{ "defaultProvider": "gateway", "defaultModel": "turbo", "theme": "dark/dark" }\n' > "$AGENT/settings.json"
|
||||||
|
printf '{ "gateway": "HOSTSECRET-must-not-travel" }' > "$AGENT/auth.json"
|
||||||
|
export AISB_PI_AGENT_DIR="$AGENT" AISB_PI_TOKEN_ENV=AI_API_TOKEN AISB_PI_TOKEN_AUTO=1
|
||||||
|
export AI_API_TOKEN=container-token-777
|
||||||
|
export AISB_HOME_VOLUME=aisb-test-pihome
|
||||||
|
|
||||||
|
sb $DARK --harness pi -- bash -lc '
|
||||||
|
f="$HOME/.pi/agent/models.json"
|
||||||
|
[ -f "$f" ] && echo seeded=yes || echo seeded=no
|
||||||
|
[ "$(id -u)" = 1000 ] || echo uid=wrong
|
||||||
|
echo "mode=$(stat -c %a "$f" 2>/dev/null)"
|
||||||
|
grep -q "\$AI_API_TOKEN" "$f" && echo ref=env || echo ref=other
|
||||||
|
grep -q "pass show" "$f" && echo LEAKED_CMD || echo cmd=gone
|
||||||
|
grep -q "HOSTSECRET" "$f" && echo LEAKED_KEY || echo key=gone
|
||||||
|
[ -f "$HOME/.pi/agent/auth.json" ] && echo LEAKED_AUTH || echo auth=absent
|
||||||
|
grep -q "turbo" "$HOME/.pi/agent/settings.json" && echo settings=yes
|
||||||
|
[ -n "$AI_API_TOKEN" ] && echo token=present || echo token=missing'
|
||||||
|
assert_contains "seeded=yes" "pi's config is seeded into the sandbox home"
|
||||||
|
assert_contains "mode=600" "and only the sandbox user can read it"
|
||||||
|
assert_contains "ref=env" "the credential stays an environment reference"
|
||||||
|
assert_contains "cmd=gone" "the host '!command' did not cross"
|
||||||
|
assert_contains "key=gone" "nor did the literal key"
|
||||||
|
assert_contains "auth=absent" "auth.json was not bridged"
|
||||||
|
assert_contains "settings=yes" "settings.json is in place"
|
||||||
|
assert_contains "token=present" "and the token reaches pi by env"
|
||||||
|
assert_absent "container-token-777" "while its value is never printed"
|
||||||
|
assert_absent "uid=wrong" "seeded files belong to the sandbox user"
|
||||||
|
|
||||||
|
# an edit made inside the sandbox survives; a change on the host does not get lost
|
||||||
|
sb $DARK --harness pi -- bash -c 'sed -i "s/turbo/juggernaut/" "$HOME/.pi/agent/models.json"; echo edited' >/dev/null
|
||||||
|
sb $DARK --harness pi -- bash -lc 'grep -q juggernaut "$HOME/.pi/agent/models.json" && echo edit_kept || echo edit_lost'
|
||||||
|
assert_contains "edit_kept" "a change made inside the sandbox is not clobbered"
|
||||||
|
|
||||||
|
sb $DARK --harness pi -- bash -lc 'grep -q juggernaut "$HOME/.pi/agent/models.json" && echo still_here' 2>/dev/null
|
||||||
|
sed -i 's/262144/524288/' "$AGENT/models.json"
|
||||||
|
sb $DARK --harness pi -- bash -lc '
|
||||||
|
grep -q 524288 "$HOME/.pi/agent/models.json" && echo host_change_ignored_fully || echo host_change_partly_ignored
|
||||||
|
grep -q juggernaut "$HOME/.pi/agent/models.json" && echo edit_still_kept'
|
||||||
|
assert_contains "edit_still_kept" "the sandbox edit still wins over the bridged file"
|
||||||
|
|
||||||
|
sb $DARK --harness pi -- bash -c 'rm -f "$HOME/.pi/agent/models.json"; echo reset' >/dev/null
|
||||||
|
sb $DARK --harness pi -- bash -lc 'grep -q 524288 "$HOME/.pi/agent/models.json" && echo reseeded || echo not_reseeded'
|
||||||
|
assert_contains "reseeded" "deleting it inside the sandbox takes the host version again"
|
||||||
|
|
||||||
|
unset AI_API_TOKEN
|
||||||
|
sb_timeout 90 $DARK --harness pi
|
||||||
|
assert_rc 1 "a run without the credential is refused before anything starts"
|
||||||
|
assert_contains "not going to reach the container" "and it says why"
|
||||||
|
sb_env AI_API_TOKEN= --net none --harness pi
|
||||||
|
assert_rc 1 "an empty token is refused too -- pi would send an empty credential"
|
||||||
|
unset AISB_PI_AGENT_DIR AISB_PI_TOKEN_ENV AISB_PI_TOKEN_AUTO AISB_HOME_VOLUME
|
||||||
|
sb clean --all --volumes=aisb-test-pihome > /dev/null
|
||||||
|
|
||||||
t_name "doctor"
|
t_name "doctor"
|
||||||
sb doctor
|
sb doctor
|
||||||
assert_rc 0 "doctor reports no problems"
|
assert_rc 0 "doctor reports no problems"
|
||||||
assert_contains "bind mount + uid mapping" "doctor verifies uid mapping"
|
assert_contains "bind mount + uid mapping" "doctor verifies uid mapping"
|
||||||
|
assert_contains "base pin:" "doctor reports which base digest the image was built on"
|
||||||
assert_contains "rootless" "doctor verifies rootless podman"
|
assert_contains "rootless" "doctor verifies rootless podman"
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ sb() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# sb_timeout <seconds> [args...] -- like sb, but bounded. Use it when a test expects
|
||||||
|
# the script to refuse: the refusal happens before podman, and the timeout is there in
|
||||||
|
# case a regression turns "refused instantly" into "hung forever".
|
||||||
|
sb_timeout() {
|
||||||
|
local t="$1"; shift
|
||||||
|
OUT="$(timeout "$t" "$AISB" "$@" 2>&1 </dev/null)" && RC=$? || RC=$?
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# sb_env VAR=val ... -- args (env prefix, without leaking into the test shell)
|
# sb_env VAR=val ... -- args (env prefix, without leaking into the test shell)
|
||||||
sb_env() {
|
sb_env() {
|
||||||
local -a envs=()
|
local -a envs=()
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ for f in "${FILES[@]}"; do
|
|||||||
(
|
(
|
||||||
export AISB_STATE_DIR="$W/state" AISB_CACHE_DIR="$W/cache" AISB_CONFIG_DIR="$W/cfg"
|
export AISB_STATE_DIR="$W/state" AISB_CACHE_DIR="$W/cache" AISB_CONFIG_DIR="$W/cfg"
|
||||||
export WORK="$W" TESTS="$HERE" AISB_TEST_COUNTS="$COUNTS"
|
export WORK="$W" TESTS="$HERE" AISB_TEST_COUNTS="$COUNTS"
|
||||||
|
# Unit files must not touch a registry: pins come from the seeded cache and the
|
||||||
|
# digest lookup is a stub. e2e files keep the real behaviour.
|
||||||
|
case "$f" in
|
||||||
|
*/unit/*) export AISB_BASE_AUTO_UPDATE="${AISB_BASE_AUTO_UPDATE:-0}" \
|
||||||
|
AISB_BASE_PULL_FALLBACK="${AISB_BASE_PULL_FALLBACK:-0}" ;;
|
||||||
|
esac
|
||||||
|
# Never bridge the developer's own ~/.pi/agent into a test: tests that exercise the
|
||||||
|
# bridge point AISB_PI_AGENT_DIR at a fixture of their own. Same for a real token.
|
||||||
|
export AISB_PI_AGENT_DIR="${AISB_PI_AGENT_DIR:-$W/absent-pi-agent-dir}"
|
||||||
|
unset AI_API_TOKEN AISB_PI_TOKEN_ENV AISB_PI_CONFIG AISB_PI_TOKEN_AUTO
|
||||||
# NOTE: HOME is deliberately not overridden -- podman storage lives there.
|
# NOTE: HOME is deliberately not overridden -- podman storage lives there.
|
||||||
unset AISB_ALLOW AISB_ENV AISB_MOUNT AISB_INSTALL AISB_NET AISB_EXTRA_ARGS AISB_SESSION_OVERRIDE
|
unset AISB_ALLOW AISB_ENV AISB_MOUNT AISB_INSTALL AISB_NET AISB_EXTRA_ARGS AISB_SESSION_OVERRIDE
|
||||||
source "$f"
|
source "$f"
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# tests/unit/07_base.sh -- base image pinning: reproducibility, TTL, offline fallbacks.
|
||||||
|
#
|
||||||
|
# No registry traffic: the digest lookup binary is injectable (AISB_SKOPEO), so the
|
||||||
|
# whole refresh path is testable with a two-line stub.
|
||||||
|
source "$TESTS/lib.sh"
|
||||||
|
|
||||||
|
BASE="docker.io/library/ubuntu:24.04"
|
||||||
|
PIN1="sha256:$(printf '1%.0s' $(seq 64))"
|
||||||
|
PIN2="sha256:$(printf '2%.0s' $(seq 64))"
|
||||||
|
PIN3="sha256:$(printf '3%.0s' $(seq 64))"
|
||||||
|
PINX="sha256:$(printf '9%.0s' $(seq 64))"
|
||||||
|
|
||||||
|
# The stub stands in for skopeo and prints whatever digest is in this file, so a test
|
||||||
|
# can decide what "upstream" says -- including "unreachable", by deleting it.
|
||||||
|
FAKE="$WORK/fake-skopeo"
|
||||||
|
export FAKE_DIGEST_FILE="$WORK/digest"
|
||||||
|
cat > "$FAKE" <<'EOS'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
case "${1:-}" in
|
||||||
|
--version) echo "skopeo version test-stub" ;;
|
||||||
|
inspect) [[ -r "$FAKE_DIGEST_FILE" ]] || exit 1
|
||||||
|
cat "$FAKE_DIGEST_FILE" ;;
|
||||||
|
*) exit 1 ;;
|
||||||
|
esac
|
||||||
|
EOS
|
||||||
|
chmod +x "$FAKE"
|
||||||
|
export AISB_SKOPEO="$FAKE"
|
||||||
|
export AISB_BASE_AUTO_UPDATE=0 # nothing reaches the network by accident here
|
||||||
|
export AISB_BASE_TTL_DAYS=7
|
||||||
|
export AISB_BASE_PULL_FALLBACK=0 # never fall back to a real 'podman pull' in a unit test
|
||||||
|
|
||||||
|
state_of() { printf '%s/base/%s.env' "$AISB_CACHE_DIR" "$(printf '%s' "$1" | sha256sum | cut -c1-12)"; }
|
||||||
|
seed_pin() { # $1=ref $2=digest $3=age_days
|
||||||
|
local f; f="$(state_of "$1")"; mkdir -p "${f%/*}"
|
||||||
|
printf 'ref=%s\ndigest=%s\nresolved_at=%s\n' "$1" "$2" \
|
||||||
|
"$(( $(date +%s) - (${3:-0} * 86400) ))" > "$f"
|
||||||
|
}
|
||||||
|
clear_pin() { rm -f "$(state_of "$1")"; }
|
||||||
|
says_pin() { printf '%s' "$OUT" | grep -qF -- "$1"; }
|
||||||
|
cf() { sb --print-containerfile "$@"; }
|
||||||
|
policy() { sb --print-policy --net none "$@"; }
|
||||||
|
|
||||||
|
t_name "an unpinned base builds from the tag and says so"
|
||||||
|
clear_pin "$BASE"
|
||||||
|
cf
|
||||||
|
assert_contains 'aisb.base-digest="unpinned"' "the image records that it had no digest"
|
||||||
|
if says_pin "FROM ${BASE}@"; then
|
||||||
|
_bad "FROM stays a plain tag when nothing is recorded"
|
||||||
|
else
|
||||||
|
_ok "FROM stays a plain tag when nothing is recorded"
|
||||||
|
fi
|
||||||
|
|
||||||
|
t_name "a recorded digest is what lands in FROM"
|
||||||
|
printf '%s\n' "$PIN1" > "$FAKE_DIGEST_FILE"
|
||||||
|
seed_pin "$BASE" "$PIN1" 0
|
||||||
|
cf
|
||||||
|
assert_contains "FROM ${BASE}@${PIN1}" "the Containerfile is pinned to the digest"
|
||||||
|
assert_contains "aisb.base-digest=\"$PIN1\"" "and labels the image with it"
|
||||||
|
assert_contains "aisb.node=" "the Node version is labelled too"
|
||||||
|
assert_contains "aisb.pi-version=" "and the harness version"
|
||||||
|
assert_contains "/etc/aisb/manifest.txt" "the package manifest is written into the image"
|
||||||
|
|
||||||
|
t_name "preview modes never touch the registry"
|
||||||
|
printf '%s\n' "$PINX" > "$FAKE_DIGEST_FILE" # upstream would answer something else
|
||||||
|
export AISB_BASE_AUTO_UPDATE=1
|
||||||
|
cf
|
||||||
|
assert_contains "$PIN1" "--print-containerfile used the recorded digest, not the registry"
|
||||||
|
assert_absent "$PINX" "nothing from the stub leaked into the Containerfile"
|
||||||
|
policy
|
||||||
|
assert_contains "base_digest=$PIN1" "--print-policy likewise"
|
||||||
|
export AISB_BASE_AUTO_UPDATE=0
|
||||||
|
|
||||||
|
t_name "the pin is part of the image identity"
|
||||||
|
# refs are read at top level: sb() sets OUT, and a command substitution would throw
|
||||||
|
# that assignment away.
|
||||||
|
refs() { printf '%s %s' "$(printf '%s' "$OUT" | grep '^image=' | cut -d= -f2)" \
|
||||||
|
"$(printf '%s' "$OUT" | grep '^gate_image=' | cut -d= -f2)"; }
|
||||||
|
clear_pin "$BASE"; policy; read -r UNPINNED_IMG UNPINNED_GATE < <(refs)
|
||||||
|
seed_pin "$BASE" "$PIN1" 0; policy; read -r ONE ONE_GATE < <(refs)
|
||||||
|
seed_pin "$BASE" "$PIN2" 0; policy; read -r TWO TWO_GATE < <(refs)
|
||||||
|
[[ "$ONE" != "$UNPINNED_IMG" ]] && _ok "pinning changes the tag" || _bad "pinning changes the tag"
|
||||||
|
[[ -n "$UNPINNED_GATE" && "$UNPINNED_GATE" != "$ONE_GATE" ]] \
|
||||||
|
&& _ok "the gate image tag moves with the pin too" || _bad "the gate image tag moves with the pin too"
|
||||||
|
[[ "$ONE" != "$TWO" ]] && _ok "a new base digest means a new image, not a mutated one" || _bad "a new base digest means a new image, not a mutated one"
|
||||||
|
[[ "$ONE_GATE" != "$TWO_GATE" ]] && _ok "the gate image follows the same base" || _bad "the gate image follows the same base"
|
||||||
|
assert_eq "yes" "$([[ "$TWO" == localhost/ai-sandbox:ubuntu-* ]] && echo yes || echo no)" "the tag is named after the base, not hardcoded"
|
||||||
|
policy --base-image docker.io/library/debian:12
|
||||||
|
assert_contains "image=localhost/ai-sandbox:debian-" "a different base gets an honest tag"
|
||||||
|
|
||||||
|
t_name "a base the user pinned by digest is used verbatim"
|
||||||
|
cf --base-image "alpine@${PIN3}"
|
||||||
|
assert_contains "FROM alpine@${PIN3}" "FROM is exactly what was asked for"
|
||||||
|
policy --base-image "alpine@${PIN3}"
|
||||||
|
assert_contains "base_digest=$PIN3" "and reported as the pin"
|
||||||
|
if [[ -e "$(state_of "alpine@${PIN3}")" ]]; then
|
||||||
|
_bad "no state is written for a ref that is already pinned"
|
||||||
|
else
|
||||||
|
_ok "no state is written for a ref that is already pinned"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --print-policy is the deterministic preview surface and never resolves; `inspect`
|
||||||
|
# is the "ask the machine now" surface, so TTL behaviour is asserted through it.
|
||||||
|
t_name "the TTL decides when the digest is re-resolved"
|
||||||
|
printf '%s\n' "$PIN3" > "$FAKE_DIGEST_FILE"
|
||||||
|
clear_pin "$BASE"; seed_pin "$BASE" "$PIN1" 30 # 30 days old
|
||||||
|
policy
|
||||||
|
assert_contains "$PIN1" "an old pin is kept when auto-update is off"
|
||||||
|
export AISB_BASE_AUTO_UPDATE=1
|
||||||
|
AISB_BASE_TTL_DAYS=-1 sb inspect --net none
|
||||||
|
assert_contains "$PIN1" "ttl -1 pins until told otherwise"
|
||||||
|
AISB_BASE_TTL_DAYS=7 sb inspect --net none
|
||||||
|
assert_contains "$PIN3" "past the TTL the upstream digest replaces the stale pin"
|
||||||
|
grep -qF "$PIN3" "$(state_of "$BASE")" && _ok "and the new digest is recorded" || _bad "and the new digest is recorded"
|
||||||
|
AISB_BASE_TTL_DAYS=7 sb inspect --net none
|
||||||
|
assert_contains "$PIN3" "inside the new TTL nothing is re-resolved"
|
||||||
|
printf '%s\n' "$PIN3" > "$FAKE_DIGEST_FILE" # (unchanged, for clarity of intent)
|
||||||
|
AISB_BASE_TTL_DAYS=7 policy
|
||||||
|
assert_contains "$PIN3" "the preview surface still reports the recorded pin"
|
||||||
|
clear_pin "$BASE"
|
||||||
|
AISB_BASE_TTL_DAYS=7 sb inspect --net none
|
||||||
|
assert_contains "base_digest=$PIN3" "a first run resolves and records"
|
||||||
|
|
||||||
|
t_name "an unreachable registry falls back to the recorded digest"
|
||||||
|
seed_pin "$BASE" "$PIN1" 99
|
||||||
|
rm -f "$FAKE_DIGEST_FILE" # the stub now fails
|
||||||
|
export AISB_BASE_TTL_DAYS=0
|
||||||
|
sb inspect --net none
|
||||||
|
assert_contains "base_digest=$PIN1" "the stale pin is preferred over a mutable tag"
|
||||||
|
cf
|
||||||
|
assert_contains "FROM ${BASE}@${PIN1}" "and the build stays pinned"
|
||||||
|
|
||||||
|
t_name "nothing recorded and no registry: unpinned, with a warning"
|
||||||
|
clear_pin "$BASE"
|
||||||
|
sb inspect --net none
|
||||||
|
assert_contains "UNPINNED" "the run warns that the build is not reproducible"
|
||||||
|
cf
|
||||||
|
assert_contains 'aisb.base-digest="unpinned"' "and the image carries that fact"
|
||||||
|
|
||||||
|
t_name "update --check reports without rebuilding"
|
||||||
|
printf '%s\n' "$PIN3" > "$FAKE_DIGEST_FILE"
|
||||||
|
seed_pin "$BASE" "$PIN1" 0
|
||||||
|
sb update --check
|
||||||
|
assert_rc 0 "a check succeeds"
|
||||||
|
assert_contains "base=$BASE" "it names the base"
|
||||||
|
assert_contains "recorded=$PIN1" "it shows the recorded digest"
|
||||||
|
assert_contains "upstream=$PIN3" "it shows the upstream digest"
|
||||||
|
assert_contains "update is available" "and says an update exists"
|
||||||
|
sb update --check --no-gate 2>/dev/null
|
||||||
|
printf '%s' "$PIN3" > "$FAKE_DIGEST_FILE"; seed_pin "$BASE" "$PIN3" 0
|
||||||
|
sb update --check
|
||||||
|
assert_contains "up to date" "and says nothing to do when the pin already matches"
|
||||||
|
rm -f "$FAKE_DIGEST_FILE"
|
||||||
|
sb update --check
|
||||||
|
assert_contains "could not reach the registry" "and says so when the registry is unreachable"
|
||||||
|
assert_rc 0 "an unreachable registry is reported, not fatal"
|
||||||
|
|
||||||
|
t_name "a bad ttl in config is refused, not obeyed blindly"
|
||||||
|
# the environment outranks config by design, so drop it to exercise the config path
|
||||||
|
unset AISB_BASE_TTL_DAYS
|
||||||
|
mkdir -p .ai-sandbox
|
||||||
|
printf 'base_ttl_days = soon\n' > .ai-sandbox/config
|
||||||
|
policy
|
||||||
|
assert_contains "must be an integer" "it says the value is not usable"
|
||||||
|
assert_contains "$PIN3" "and it carries on with the default"
|
||||||
|
rm -rf .ai-sandbox
|
||||||
|
export AISB_BASE_TTL_DAYS=7
|
||||||
|
|
||||||
|
t_name "the policy output explains the pin"
|
||||||
|
export AISB_BASE_AUTO_UPDATE=0 AISB_BASE_TTL_DAYS=7
|
||||||
|
seed_pin "$BASE" "$PIN1" 2
|
||||||
|
policy
|
||||||
|
assert_contains "base=$BASE" "the policy names the base tag"
|
||||||
|
assert_contains "base_digest=$PIN1" "the pin in force"
|
||||||
|
assert_contains "base_age_days=2" "and how old the pin is"
|
||||||
|
|
||||||
|
t_name "doctor's warning when a built image predates the pin"
|
||||||
|
# doctor itself starts a container, so check the logic it reports on, not the run:
|
||||||
|
# an image labelled with a different digest than the pin is the silent-staleness case.
|
||||||
|
seed_pin "$BASE" "$PIN2" 0
|
||||||
|
cf
|
||||||
|
assert_contains "aisb.base-digest=\"$PIN2\"" "a rebuild would be labelled with the new pin"
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# tests/unit/08_pi_config.sh -- the pi configuration bridge.
|
||||||
|
#
|
||||||
|
# The bridge rewrites the host agent directory instead of copying it, so what matters
|
||||||
|
# most here is what does NOT cross the boundary: the credential, host commands, host
|
||||||
|
# paths, auth.json. Everything is preview (`--print-policy` / `--dry-run`) plus the
|
||||||
|
# refusal paths, so no container is started and no token is real.
|
||||||
|
source "$TESTS/lib.sh"
|
||||||
|
|
||||||
|
AGENT="$WORK/agent"; mkdir -p "$AGENT"
|
||||||
|
export AISB_PI_AGENT_DIR="$AGENT" AISB_PI_TOKEN_AUTO=1
|
||||||
|
export AI_API_TOKEN=sandbox-only-token-4242
|
||||||
|
|
||||||
|
CANARY_SECRET='REALSECRET-nev3r-lef7s-the-host'
|
||||||
|
CANARY_CMD='pass show services/gateway | head -1'
|
||||||
|
|
||||||
|
cat > "$AGENT/models.json" <<EOF
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"gateway": {
|
||||||
|
"baseUrl": "https://aipi.reeseapps.com/v1",
|
||||||
|
"api": "openai-completions",
|
||||||
|
"apiKey": "!$CANARY_CMD",
|
||||||
|
"headers": { "X-Trace": "!date +%s", "X-Team": "self" },
|
||||||
|
"models": [
|
||||||
|
{ "id": "juggernaut", "reasoning": true, "contextWindow": 131072 },
|
||||||
|
{ "id": "turbo", "reasoning": true, "contextWindow": 262144 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lab-ollama": {
|
||||||
|
"baseUrl": "http://10.20.0.7:11434/v1",
|
||||||
|
"api": "openai-completions",
|
||||||
|
"models": [ { "id": "small" } ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
# sessionDir points at the *real* host home on purpose: that is the case the bridge
|
||||||
|
# has to drop, and HOME must stay the real home for podman's sake.
|
||||||
|
cat > "$AGENT/settings.json" <<EOF
|
||||||
|
{
|
||||||
|
"defaultProvider": "gateway",
|
||||||
|
"defaultModel": "turbo",
|
||||||
|
"theme": "dark/dark",
|
||||||
|
"defaultThinkingLevel": "xhigh",
|
||||||
|
"enableInstallTelemetry": false,
|
||||||
|
"thinkingBudgets": { "xhigh": 65536 },
|
||||||
|
"sessionDir": "$HOME/pi-sessions"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
printf '{ "gateway": "%s" }' "$CANARY_SECRET" > "$AGENT/auth.json"
|
||||||
|
printf '{}' > "$AGENT/models-store.json"
|
||||||
|
|
||||||
|
clear_sessions() { rm -rf "${AISB_STATE_DIR:?}"/sessions/*; }
|
||||||
|
gen_file() { find "$AISB_STATE_DIR/sessions" -path "*/pi/$1" -print -quit 2>/dev/null; }
|
||||||
|
restore_models() { cat > "$AGENT/models.json" <<EOF
|
||||||
|
{ "providers": { "gateway": { "baseUrl": "https://aipi.reeseapps.com/v1",
|
||||||
|
"api": "openai-completions", "apiKey": "literal-key-never-copied",
|
||||||
|
"models": [ { "id": "turbo" } ] } } }
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
t_name "the credential becomes an environment reference, not a copy"
|
||||||
|
clear_sessions
|
||||||
|
sb --keep --print-policy --net none
|
||||||
|
assert_contains "pi_config=bridged from $AGENT" "the bridge reports where the config came from"
|
||||||
|
assert_contains "pi_token_env=AI_API_TOKEN (set)" "and whether the token is in reach"
|
||||||
|
M="$(gen_file models.json)"
|
||||||
|
[[ -n "$M" ]] && _ok "models.json was generated" || _bad "models.json was generated"
|
||||||
|
if [[ -n "$M" ]]; then
|
||||||
|
grep -qF '"apiKey": "$AI_API_TOKEN"' "$M" && _ok "apiKey is the variable; pi resolves it at request time" || _bad "apiKey is the variable; pi resolves it at request time"
|
||||||
|
grep -qF "$CANARY_CMD" "$M" && _bad "the host '!command' is gone" || _ok "the host '!command' is gone"
|
||||||
|
grep -qF "$CANARY_SECRET" "$M" && _bad "the literal key is gone" || _ok "the literal key is gone"
|
||||||
|
grep -qF 'literal-key-never-copied' "$M" && _bad "no literal key from the host file" || _ok "no literal key from the host file"
|
||||||
|
grep -qF '"X-Team": "self"' "$M" && _ok "an ordinary header survives" || _bad "an ordinary header survives"
|
||||||
|
grep -qF 'X-Trace' "$M" && _bad "a header built by a host command is dropped" || _ok "a header built by a host command is dropped"
|
||||||
|
fi
|
||||||
|
assert_contains "dropped header(s) X-Trace" "and it says which one it dropped"
|
||||||
|
if printf '%s' "$OUT" | grep -qF "$AI_API_TOKEN"; then
|
||||||
|
_bad "the token value never appears in the policy output"
|
||||||
|
else
|
||||||
|
_ok "the token value never appears in the policy output"
|
||||||
|
fi
|
||||||
|
|
||||||
|
t_name "providers and models are carried over untouched"
|
||||||
|
if [[ -n "$M" ]]; then
|
||||||
|
grep -qF '"baseUrl": "https://aipi.reeseapps.com/v1"' "$M" && _ok "the gateway endpoint" || _bad "the gateway endpoint"
|
||||||
|
grep -qF '"id": "juggernaut"' "$M" && grep -qF '"id": "turbo"' "$M" && _ok "both models" || _bad "both models"
|
||||||
|
grep -qF '"contextWindow": 262144' "$M" && _ok "with their limits" || _bad "with their limits"
|
||||||
|
grep -qF '"lab-ollama"' "$M" && _ok "and the second provider" || _bad "and the second provider"
|
||||||
|
fi
|
||||||
|
JSON_CHECK="$(python3 - "${M:-/nonexistent}" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
prov = json.load(open(sys.argv[1]))["providers"]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"invalid:{e}"); raise SystemExit
|
||||||
|
if "apiKey" in prov.get("lab-ollama", {}):
|
||||||
|
print("gained-a-key")
|
||||||
|
elif prov["gateway"]["apiKey"] != "$AI_API_TOKEN":
|
||||||
|
print("wrong-key-ref")
|
||||||
|
else:
|
||||||
|
print("json-valid")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
assert_eq "json-valid" "$JSON_CHECK" "valid JSON, and a provider without a key does not gain one"
|
||||||
|
assert_contains "pi_endpoints=10.20.0.7 aipi.reeseapps.com" "both endpoints are reported"
|
||||||
|
|
||||||
|
t_name "settings travel, host paths do not"
|
||||||
|
S="$(gen_file settings.json)"
|
||||||
|
if [[ -n "$S" ]]; then
|
||||||
|
for pair in '"defaultProvider": "gateway"' '"defaultModel": "turbo"' '"theme": "dark/dark"' \
|
||||||
|
'"defaultThinkingLevel": "xhigh"' '"xhigh": 65536' '"enableInstallTelemetry": false'; do
|
||||||
|
grep -qF "$pair" "$S" && _ok "kept $pair" || _bad "kept $pair"
|
||||||
|
done
|
||||||
|
grep -qF 'sessionDir' "$S" && _bad "a value pointing at the host home is dropped" || _ok "a value pointing at the host home is dropped"
|
||||||
|
fi
|
||||||
|
assert_contains "dropped settings sessionDir" "and it says so"
|
||||||
|
|
||||||
|
t_name "auth.json and the credential store are never bridged"
|
||||||
|
if find "$AISB_STATE_DIR/sessions" \( -name auth.json -o -name models-store.json \) | grep -q .; then
|
||||||
|
_bad "only models.json and settings.json are bridged"
|
||||||
|
else
|
||||||
|
_ok "only models.json and settings.json are bridged"
|
||||||
|
fi
|
||||||
|
if grep -rlF "$CANARY_SECRET" "$AISB_STATE_DIR" "$AISB_CACHE_DIR" > /dev/null 2>&1; then
|
||||||
|
_bad "the host credential appears nowhere in ai-sandbox state"
|
||||||
|
else
|
||||||
|
_ok "the host credential appears nowhere in ai-sandbox state"
|
||||||
|
fi
|
||||||
|
|
||||||
|
t_name "the token is passed by name, once, and announced"
|
||||||
|
assert_contains "passing \$AI_API_TOKEN from the host" "the auto-pass is announced"
|
||||||
|
assert_eq "env_names=AI_API_TOKEN" "$(printf '%s' "$OUT" | grep '^env_names=' || true)" "exactly one variable is passed"
|
||||||
|
assert_absent "$AI_API_TOKEN" "and only its name is reported"
|
||||||
|
|
||||||
|
t_name "--no-pi-token-env leaves it out and says so"
|
||||||
|
clear_sessions
|
||||||
|
sb --keep --no-pi-token-env --print-policy --net none
|
||||||
|
assert_eq "env_names=" "$(printf '%s' "$OUT" | grep '^env_names=' || true)" "nothing is passed"
|
||||||
|
assert_contains "will not reach the container" "preview warns instead of launching a pi with no credential"
|
||||||
|
|
||||||
|
t_name "a bridged provider with no credential in reach is refused, not attempted"
|
||||||
|
unset AI_API_TOKEN
|
||||||
|
sb --net none --harness pi
|
||||||
|
assert_rc 1 "the run refuses to start"
|
||||||
|
assert_contains 'asks for $AI_API_TOKEN, but it is not going to reach the container' "it names the variable and why"
|
||||||
|
assert_contains "env = AI_API_TOKEN" "it shows the config-file way"
|
||||||
|
assert_contains "--no-pi-config" "and the way to opt out"
|
||||||
|
assert_eq "0" "$([[ -n "$(podman ps -q --filter label=ai-sandbox=1)" ]] && echo 1 || echo 0)" "and nothing was started"
|
||||||
|
|
||||||
|
t_name "an empty token is treated as missing"
|
||||||
|
sb_env AI_API_TOKEN= --net none --harness pi # set, but empty
|
||||||
|
assert_rc 1 "an empty credential is refused, not sent"
|
||||||
|
assert_contains "unset or empty" "and it says which"
|
||||||
|
sb_env AI_API_TOKEN= --net none --print-policy
|
||||||
|
assert_contains "pi_token_env=AI_API_TOKEN (set but empty)" "preview distinguishes the two states"
|
||||||
|
|
||||||
|
t_name "but only when pi is the thing being launched"
|
||||||
|
export AI_API_TOKEN=x
|
||||||
|
sb --net none --dry-run -- make test
|
||||||
|
assert_absent "not going to reach the container" "a replaced command needs no credential"
|
||||||
|
sb --net none --dry-run --shell
|
||||||
|
assert_rc 0 "--shell works without a token"
|
||||||
|
assert_absent "not going to reach the container" "and does not demand one"
|
||||||
|
sb --net none --dry-run --harness codex
|
||||||
|
assert_absent "not going to reach the container" "codex does not read pi's models.json"
|
||||||
|
|
||||||
|
t_name "--no-pi-config opts out cleanly"
|
||||||
|
sb --no-pi-config --net none --print-policy
|
||||||
|
assert_absent "pi_config=" "no bridge is reported"
|
||||||
|
assert_absent "not going to reach the container" "and nothing is required"
|
||||||
|
clear_sessions
|
||||||
|
sb --keep --no-pi-config --net none -- true
|
||||||
|
if [[ -n "$(gen_file models.json)" ]]; then _bad "nothing was generated"; else _ok "nothing was generated"; fi
|
||||||
|
|
||||||
|
t_name "the token variable name is yours to choose"
|
||||||
|
clear_sessions
|
||||||
|
sb --keep --pi-token-env GW_TOKEN --print-policy --net none
|
||||||
|
assert_contains "pi_token_env=GW_TOKEN (NOT SET)" "reported under the new name"
|
||||||
|
clear_sessions
|
||||||
|
sb_env GW_TOKEN=whatever-gw --keep --pi-token-env GW_TOKEN --print-policy --net none
|
||||||
|
assert_contains "pi_token_env=GW_TOKEN (set)" "it is recognised as in reach"
|
||||||
|
assert_contains "passing \$GW_TOKEN" "and announced under that name"
|
||||||
|
M2="$(gen_file models.json)"
|
||||||
|
grep -qF '"apiKey": "$GW_TOKEN"' "${M2:-/nonexistent}" && _ok "the generated config references it" || _bad "the generated config references it"
|
||||||
|
sb --net none --harness pi --pi-token-env GW_TOKEN # GW_TOKEN is not set
|
||||||
|
assert_rc 1 "and it is enforced under that name too"
|
||||||
|
|
||||||
|
t_name "a missing agent directory is a skip, not an error"
|
||||||
|
sb_env AISB_PI_AGENT_DIR="$WORK/nope" --net none --print-policy
|
||||||
|
assert_rc 0 "nothing to bridge, nothing demanded"
|
||||||
|
assert_absent "pi_config=" "and no bridge is claimed"
|
||||||
|
sb_env AISB_PI_AGENT_DIR="$WORK/nope" --net none --harness pi
|
||||||
|
assert_rc 0 "pi may still be launched, with its own defaults"
|
||||||
|
|
||||||
|
t_name "broken JSON is reported, not propagated"
|
||||||
|
printf '{ "providers": oops' > "$AGENT/models.json"
|
||||||
|
clear_sessions
|
||||||
|
sb --keep --net none --print-policy
|
||||||
|
assert_contains "models.json is not valid JSON" "it says the file is broken"
|
||||||
|
if grep -qF 'oops' "${AISB_STATE_DIR}/sessions"/*/mnt/pi/models.json 2>/dev/null; then
|
||||||
|
_bad "no invalid file is placed in front of pi"
|
||||||
|
else
|
||||||
|
_ok "no invalid file is placed in front of pi"
|
||||||
|
fi
|
||||||
|
restore_models
|
||||||
|
|
||||||
|
t_name "the model endpoint has to be allowed, and says when it is not"
|
||||||
|
sb --net allow --allow registry.npmjs.org --print-policy
|
||||||
|
assert_contains "the model endpoint is not in the allowlist" "a pi that cannot reach its gateway is a warning, not a mystery"
|
||||||
|
assert_contains "add --allow aipi.reeseapps.com" "and it names the host to add"
|
||||||
|
sb --net allow --allow aipi.reeseapps.com --allow 10.20.0.7 --print-policy
|
||||||
|
assert_absent "not in the allowlist" "silent once both endpoints are allowed"
|
||||||
|
|
||||||
|
t_name "the bridge never widens the allowlist by itself"
|
||||||
|
sb --net allow --allow aipi.reeseapps.com --print-policy
|
||||||
|
assert_contains "gate_domains=aipi.reeseapps.com" "what you asked for is what you got"
|
||||||
|
assert_absent "10.20.0.7 80,443" "the discovered endpoint was not added on your behalf"
|
||||||
|
|
||||||
|
t_name "an invalid token variable name is rejected"
|
||||||
|
sb --net none --pi-token-env '9bad name' --print-policy
|
||||||
|
assert_rc 1 "rejected"
|
||||||
|
assert_contains "not a valid identifier" "with a clear reason"
|
||||||
Reference in New Issue
Block a user