mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-09 15:24:01 +01:00
The sandbox provides an isolated Docker environment for AI-assisted development (OpenCode + Claude Code + Qwen Code) against the local AkkudoktorEOS repository. The sandbox does not protect the repository from harmful changes by the agent. It limits what a misled agent can reach on the machine, but the agent can still write any change into the repository. Primary security defence is to check every change before it is run on the host. Same holds for opening a pull request or updating one. Additional changes - Reduce AGENTS.md to the project summary, baseline rules, completion checklist, and an index of the skills. - Add skills for the Python environment and REST API error responses. - Add the development guide page docs/develop/ai.md: the AI coding workflow, how agents get the project rules from AGENTS.md and ai/skills, and how to use the sandbox (start, agent logins, project commands with a virtual environment outside the workspace, running EOS and EOSdash, GitHub access, persistent data, security model). - Add a checklist for reviewing agent changes before pushing, starting with .git, which stays writable in the sandbox. - Check out all text files with LF line endings (.gitattributes), also on Windows with core.autocrlf, so the sandbox scripts run in the container and git in the container agrees with git on the host. Batch files keep CRLF. - Publish the ports of ai/compose.yaml on request (AI_SANDBOX_PORTS=1). docker compose run publishes them only with --service-ports. Off by default, so that several sandboxes can run in parallel. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
8.5 KiB
Bash
Executable File
214 lines
8.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ai/run.sh
|
|
# Entry point for the AkkudoktorEOS AI development sandbox (Linux/macOS).
|
|
#
|
|
# Launcher:
|
|
# - Sanity-checks ai/compose.yaml for the main sandbox controls. This is not
|
|
# a complete validation; the protection of the sandbox definition is the
|
|
# read-only mount of ai/ in the container.
|
|
# - Only forwards arguments after `--` as the in-container command, never
|
|
# as docker compose options.
|
|
# - Requires every published port to bind to 127.0.0.1 (loopback on the host).
|
|
# - Optional rebuild (AI_SANDBOX_REBUILD=1); cached image otherwise.
|
|
# - Optional publishing of the compose ports (AI_SANDBOX_PORTS=1).
|
|
# - Optional write access to ai/skills (AI_SANDBOX_SKILLS_RW=1).
|
|
# - Regenerates ai/.env atomically with HOST_UID/HOST_GID and mode 0600.
|
|
# - Works with bash 3.2 (macOS /bin/bash).
|
|
|
|
set -Eeuo pipefail
|
|
IFS=$'\n\t'
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
cd "$REPO_ROOT"
|
|
|
|
COMPOSE_FILE="ai/compose.yaml"
|
|
ENV_FILE="ai/.env"
|
|
ENV_EXAMPLE="ai/.env.example"
|
|
|
|
trap 'echo "ERROR: ai/run.sh failed at line $LINENO (exit $?)." >&2' ERR
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 0. Arguments. Docker compose options cannot be passed; everything after
|
|
# `--` is the command to run in the container.
|
|
# ---------------------------------------------------------------------------
|
|
EXTRA_CMD=()
|
|
if [[ $# -gt 0 ]]; then
|
|
if [[ "$1" != "--" ]]; then
|
|
echo "ERROR: extra arguments must be supplied after '--' as the in-container command." >&2
|
|
echo " Example: ./ai/run.sh -- bash -lc 'pytest -q'" >&2
|
|
exit 2
|
|
fi
|
|
shift
|
|
EXTRA_CMD=("$@")
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Prerequisites.
|
|
# ---------------------------------------------------------------------------
|
|
echo "==> Checking prerequisites..."
|
|
|
|
command -v docker >/dev/null || { echo "ERROR: docker not installed." >&2; exit 1; }
|
|
docker compose version >/dev/null 2>&1 \
|
|
|| { echo "ERROR: docker compose v2 not available." >&2; exit 1; }
|
|
|
|
if ! docker info >/dev/null 2>&1; then
|
|
echo "ERROR: docker daemon not reachable." >&2
|
|
echo " - Is the daemon running? (systemctl status docker)" >&2
|
|
echo " - Is your user in 'docker' group? (groups)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
[[ -f pyproject.toml ]] \
|
|
|| { echo "ERROR: pyproject.toml not found; run from inside the repo." >&2; exit 1; }
|
|
[[ -f "$COMPOSE_FILE" ]] \
|
|
|| { echo "ERROR: $COMPOSE_FILE missing." >&2; exit 1; }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Sanity-check the sandbox controls in compose.yaml.
|
|
# This catches accidental edits, not every weakening edit. The agent
|
|
# cannot edit compose.yaml from inside the sandbox (ai/ is read-only there);
|
|
# review changes to ai/ before you start the sandbox.
|
|
# ---------------------------------------------------------------------------
|
|
echo "==> Checking sandbox controls in $COMPOSE_FILE ..."
|
|
|
|
compose_text="$(cat "$COMPOSE_FILE")"
|
|
|
|
require_in_compose() {
|
|
local needle="$1" why="$2"
|
|
if ! grep -Eq -- "$needle" <<<"$compose_text"; then
|
|
echo "ERROR: compose.yaml does not contain: $needle" >&2
|
|
echo " $why" >&2
|
|
exit 3
|
|
fi
|
|
}
|
|
|
|
require_in_compose 'cap_drop:[[:space:]]*$|cap_drop:[[:space:]]*\[' 'Missing cap_drop (must drop ALL).'
|
|
require_in_compose '-\s*"?ALL"?|\[[[:space:]]*"?ALL"?[[:space:]]*\]' 'cap_drop must include ALL.'
|
|
require_in_compose 'no-new-privileges:true|no-new-privileges:[[:space:]]*true' 'Missing no-new-privileges:true.'
|
|
require_in_compose 'read_only:[[:space:]]*true' 'Missing read_only:true.'
|
|
require_in_compose '\.\./ai:/workspace/ai:ro' 'ai/ must be mounted read-only.'
|
|
|
|
# The docker socket must never be mounted.
|
|
if grep -Eq '/var/run/docker\.sock' <<<"$compose_text"; then
|
|
echo "ERROR: compose.yaml mounts the Docker socket. This is an escape vector." >&2
|
|
exit 3
|
|
fi
|
|
|
|
# privileged must be false or absent.
|
|
if grep -Eq 'privileged:[[:space:]]*true' <<<"$compose_text"; then
|
|
echo "ERROR: compose.yaml sets privileged:true; refusing to start." >&2
|
|
exit 3
|
|
fi
|
|
|
|
# network_mode: host must never be used.
|
|
if grep -Eq 'network_mode:[[:space:]]*"?host"?' <<<"$compose_text"; then
|
|
echo "ERROR: compose.yaml sets network_mode: host; refusing to start." >&2
|
|
exit 3
|
|
fi
|
|
|
|
# Every published port MUST bind to 127.0.0.1. Ports are allowed here because
|
|
# the sandbox is used for local testing; the constraint is that nothing inside
|
|
# becomes reachable from the LAN/VPN.
|
|
if grep -Eq '^[[:space:]]*ports:' <<<"$compose_text"; then
|
|
if ! awk '
|
|
/^[[:space:]]*ports:[[:space:]]*$/ { inports=1; next }
|
|
inports && /^[[:space:]]*-/ {
|
|
line=$0
|
|
sub(/^[[:space:]]*-[[:space:]]*/, "", line)
|
|
gsub(/["'"'"']/, "", line)
|
|
if (line !~ /^127\.0\.0\.1:/) {
|
|
print "OFFENDING: " line > "/dev/stderr"
|
|
bad=1
|
|
}
|
|
next
|
|
}
|
|
inports && !/^[[:space:]]*-/ && !/^[[:space:]]*$/ { inports=0 }
|
|
END { exit bad ? 1 : 0 }
|
|
' <<<"$compose_text"; then
|
|
echo "ERROR: compose.yaml publishes a port on a non-loopback interface." >&2
|
|
echo " Every entry under 'ports:' must start with '127.0.0.1:'." >&2
|
|
echo " Change e.g. \"8503:8503\" to \"127.0.0.1:8503:8503\"." >&2
|
|
exit 3
|
|
fi
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Regenerate ai/.env atomically (mode 0600). Do not leak .bak files.
|
|
# ---------------------------------------------------------------------------
|
|
if [[ ! -f "$ENV_FILE" ]]; then
|
|
if [[ -f "$ENV_EXAMPLE" ]]; then
|
|
echo "==> Creating $ENV_FILE from $ENV_EXAMPLE"
|
|
install -m 0600 "$ENV_EXAMPLE" "$ENV_FILE"
|
|
else
|
|
echo "==> Creating empty $ENV_FILE"
|
|
: >"$ENV_FILE"
|
|
chmod 0600 "$ENV_FILE"
|
|
fi
|
|
fi
|
|
chmod 0600 "$ENV_FILE"
|
|
|
|
HOST_UID="$(id -u)"
|
|
HOST_GID="$(id -g)"
|
|
|
|
tmp_env="$(mktemp "${ENV_FILE}.XXXXXX")"
|
|
trap 'rm -f "$tmp_env"' EXIT
|
|
{
|
|
grep -Ev '^HOST_(UID|GID)=' "$ENV_FILE" || true
|
|
printf 'HOST_UID=%s\n' "$HOST_UID"
|
|
printf 'HOST_GID=%s\n' "$HOST_GID"
|
|
} >"$tmp_env"
|
|
mv -f "$tmp_env" "$ENV_FILE"
|
|
chmod 0600 "$ENV_FILE"
|
|
trap 'echo "ERROR: ai/run.sh failed at line $LINENO (exit $?)." >&2' ERR
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Rebuild is opt-in. A tampered Dockerfile/entrypoint cannot be silently
|
|
# rebuilt by a wrapper invocation.
|
|
# ---------------------------------------------------------------------------
|
|
BUILD_FLAG=()
|
|
if [[ "${AI_SANDBOX_REBUILD:-0}" == "1" ]]; then
|
|
echo "==> Rebuilding image (AI_SANDBOX_REBUILD=1)"
|
|
BUILD_FLAG=(--build)
|
|
else
|
|
echo "==> Using cached image (set AI_SANDBOX_REBUILD=1 to rebuild)"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Port publishing is opt-in. `docker compose run` publishes the ports of
|
|
# compose.yaml (loopback-only, verified above) only with --service-ports.
|
|
# Off by default, so several sandboxes can run in parallel.
|
|
# ---------------------------------------------------------------------------
|
|
PORTS_FLAG=()
|
|
if [[ "${AI_SANDBOX_PORTS:-0}" == "1" ]]; then
|
|
echo "==> Publishing the compose ports on 127.0.0.1 (AI_SANDBOX_PORTS=1)"
|
|
PORTS_FLAG=(--service-ports)
|
|
else
|
|
echo "==> Ports not published (set AI_SANDBOX_PORTS=1 to publish them)"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. ai/skills is read-only in the container unless AI_SANDBOX_SKILLS_RW=1.
|
|
# Always set the mode explicitly, so a value in ai/.env cannot change it.
|
|
# ---------------------------------------------------------------------------
|
|
if [[ "${AI_SANDBOX_SKILLS_RW:-0}" == "1" ]]; then
|
|
echo "==> ai/skills is writable in the sandbox (AI_SANDBOX_SKILLS_RW=1)"
|
|
echo " Review every change to ai/skills before you commit or push it."
|
|
export AI_SANDBOX_SKILLS_MODE=rw
|
|
else
|
|
export AI_SANDBOX_SKILLS_MODE=ro
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Run. Use exec so signals reach docker compose directly.
|
|
# ${arr[@]+"${arr[@]}"} expands an empty array safely under `set -u` in
|
|
# bash < 4.4 (macOS /bin/bash).
|
|
# ---------------------------------------------------------------------------
|
|
echo "==> Starting AI dev sandbox (UID=$HOST_UID, GID=$HOST_GID)..."
|
|
|
|
exec docker compose -f "$COMPOSE_FILE" run --rm \
|
|
${BUILD_FLAG[@]+"${BUILD_FLAG[@]}"} \
|
|
${PORTS_FLAG[@]+"${PORTS_FLAG[@]}"} \
|
|
ai-dev \
|
|
${EXTRA_CMD[@]+"${EXTRA_CMD[@]}"}
|