#!/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:" 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" exposed="$(files_matching Dockerfile 'Dockerfile.*' | xargs -r grep -hoiE '^EXPOSE[[:space:]]+[0-9]+' 2>/dev/null \ | grep -oE '[0-9]+' | sort -u | tr '\n' ' ')" [[ -n "$exposed" ]] && note "EXPOSEd in the Dockerfile: $exposed" # The address matters as much as the port: a dev server pinned to loopback answers nothing on a # published port, so quote the project's own setting back when it says 127.0.0.1. binds="$(grep -rIhoE --include='vite.config.*' --include='next.config.*' --include='webpack*.js' \ --include='gunicorn*' --include='Procfile' --include='settings.py' --include='*.toml' \ '(host|HOST|BIND|bind|--host|-b)[^0-9A-Za-z]{0,4}(0\.0\.0\.0|127\.0\.0\.1|\[::1\]|localhost)' . 2>/dev/null \ | grep -oE '0\.0\.0\.0|127\.0\.0\.1|\[::1\]|localhost' | sort | tr '\n' ' ')" [[ -n "$binds" ]] && note "addresses the project's own config binds to: $binds" note "outbound (sandbox -> a service on this machine): allow = 100.64.0.1:." note "inbound (host -> a server the sandbox runs): publish = -- loopback unless an address" note " is written in the spec. The two are not interchangeable: publish is ingress, allow is egress." note " a published port only reaches a server listening on 0.0.0.0 inside (measured), so a config that" note " binds loopback above needs that said in the same change. 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 publish publish_addr 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 if [[ "$k" == publish ]]; then case "$v" in */*) warn "publish = $v has a protocol suffix: one port per entry, no /udp" ;; *-*) warn "publish = $v is a range: ai-sandbox takes one port per entry, so a reviewed line is never a block of ports" ;; \[*) note "publish = $v -- an ipv6 bind in brackets, then host:container" ;; *:*:*) note "publish = $v -- addr:host:container; anything not loopback is an open door and warns on every run" ;; *:*) note "publish = $v -- host port -> container port, bound to 127.0.0.1 (the default)" ;; *[!0-9]*) warn "publish = $v is not a port, host:container or addr:host:container -- ai-sandbox refuses it" ;; *) note "publish = $v -- 127.0.0.1:$v -> container $v; the server must bind 0.0.0.0 inside" ;; esac fi if [[ "$k" == publish_addr ]]; then case "$v" in 127.*|localhost|::1|\[::1\]) note "publish_addr = $v -- loopback, the safe default" ;; *) warn "publish_addr = $v in a repo config widens every bare publish line in this directory -- the user's config, if anywhere" ;; esac 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 / publish / 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 -- and from the host too if you" note " wrote publish: that port exists only while a sandbox runs, and only 0.0.0.0 inside answers it"