Add controlled coding agent automation
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-08-21 22:49:49 +02:00
parent 9bac41f071
commit bfa5cdcd3e
25 changed files with 1263 additions and 1 deletions

View File

@@ -0,0 +1 @@
__version__ = "0.1.0"

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
import os
import pwd
import subprocess
from pathlib import Path
from .config import Settings
class AiderAgent:
def __init__(self, settings: Settings) -> None:
self.settings = settings
def run(self, workspace: Path, task: str) -> str:
task_file = workspace / ".git" / "failure-task.txt"
task_file.write_text(task)
task_file.chmod(0o600)
command = [
self.settings.agent_command,
"--model",
self.settings.agent_model,
"--message-file",
str(task_file),
"--yes",
"--no-auto-commits",
"--no-dirty-commits",
"--no-stream",
]
coder = pwd.getpwnam("coder")
os.chown(task_file, coder.pw_uid, coder.pw_gid)
for path in workspace.rglob("*"):
if ".git" not in path.parts:
os.chown(path, coder.pw_uid, coder.pw_gid, follow_symlinks=False)
os.chown(workspace, coder.pw_uid, coder.pw_gid, follow_symlinks=False)
environment = {
"HOME": coder.pw_dir,
"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
}
for name in ("MISTRAL_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"):
if os.getenv(name):
environment[name] = os.environ[name]
def drop_privileges() -> None:
os.setgroups([])
os.setgid(coder.pw_gid)
os.setuid(coder.pw_uid)
result = subprocess.run(
command,
cwd=workspace,
env=environment,
text=True,
capture_output=True,
check=False,
preexec_fn=drop_privileges,
timeout=3600,
)
if result.returncode != 0:
raise RuntimeError(f"Aider failed ({result.returncode}): {result.stderr[-4000:]}")
return result.stdout[-12_000:]

