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
232 lines
13 KiB
Bash
Executable File
232 lines
13 KiB
Bash
Executable File
#!/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"
|