feat: executor v0.3.0: multi-credential named relay (CRED_<NAME>_USER/SECRET env; full secret scrub)

This commit is contained in:
2026-10-04 01:30:12 +00:00
parent e06c8724e6
commit 3e9caa9ac6
+62 -27
View File
@@ -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")