36
src/lai_automation/cli.py Normal file
View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import argparse
import logging
from .config import Settings
from .worker import AutomationWorker
def main() -> None:
parser = argparse.ArgumentParser(description="LAI controlled coding-agent automation")
parser.add_argument("command", choices=["once", "worker", "check"])
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
settings = Settings.from_env()
if args.command == "check":
print(f"mode={settings.mode}")
print(f"source={settings.source_repository}")
print(f"model={settings.agent_model}")
print(f"direct_main_enabled={settings.allow_direct_main}")
return
worker = AutomationWorker(settings)
if args.command == "once":
worker.run_once()
else:
worker.run_forever()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
def _bool(name: str, default: bool = False) -> bool:
return os.getenv(name, str(default)).lower() in {"1", "true", "yes", "on"}
def _commands(value: str) -> tuple[str, ...]:
return tuple(command.strip() for command in value.split(";;") if command.strip())
@dataclass(frozen=True)
class Settings:
database_host: str
database_port: int
database_name: str
database_user: str
database_password: str
source_repository: str
source_branch: str
workspace_root: Path
mode: str
allow_direct_main: bool
gitea_url: str
gitea_owner: str
gitea_repository: str
gitea_token: str
gitea_username: str
agent_command: str
agent_model: str
test_commands: tuple[str, ...]
poll_seconds: int
max_changed_files: int
max_changed_lines: int
stale_claim_minutes: int
dry_run: bool
@classmethod
def from_env(cls) -> Settings:
mode = os.getenv("AUTOMATION_MODE", "dry-run").lower()
if mode not in {"dry-run", "pr", "direct-main"}:
raise ValueError("AUTOMATION_MODE must be dry-run, pr, or direct-main")
settings = cls(
database_host=os.getenv("FAILURE_DB_HOST", "host.docker.internal"),
database_port=int(os.getenv("FAILURE_DB_PORT", "33060")),
database_name=os.getenv("FAILURE_DB_NAME", "laravel_ai_chat"),
database_user=os.getenv("FAILURE_DB_USER", "laravel"),
database_password=os.getenv("FAILURE_DB_PASSWORD", ""),
source_repository=os.getenv(
"SOURCE_REPOSITORY", "https://gitea.tisinfra.de/christian.meyer/lai.git"
),
source_branch=os.getenv("SOURCE_BRANCH", "main"),
workspace_root=Path(os.getenv("WORKSPACE_ROOT", "/workspaces")),
mode=mode,
allow_direct_main=_bool("ALLOW_DIRECT_MAIN"),
gitea_url=os.getenv("GITEA_URL", "https://gitea.tisinfra.de"),
gitea_owner=os.getenv("GITEA_OWNER", "christian.meyer"),
gitea_repository=os.getenv("GITEA_REPOSITORY", "lai"),
gitea_token=os.getenv("GITEA_TOKEN", ""),
gitea_username=os.getenv("GITEA_USERNAME", "automation-bot"),
agent_command=os.getenv("AGENT_COMMAND", "aider"),
agent_model=os.getenv("AGENT_MODEL", "mistral/mistral-medium-latest"),
test_commands=_commands(
os.getenv(
"TEST_COMMANDS",
"composer install --no-interaction --prefer-dist;;php artisan test",
)
),
poll_seconds=int(os.getenv("POLL_SECONDS", "60")),
max_changed_files=int(os.getenv("MAX_CHANGED_FILES", "12")),
max_changed_lines=int(os.getenv("MAX_CHANGED_LINES", "800")),
stale_claim_minutes=int(os.getenv("STALE_CLAIM_MINUTES", "90")),
dry_run=mode == "dry-run",
)
settings.validate()
return settings
def validate(self) -> None:
if self.mode == "direct-main" and not self.allow_direct_main:
raise ValueError("direct-main requires ALLOW_DIRECT_MAIN=true")
if self.mode in {"pr", "direct-main"} and not self.gitea_token:
raise ValueError(f"{self.mode} requires GITEA_TOKEN")
if not self.database_password:
raise ValueError("FAILURE_DB_PASSWORD is required")

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
import json
from collections.abc import Iterator
from contextlib import contextmanager
import pymysql
from pymysql.cursors import DictCursor
from .config import Settings
from .models import FailureReport
class FailureRepository:
def __init__(self, settings: Settings) -> None:
self.settings = settings
@contextmanager
def connection(self) -> Iterator[pymysql.Connection]:
connection = pymysql.connect(
host=self.settings.database_host,
port=self.settings.database_port,
user=self.settings.database_user,
password=self.settings.database_password,
database=self.settings.database_name,
cursorclass=DictCursor,
autocommit=False,
connect_timeout=10,
read_timeout=30,
write_timeout=30,
)
try:
yield connection
finally:
connection.close()
def claim_next(self) -> FailureReport | None:
with self.connection() as connection, connection.cursor() as cursor:
try:
cursor.execute(
"""
UPDATE failure_reports
SET status = 'open', updated_at = NOW()
WHERE status = 'agent_running'
AND updated_at < DATE_SUB(NOW(), INTERVAL %s MINUTE)
""",
(self.settings.stale_claim_minutes,),
)
connection.commit()
cursor.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")
cursor.execute("START TRANSACTION")
except Exception:
connection.rollback()
raise
cursor.execute(
"""
SELECT * FROM failure_reports
WHERE status = 'open'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
"""
)
row = cursor.fetchone()
if not row:
connection.commit()
return None
cursor.execute(
"UPDATE failure_reports SET status = 'agent_running', updated_at = NOW() WHERE id = %s",
(row["id"],),
)
connection.commit()
return FailureReport.from_row(row)
def release(self, report_id: int, reason: str) -> None:
self._finish(report_id, "open", {"last_dry_run": reason})
def complete(self, report_id: int, metadata: dict) -> None:
self._finish(report_id, "resolved", metadata)
def fail(self, report_id: int, error: str, metadata: dict | None = None) -> None:
payload = {"agent_error": error, **(metadata or {})}
self._finish(report_id, "agent_failed", payload)
def _finish(self, report_id: int, status: str, additions: dict) -> None:
with self.connection() as connection, connection.cursor() as cursor:
try:
cursor.execute("SELECT context FROM failure_reports WHERE id = %s", (report_id,))
row = cursor.fetchone() or {}
context = row.get("context") or {}
if isinstance(context, str):
context = json.loads(context)
context.update(additions)
cursor.execute(
"""
UPDATE failure_reports
SET status = %s, context = %s, updated_at = NOW()
WHERE id = %s
""",
(status, json.dumps(context, ensure_ascii=True), report_id),
)
connection.commit()
except Exception as exception:
connection.rollback()
raise RuntimeError(
f"Could not finalize failure report {report_id} as {status}"
) from exception

143
src/lai_automation/git.py Normal file
View File

