add right-sandbox: write a project's own ai-sandbox policy
Turns a project directory into that project's policy, so a plain `ai-sandbox` in the directory is the correct, reviewable invocation. It writes the two files load_config() actually reads -- .ai-sandbox/allow and .ai-sandbox/config -- and nothing else: no sandbox.yaml, no local override, no invented schema, because a file nobody reads is a policy nobody has. scripts/survey.sh gathers the evidence read-only and prints no values: variable NAMES, remote and registry HOSTS, service PORTS, with URL credentials stripped before anything is printed -- the same rule --dry-run, --print-policy and the session log hold themselves to. The agent decides from that evidence, then verifies with --dry-run and --print-policy rather than assuming an allowlist entry means a working toolchain. references/settings-map.md maps every accepted key and flag to its default, plus per-ecosystem allowlists and the survey-to-policy cheat sheet. references/port-and-loopback.md is the honest answer on ports: outbound to a service on the machine via the mapped loopback address 100.64.0.1 (needs an allow entry and a client that does not hand the request to the egress proxy), and why a port the sandbox listens on is not reachable from the host in --net allow -- with probes, because that half is a reading of gen_rules(), not a measurement. Policy files only. Never builds, never starts a container, and never writes a real credential: it writes .ai-sandbox/env.example and tells the user what to put in the gitignored 0600 file. LLM-Generated: true
This commit is contained in:
@@ -0,0 +1,223 @@
|
|||||||
|
---
|
||||||
|
name: right-sandbox
|
||||||
|
description: Analyzes a project directory and writes that project's ai-sandbox policy — .ai-sandbox/allow plus .ai-sandbox/config (egress allowlist, environment variable names, bind mounts, working directory, resource limits, harness, extra image packages, access to services on the host) — so that a plain `ai-sandbox` run in the directory is the correct, reviewable invocation. Use when the user asks to set up, configure, write, fix, review or tighten ai-sandbox or the sandbox for a project, wants a .ai-sandbox config committed with the code, or asks why a sandboxed agent cannot reach a package registry, a database, a localhost port or its model gateway. Writes policy files only — never builds, never starts a container, never puts a credential value in a file.
|
||||||
|
---
|
||||||
|
|
||||||
|
# right-sandbox
|
||||||
|
|
||||||
|
Produce the two files ai-sandbox reads from a project, and nothing else:
|
||||||
|
|
||||||
|
| file | contents | committed |
|
||||||
|
|---|---|---|
|
||||||
|
| `.ai-sandbox/allow` | one egress target per line, `#` comments ok | yes — the policy gets reviewed with the code |
|
||||||
|
| `.ai-sandbox/config` | `key = value`: net, harness, limits, mounts, env names, image | yes |
|
||||||
|
| `.ai-sandbox/env` | optional `NAME=VALUE`, mode `600` | **never** — gitignore it |
|
||||||
|
|
||||||
|
`load_config()` reads `./.ai-sandbox/{config,allow}` (after `~/.config/ai-sandbox/{config,allow}`)
|
||||||
|
and nothing else, so do not invent `sandbox.yaml`, `local`, `env.json` or a `phases/` directory in
|
||||||
|
there — a file nobody reads is a policy nobody has.
|
||||||
|
|
||||||
|
Precedence for every key: built-in default < user config < repo config < `AISB_*` env < CLI flag.
|
||||||
|
The repeatable keys (`allow`, `env`, `mount`, `install`, `label`) **accumulate** across all five
|
||||||
|
sources rather than replacing, so a repo config adds to the user's, it does not silence it.
|
||||||
|
|
||||||
|
## What makes a config right
|
||||||
|
|
||||||
|
1. **Names, never values.** Both committed files end up in the repository, in `--print-policy` and
|
||||||
|
in a reviewer's diff. Credentials live in the environment or in `.ai-sandbox/env` (`chmod 600`,
|
||||||
|
gitignored); the config says `env = AI_API_TOKEN`. A literal `NAME=VALUE` is only for things
|
||||||
|
that are not secrets, e.g. `env = DATABASE_URL=postgresql://dev@100.64.0.1:5432/app`.
|
||||||
|
2. **Every key and flag must be real.** An unknown config key is warned about and ignored — a typo
|
||||||
|
is a silently missing policy, which is worse than an error. The authority is the installed
|
||||||
|
program: `ai-sandbox --help`, plus `cfg_read_kv()`'s key list in the script.
|
||||||
|
`references/settings-map.md` holds the list as of writing — check it, do not trust it.
|
||||||
|
Known trap: `project_dest` is accepted by the parser and read by nothing; the key is `workdir`.
|
||||||
|
3. **The git remote stays out.** No `github.com`, `gitlab.com`, `bitbucket.org` or ssh host in
|
||||||
|
`allow` unless the user asks after hearing the cost: an allowlisted remote plus a token or a
|
||||||
|
mounted `~/.ssh` lets a prompt-injected agent ship code nobody reviewed (threat model #1).
|
||||||
|
`git status`, `diff`, `log`, `blame` all work without it, and `ai-sandbox-autophase` commits and
|
||||||
|
pushes on the host precisely so that this line can stay out. If a dependency genuinely has to be
|
||||||
|
fetched over a git host, say which dependency in a comment above the line and prefer a
|
||||||
|
read-only, short-lived token.
|
||||||
|
4. **Escape hatches never go in a committed config**: `net = full`, `extra_args`,
|
||||||
|
`--allow-unenforced`, `--insecure-keep-root`. They are per-run decisions named to be
|
||||||
|
uncomfortable, and a repo file outlives the run that justified it. A run refusing to start
|
||||||
|
(exit 77) is the policy working — debug the host, do not route around it.
|
||||||
|
5. **Least mount.** The cwd is already mounted read-write at its host path, and `~/.agents` is
|
||||||
|
shared read-only with no flag to change it. Never propose a writable skills mount: a sandbox
|
||||||
|
that can write a `SKILL.md` has written an instruction the user follows later on the host.
|
||||||
|
Every extra `mount` line needs a one-line reason in the file.
|
||||||
|
6. **Verify before reporting success** (§5). "It should work" is not an acceptable result for a
|
||||||
|
security policy; `--print-policy` and `--dry-run` answer without building or starting anything.
|
||||||
|
|
||||||
|
## 1. Survey the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/survey.sh [project-dir] # default: $PWD; resolve scripts/ against this skill's directory
|
||||||
|
```
|
||||||
|
|
||||||
|
It is read-only and prints no secret values — variable *names*, remote *hosts*, registry *hosts*
|
||||||
|
and service *ports*. Read its output, then read the files it names: the lockfiles, the compose
|
||||||
|
file, `.env.example`, the dev-server config, CI, and the Makefile or task runner. Prefer evidence
|
||||||
|
over inference: a `pyproject.toml` with an `[[tool.uv.index]]` is a private index, an empty
|
||||||
|
`node_modules` tells you nothing.
|
||||||
|
|
||||||
|
Also read what already exists — `.ai-sandbox/config`, `.ai-sandbox/allow`, the user's
|
||||||
|
`~/.config/ai-sandbox/{config,allow}` — and extend it rather than replacing a decision the user
|
||||||
|
made deliberately.
|
||||||
|
|
||||||
|
## 2. Decide each setting from evidence
|
||||||
|
|
||||||
|
| need | evidence | setting |
|
||||||
|
|---|---|---|
|
||||||
|
| package installs | lockfiles (`pnpm-lock.yaml`, `uv.lock`, `Cargo.lock`, `go.sum`, `Gemfile.lock`, `pom.xml`…) | `allow` entries for the registries that ecosystem really contacts — see the table in `references/settings-map.md` |
|
||||||
|
| model gateway | `~/.pi/agent/models.json` `baseUrl` host, or the base URL the user's harness is configured with | `allow = <gateway host>`; without it a bridged pi starts and cannot answer |
|
||||||
|
| a database / queue / cache | `docker-compose*.yml` published ports, `.env` URLs, `DATABASE_URL` | `allow = 100.64.0.1:<published port>` + an env var that points the app at `100.64.0.1` — see `references/port-and-loopback.md` |
|
||||||
|
| a non-HTTP backend | pinned host:port (a LAN inference box, an internal API) | `allow = <ip or cidr>:<port>` (IP/CIDR become netfilter rules; domains go through the gate) |
|
||||||
|
| credential the harness needs | `.env.example`, CI `secrets.*`, `--env NAME` in the user's launcher | `env = NAME` (name only), and list what the user must export |
|
||||||
|
| reference material / sibling repo | imports or docs that read `../something` | `mount = ../something:/something:ro` |
|
||||||
|
| push over ssh | `~/.ssh` and an `ssh://` remote | do **not** add by default; rule 3 |
|
||||||
|
| a tool the run needs that the image lacks | build logs, `apt-get` in the project's Dockerfile, `rg`, `jq`, a DB client, `ffmpeg` | `install = <apt package>` (each distinct set gets its own cached image tag). Decide it **here**: inside, the harness is uid 1000 with no root and no package manager, so `apt-get` is not something it can do later |
|
||||||
|
| heavy build / flaky cap | repo size, test suite runtime, the user's machine | `cpus`, `memory`, `pids`, `tmpfs_size` |
|
||||||
|
| harness that is not pi | `.claude/`, `codex` config, the user's habit | `harness = pi \| codex \| claude \| none \| "<your command>"` |
|
||||||
|
| paths must look like the host's | a harness that titles by cwd; logs the user pastes | leave `workdir = auto`; set it only when the host path buries an image path |
|
||||||
|
| project-specific skills/prompts | a project `.agents/` | nothing — it arrives with the project mount |
|
||||||
|
| untrusted-code review | the user says so | `net = none`, `agents = 0`, `home_cache = 0`, `read-only` on the CLI, not in the file |
|
||||||
|
|
||||||
|
Two decisions the tool already makes that you should not repeat in the file: the thinking level is
|
||||||
|
`xhigh` policy for a bridged pi, and the pi config bridge is on with the credential by environment
|
||||||
|
reference. Only write `thinking` or `pi_token_env` when the project genuinely differs, with a
|
||||||
|
comment saying why.
|
||||||
|
|
||||||
|
## 3. Write the files
|
||||||
|
|
||||||
|
`.ai-sandbox/allow` — one target per line, grouped and commented, because this file is the diff a
|
||||||
|
human reviews:
|
||||||
|
|
||||||
|
```
|
||||||
|
# model gateway -- without it the sandboxed harness cannot answer
|
||||||
|
gateway.example.com
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
registry.npmjs.org
|
||||||
|
nodejs.org # node-gyp / prebuild downloads
|
||||||
|
|
||||||
|
# services on this machine, reached through the mapped loopback address (§ports)
|
||||||
|
100.64.0.1:5432 # postgres, `docker compose up db`
|
||||||
|
```
|
||||||
|
|
||||||
|
`.ai-sandbox/config` — only the keys this project needs; a comment per non-obvious one:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# ai-sandbox policy for <project>. Committed on purpose: review it like code.
|
||||||
|
# Values belong in .ai-sandbox/env (0600, gitignored) or the environment, never here.
|
||||||
|
|
||||||
|
net = allow
|
||||||
|
harness = pi
|
||||||
|
|
||||||
|
# the toolchain needs a bit more than the default 4 cpu / 4g
|
||||||
|
cpus = 8
|
||||||
|
memory = 8g
|
||||||
|
|
||||||
|
# design docs live beside the repo
|
||||||
|
mount = ../specs:/specs:ro
|
||||||
|
|
||||||
|
# names only; ai-sandbox passes them by name through a 0600 file
|
||||||
|
env = AI_API_TOKEN
|
||||||
|
env = @./.ai-sandbox/env
|
||||||
|
|
||||||
|
# apt packages the test suite needs that the image lacks
|
||||||
|
install = postgresql-client
|
||||||
|
```
|
||||||
|
|
||||||
|
`env = @path` is the config-file form of `--env-file` (`--env-file F` becomes the entry `@F`), so
|
||||||
|
it works today and fails closed if the file is missing — note in the file that a newcomer must
|
||||||
|
`cp .ai-sandbox/env.example .ai-sandbox/env && chmod 600` it. If you create `env.example`, put
|
||||||
|
names with dummy values in it and add `.ai-sandbox/env` to `.gitignore` (never overwrite an
|
||||||
|
existing ignore file's rules — append).
|
||||||
|
|
||||||
|
Never write `.ai-sandbox/env` yourself with real values: write the example, then tell the user what
|
||||||
|
to put in it.
|
||||||
|
|
||||||
|
## 4. Egress in one paragraph (get the shape right)
|
||||||
|
|
||||||
|
* A **domain** entry is matched by the gate at CONNECT time, including subdomains. Its `:port` does
|
||||||
|
not add a netfilter rule and the gate only relays **80, 443, 8443** (`Safe_ports`/`SSL_ports` in
|
||||||
|
the generated squid config), so `--allow host:8080` will not do what it looks like.
|
||||||
|
* An **IP, CIDR or `[ipv6]`** entry becomes a direct netfilter rule, default ports 80,443 unless you
|
||||||
|
write `:port` — this is how non-HTTP traffic (postgres, redis, a LAN model box) is allowed.
|
||||||
|
* `--no-gate` skips the proxy: domains are resolved once, on the host, at start and **DNS is
|
||||||
|
opened**; weaker for CDNs and rotating IPs. `--proxy host:port` uses a proxy you already run
|
||||||
|
(default port 3128). Neither belongs in a committed config without a comment justifying it.
|
||||||
|
* An empty allowlist in `allow` mode is a hard error. Never "fix" that by widening net.
|
||||||
|
|
||||||
|
## 5. Verify (no builds, no containers, no network)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <project>
|
||||||
|
ai-sandbox --dry-run # the exact podman invocation + the # egress / # env header lines;
|
||||||
|
# works with no podman and leaves no session directory behind
|
||||||
|
ai-sandbox --print-policy # net=, allow=, gate_domains=, direct=, env_names=, env_missing=,
|
||||||
|
# mounts=, workdir=, agents=, thinking= -- as key=value lines
|
||||||
|
```
|
||||||
|
|
||||||
|
Start with `--dry-run`: it is the one preview path with no side effects at all. `--print-policy`
|
||||||
|
gives the friendlier key=value form but also ensures the home-cache volume exists, so it needs
|
||||||
|
podman present — treat a failure there as "this machine has no podman", not as a policy problem.
|
||||||
|
|
||||||
|
Check, in this order, and fix the files instead of explaining the output:
|
||||||
|
|
||||||
|
1. no `warning: --env NAME requested but not set` for a name nobody intended (unset names are fine
|
||||||
|
to ship — say so in the report — but each must be a deliberate line, not a typo).
|
||||||
|
2. every allow entry appears in `domains=[…]` or `direct=[…]` (or `gate_domains=`/`direct=` under
|
||||||
|
`--print-policy`) — an entry in neither is a parse problem.
|
||||||
|
3. `workdir=` is the host path, not `/project`, unless the fallback was intended (`workdir_note=`
|
||||||
|
says why).
|
||||||
|
4. `mounts=` and `agents=` are what you wrote and nothing more.
|
||||||
|
5. the dry run shows `--network pasta:--map-host-loopback,100.64.0.1`, `--cap-drop=ALL`,
|
||||||
|
`--security-opt no-new-privileges`, `--userns keep-id:…` and no secret values.
|
||||||
|
6. `--print-policy | grep -i` for each credential name from §1: the *name* may appear, a *value*
|
||||||
|
must not.
|
||||||
|
|
||||||
|
Then, with the user watching (this builds the image the first time and starts a container):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ai-sandbox --shell # egress, mounts, env present
|
||||||
|
ai-sandbox --net none -- make test # the offline half of the toolchain
|
||||||
|
ai-sandbox doctor # host readiness
|
||||||
|
```
|
||||||
|
|
||||||
|
Probe the two or three endpoints the project actually needs from inside (`curl -sS -m 5 -o
|
||||||
|
/dev/null -w '%{http_code}\n' https://registry.npmjs.org/`) rather than assuming an allowlist entry
|
||||||
|
means a working toolchain.
|
||||||
|
|
||||||
|
## 6. Report
|
||||||
|
|
||||||
|
Finish with exactly these five points, so the user can act without reading the files:
|
||||||
|
|
||||||
|
1. **Files written** — paths, plus the `.gitignore` change if any.
|
||||||
|
2. **`cd <dir> && ai-sandbox`** is now the invocation (and the alias note if they use one).
|
||||||
|
3. **What they must export** first (`env =` names that are unset), and where values may go.
|
||||||
|
4. **What is deliberately not allowed** — the git remote first, then anything you declined to open
|
||||||
|
because the honest answer was "not without weakening the sandbox" (inbound port publishing is
|
||||||
|
usually on this list).
|
||||||
|
5. **What you verified and how** — the two preview commands and any live probe, with results.
|
||||||
|
|
||||||
|
## Ports, localhost, "the app must be reachable"
|
||||||
|
|
||||||
|
Read `references/port-and-loopback.md` before promising anything about ports. Short version: a
|
||||||
|
sandboxed process reaches *services on your machine* through the mapped loopback address
|
||||||
|
`100.64.0.1`, which needs an `allow` entry **and** a client that does not send that request to the
|
||||||
|
egress proxy; and a port the sandbox listens on is **not** reachable from the host in `--net allow`
|
||||||
|
mode, because the generated ruleset's input chain is `policy drop`. Verify on the machine rather
|
||||||
|
than repeating that from memory — the file has the probes.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
* `references/settings-map.md` — every accepted config key and flag, defaults, per-ecosystem
|
||||||
|
allowlists, the survey→policy cheat sheet.
|
||||||
|
* `references/port-and-loopback.md` — inbound vs outbound, `100.64.0.1`, the gate's port limits,
|
||||||
|
probes, the three workable dev-server patterns.
|
||||||
|
* `scripts/survey.sh` — the read-only evidence gatherer (prints no values).
|
||||||
|
* The tool's own docs, if this repo is present: `README.md`, `docs/networking.md` (measurements),
|
||||||
|
`docs/threat-model.md` (what is *not* protected), `docs/recipes.md` (invocations).
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Ports and loopback
|
||||||
|
|
||||||
|
Two separate problems wear the same name. Get the direction right before touching a config.
|
||||||
|
|
||||||
|
| | what it is | possible in `--net allow`? |
|
||||||
|
|---|---|---|
|
||||||
|
| **outbound** | the sandboxed process reaches a service running on your machine (dev postgres, redis, a mock API, a local model server) | **yes** — one allow entry, sometimes plus a proxy bypass |
|
||||||
|
| **inbound** | your browser on the host reaches a server the sandbox started | **no** — the generated ruleset drops it; three workarounds below |
|
||||||
|
|
||||||
|
Container-to-container is a third case, already measured on the reference host: two containers on a
|
||||||
|
shared user-defined network **cannot reach each other**, so do not design a sidecar proxy, a
|
||||||
|
service-mesh sandbox or a "sandbox plus its own database container" topology. The database runs on
|
||||||
|
the host (compose) and is reached as in §1.
|
||||||
|
|
||||||
|
## 1. Outbound: a service on your machine
|
||||||
|
|
||||||
|
With `--net allow` the container is started as
|
||||||
|
`--network pasta:--map-host-loopback,<gate_addr>` (`gate_addr`, default `100.64.0.1`). Inside the
|
||||||
|
container, **`100.64.0.1:PORT` lands on the host's `127.0.0.1:PORT`**. `127.0.0.1` inside the
|
||||||
|
container is the container itself and almost never what you meant.
|
||||||
|
|
||||||
|
Two layers have to agree, and this is where every "it still does not work" comes from:
|
||||||
|
|
||||||
|
1. **The ruleset.** Only the gate's port is routable through that address by default. Add:
|
||||||
|
```
|
||||||
|
allow = 100.64.0.1:5432 # postgres published by docker compose on the host
|
||||||
|
```
|
||||||
|
(IP entries become direct netfilter rules — the right shape for a non-HTTP protocol.)
|
||||||
|
2. **The proxy variables.** When a gate is in use, the container gets `HTTP(S)_PROXY`/`ALL_PROXY`
|
||||||
|
and `NO_PROXY=localhost,127.0.0.1,::1`. A client that honours them sends the request to the gate,
|
||||||
|
which allows the destination if it is in the allowlist **and the port is one of 80, 443, 8443**.
|
||||||
|
|
||||||
|
So:
|
||||||
|
|
||||||
|
| the service | what the config needs |
|
||||||
|
|---|---|
|
||||||
|
| postgres, redis, amqp, ssh, a raw socket — anything that ignores proxy variables | `allow = 100.64.0.1:<port>` |
|
||||||
|
| HTTP on 80, 443 or 8443 | `allow = 100.64.0.1:<port>` (the gate relays it) |
|
||||||
|
| HTTP on any other port (3000, 8000, 5173, 9200…) | `allow = 100.64.0.1:<port>` **and** a proxy bypass for that address, and measure which of the two `NO_PROXY` values wins |
|
||||||
|
|
||||||
|
The bypass, and the honest way to confirm it:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# .ai-sandbox/config -- the dev API on the host is not reachable through the egress gate
|
||||||
|
env = NO_PROXY=100.64.0.1,localhost,127.0.0.1,::1
|
||||||
|
env = no_proxy=100.64.0.1,localhost,127.0.0.1,::1
|
||||||
|
```
|
||||||
|
|
||||||
|
ai-sandbox sets `NO_PROXY` with `--env` *before* the env file, so the interaction between that and
|
||||||
|
an env-file value is podman's flag handling, not ai-sandbox's. Do not reason about it: run
|
||||||
|
`ai-sandbox --dry-run`, look at the order of the flags, and probe for real:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ai-sandbox --allow 100.64.0.1:3000 --shell
|
||||||
|
curl -sS -m 3 -o /dev/null -w '%{http_code}\n' http://100.64.0.1:3000/health
|
||||||
|
env | grep -i -E 'proxy|no_proxy'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then point the app at the mapped address without putting a secret in a committed file — a URL with
|
||||||
|
no password in it is not a secret:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
env = DATABASE_URL=postgresql://dev@100.64.0.1:5432/app_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
If `100.64.0.1` collides with a real network the project must reach, change `gate_addr` (and
|
||||||
|
remember the gate itself is reached at that address too).
|
||||||
|
|
||||||
|
## 2. Inbound: "I want to see the app the agent is building"
|
||||||
|
|
||||||
|
There is **no publish flag** (`--publish`/`-p` are not in `parse_args()`). You can push one through
|
||||||
|
the escape hatch — `extra_args = -p 8000:8000` or `AISB_EXTRA_ARGS="-p 8000:8000"` — and then read
|
||||||
|
the ruleset the entrypoint installs: the `input` chain is `policy drop` with room only for `lo`,
|
||||||
|
`established,related` and a few icmpv6 types, so a fresh inbound connection is dropped inside the
|
||||||
|
container. Confirm on the machine instead of trusting that reading:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AISB_EXTRA_ARGS="-p 8000:8000" ai-sandbox --shell
|
||||||
|
# inside: python3 -m http.server 8000 &
|
||||||
|
# host: curl -sS -m 3 -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected on a stock host in `--net allow`: no answer. If yours answers, that is a fact worth adding
|
||||||
|
to `docs/networking.md` — measure and report it, do not build the workflow on it.
|
||||||
|
|
||||||
|
What to tell the user instead, in order of preference:
|
||||||
|
|
||||||
|
1. **Run the server on the host.** The project mount *is* their disk: the agent edits, the host's
|
||||||
|
`npm run dev` / `runserver` picks it up, the browser hits a normal host port. Nothing is
|
||||||
|
published, nothing is opened, egress stays enforced. This is the answer for 90% of "preview the
|
||||||
|
app" requests.
|
||||||
|
2. **One named preview run**, when the server itself must be the sandboxed process (the agent needs
|
||||||
|
to drive it, seed it, watch its logs):
|
||||||
|
```bash
|
||||||
|
AISB_EXTRA_ARGS="-p 8000:8000" ai-sandbox --net full -- npm run dev
|
||||||
|
```
|
||||||
|
Say out loud what this is: `--net full` means that run has **no egress policy**. It is a
|
||||||
|
preview of a trusted project, not a project setting — which is why it never belongs in
|
||||||
|
`.ai-sandbox/config`.
|
||||||
|
3. **Split the loop**: the agent builds and unit-tests in the sandbox, writes a screenshotable
|
||||||
|
static export or a `make preview` target, and the human runs that target. This keeps the review
|
||||||
|
step where it belongs.
|
||||||
|
|
||||||
|
If the user genuinely needs publish-with-enforcement, the honest answer is that it is not
|
||||||
|
implemented: a `--publish PORT` would need an `input` accept rule per port in `gen_rules()`, the
|
||||||
|
port list carried in the session policy, and a threat-model line about who may connect. Offer to
|
||||||
|
write it as a change to `bin/ai-sandbox` — as its own phase, with a `tests/e2e` probe — rather than
|
||||||
|
hacking `extra_args` into a committed config and calling it secure.
|
||||||
|
|
||||||
|
## 3. Notes that keep coming up
|
||||||
|
|
||||||
|
* The gate binds the **host's loopback** only, on a free port at or above `gate_port` (18081);
|
||||||
|
`ai-sandbox gate status` and `gate logs` show a long-lived one.
|
||||||
|
* `NO_PROXY` covering `localhost` is correct and must stay — tooling that refuses to proxy the
|
||||||
|
container's own loopback (dev databases, auth servers, vite HMR) breaks otherwise.
|
||||||
|
* `--no-gate` opens DNS inside the container; the mapped loopback address still needs its `allow`
|
||||||
|
line.
|
||||||
|
* `--net none` has no interface at all: neither direction works, which makes it the right mode for
|
||||||
|
`make test` on a toolchain that is already installed.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Settings map
|
||||||
|
|
||||||
|
Check this against the installed program before writing a config: `ai-sandbox --help`, and
|
||||||
|
`cfg_read_kv()` / `resolve_opts()` in the script. Where they disagree, the script wins and this
|
||||||
|
file is the bug.
|
||||||
|
|
||||||
|
## Precedence
|
||||||
|
|
||||||
|
```
|
||||||
|
built-in default < ~/.config/ai-sandbox/{config,allow} < ./.ai-sandbox/{config,allow}
|
||||||
|
< AISB_* environment < command-line flag
|
||||||
|
```
|
||||||
|
|
||||||
|
`allow`, `env`, `mount`, `install`, `label` **accumulate** from every source (deduped); every other
|
||||||
|
key replaces. So a repo file cannot revoke a user-wide `allow` line — say so if the user expects a
|
||||||
|
repo config to tighten their own machine.
|
||||||
|
|
||||||
|
## The container, for orientation
|
||||||
|
|
||||||
|
Anything named "container" below is inside the sandbox: uid/gid **1000**, the image's own `sandbox`
|
||||||
|
account, its home the value of `HOME` in there (`--userns=keep-id` makes files the agent writes
|
||||||
|
owned by *you* on the host). So `~/.agents` exists on both sides of the wall and they are different
|
||||||
|
directories — the host one is bind-mounted read-only onto the container one at `agents_dest`.
|
||||||
|
`/project` is the image's own directory and the fallback working directory, not the normal one.
|
||||||
|
|
||||||
|
## Config keys (`key = value`, `#` comments)
|
||||||
|
|
||||||
|
| key | default | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `net` | `allow` | `none` \| `allow` \| `full`. `allow` requires a non-empty allowlist. |
|
||||||
|
| `harness` | `pi` | `pi` \| `codex` \| `claude` \| `none` \| a command line, e.g. `pi --model turbo` |
|
||||||
|
| `workdir` | `auto` | `auto` mirrors the host path; or an absolute path. **`project_dest` is parsed and never read — use `workdir`.** |
|
||||||
|
| `cpus` | `4` | |
|
||||||
|
| `memory` | `4g` | `512m`/`4g` shape |
|
||||||
|
| `pids` | `512` | |
|
||||||
|
| `tmpfs_size` | `1g` | sizes `/tmp` and, with `home_cache = 0`, the container home |
|
||||||
|
| `base_image` | `docker.io/library/ubuntu:24.04` | may itself be `name@sha256:…` |
|
||||||
|
| `node_version` | `22.23.4` | checksum-verified against nodejs.org |
|
||||||
|
| `pi_version` | `1.4.0` | npm package version baked into the image |
|
||||||
|
| `base_ttl_days` | `7` | `0` re-resolve every build, `-1` never |
|
||||||
|
| `base_auto_update` | `1` | `0` keeps the recorded digest offline |
|
||||||
|
| `selinux` | `:z` when `getenforce` exists | `:z`, `:Z` or empty |
|
||||||
|
| `home_cache` | `1` | `0` = throwaway tmpfs home (also `--ephemeral-home`) |
|
||||||
|
| `gate_addr` | `100.64.0.1` | the address that *means* the host's loopback inside the container |
|
||||||
|
| `gate_port` | `18081` | preferred first port; next free is used |
|
||||||
|
| `extra_args` | empty | appended verbatim to `podman run`, word-split. Escape hatch: do not commit it |
|
||||||
|
| `pi_config` | `1` | bridge `~/.pi/agent/{models,settings}.json` with the credential as `$VAR` |
|
||||||
|
| `pi_token_env` | `AI_API_TOKEN` | the variable the bridged config reads |
|
||||||
|
| `pi_token_auto` | `1` | pass that variable automatically when set |
|
||||||
|
| `pi_agent_dir` | `~/.pi/agent` | where to read the host config from |
|
||||||
|
| `thinking` | `xhigh` | `off minimal low medium high xhigh max host`; pi only, re-applied every run |
|
||||||
|
| `agents` | `1` | share `~/.agents` read-only when the directory exists |
|
||||||
|
| `agents_dir` | `~/.agents` | which directory; a project's own `.agents/` needs nothing |
|
||||||
|
| `agents_dest` | the container's `~/.agents` | keep it under the container `$HOME` — that is the only place a harness looks for user-level skills |
|
||||||
|
| `allow` | — | repeatable; same grammar as `--allow` |
|
||||||
|
| `env` | — | repeatable; `NAME`, `NAME=VALUE` (no secret in a committed file), or `@path` = an env file |
|
||||||
|
| `mount` | — | repeatable; `SRC[:DEST][:ro\|rw]`, default `DEST=/mnt/<basename>` |
|
||||||
|
| `install` | — | repeatable; apt packages, each distinct set is its own image tag |
|
||||||
|
| `label` | — | repeatable; `K=V` on the container |
|
||||||
|
|
||||||
|
Anything else warns (`unknown key`) and is ignored.
|
||||||
|
|
||||||
|
## Flags
|
||||||
|
|
||||||
|
```
|
||||||
|
egress --net none|allow|full --allow HOST[:PORT] --allow-file F --proxy HOST:PORT --no-gate
|
||||||
|
files --mount SRC[:DEST][:ro] --no-cwd --workdir DIR|auto --read-only
|
||||||
|
--home-cache --ephemeral-home --agents | --no-agents --agents-dir PATH
|
||||||
|
env --env NAME --env NAME=VALUE --env-file F (F should be 0600)
|
||||||
|
image --base-image REF --install PKG --no-build
|
||||||
|
harness --harness NAME --shell --thinking LEVEL --pi-token-env NAME --no-pi-token-env
|
||||||
|
--no-pi-config
|
||||||
|
limits --cpus N --memory SIZE --pids N --tmpfs-size SIZE
|
||||||
|
run --name NAME --label K=V --keep --interactive | --no-interactive --no-tty
|
||||||
|
preview --dry-run|-n --print-policy --print-entrypoint --print-containerfile
|
||||||
|
hatches --allow-unenforced --insecure-keep-root --strict-args
|
||||||
|
```
|
||||||
|
|
||||||
|
Parsing stops at the first positional or unknown argument, which becomes an argument **to the
|
||||||
|
harness** — that is what makes `alias pi=ai-sandbox` behave like the harness. `--` replaces the
|
||||||
|
command entirely. `--strict-args` makes an unknown flag an error instead (use it in scripts).
|
||||||
|
|
||||||
|
Subcommands: `build [--rebuild] [--gate] [--refresh-base]`, `update [--check]`, `doctor [--deep]`,
|
||||||
|
`gate up|down|status|logs`, `ps`, `clean [--all] [--volumes[=GLOB]]`, `inspect`, `version`.
|
||||||
|
|
||||||
|
Exit status: the command's own code; **77** the egress ruleset could not be installed; **78** the
|
||||||
|
policy file was missing in the container. Both refusals are the sandbox working.
|
||||||
|
|
||||||
|
## Allow-entry grammar
|
||||||
|
|
||||||
|
| entry | effect |
|
||||||
|
|---|---|
|
||||||
|
| `example.com` | gate matches that name and anything below it, at CONNECT time |
|
||||||
|
| `example.com:8443` | same; the port does **not** create a netfilter rule, and the gate only relays 80/443/8443 |
|
||||||
|
| `10.0.0.5:5432` | direct netfilter rule for that host and port — the way to allow non-HTTP |
|
||||||
|
| `10.20.0.0/16` | direct rule for the subnet, default ports 80,443 unless `:port` |
|
||||||
|
| `[fd00::5]:8443` | direct ipv6 rule |
|
||||||
|
| `localhost` | resolves on the host → a direct rule for `127.0.0.1`, i.e. the *container's* loopback. For your machine's services use `100.64.0.1` |
|
||||||
|
|
||||||
|
## Per-ecosystem starting points
|
||||||
|
|
||||||
|
Add only what the project's own lockfiles and build files prove it contacts. Everything left out
|
||||||
|
fails loudly, which is the feature.
|
||||||
|
|
||||||
|
| stack | entries |
|
||||||
|
|---|---|
|
||||||
|
| npm | `registry.npmjs.org` (+ `nodejs.org` and, for prebuilds, `release-assets.githubusercontent.com` — read the git-host warning before adding anything from `github.com`) |
|
||||||
|
| python | `pypi.org`, `files.pythonhosted.org` (the file CDN is separate; both are needed). A private index: whatever host `--index-url`, `PIP_INDEX_URL`, `[[tool.uv.index]]` or `tool.poetry.source` names |
|
||||||
|
| rust | `crates.io`, `static.crates.io`, `index.crates.io` (+ `static.rust-lang.org` if it installs toolchains) |
|
||||||
|
| go | `proxy.golang.org`, `sum.golang.org` (+ the host of a private module path, `allow = <host>` and `GOPRIVATE` in the env) |
|
||||||
|
| ruby | `rubygems.org`, `index.rubygems.org` |
|
||||||
|
| php | `repo.packagist.org`, `api.github.com` (composer metadata; check before allowing) |
|
||||||
|
| jvm | `repo1.maven.org`, `repo.maven.apache.org`, `plugins.gradle.org`, `services.gradle.org`, `dl.google.com` |
|
||||||
|
| dotnet | `api.nuget.org` |
|
||||||
|
| terraform | `registry.terraform.io`, `releases.hashicorp.com` |
|
||||||
|
| playwright / puppeteer | their download hosts (`cdn.playwright.dev`, `storage.googleapis.com`) — or commit the browsers and go `--net none` |
|
||||||
|
| container builds inside the sandbox | `registry-1.docker.io`, `auth.docker.io`, `production.cloudflare.docker.com` |
|
||||||
|
|
||||||
|
## Gotchas worth a comment in the file
|
||||||
|
|
||||||
|
* `--env NAME=VALUE` shows up in `ps`; the name-only and `--env-file` forms do not.
|
||||||
|
* An unset variable named with `--env` **fails the run** (in preview it is reported as
|
||||||
|
`env_missing=` instead) — that fail-closed behaviour is why preview output is worth reading.
|
||||||
|
* The image's home is a named volume (`aisb-home`) that outlives the image: after a rebuild the
|
||||||
|
home may be stale. `ai-sandbox clean --volumes` reclaims unused `aisb-*` volumes.
|
||||||
|
* A project under `/tmp`, `/usr`, `/etc`, `/var`, `/opt`, `/run`, `/home` itself or the container's
|
||||||
|
own home cannot mirror its host path and lands in `/project`, with a line saying so. Do not "fix"
|
||||||
|
that with `--workdir /tmp/...`; the tmpfs covers it.
|
||||||
|
* The host path may not contain `:` at all — podman cannot parse such a mount spec, and
|
||||||
|
ai-sandbox refuses rather than passing the error along.
|
||||||
|
* Nothing is inherited but `TERM LANG LC_ALL LC_CTYPE TZ` (plus `HOME`, set to the container home,
|
||||||
|
`AISB=1`, `AISB_SESSION`). A tool that needs `PATH`, a dotfile or anything else from your real home
|
||||||
|
needs an explicit grant.
|
||||||
|
* `.env` files inside the project are readable by the agent — that is the point of the mount, and
|
||||||
|
it is why the *sandbox* token should be a narrow one.
|
||||||
+231
@@ -0,0 +1,231 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# survey.sh -- gather the evidence a .ai-sandbox policy needs from a project directory.
|
||||||
|
#
|
||||||
|
# Read-only, and it prints no secret values: variable NAMES, registry HOSTS, service PORTS. A URL
|
||||||
|
# is reduced to its host and any credentials inside one are stripped before anything is printed --
|
||||||
|
# the rule ai-sandbox holds itself to in --dry-run, --print-policy and the session log.
|
||||||
|
#
|
||||||
|
# bash survey.sh [project-dir] # default: $PWD
|
||||||
|
#
|
||||||
|
# It reports what it found and proposes nothing on its own authority: the agent decides, writes the
|
||||||
|
# two files, and verifies them with --print-policy / --dry-run.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
DIR="${1:-$PWD}"
|
||||||
|
[[ -d "$DIR" ]] || { printf 'not a directory: %s\n' "$DIR" >&2; exit 2; }
|
||||||
|
cd "$DIR" || exit 2
|
||||||
|
|
||||||
|
section() { printf '\n== %s\n' "$1"; }
|
||||||
|
note() { printf ' %s\n' "$1"; }
|
||||||
|
found() { printf ' [x] %s\n' "$1"; }
|
||||||
|
warn() { printf ' [!] %s\n' "$1"; }
|
||||||
|
list() { while IFS= read -r l; do [[ -n "$l" ]] && printf ' %s\n' "$l"; done; }
|
||||||
|
|
||||||
|
# Text in, sorted unique hosts out, with URL credentials gone. Never emits a path or a value.
|
||||||
|
hosts_of() {
|
||||||
|
sed -E -e 's#(://)[^/@[:space:]"'"'"']*@#\1#g' -e 's#^[a-z][a-z0-9+.-]*://##' -e 's#^//##' -e 's#^[A-Za-z0-9._-]+@##' \
|
||||||
|
| grep -oE '^[A-Za-z0-9._*+-]+(:[0-9]{1,5})?' | sort -u || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Existing files by basename glob, shallow, skipping build and dependency noise.
|
||||||
|
files_matching() {
|
||||||
|
local g
|
||||||
|
for g in "$@"; do
|
||||||
|
find . -maxdepth 3 \( -name node_modules -o -name .git -o -name .venv -o -name venv \
|
||||||
|
-o -name target -o -name dist -o -name build -o -name __pycache__ -o -name vendor \) -prune \
|
||||||
|
-o -name "$g" -print 2>/dev/null
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
printf 'project survey -- %s\n' "$DIR"
|
||||||
|
note "reads only, writes nothing, prints no secret values"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- the tool
|
||||||
|
section "ai-sandbox on this machine"
|
||||||
|
if command -v ai-sandbox >/dev/null 2>&1; then
|
||||||
|
found "$(ai-sandbox version 2>/dev/null || command -v ai-sandbox)"
|
||||||
|
note "script: $(readlink -f "$(command -v ai-sandbox)")"
|
||||||
|
note "policy: $PWD/.ai-sandbox/{config,allow} user: ${AISB_CONFIG_DIR:-$HOME/.config/ai-sandbox}/{config,allow}"
|
||||||
|
note "the flag and key list for THIS version: ai-sandbox --help"
|
||||||
|
else
|
||||||
|
warn "ai-sandbox is not on PATH -- install it before writing a policy nobody can run"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- repository
|
||||||
|
section "repository"
|
||||||
|
remote_hosts=""
|
||||||
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
note "branch $(git branch --show-current 2>/dev/null || echo '?'), $(git status --porcelain 2>/dev/null | wc -l) dirty path(s)"
|
||||||
|
remote_hosts="$(git remote -v 2>/dev/null | awk '{print $2}' | hosts_of | cut -d: -f1 | sort -u)"
|
||||||
|
if [[ -n "$remote_hosts" ]]; then
|
||||||
|
printf ' remotes (hosts only):%s\n' "$(printf '%s' "$remote_hosts" | tr '\n' ' ')"
|
||||||
|
note "allowlisting a remote host hands the sandbox the ability to ship what it wrote"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
note "not a git repository"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- stacks
|
||||||
|
section "toolchains (manifest -> the endpoints that ecosystem needs)"
|
||||||
|
declare -A STACK=(
|
||||||
|
[package-lock.json]="npm | registry.npmjs.org"
|
||||||
|
[pnpm-lock.yaml]="pnpm | registry.npmjs.org"
|
||||||
|
[yarn.lock]="yarn | registry.npmjs.org"
|
||||||
|
[pyproject.toml]="python | pypi.org files.pythonhosted.org"
|
||||||
|
[requirements.txt]="python | pypi.org files.pythonhosted.org"
|
||||||
|
[poetry.lock]="python/poetry | pypi.org files.pythonhosted.org"
|
||||||
|
[uv.lock]="python/uv | pypi.org files.pythonhosted.org"
|
||||||
|
[Pipfile.lock]="python/pipenv | pypi.org files.pythonhosted.org"
|
||||||
|
[Cargo.lock]="rust | crates.io static.crates.io index.crates.io"
|
||||||
|
[go.sum]="go | proxy.golang.org sum.golang.org"
|
||||||
|
[Gemfile.lock]="ruby | rubygems.org index.rubygems.org"
|
||||||
|
[composer.json]="php | repo.packagist.org"
|
||||||
|
[pom.xml]="maven | repo1.maven.org"
|
||||||
|
[build.gradle]="gradle | repo.maven.apache.org plugins.gradle.org services.gradle.org"
|
||||||
|
[build.gradle.kts]="gradle | repo.maven.apache.org plugins.gradle.org services.gradle.org"
|
||||||
|
[mix.exs]="elixir | hex.pm repo.hex.pm"
|
||||||
|
[deno.json]="deno | deno.land registry.npmjs.org"
|
||||||
|
[*.csproj]="dotnet | api.nuget.org"
|
||||||
|
[*.tf]="terraform | registry.terraform.io releases.hashicorp.com"
|
||||||
|
)
|
||||||
|
stacks=0
|
||||||
|
for f in "${!STACK[@]}"; do
|
||||||
|
hits="$(files_matching "$f")"
|
||||||
|
[[ -n "$hits" ]] || continue
|
||||||
|
found "$(printf '%-26s %s' "${STACK[$f]}" "$(printf '%s\n' "$hits" | head -2 | tr '\n' ' ')")"
|
||||||
|
stacks=$((stacks + 1))
|
||||||
|
done
|
||||||
|
(( stacks )) || note "no recognised manifest within depth 3 -- script tree, docs tree, or an unusual layout"
|
||||||
|
note "also read: $(files_matching Makefile Taskfile.yml Dockerfile 'Dockerfile.*' Justfile package.json pyproject.toml | head -6 | tr '\n' ' ')"
|
||||||
|
|
||||||
|
section "custom registries named in the project's own files"
|
||||||
|
idx="$(grep -rIhoE --include='*.toml' --include='*.txt' --include='*.cfg' --include='*.json' \
|
||||||
|
--include='*.yaml' --include='*.yml' --include='*.npmrc' --include='*.pip.conf' --include='Makefile' \
|
||||||
|
'(--index-url|--extra-index-url|--add-registry|index-url|GOPROXY|npmRegistryServer)[[:space:]]*[=:]?[[:space:]]*"?[^[:space:]#",]+' \
|
||||||
|
. 2>/dev/null \
|
||||||
|
| sed -E 's/^(-{1,2})?[A-Za-z-]*(index-url|add-registry|GOPROXY|npmRegistryServer)[[:space:]]*[=:]?[[:space:]]*"?//' \
|
||||||
|
| hosts_of)"
|
||||||
|
if [[ -n "$idx" ]]; then list <<< "$idx"; note "these decide whether the build works at all"; else note "none found"; fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- services and ports
|
||||||
|
section "services and ports"
|
||||||
|
compose="$(files_matching 'docker-compose*.yml' 'docker-compose*.yaml' compose.yml compose.yaml)"
|
||||||
|
if [[ -n "$compose" ]]; then
|
||||||
|
for c in $compose; do
|
||||||
|
note "${c#./}:"
|
||||||
|
svc="$(grep -oE '^ [a-zA-Z0-9_.-]+:[[:space:]]*$' "$c" | tr -d ' :' | tr '\n' ' ')"
|
||||||
|
[[ -n "$svc" ]] && printf ' services: %s\n' "$svc"
|
||||||
|
ports="$(grep -oE '[0-9]{1,5}:[0-9]{1,5}' "$c" | sort -u | tr '\n' ' ')"
|
||||||
|
[[ -n "$ports" ]] && printf ' published: %s -> one "allow = 100.64.0.1:<port>" per port you grant\n' "$ports"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
note "no compose file"
|
||||||
|
fi
|
||||||
|
devports="$(grep -rIhoE --include='vite.config.*' --include='next.config.*' --include='webpack*.js' \
|
||||||
|
--include='package.json' --include='*.env' --include='*.env.*' --include='settings.py' \
|
||||||
|
--include='Procfile' --include='manage.py' \
|
||||||
|
'(--[Pp][Oo][Rr][Tt][[:space:]=]+[0-9]{4,5})|([Pp][Oo][Rr][Tt][[:space:]]*[=:][[:space:]]*"?[0-9]{4,5})' . 2>/dev/null \
|
||||||
|
| grep -oE '[0-9]{4,5}$' | sort -u | tr '\n' ' ')"
|
||||||
|
[[ -n "$devports" ]] && note "ports named in dev-server configs: $devports"
|
||||||
|
note "outbound (sandbox -> a service on this machine) is grantable: 100.64.0.1:<port>."
|
||||||
|
note "inbound (host -> a server the sandbox started) is dropped by the ruleset -- references/port-and-loopback.md"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- environment names
|
||||||
|
section "environment variable NAMES the project references (no values)"
|
||||||
|
names="$(
|
||||||
|
files_matching .env .env.* '*.env.example' '*.env.sample' '*.env.template' env.example \
|
||||||
|
| xargs -r grep -hoE '^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=' 2>/dev/null
|
||||||
|
[[ -n "$compose" ]] && printf '%s\n' $compose | xargs -r grep -hoE '^[[:space:]]+-?[[:space:]]*[A-Z][A-Z0-9_]{2,}[:=]' 2>/dev/null
|
||||||
|
grep -rIhoE 'secrets\.[A-Z][A-Z0-9_]+' .github 2>/dev/null
|
||||||
|
files_matching Dockerfile 'Dockerfile.*' | xargs -r grep -hoE '^ENV[[:space:]]+[A-Za-z_][A-Za-z0-9_]*' 2>/dev/null | awk '{print $2}'
|
||||||
|
)"
|
||||||
|
names="$(printf '%s\n' "$names" | sed -E 's/^[[:space:]]*(export[[:space:]]+)?//; s/^secrets\.//; s/[[:space:]]*[=:]$//' \
|
||||||
|
| sort -u | grep -vE '^(PATH|HOME|USER|SHELL|PWD|LANG|LC_ALL|LC_CTYPE|TZ|TERM|HOSTNAME|SHLVL|ENV|_)$')"
|
||||||
|
if [[ -n "$names" ]]; then
|
||||||
|
printf ' %s\n' "$(printf '%s\n' "$names" | tr '\n' ' ')"
|
||||||
|
note "grant what a run needs as 'env = NAME' -- names only"
|
||||||
|
secretn="$(printf '%s\n' "$names" | grep -iE 'KEY|TOKEN|SECRET|PASSW|CREDENTIAL|AUTH' | tr '\n' ' ')"
|
||||||
|
[[ -n "$secretn" ]] && warn "credential-shaped (values must never enter a committed file): $secretn"
|
||||||
|
else
|
||||||
|
note "none in .env*/compose/Dockerfile/CI -- check the README and the user's launcher"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- the model endpoint
|
||||||
|
section "model endpoint on this host (from the pi agent config)"
|
||||||
|
pi_dir="${AISB_PI_AGENT_DIR:-$HOME/.pi/agent}"
|
||||||
|
if [[ -r "$pi_dir/models.json" ]]; then
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
eps="$(jq -r '.. | .baseUrl? // empty' "$pi_dir/models.json" 2>/dev/null | hosts_of)"
|
||||||
|
else
|
||||||
|
eps="$(grep -oE '"baseUrl"[[:space:]]*:[[:space:]]*"[^"]+"' "$pi_dir/models.json" 2>/dev/null | sed -E 's/.*"baseUrl"[[:space:]]*:[[:space:]]*"//' | hosts_of)"
|
||||||
|
fi
|
||||||
|
if [[ -n "$eps" ]]; then
|
||||||
|
list <<< "$eps"
|
||||||
|
note "the bridge turns apiKey into \$AI_API_TOKEN and copies nothing -- but this host must be allowed"
|
||||||
|
note "no credential value was read or printed"
|
||||||
|
else
|
||||||
|
note "models.json is readable, no baseUrl in it"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
note "no readable $pi_dir/models.json -- nothing to bridge"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- existing policy
|
||||||
|
section "existing policy"
|
||||||
|
KV_KEYS="net harness workdir cpus memory pids tmpfs_size base_image node_version pi_version \
|
||||||
|
base_ttl_days base_auto_update selinux home_cache gate_addr gate_port extra_args pi_config \
|
||||||
|
pi_token_env pi_token_auto pi_agent_dir thinking agents agents_dir agents_dest allow env mount install label"
|
||||||
|
for f in .ai-sandbox/config .ai-sandbox/allow; do
|
||||||
|
[[ -r "$f" ]] && found "$f ($(wc -l < "$f") lines)" || note "$f: absent"
|
||||||
|
done
|
||||||
|
if [[ -r .ai-sandbox/config ]]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
line="${line%%#*}"
|
||||||
|
line="$(printf '%s' "$line" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
||||||
|
[[ -n "$line" && "$line" == *=* ]] || continue
|
||||||
|
k="$(printf '%s' "${line%%=*}" | sed -E 's/[[:space:]]+$//')"
|
||||||
|
v="$(printf '%s' "${line#*=}" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//; s/^"//; s/"$//')"
|
||||||
|
[[ " $KV_KEYS " == *" $k "* ]] || warn "unknown key (ai-sandbox ignores it): $k"
|
||||||
|
[[ "$k" != project_dest ]] || warn "project_dest is parsed and read by nothing -- use workdir"
|
||||||
|
if [[ "$k" == env && "$v" == *=* && "${v%%=*}" =~ (KEY|TOKEN|SECRET|PASSW|CREDENTIAL|AUTH) ]]; then
|
||||||
|
warn "env = ${v%%=*}=... is a literal value for a credential-shaped name in a committed file"
|
||||||
|
fi
|
||||||
|
if [[ "$k" == net && "$v" == full ]]; then warn "net = full in a committed config: that is an escape hatch, not a policy"; fi
|
||||||
|
if [[ "$k" == extra_args ]]; then warn "extra_args in a committed config: the podman escape hatch -- it belongs on one command line"; fi
|
||||||
|
done < .ai-sandbox/config
|
||||||
|
fi
|
||||||
|
if [[ -r .ai-sandbox/allow ]]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
line="$(printf '%s' "${line%%#*}" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
||||||
|
[[ -n "$line" ]] || continue
|
||||||
|
body="$line"
|
||||||
|
host="$(printf '%s' "$line" | sed -E 's/^\[([^]]*)\].*/\1/; s/^([^:]*):.*/\1/')"
|
||||||
|
if printf '%s' "$body" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}(/[0-9]{1,2})?(:[0-9]{1,5})?$'; then
|
||||||
|
note "allow $body -- ipv4/cidr: a direct netfilter rule (ports 80,443 unless :port)"
|
||||||
|
elif printf '%s' "$line" | grep -qE '^\[[^]]*\](:[0-9]{1,5})?$' \
|
||||||
|
|| printf '%s' "$host" | grep -qE '^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$'; then
|
||||||
|
note "allow $body -- ipv6: a direct rule; write it as [addr]:port"
|
||||||
|
elif printf '%s' "$host" | grep -qE '^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$'; then
|
||||||
|
note "allow $body -- domain: the gate filters it at CONNECT (only 80,443,8443 relayed)"
|
||||||
|
else
|
||||||
|
warn "allow $body -- matches no grammar (domain, ip, cidr, [ipv6]:port): check the spelling"
|
||||||
|
fi
|
||||||
|
if [[ -n "$remote_hosts" ]] && printf '%s\n' "$remote_hosts" | grep -qxF -- "$host"; then
|
||||||
|
warn "allow $body is a git remote of this repository -- the sandbox could push what it wrote"
|
||||||
|
fi
|
||||||
|
done < .ai-sandbox/allow
|
||||||
|
fi
|
||||||
|
if [[ -e .ai-sandbox/env ]]; then
|
||||||
|
mode="$(stat -c '%a' .ai-sandbox/env 2>/dev/null || echo '?')"
|
||||||
|
[[ "$mode" == 600 || "$mode" == 400 ]] && found ".ai-sandbox/env present, mode $mode" || warn ".ai-sandbox/env is mode $mode -- chmod 600"
|
||||||
|
git check-ignore -q .ai-sandbox/env 2>/dev/null || warn ".ai-sandbox/env is not gitignored"
|
||||||
|
else
|
||||||
|
note "no .ai-sandbox/env -- write env.example with names and dummy values; the user writes the real one"
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "next"
|
||||||
|
note "1. decide net / allow / env / mount / install / limits from the above (SKILL.md step 2)"
|
||||||
|
note "2. write .ai-sandbox/allow and .ai-sandbox/config, commented, names only"
|
||||||
|
note "3. verify with ai-sandbox --print-policy and --dry-run (no build, no container, no network)"
|
||||||
|
note "4. then one live probe per endpoint the project really needs"
|
||||||
Reference in New Issue
Block a user