diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d50a09f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..50cfc1b --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# coreci/executor-config — coreci-executor (Write Executor, Design v1.0) + +Single governed write rail from Open WebUI agents to Gitea. +- `allowlist.yaml` — policy engine config (AUTO_RUN vs NEEDS_APPROVAL; fail-closed) +- `compose.yaml` + `executor.env.example` — deployment (secrets in .env only) +- `app/` — coreci-executor v0.1.1 (FastAPI) +- `docs/` — approval-UI spec, runbook, Open WebUI registration guide +- `logs/` — git-durable audit JSONL (one line per request event) + +Governance: agent authors -> executor gates -> PO approves (NEEDS_APPROVAL) -> +executor runs as `coreci-executor` (non-admin; collaborator-write ONLY on the +repos granted in the deployment pack) -> audit + Discord notify. +Secrets never in chat, repos, or tool calls — executor env vars only. diff --git a/allowlist.yaml b/allowlist.yaml index 9cfb2c4..ee78d4d 100644 --- a/allowlist.yaml +++ b/allowlist.yaml @@ -12,4 +12,4 @@ agents: kinds: bash_pack: auto_run_targets: - - coreci/executor-config \ No newline at end of file + - coreci/executor-config diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..df2db17 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + git curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py . +EXPOSE 8000 +CMD ["uvicorn","main:app","--host","0.0.0.0","--port","8000"] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..dc25011 --- /dev/null +++ b/app/main.py @@ -0,0 +1,227 @@ +"""coreci-executor v0.1.1 — governed write rail (Design v1.0, approved 2026-10-03). + +Tool endpoints (X-Executor-Key auth, for Open WebUI Global Tool Server): + POST /submit_request {agent, kind, targets[], content, justification} + GET /request_status?request_id=... + GET /health +Web UI (passcode session): / (queue), /ui/request/{id}, /ui/audit, + /ui/login, POST /ui/request/{id}/approve|deny. +Policy: allowlist.yaml (fail-closed NEEDS_APPROVAL; AUTO_RUN for agent's own +home-repo packs). Packs must be credential-free; auth is injected at run time. +Audit: JSONL to logs/ (+ optional Discord webhook). Secrets stay in env. +""" +import hashlib +import json +import os +import re +import shutil +import subprocess +import tempfile +import threading +import uuid +from datetime import datetime, timezone +from html import escape as esc +from pathlib import Path + +import httpx +import yaml +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +APP_DIR = Path(__file__).resolve().parent +ENV = os.environ +API_KEY = ENV["EXECUTOR_API_KEY"] +PASSCODE = ENV["EXECUTOR_PASSCODE"] +GITEA_URL = ENV.get("GITEA_URL", "https://git.coreci.dev") +GITEA_HOST = GITEA_URL.split("://", 1)[-1] +GITEA_USER = ENV.get("GITEA_USER", "coreci-executor") +GITEA_TOKEN = ENV["GITEA_TOKEN"] +DISCORD_URL = ENV.get("DISCORD_WEBHOOK_URL", "") +ALLOWLIST_PATH = ENV.get("ALLOWLIST_PATH", "/config/allowlist.yaml") +WORK_ROOT = Path(ENV.get("WORK_ROOT", "/work")) +AUDIT_DIR = APP_DIR.parent / "logs" +MAX_TAIL = int(ENV.get("MAX_OUTPUT_TAIL", "4000")) +TIMEOUT_S = int(ENV.get("REQUEST_TIMEOUT_S", "900")) +COOKIE_NAME = "exec_session" +CRED_RE = re.compile(r"https?://[^/\s@]+:[^@\s]+@") + +app = FastAPI(title="coreci-executor", version="0.1.1") +REQS: dict[str, dict] = {} +LOCK = threading.Lock() + +def now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + +def get_req(rid: str) -> dict: + r = REQS.get(rid) + if r is None: + raise HTTPException(404, "no such request") + return r + +def audit(evt: str, r: dict, **extra) -> None: + AUDIT_DIR.mkdir(parents=True, exist_ok=True) + rec = {"ts": now(), "event": evt, "id": r.get("id"), + "agent": r.get("agent"), "kind": r.get("kind"), + "targets": r.get("targets"), "decision": r.get("decision"), + "actor": extra.pop("actor", None), **extra} + line = json.dumps(rec, default=str) + (AUDIT_DIR / f"audit-{datetime.now(timezone.utc):%Y-%m-%d}.jsonl").open("a").write(line + "\n") + if DISCORD_URL: + try: + httpx.post(DISCORD_URL, json={"content": f"[executor] {evt}: {line[:1500]}"}, timeout=5) + except Exception: + pass + +def classify(agent: str, kind: str, targets: list) -> str: + cfg = yaml.safe_load(Path(ALLOWLIST_PATH).read_text()) or {} + ag = (cfg.get("agents") or {}).get(agent) or {} + auto = ((ag.get("kinds") or {}).get(kind) or {}).get("auto_run_targets") or [] + return "AUTO_RUN" if targets and all(t in auto for t in targets) else "NEEDS_APPROVAL" + +def run_pack(rid: str) -> None: + r = REQS[rid] + r["status"] = "running"; audit("started", r) + home = Path(tempfile.mkdtemp(dir=str(WORK_ROOT), prefix="req-")) + (home / ".git-credentials").write_text( + f"https://{GITEA_USER}:{GITEA_TOKEN}@{GITEA_HOST}\n") + script = "git config --global credential.helper store\n" + r["content"] + env = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(home), + "GIT_TERMINAL_PROMPT": "0", "GITEA_URL": GITEA_URL, + "GITEA_TOKEN": GITEA_TOKEN, "GITEA_USER": GITEA_USER, "TZ": "UTC"} + try: + p = subprocess.run(["bash", "-c", script], capture_output=True, + text=True, timeout=TIMEOUT_S, cwd=home, env=env) + tail = (p.stdout[-MAX_TAIL:] + "\n" + p.stderr[-MAX_TAIL:]).strip() + r.update(status="completed" if p.returncode == 0 else "failed", + exit_code=p.returncode, + output=tail.replace(GITEA_TOKEN, "***"), finished_at=now()) + audit("completed" if p.returncode == 0 else "failed", r, exit_code=p.returncode) + except subprocess.TimeoutExpired: + r.update(status="failed", output="TIMEOUT", finished_at=now()); audit("failed", r, error="timeout") + except Exception as e: + r.update(status="failed", output=str(e)[:MAX_TAIL], finished_at=now()); audit("failed", r, error=str(e)[:200]) + finally: + try: + (home / ".git-credentials").unlink() + except Exception: + pass + shutil.rmtree(home, ignore_errors=True) + +def session_ok(request: Request) -> bool: + return request.cookies.get(COOKIE_NAME) == hashlib.sha256(PASSCODE.encode()).hexdigest() + +def page(title: str, body: str) -> str: + return (f"
| id | agent | targets | " + f"status | decision |
|---|
agent {r['agent']} | kind {r['kind']} | targets {','.join(r['targets'])} " + f"| decision {r['decision']}
{esc(r['justification'])}
" + f"{esc(r['content'])}"
+ f"{esc(r.get('output', '(pending)'))}{act}"))
+
+@app.post("/ui/request/{rid}/approve")
+def approve(rid: str, request: Request):
+ if not session_ok(request):
+ return RedirectResponse("/ui/login")
+ r = get_req(rid)
+ if r["status"] == "pending_approval":
+ r["status"] = "running"; r["decision"] = "APPROVED"; audit("approved", r, actor="PO")
+ threading.Thread(target=run_pack, args=(rid,), daemon=True).start()
+ return RedirectResponse(f"/ui/request/{rid}", status_code=303)
+
+@app.post("/ui/request/{rid}/deny")
+def deny(rid: str, request: Request):
+ if not session_ok(request):
+ return RedirectResponse("/ui/login")
+ r = get_req(rid)
+ if r["status"] == "pending_approval":
+ r["status"] = "denied"; audit("denied", r, actor="PO")
+ return RedirectResponse(f"/ui/request/{rid}", status_code=303)
+
+@app.get("/ui/audit")
+def audit_view(request: Request):
+ if not session_ok(request):
+ return RedirectResponse("/ui/login")
+ f = AUDIT_DIR / f"audit-{datetime.now(timezone.utc):%Y-%m-%d}.jsonl"
+ txt = f.read_text()[-20000:] if f.exists() else "(empty)"
+ return HTMLResponse(page("audit", f"{esc(txt)}"))
diff --git a/app/requirements.txt b/app/requirements.txt
new file mode 100644
index 0000000..fe1501f
--- /dev/null
+++ b/app/requirements.txt
@@ -0,0 +1,4 @@
+fastapi==0.115.*
+uvicorn==0.32.*
+pyyaml==6.*
+httpx==0.27.*
diff --git a/compose.yaml b/compose.yaml
index 4a110d3..c70f740 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -4,14 +4,14 @@ services:
container_name: coreci-executor
restart: unless-stopped
ports:
- - "127.0.0.1:8765:8000" # HAProxy proxies executor.coreci.dev → here
- env_file: .env # secrets live here, never in git
+ - "127.0.0.1:8765:8000" # HAProxy proxies executor.coreci.dev -> here
+ env_file: .env
environment:
- ALLOWLIST_PATH=/config/allowlist.yaml
- WORK_ROOT=/work
volumes:
- - .:/config:ro # allowlist + docs, read-only in container
- - ./logs:/app/logs # audit JSONL (committed back to git)
+ - .:/config:ro
+ - ./logs:/app/logs
- executor-work:/work
volumes:
- executor-work: {}
\ No newline at end of file
+ executor-work: {}
diff --git a/docs/approval-ui-spec.md b/docs/approval-ui-spec.md
new file mode 100644
index 0000000..6e23154
--- /dev/null
+++ b/docs/approval-ui-spec.md
@@ -0,0 +1,19 @@
+# Approval UI — spec (v0 as-built in app/main.py v0.1.1; v1 roadmap)
+
+## v0 (as-built)
+- Passcode-gated session cookie (EXECUTOR_PASSCODE; httpOnly; SameSite=Strict).
+ Intended behind HAProxy with TLS for the PVE trust domain.
+- Pages: / (queue: pending + recent), /ui/request/{id} (full pack content,
+ agent, targets, justification, classification), /ui/audit (recent events).
+- Actions: POST-only approve / deny (no GET-side effects; one-click from the
+ queue page; Discord notification links here).
+- Queue states: pending_approval -> queued/running -> completed | failed | denied.
+- Every event appended to logs/audit-*.jsonl (id, ts, agent, kind, targets,
+ decision, actor, outcome tail).
+
+## v1 roadmap (post-P0, not yet built)
+- Typed confirmation (click + type request-id fragment) for high-impact ops.
+- HAProxy basic-auth layer in front of the passcode session.
+- Diff preview for gitea_api_ops requests (planned kind v0.2).
+- Session idle timeout + login throttling.
+- Approver identity in audit (first approver = the PO by convention).
diff --git a/docs/openwebui-registration.md b/docs/openwebui-registration.md
new file mode 100644
index 0000000..2c3bd63
--- /dev/null
+++ b/docs/openwebui-registration.md
@@ -0,0 +1,15 @@
+# Registering the executor as an Open WebUI Global Tool Server
+
+1. Ensure the executor is running and reachable from the Open WebUI BACKEND
+ (not your browser): http://