mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0464bc347 | ||
|
|
c8a3af4f3e | ||
|
|
129adb9285 |
No files matched your search
@@ -27,7 +27,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]7
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.16
|
||||
hub_ref: v1.1.17
|
||||
@@ -16,8 +16,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]7
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.16
|
||||
hub_ref: v1.1.17
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -25,8 +25,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
detect-and-trigger:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]7
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.16
|
||||
hub_ref: v1.1.17
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -43,9 +43,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]6
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]7
|
||||
with:
|
||||
hub_ref: v1.1.16
|
||||
hub_ref: v1.1.17
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
@@ -87,9 +87,10 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
statuses: write # the assess job records security/outsider-review
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.16
|
||||
hub_ref: v1.1.17
|
||||
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]7
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.16
|
||||
hub_ref: v1.1.17
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]6
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]7
|
||||
with:
|
||||
hub_ref: v1.1.16
|
||||
hub_ref: v1.1.17
|
||||
- name: Detect conflicting PRs and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]7
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.16
|
||||
hub_ref: v1.1.17
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user