@@ -0,0 +1,143 @@
from __future__ import annotations
import os
import pwd
import shlex
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
from .config import Settings
@dataclass(frozen=True)
class ChangeSummary:
files: tuple[str, ...]
added_lines: int
deleted_lines: int
@property
def changed_lines(self) -> int:
return self.added_lines + self.deleted_lines
class GitWorkspace:
def __init__(self, settings: Settings, report_id: int) -> None:
self.settings = settings
self.path = settings.workspace_root / f"failure-{report_id}"
self.branch = f"ai/failure-{report_id}"
def prepare(self) -> None:
repository = urlparse(self.settings.source_repository)
gitea = urlparse(self.settings.gitea_url)
if repository.scheme != "https" or repository.hostname != gitea.hostname:
raise ValueError("SOURCE_REPOSITORY must use HTTPS on the configured Gitea host")
shutil.rmtree(self.path, ignore_errors=True)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._git("clone", "--branch", self.settings.source_branch, "--single-branch", self.settings.source_repository, str(self.path), cwd=self.path.parent)
self._git("checkout", "-b", self.branch)
def run(
self,
command: str,
check: bool = True,
username: str | None = None,
) -> subprocess.CompletedProcess[str]:
environment = None
drop_privileges = None
if username:
account = pwd.getpwnam(username)
environment = {
"HOME": account.pw_dir,
"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
}
def drop_privileges() -> None:
os.setgroups([])
os.setgid(account.pw_gid)
os.setuid(account.pw_uid)
return subprocess.run(
shlex.split(command),
cwd=self.path,
env=environment,
text=True,
capture_output=True,
check=check,
timeout=1800,
preexec_fn=drop_privileges,
)
def summary(self) -> ChangeSummary:
self._git("add", "-N", "--all")
output = self._git("diff", "--numstat").stdout
files: list[str] = []
added = deleted = 0
for line in output.splitlines():
add, remove, filename = line.split("\t", 2)
files.append(filename)
added += int(add) if add.isdigit() else 0
deleted += int(remove) if remove.isdigit() else 0
return ChangeSummary(tuple(files), added, deleted)
def commit(self, report_id: int) -> str:
self._git("add", "--all")
self._git("commit", "-m", f"Fix failure report #{report_id}")
return self._git("rev-parse", "HEAD").stdout.strip()
def push_branch(self) -> None:
self._git_authenticated("push", "origin", f"HEAD:{self.branch}")
def push_main(self) -> None:
self._git_authenticated("push", "origin", f"HEAD:{self.settings.source_branch}")
def cleanup(self) -> None:
if self.path.exists():
shutil.rmtree(self.path)
def _git(self, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=cwd or self.path,
text=True,
capture_output=True,
check=True,
timeout=300,
)
def _git_authenticated(self, *args: str) -> subprocess.CompletedProcess[str]:
askpass = self.path / ".git" / "lai-askpass.sh"
username_file = self.path / ".git" / "lai-bot-user"
token_file = self.path / ".git" / "lai-bot-token"
try:
username_file.write_text(self.settings.gitea_username)
token_file.write_text(self.settings.gitea_token)
username_file.chmod(0o600)
token_file.chmod(0o600)
askpass.write_text(
"#!/bin/sh\ncase \"$1\" in *Username*) cat \"$GIT_BOT_USER_FILE\";; *) cat \"$GIT_BOT_TOKEN_FILE\";; esac\n"
)
askpass.chmod(0o700)
env = {
"HOME": os.environ.get("HOME", "/root"),
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"GIT_ASKPASS": str(askpass),
"GIT_TERMINAL_PROMPT": "0",
"GIT_BOT_USER_FILE": str(username_file),
"GIT_BOT_TOKEN_FILE": str(token_file),
}
return subprocess.run(
["git", *args],
cwd=self.path,
env=env,
text=True,
capture_output=True,
check=True,
timeout=300,
)
finally:
askpass.unlink(missing_ok=True)
username_file.unlink(missing_ok=True)
token_file.unlink(missing_ok=True)

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from .config import Settings
class GiteaClient:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"token {settings.gitea_token}",
"Accept": "application/json",
})
retry = Retry(
total=3,
backoff_factor=1,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=("POST",),
)
self.session.mount("https://", HTTPAdapter(max_retries=retry))
def create_pull_request(self, branch: str, title: str, body: str) -> str:
url = (
f"{self.settings.gitea_url.rstrip('/')}/api/v1/repos/"
f"{self.settings.gitea_owner}/{self.settings.gitea_repository}/pulls"
)
response = self.session.post(
url,
json={
"base": self.settings.source_branch,
"head": branch,
"title": title,
"body": body,
},
timeout=30,
)
response.raise_for_status()
payload = response.json()
pull_request_url = payload.get("html_url") or payload.get("url")
if not pull_request_url:
raise ValueError("Gitea response did not contain a pull request URL")
return pull_request_url

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class FailureReport:
id: int
chat_message_id: int | None
source: str
category: str
user_feedback: str | None
prompt: str | None
response: str | None
sql_query: str | None
error_message: str | None
context: dict[str, Any]
@classmethod
def from_row(cls, row: dict[str, Any]) -> FailureReport:
context = row.get("context") or {}
if isinstance(context, str):
context = json.loads(context)
return cls(
id=row["id"],
chat_message_id=row.get("chat_message_id"),
source=row["source"],
category=row["category"],
user_feedback=row.get("user_feedback"),
prompt=row.get("prompt"),
response=row.get("response"),
sql_query=row.get("sql_query"),
error_message=row.get("error_message"),
context=context,
)

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from .config import Settings
from .git import ChangeSummary
_ALWAYS_DENIED = (
".env",
"vendor/",
"storage/",
"bootstrap/cache/",
"docker/",
"docker-compose",
".gitea/",
".github/",
)
_DIRECT_MAIN_DENIED = (
"database/migrations/",
"app/Http/Middleware/",
"app/Models/User.php",
"app/Services/PermissionService.php",
"app/Services/SQLValidatorService.php",
"config/auth.php",
"config/database.php",
"routes/",
)
@dataclass(frozen=True)
class PolicyResult:
allowed: bool
errors: tuple[str, ...]
def evaluate_changes(
summary: ChangeSummary,
settings: Settings,
workspace: Path | None = None,
) -> PolicyResult:
errors: list[str] = []
if not summary.files:
errors.append("Agent produced no file changes")
if len(summary.files) > settings.max_changed_files:
errors.append(f"Changed {len(summary.files)} files; limit is {settings.max_changed_files}")
if summary.changed_lines > settings.max_changed_lines:
errors.append(f"Changed {summary.changed_lines} lines; limit is {settings.max_changed_lines}")
for filename in summary.files:
if PurePosixPath(filename).is_absolute() or ".." in PurePosixPath(filename).parts:
errors.append(f"Invalid changed path: {filename}")
continue
if workspace and (workspace / filename).is_symlink():
errors.append(f"Symbolic links are not allowed: {filename}")
continue
if any(filename == prefix or filename.startswith(prefix) for prefix in _ALWAYS_DENIED):
errors.append(f"Denied path changed: {filename}")
if settings.mode == "direct-main" and any(
filename == prefix or filename.startswith(prefix) for prefix in _DIRECT_MAIN_DENIED
):
errors.append(f"Direct-main denied path changed: {filename}")
return PolicyResult(not errors, tuple(errors))

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import re
from .models import FailureReport
_SECRET_PATTERNS = (
(
re.compile(r"(?i)\b(?:mysql|mariadb|postgres(?:ql)?|sqlserver)://[^\s]+"),
"[DATABASE_URL]",
),
(re.compile(r"(?i)(password|passwd|secret|token|api[_ -]?key)\s*[:=]\s*\S+"), r"\1=[REDACTED]"),
(re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), "[EMAIL]"),
(re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "[IP]"),
(re.compile(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]+"), "Bearer [REDACTED]"),
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[AWS_KEY]"),
(re.compile(r"-----BEGIN [A-Z ]+KEY-----[\s\S]+?-----END [A-Z ]+KEY-----"), "[PRIVATE_KEY]"),
(re.compile(r"\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"), "[JWT]"),
)
def redact(value: str | None) -> str:
result = value or ""
for pattern, replacement in _SECRET_PATTERNS:
result = pattern.sub(replacement, result)
return result[:12_000]
def build_task(report: FailureReport) -> str:
prompt = redact(report.prompt)
feedback = redact(report.user_feedback)
error = redact(report.error_message)
sql = redact(report.sql_query)
return f"""You are fixing failure report #{report.id} in the Laravel AI Chat repository.
Rules:
- First add a regression test that reproduces the reported behavior.
- Make the smallest production change that fixes the root cause.
- Preserve read-only SQL validation and tenant isolation.
- Never read or modify .env files, credentials, deployment secrets, or production data.
- Do not weaken tests, authentication, authorization, SQL validation, or safety checks.
- Do not commit or push; the orchestrator owns Git publication.
Failure category: {report.category}
Source: {report.source}
Model: {redact(str(report.context.get('model', 'unknown')))}
Database type: {redact(str(report.context.get('database_type', 'unknown')))}
User request:
{prompt or '[not available]'}
User feedback:
{feedback or '[not available]'}
Observed error:
{error or '[not available]'}
Generated SQL, if relevant:
{sql or '[not available]'}
Run focused tests while working. Leave the repository with the fix and regression test applied.
"""

