feat: executor v0.3.0: multi-credential named relay (CRED_<NAME>_USER/SECRET env; full secret scrub)
This commit is contained in:
+62
-27
@@ -1,24 +1,24 @@
|
||||
"""coreci-executor v0.2.0 — governed write rail (Design v1.0, approved 2026-10-03).
|
||||
"""coreci-executor v0.3.0 — governed write rail (Design v1.0 + v0.3 onboarding spec).
|
||||
|
||||
Tool endpoints (Authorization: Bearer <EXECUTOR_API_KEY> or X-Executor-Key):
|
||||
POST /submit_request {agent, kind, targets[], content, justification,
|
||||
credentials: {gitea_user, gitea_token} (optional)}
|
||||
credentials: {name: {user, secret}} (optional)}
|
||||
GET /request_status?request_id=...
|
||||
GET /health
|
||||
Web UI (passcode session — EXECUTOR_PASSCODE): / (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.
|
||||
home-repo packs). PROVISIONING KIND IS NEVER AUTO_RUNNABLE.
|
||||
Audit: JSONL to logs/ (+ optional Discord webhook). Secrets stay in env.
|
||||
|
||||
v0.1.2: CORS for browser-origin schema fetch; Bearer auth support (Open WebUI).
|
||||
v0.1.3: never-500 login; OpenAPI securitySchemes (Swagger Authorize button).
|
||||
v0.1.4: allow_headers wildcard — Open WebUI's tool layer sends X-Session-Id.
|
||||
v0.1.5: allow_origins wildcard — the code sandbox sends Origin: null.
|
||||
v0.2.0: agent-owned credential relay — credentials {gitea_user, gitea_token}
|
||||
ride the request envelope (never the pack content); Gitea enforces
|
||||
the relayed identity's own permissions; falls back to the executor
|
||||
identity when the envelope is absent.
|
||||
v0.2.0: agent-owned credential relay — credentials ride the request envelope.
|
||||
v0.3.0: multi-credential named relay (CRED_<NAME>_USER/SECRET env pairs; all
|
||||
secrets scrubbed from output); 'provisioning' kind = NEEDS_APPROVAL
|
||||
by construction; audit records credential NAMES only; CEO language.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
@@ -56,13 +56,14 @@ 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]+@")
|
||||
PROVISIONING_KIND = "provisioning"
|
||||
|
||||
app = FastAPI(title="coreci-executor", version="0.2.0")
|
||||
app = FastAPI(title="coreci-executor", version="0.3.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # v0.1.5: sandbox iframe sends Origin: null
|
||||
allow_origins=["*"],
|
||||
allow_methods=["GET", "POST", "OPTIONS"],
|
||||
allow_headers=["*"], # v0.1.4: Open WebUI tool calls carry X-Session-Id
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
REQS: dict[str, dict] = {}
|
||||
@@ -82,7 +83,7 @@ def audit(evt: str, r: dict, **extra) -> None:
|
||||
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"),
|
||||
"auth": ("relay:" + r.get("relay_user")) if r.get("relay_token") else "executor",
|
||||
"auth": ",".join(sorted(r.get("creds_names") or [])) or "executor",
|
||||
"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")
|
||||
@@ -93,17 +94,43 @@ def audit(evt: str, r: dict, **extra) -> None:
|
||||
pass
|
||||
|
||||
def classify(agent: str, kind: str, targets: list) -> str:
|
||||
# provisioning is NEVER auto-runnable, regardless of allowlist contents
|
||||
if kind == PROVISIONING_KIND:
|
||||
return "NEEDS_APPROVAL"
|
||||
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 normalize_creds(raw) -> dict:
|
||||
"""Accept {gitea_user, gitea_token} (v0.2.0 shape) or {name: {user, secret}}.
|
||||
Returns {name: {user, secret}} with stripped strings."""
|
||||
creds: dict[str, dict] = {}
|
||||
if not isinstance(raw, dict):
|
||||
return creds
|
||||
if ("gitea_user" in raw or "gitea_token" in raw) and "gitea" not in raw:
|
||||
u = (raw.get("gitea_user") or "").strip()
|
||||
s = (raw.get("gitea_token") or "").strip()
|
||||
if u or s:
|
||||
creds["gitea"] = {"user": u, "secret": s}
|
||||
return creds
|
||||
for name, pair in raw.items():
|
||||
if not isinstance(pair, dict):
|
||||
continue
|
||||
u = (pair.get("user") or "").strip()
|
||||
s = (pair.get("secret") or "").strip()
|
||||
if u or s:
|
||||
creds[str(name)] = {"user": u, "secret": s}
|
||||
return creds
|
||||
|
||||
def run_pack(rid: str) -> None:
|
||||
r = REQS[rid]
|
||||
r["status"] = "running"; audit("started", r)
|
||||
relay_user, relay_token = r.get("relay_user") or "", r.get("relay_token") or ""
|
||||
git_user = relay_user or GITEA_USER
|
||||
git_token = relay_token or GITEA_TOKEN
|
||||
creds = r.get("creds") or {}
|
||||
# Primary identity: first pair with a user, else executor identity
|
||||
primary = next((c for c in creds.values() if c["user"]), None)
|
||||
git_user = primary["user"] if primary else GITEA_USER
|
||||
git_token = primary["secret"] if primary else GITEA_TOKEN
|
||||
home = Path(tempfile.mkdtemp(dir=str(WORK_ROOT), prefix="req-"))
|
||||
(home / ".git-credentials").write_text(
|
||||
f"https://{git_user}:{git_token}@{GITEA_HOST}\n")
|
||||
@@ -111,15 +138,25 @@ def run_pack(rid: str) -> None:
|
||||
env = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(home),
|
||||
"GIT_TERMINAL_PROMPT": "0", "GITEA_URL": GITEA_URL,
|
||||
"GITEA_TOKEN": git_token, "GITEA_USER": git_user, "TZ": "UTC"}
|
||||
# expose every named credential as env pairs; track secrets for scrubbing
|
||||
secrets = [GITEA_TOKEN]
|
||||
for name, c in creds.items():
|
||||
cap = re.sub(r"[^A-Z0-9]", "_", name.upper())
|
||||
if c["user"]:
|
||||
env[f"CRED_{cap}_USER"] = c["user"]
|
||||
if c["secret"]:
|
||||
env[f"CRED_{cap}_SECRET"] = c["secret"]
|
||||
secrets.append(c["secret"])
|
||||
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()
|
||||
if relay_token:
|
||||
tail = tail.replace(relay_token, "***")
|
||||
for s in secrets:
|
||||
if s:
|
||||
tail = tail.replace(s, "***")
|
||||
r.update(status="completed" if p.returncode == 0 else "failed",
|
||||
exit_code=p.returncode,
|
||||
output=tail.replace(GITEA_TOKEN, "***"), finished_at=now())
|
||||
output=tail, 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")
|
||||
@@ -150,7 +187,7 @@ def page(title: str, body: str) -> str:
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "version": "0.2.0", "requests": len(REQS)}
|
||||
return {"status": "ok", "version": "0.3.0", "requests": len(REQS)}
|
||||
|
||||
@app.post("/submit_request")
|
||||
async def submit(request: Request):
|
||||
@@ -160,11 +197,9 @@ async def submit(request: Request):
|
||||
agent, kind = d.get("agent", ""), d.get("kind", "bash_pack")
|
||||
targets = d.get("targets") or []
|
||||
content, just = d.get("content", ""), d.get("justification", "")
|
||||
creds = d.get("credentials") or {}
|
||||
relay_user = (creds.get("gitea_user") or "").strip()
|
||||
relay_token = (creds.get("gitea_token") or "").strip()
|
||||
if kind != "bash_pack":
|
||||
raise HTTPException(400, "v0 supports kind=bash_pack only")
|
||||
creds = normalize_creds(d.get("credentials"))
|
||||
if kind not in ("bash_pack", PROVISIONING_KIND):
|
||||
raise HTTPException(400, "v0 supports kind=bash_pack or provisioning only")
|
||||
if not (agent and content and just):
|
||||
raise HTTPException(400, "agent, content, justification required")
|
||||
if not isinstance(targets, list) or not targets or not all(isinstance(t, str) for t in targets):
|
||||
@@ -175,7 +210,7 @@ async def submit(request: Request):
|
||||
r = {"id": rid, "agent": agent, "kind": kind, "targets": targets,
|
||||
"content": content, "justification": just, "submitted_at": now(),
|
||||
"status": "pending_approval", "decision": classify(agent, kind, targets),
|
||||
"relay_user": relay_user, "relay_token": relay_token}
|
||||
"creds": creds, "creds_names": sorted(creds.keys())}
|
||||
with LOCK:
|
||||
REQS[rid] = r
|
||||
audit("submitted", r)
|
||||
@@ -190,7 +225,7 @@ def status(request_id: str, request: Request):
|
||||
raise HTTPException(401, "unauthorized")
|
||||
r = get_req(request_id)
|
||||
return {k: r.get(k) for k in ("id", "agent", "kind", "targets", "decision",
|
||||
"status", "exit_code", "output", "submitted_at", "finished_at")}
|
||||
"status", "exit_code", "output", "submitted_at", "finished_at")}
|
||||
|
||||
@app.get("/")
|
||||
def root(request: Request):
|
||||
@@ -247,7 +282,7 @@ def approve(rid: str, request: 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")
|
||||
r["status"] = "running"; r["decision"] = "APPROVED"; audit("approved", r, actor="CEO")
|
||||
threading.Thread(target=run_pack, args=(rid,), daemon=True).start()
|
||||
return RedirectResponse(f"/ui/request/{rid}", status_code=303)
|
||||
|
||||
@@ -257,7 +292,7 @@ def deny(rid: str, request: Request):
|
||||
return RedirectResponse("/ui/login")
|
||||
r = get_req(rid)
|
||||
if r["status"] == "pending_approval":
|
||||
r["status"] = "denied"; audit("denied", r, actor="PO")
|
||||
r["status"] = "denied"; audit("denied", r, actor="CEO")
|
||||
return RedirectResponse(f"/ui/request/{rid}", status_code=303)
|
||||
|
||||
@app.get("/ui/audit")
|
||||
|
||||
Reference in New Issue
Block a user