docs(ai): move agent rules to ai/skills and add an AI development sandbox (#1498)

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>
This commit is contained in:
Bobby Noelte
2026-11-09 09:15:12 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 628226d3d9
commit 2245976de4
21 changed files with 2041 additions and 150 deletions
+299
View File
@@ -0,0 +1,299 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
:: ==========================================================================
:: ai\run.bat
:: Entry point for the AkkudoktorEOS AI development sandbox (Windows CMD).
::
:: 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. Each argument is forwarded with its own
:: quotes, so cmd does not interpret |, &, < or > inside a quoted
:: argument. `!` and `%` cannot be passed through; use run.ps1 for them.
:: - Optional write access to ai/skills (AI_SANDBOX_SKILLS_RW=1).
:: - Requires every published port to bind to 127.0.0.1 (loopback on the host).
:: - Optional rebuild (AI_SANDBOX_REBUILD=1); cached image otherwise.
:: - Windows: UID/GID bind-mount mapping does not apply, HOST_UID/HOST_GID
:: fall back to the defaults from ai\.env.
:: ==========================================================================
:: Resolve script directory and repo root.
set "SCRIPT_DIR=%~dp0"
if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
for %%I in ("%SCRIPT_DIR%\..") do set "REPO_ROOT=%%~fI"
pushd "%REPO_ROOT%" >nul 2>&1
if errorlevel 1 (
echo ERROR: cannot cd into "%REPO_ROOT%". 1>&2
exit /b 1
)
set "COMPOSE_FILE=ai\compose.yaml"
set "ENV_FILE=ai\.env"
set "ENV_EXAMPLE=ai\.env.example"
:: ==========================================================================
:: 0. Arguments. Docker compose options cannot be passed; everything after
:: `--` is the command to run in the container.
:: ==========================================================================
set "EXTRA_CMD="
set "SAW_DASHDASH=0"
:parse_args
if "%~1"=="" goto end_parse_args
:: Everything after `--` is forwarded to the container as given. %1 keeps
:: the quotes of the argument, which protect |, &, < and > inside it from
:: cmd; "%~1" inside quotes would expose them to cmd.
if "%SAW_DASHDASH%"=="1" (
rem Forward the argument with its own quotes.
if defined EXTRA_CMD (
set EXTRA_CMD=!EXTRA_CMD! %1
) else (
set EXTRA_CMD=%1
)
shift
goto parse_args
)
if "%~1"=="--" (
set "SAW_DASHDASH=1"
shift
goto parse_args
)
echo ERROR: extra arguments must be supplied after '--' as the in-container command. 1>&2
echo Example: ai\run.bat -- bash -lc "pytest -q" 1>&2
popd >nul
exit /b 2
:end_parse_args
:: ==========================================================================
:: 1. Prerequisites.
:: ==========================================================================
echo ==^> Checking prerequisites...
where docker >nul 2>&1
if errorlevel 1 (
echo ERROR: docker is not installed. 1>&2
popd >nul
exit /b 1
)
docker compose version >nul 2>&1
if errorlevel 1 (
echo ERROR: docker compose v2 is not available. 1>&2
popd >nul
exit /b 1
)
docker info >nul 2>&1
if errorlevel 1 (
echo ERROR: docker daemon is not reachable. 1>&2
echo - Is Docker Desktop running? 1>&2
echo - Try 'docker info' manually. 1>&2
popd >nul
exit /b 1
)
if not exist "pyproject.toml" (
echo ERROR: pyproject.toml not found; run from inside the AkkudoktorEOS repository. 1>&2
popd >nul
exit /b 1
)
if not exist "%COMPOSE_FILE%" (
echo ERROR: %COMPOSE_FILE% missing. 1>&2
popd >nul
exit /b 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.
:: CMD cannot span lines, so multi-line YAML values are checked
:: line-by-line with findstr. Patterns must not use `$`: findstr only
:: matches it before CRLF, and the file may have LF line endings.
:: ==========================================================================
echo ==^> Checking sandbox controls in %COMPOSE_FILE% ...
call :require_line "read_only:[ ]*true" "Missing read_only:true."
if errorlevel 1 goto verify_failed
call :require_line "no-new-privileges:[ ]*true" "Missing no-new-privileges:true."
if errorlevel 1 goto verify_failed
:: cap_drop must be present and must include ALL.
call :require_line "cap_drop:" "Missing cap_drop."
if errorlevel 1 goto verify_failed
:: No caret anchor: call doubles carets in quoted arguments.
call :require_line "[ ]-[ ]*ALL" "cap_drop must include ALL."
if errorlevel 1 goto verify_failed
:: ai/ must be mounted read-only.
call :require_text "../ai:/workspace/ai:ro" "ai/ must be mounted read-only."
if errorlevel 1 goto verify_failed
:: Docker socket / named pipe must never be mounted.
findstr /R /C:"/var/run/docker\.sock" "%COMPOSE_FILE%" >nul 2>&1
if not errorlevel 1 (
echo ERROR: compose.yaml mounts the Docker socket. This is an escape vector. 1>&2
goto verify_failed
)
findstr /R /C:"//\./pipe/docker_engine" "%COMPOSE_FILE%" >nul 2>&1
if not errorlevel 1 (
echo ERROR: compose.yaml mounts the Docker named pipe. This is an escape vector. 1>&2
goto verify_failed
)
:: privileged:true is forbidden.
findstr /R /C:"privileged:[ ]*true" "%COMPOSE_FILE%" >nul 2>&1
if not errorlevel 1 (
echo ERROR: compose.yaml sets privileged:true; refusing to start. 1>&2
goto verify_failed
)
:: network_mode: host is forbidden.
findstr /R /C:"network_mode:[ ]*host" "%COMPOSE_FILE%" >nul 2>&1
if not errorlevel 1 (
echo ERROR: compose.yaml sets network_mode: host; refusing to start. 1>&2
goto verify_failed
)
:: Every port entry under `ports:` must start with 127.0.0.1:
call :check_ports
if errorlevel 1 goto verify_failed
goto verify_ok
:verify_failed
popd >nul
exit /b 3
:verify_ok
:: ==========================================================================
:: 3. Ensure ai\.env exists. HOST_UID/HOST_GID do not apply on Windows;
:: the defaults already present in ai\.env stand.
:: ==========================================================================
if not exist "%ENV_FILE%" (
if exist "%ENV_EXAMPLE%" (
echo ==^> Creating %ENV_FILE% from %ENV_EXAMPLE%
copy /Y "%ENV_EXAMPLE%" "%ENV_FILE%" >nul
) else (
echo ==^> Creating empty %ENV_FILE%
type nul > "%ENV_FILE%"
)
)
:: ==========================================================================
:: 4. Rebuild is opt-in.
:: ==========================================================================
set "BUILD_FLAG="
if /I "%AI_SANDBOX_REBUILD%"=="1" (
echo ==^> Rebuilding image ^(AI_SANDBOX_REBUILD=1^)
set "BUILD_FLAG=--build"
) else (
echo ==^> Using cached image ^(set AI_SANDBOX_REBUILD=1 to rebuild^)
)
:: ==========================================================================
:: 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.
:: ==========================================================================
set "PORTS_FLAG="
if /I "%AI_SANDBOX_PORTS%"=="1" (
echo ==^> Publishing the compose ports on 127.0.0.1 ^(AI_SANDBOX_PORTS=1^)
set "PORTS_FLAG=--service-ports"
) else (
echo ==^> Ports not published ^(set AI_SANDBOX_PORTS=1 to publish them^)
)
:: ==========================================================================
:: 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.
:: setlocal keeps the variable out of the calling CMD session.
:: ==========================================================================
if "%AI_SANDBOX_SKILLS_RW%"=="1" (
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.
set "AI_SANDBOX_SKILLS_MODE=rw"
) else (
set "AI_SANDBOX_SKILLS_MODE=ro"
)
:: ==========================================================================
:: 7. Run. The forwarded command runs in the Linux container as given;
:: each argument in EXTRA_CMD keeps its own quotes.
:: ==========================================================================
echo ==^> Starting AI dev sandbox...
call docker compose -f "%COMPOSE_FILE%" run --rm %BUILD_FLAG% %PORTS_FLAG% ai-dev %EXTRA_CMD%
set "RC=%ERRORLEVEL%"
popd >nul
exit /b %RC%
:: ==========================================================================
:: Subroutines
:: ==========================================================================
:: :require_line <regex> <message>
:: Returns errorlevel 1 if the pattern is not found in %COMPOSE_FILE%.
:require_line
findstr /R /C:"%~1" "%COMPOSE_FILE%" >nul 2>&1
if errorlevel 1 (
echo ERROR: compose.yaml does not contain a line matching: %~1 1>&2
echo %~2 1>&2
exit /b 1
)
exit /b 0
:: :require_text <literal> <message>
:: Returns errorlevel 1 if the literal text is not found in %COMPOSE_FILE%.
:require_text
findstr /L /C:"%~1" "%COMPOSE_FILE%" >nul 2>&1
if errorlevel 1 (
echo ERROR: compose.yaml does not contain: %~1 1>&2
echo %~2 1>&2
exit /b 1
)
exit /b 0
:: :check_ports
:: Walks each line of compose.yaml. When inside the `ports:` block, every
:: `- "..."` entry must start with 127.0.0.1:.
:check_ports
setlocal EnableDelayedExpansion
set "IN_PORTS=0"
for /f "usebackq delims=" %%L in ("%COMPOSE_FILE%") do (
set "LINE=%%L"
set "STRIPPED=!LINE: =!"
if "!STRIPPED!"=="ports:" (
set "IN_PORTS=1"
) else (
if "!IN_PORTS!"=="1" (
if "!STRIPPED:~0,1!"=="-" (
set "ENTRY=!STRIPPED:~1!"
rem strip all double and single quotes
set "ENTRY=!ENTRY:"=!"
set "ENTRY=!ENTRY:'=!"
if "!ENTRY:~0,10!" NEQ "127.0.0.1:" (
echo ERROR: compose.yaml publishes a port on a non-loopback interface: !ENTRY! 1>&2
echo Every entry under 'ports:' must start with '127.0.0.1:'. 1>&2
endlocal & exit /b 1
)
) else if not "!STRIPPED!"=="" (
set "IN_PORTS=0"
)
)
)
)
endlocal & exit /b 0