Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Opus 5.5 a0464bc347 fix: only accept code-execution approvals issued by /api/code/execute
Entries created via the unauthenticated POST /api/approvals carry a
caller-chosen risk_level/agent_name and could be auto-approved under a
non-default policy, then redeemed to run arbitrary code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 01:50:28 +00:00
MervinPraisonandClaude Opus 5.5 c8a3af4f3e fix: gate code execution on approval decision and require auth to approve (fixes #311)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 01:16:30 +00:00
4 changed files with 175 additions and 5 deletions

No files matched your search

+10
View File
@@ -31,6 +31,16 @@ graph TD
3. If `praisonaiagents` is installed, delegates to `execute_code` for real execution
4. Otherwise returns a simulation response with the code size
## Approval & Auth
Every `POST /api/code/execute` records an approval decision (`tool_name: "execute_code"`, `risk_level: "medium"`) through the [approvals](../api/features-api.md) manager:
- **Default policy** (`risk_threshold: "high"`) auto-approves and records the decision; the response includes `approval_id`.
- `always_deny_tools: ["execute_code"]` → `403 {"status": "denied"}`.
- A stricter policy (e.g. `risk_threshold: "low"`) → `202 {"status": "pending_approval", "approval_id": "..."}`. After approval via `POST /api/approvals/{id}/approve`, resubmit the **same** code with `"approval_id"`. Each approval works once and only for that exact code.
When auth is enabled (`/api/auth/config` mode other than `none`), code execution, approve/deny, and `PUT /api/approvals/policies` return `401` to unauthenticated callers. The recorded `approver` is the authenticated identity, not a value from the request body.
## Configuration
```python
+24 -2
View File
@@ -46,6 +46,20 @@ def _list_backend_approvals() -> List[Dict[str, Any]] | None:
return items if isinstance(items, list) else None
def _authenticated_user(request: Request) -> Optional[str]:
"""Return the caller identity, or ``None`` when auth is enabled and the caller is
unauthenticated. In auth mode ``none`` every caller is accepted (backward compat)."""
from .auth import check_auth
result = check_auth(request)
if not result:
return None
return result.get("user") or "user"
_UNAUTHORIZED = {"error": "Authentication required"}
# ── Approval Protocol ────────────────────────────────────────────────
@@ -446,13 +460,16 @@ class ApprovalsFeature(BaseFeatureProtocol):
return JSONResponse(result, status_code=201)
async def _approve(self, request: Request) -> JSONResponse:
approver = _authenticated_user(request)
if approver is None:
return JSONResponse(_UNAUTHORIZED, status_code=401)
mgr = get_approval_manager()
approval_id = request.path_params["approval_id"]
body = await request.json() if request.headers.get("content-length") else {}
entry = mgr.approve(
approval_id,
reason=body.get("reason", ""),
approver=body.get("approver", "user"),
approver=approver,
always=body.get("always", False),
)
if entry is None:
@@ -461,13 +478,16 @@ class ApprovalsFeature(BaseFeatureProtocol):
return JSONResponse(entry)
async def _deny(self, request: Request) -> JSONResponse:
approver = _authenticated_user(request)
if approver is None:
return JSONResponse(_UNAUTHORIZED, status_code=401)
mgr = get_approval_manager()
approval_id = request.path_params["approval_id"]
body = await request.json() if request.headers.get("content-length") else {}
entry = mgr.deny(
approval_id,
reason=body.get("reason", ""),
approver=body.get("approver", "user"),
approver=approver,
always=body.get("always", False),
)
if entry is None:
@@ -496,6 +516,8 @@ class ApprovalsFeature(BaseFeatureProtocol):
)
async def _update_policies(self, request: Request) -> JSONResponse:
if _authenticated_user(request) is None:
return JSONResponse(_UNAUTHORIZED, status_code=401)
mgr = get_approval_manager()
body = await request.json()
policies = mgr.update_policies(body)
+54 -3
View File
@@ -141,6 +141,9 @@ def set_execution_manager(manager: CodeExecutionProtocol) -> None:
_execution_manager = manager
EXECUTE_TOOL_NAME = "execute_code"
# ── Feature class ────────────────────────────────────────────────────
@@ -171,13 +174,61 @@ class CodeExecutionFeature(BaseFeatureProtocol):
return h
async def _execute(self, request: Request) -> JSONResponse:
mgr = get_execution_manager()
from .approvals import _authenticated_user, _notify_subscribers, get_approval_manager
if _authenticated_user(request) is None:
return JSONResponse({"error": "Authentication required"}, status_code=401)
body = await request.json()
code = body.get("code", "")
language = body.get("language", "python")
# Every execution goes through the approval manager so a decision is
# recorded. Default policy auto-approves (risk below threshold); operators
# can require manual approval via policies (e.g. lower ``risk_threshold``).
approvals = get_approval_manager()
args = {"code": code, "language": language}
approval_id = body.get("approval_id")
if approval_id:
decision = approvals.get(approval_id)
if (
not decision
or decision.get("status") != "approved"
or decision.get("source") != EXECUTE_TOOL_NAME
or decision.get("arguments") != args
or decision.get("consumed")
):
return JSONResponse({"error": "Approval not valid for this code"}, status_code=403)
else:
decision = approvals.request_approval(
{
"tool_name": EXECUTE_TOOL_NAME,
"arguments": args,
"risk_level": "medium",
# Only approvals issued here are redeemable; POST /api/approvals
# entries (caller-chosen risk/agent) cannot authorise execution.
"source": EXECUTE_TOOL_NAME,
}
)
if decision["status"] == "denied":
return JSONResponse(
{"status": "denied", "approval_id": decision["id"], "error": "Execution denied"},
status_code=403,
)
if decision["status"] != "approved":
await _notify_subscribers("new", decision)
return JSONResponse(
{"status": "pending_approval", "approval_id": decision["id"]},
status_code=202,
)
decision["consumed"] = True # single-use: no replay after policy changes
mgr = get_execution_manager()
result = mgr.execute(
code=body.get("code", ""),
language=body.get("language", "python"),
code=code,
language=language,
timeout=body.get("timeout", 30),
)
result = {**result, "approval_id": decision["id"]}
status = 200 if result.get("status") != "error" else 400
return JSONResponse(result, status_code=status)
+87
View File
@@ -0,0 +1,87 @@
"""Code execution is gated by approvals; approval endpoints honour auth (#311)."""
import pytest
from starlette.applications import Starlette
from starlette.testclient import TestClient
from praisonaiui.features import approvals as approvals_mod
from praisonaiui.features import auth as auth_mod
from praisonaiui.features.approvals import ApprovalsFeature, SimpleApprovalManager
from praisonaiui.features.code_execution import CodeExecutionFeature
@pytest.fixture
def client(monkeypatch):
mgr = SimpleApprovalManager()
monkeypatch.setattr(approvals_mod, "_approval_manager", mgr)
monkeypatch.setitem(auth_mod._auth_config, "mode", "none")
app = Starlette(routes=ApprovalsFeature().routes() + CodeExecutionFeature().routes())
c = TestClient(app)
c.mgr = mgr
return c
CODE = {"code": "print(1)", "language": "python"}
def test_default_policy_auto_approves_and_records(client):
r = client.post("/api/code/execute", json=CODE)
assert r.status_code == 200
aid = r.json()["approval_id"]
entry = client.mgr.get(aid)
assert entry["status"] == "approved" and entry["tool_name"] == "execute_code"
def test_denied_policy_blocks_execution(client):
client.mgr.update_policies({"always_deny_tools": ["execute_code"]})
r = client.post("/api/code/execute", json=CODE)
assert r.status_code == 403
assert r.json()["status"] == "denied"
def test_pending_then_approved_executes_once(client):
client.mgr.update_policies({"risk_threshold": "low"})
r = client.post("/api/code/execute", json=CODE)
assert r.status_code == 202
aid = r.json()["approval_id"]
# Not approved yet → rejected
assert client.post("/api/code/execute", json={**CODE, "approval_id": aid}).status_code == 403
assert client.post(f"/api/approvals/{aid}/approve", json={}).status_code == 200
# Approval is bound to the exact code
other = {"code": "print(2)", "language": "python", "approval_id": aid}
assert client.post("/api/code/execute", json=other).status_code == 403
assert client.post("/api/code/execute", json={**CODE, "approval_id": aid}).status_code == 200
# Single use
assert client.post("/api/code/execute", json={**CODE, "approval_id": aid}).status_code == 403
def test_auth_required_when_enabled(client, monkeypatch):
client.mgr.update_policies({"risk_threshold": "low"})
aid = client.post("/api/code/execute", json=CODE).json()["approval_id"]
monkeypatch.setitem(auth_mod._auth_config, "mode", "api_key")
assert client.post(f"/api/approvals/{aid}/approve", json={}).status_code == 401
assert client.post(f"/api/approvals/{aid}/deny", json={}).status_code == 401
assert client.put("/api/approvals/policies", json={"auto_approve_tools": ["x"]}).status_code == 401
assert client.post("/api/code/execute", json=CODE).status_code == 401
assert client.mgr.get(aid)["status"] == "pending"
monkeypatch.setitem(auth_mod._auth_config, "api_keys", {"pk_test": {"name": "alice"}})
r = client.post(f"/api/approvals/{aid}/approve", json={}, headers={"X-API-Key": "pk_test"})
assert r.status_code == 200
assert r.json()["approver"] == "alice"
def test_externally_created_approval_cannot_authorise_execution(client):
# Policy that would auto-approve a caller-chosen low-risk entry
client.mgr.update_policies({"risk_threshold": "medium"})
r = client.post(
"/api/approvals",
json={"tool_name": "execute_code", "arguments": CODE, "risk_level": "low"},
)
assert r.json()["status"] == "approved"
aid = r.json()["id"]
assert client.post("/api/code/execute", json={**CODE, "approval_id": aid}).status_code == 403