Skip to content

Commit ca5fb7d

Browse files
committed
feat: add OPA eval CLI commands
1 parent 4cca1f5 commit ca5fb7d

3 files changed

Lines changed: 126 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ MVP Python implementation of an open control plane for agent tool governance. It
1919
- Registry persistence: registry saved to `registry.json` by default (override with `OCPA_REGISTRY_PATH`), loaded on startup; create via `/registry/tools` and `/registry/agents` (validates side_effect_level and tool references). Mutations require JWT bearer token (HS256 via `OCPA_JWT_SECRET`) and roles header `X-OCPA-Roles` containing `OCPA_ADMIN_ROLE`. Server uses FastAPI lifespan hooks for startup.
2020
- Auth: Bearer JWTs validated via HS256 shared secret (`OCPA_JWT_SECRET`) or JWKS (`OCPA_JWKS_URL`) with caching; optional audience/issuer checks; mutation RBAC enforced via required admin role. JWKS cache honors `OCPA_JWKS_CACHE_TTL` and refreshes on missing `kid` (rotation).
2121
- Policy RBAC: Rego now checks `allowed_roles` against actor `roles`/`actor_roles` in context (with `actor_id` forwarded); read-only/low/high rules still apply and high side effects require ticket + non-prod. Registry mutations are also enforced via OPA (`policies/registry_allow.rego`).
22+
- CLI: `scripts/ocpa_cli.py` provides `list-tools`, `list-agents`, `eval-tool-policy`, and `eval-registry-policy` using the OPA HTTP API.
2223

2324
## Getting Started
2425
Requirements: Python 3.9+ and Docker (for OPA/demo stack).

scripts/ocpa_cli.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from __future__ import annotations
22

33
import json
4+
from typing import Optional
45

56
import typer
67

8+
from ocpa.opa import OPAClient
79
from ocpa.registry import registry
10+
from ocpa.types import ToolMetadata
811

9-
app = typer.Typer(help="OCPA registry CLI")
12+
app = typer.Typer(help="OCPA registry and policy CLI")
1013

1114

1215
@app.command()
@@ -23,5 +26,55 @@ def list_agents():
2326
typer.echo(json.dumps([agent.__dict__ for agent in agents], indent=2))
2427

2528

29+
@app.command()
30+
def eval_tool_policy(
31+
tool_id: str = typer.Option(..., help="Tool id"),
32+
side_effect_level: str = typer.Option(..., help="none|low|high"),
33+
data_classes: str = typer.Option("", help="Comma-separated data classes"),
34+
env: str = typer.Option("dev", help="Environment (env in context)"),
35+
roles: str = typer.Option("", help="Comma-separated actor roles"),
36+
opa_url: str = typer.Option("http://localhost:8181", help="OPA base URL"),
37+
ticket_id: Optional[str] = typer.Option(None, help="Ticket id for high side-effect"),
38+
):
39+
"""Evaluate tool policy via OPA HTTP API."""
40+
client = OPAClient(opa_url)
41+
metadata = ToolMetadata(
42+
tool_id=tool_id,
43+
description="cli-eval",
44+
side_effect_level=side_effect_level,
45+
data_classes=[r for r in data_classes.split(",") if r] if data_classes else [],
46+
)
47+
context = {
48+
"env": env,
49+
"roles": [r for r in roles.split(",") if r] if roles else [],
50+
"ticket_id": ticket_id,
51+
}
52+
result = client.evaluate_tool(tool_metadata=metadata, tool_input={}, context=context)
53+
typer.echo(json.dumps(result.__dict__, indent=2))
54+
55+
56+
@app.command()
57+
def eval_registry_policy(
58+
action: str = typer.Option(..., help="create/update/delete"),
59+
resource_type: str = typer.Option(..., help="tool|agent"),
60+
env: str = typer.Option("dev", help="Environment"),
61+
roles: str = typer.Option("", help="Comma-separated actor roles"),
62+
required_admin_role: str = typer.Option("admin", help="Admin role required"),
63+
ticket_id: Optional[str] = typer.Option(None, help="Ticket id for prod"),
64+
opa_url: str = typer.Option("http://localhost:8181", help="OPA base URL"),
65+
):
66+
"""Evaluate registry mutation policy via OPA HTTP API."""
67+
client = OPAClient(opa_url)
68+
context = {
69+
"env": env,
70+
"roles": [r for r in roles.split(",") if r] if roles else [],
71+
"actor_roles": [r for r in roles.split(",") if r] if roles else [],
72+
"required_admin_role": required_admin_role,
73+
"ticket_id": ticket_id,
74+
}
75+
result = client.evaluate_registry(action=action, resource_type=resource_type, context=context, payload={})
76+
typer.echo(json.dumps(result.__dict__, indent=2))
77+
78+
2679
if __name__ == "__main__":
2780
app()

tests/test_cli.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import json
2+
3+
from typer.testing import CliRunner
4+
5+
from scripts.ocpa_cli import app
6+
7+
8+
def test_eval_tool_policy_cli(monkeypatch):
9+
runner = CliRunner()
10+
11+
def ok_post(url, json=None, timeout=None):
12+
return DummyResponse({"result": {"allow": True, "reason": "ok", "redact_paths": []}})
13+
14+
monkeypatch.setattr("ocpa.opa.requests.post", ok_post)
15+
result = runner.invoke(
16+
app,
17+
[
18+
"eval-tool-policy",
19+
"--tool-id",
20+
"t1",
21+
"--side-effect-level",
22+
"none",
23+
"--roles",
24+
"admin",
25+
"--opa-url",
26+
"http://opa",
27+
],
28+
)
29+
assert result.exit_code == 0
30+
data = json.loads(result.stdout)
31+
assert data["allowed"] is True
32+
33+
34+
def test_eval_registry_policy_cli(monkeypatch):
35+
runner = CliRunner()
36+
37+
def ok_post(url, json=None, timeout=None):
38+
return DummyResponse({"result": {"allow": False, "reason": "deny_test"}})
39+
40+
monkeypatch.setattr("ocpa.opa.requests.post", ok_post)
41+
result = runner.invoke(
42+
app,
43+
[
44+
"eval-registry-policy",
45+
"--action",
46+
"create",
47+
"--resource-type",
48+
"tool",
49+
"--roles",
50+
"admin",
51+
"--opa-url",
52+
"http://opa",
53+
],
54+
)
55+
assert result.exit_code == 0
56+
data = json.loads(result.stdout)
57+
assert data["allowed"] is False
58+
assert data["reason"] == "deny_test"
59+
60+
61+
class DummyResponse:
62+
def __init__(self, payload, status_code: int = 200):
63+
self.payload = payload
64+
self.status_code = status_code
65+
66+
def json(self):
67+
return self.payload
68+
69+
def raise_for_status(self):
70+
if self.status_code >= 400:
71+
raise Exception("status error")

0 commit comments

Comments
 (0)