View File

@@ -0,0 +1,133 @@
from __future__ import annotations
import logging
import signal
import threading
from dataclasses import asdict
from .agent import AiderAgent
from .config import Settings
from .database import FailureRepository
from .git import GitWorkspace
from .gitea import GiteaClient
from .models import FailureReport
from .policy import evaluate_changes
from .prompt import build_task
LOG = logging.getLogger(__name__)
class AutomationWorker:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.reports = FailureRepository(settings)
self.agent = AiderAgent(settings)
self.gitea = GiteaClient(settings)
self.stop_event = threading.Event()
def run_once(self) -> bool:
report = self.reports.claim_next()
if not report:
LOG.info("No open failure reports")
return False
LOG.info("Claimed failure report %s", report.id)
if self.settings.dry_run:
task = build_task(report)
LOG.info("Dry-run task for report %s:\n%s", report.id, task)
self.reports.release(report.id, "Task generated successfully; no agent executed")
return True
workspace = GitWorkspace(self.settings, report.id)
metadata: dict = {"mode": self.settings.mode, "model": self.settings.agent_model}
try:
workspace.prepare()
task = build_task(report)
metadata["agent_output"] = self.agent.run(workspace.path, task)
summary = workspace.summary()
metadata["changes"] = asdict(summary)
policy = evaluate_changes(summary, self.settings, workspace.path)
if not policy.allowed:
raise RuntimeError("Policy rejected changes: " + "; ".join(policy.errors))
metadata["tests"] = self._run_tests(workspace)
summary = workspace.summary()
metadata["changes"] = asdict(summary)
policy = evaluate_changes(summary, self.settings, workspace.path)
if not policy.allowed:
raise RuntimeError(
"Policy rejected final changes: " + "; ".join(policy.errors)
)
commit = workspace.commit(report.id)
metadata["commit"] = commit
if self.settings.mode == "pr":
workspace.push_branch()
metadata["pull_request"] = self.gitea.create_pull_request(
workspace.branch,
f"Fix failure report #{report.id}",
self._pull_request_body(report, summary, metadata["tests"]),
)
elif self.settings.mode == "direct-main":
workspace.push_main()
metadata["direct_main"] = True
self.reports.complete(report.id, metadata)
LOG.info("Completed failure report %s", report.id)
return True
except Exception as exception:
LOG.exception("Failure report %s failed", report.id)
self.reports.fail(report.id, str(exception), metadata)
return True
finally:
try:
workspace.cleanup()
except OSError:
LOG.exception("Could not clean workspace for report %s", report.id)
def run_forever(self) -> None:
signal.signal(signal.SIGTERM, self._stop)
signal.signal(signal.SIGINT, self._stop)
while not self.stop_event.is_set():
self.run_once()
self.stop_event.wait(self.settings.poll_seconds)
def _stop(self, signum: int, _frame) -> None:
LOG.info("Received signal %s; stopping after the current report", signum)
self.stop_event.set()
def _run_tests(self, workspace: GitWorkspace) -> list[dict]:
results = []
for command in self.settings.test_commands:
result = workspace.run(command, check=False, username="coder")
results.append({
"command": command,
"returncode": result.returncode,
"stdout": result.stdout[-6000:],
"stderr": result.stderr[-6000:],
})
if result.returncode != 0:
raise RuntimeError(f"Test command failed: {command}\n{result.stderr[-2000:]}")
return results
@staticmethod
def _pull_request_body(report: FailureReport, summary, tests: list[dict]) -> str:
checks = "\n".join(
f"- {'PASS' if item['returncode'] == 0 else 'FAIL'} `{item['command']}`"
for item in tests
)
files = "\n".join(f"- `{filename}`" for filename in summary.files)
return f"""Automated fix for internal failure report #{report.id}.
## Scope
{files}
## Validation
{checks}
## Risk controls
- No production database access was provided to the coding agent.
- Secret and deployment paths are blocked by policy.
- Human review is required before merge.
"""