policyaware 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- policyaware/__init__.py +46 -0
- policyaware/approvals.py +68 -0
- policyaware/audit.py +222 -0
- policyaware/cli.py +306 -0
- policyaware/data_protection.py +51 -0
- policyaware/evals.py +171 -0
- policyaware/gateway.py +79 -0
- policyaware/integrations/__init__.py +2 -0
- policyaware/integrations/fastapi.py +41 -0
- policyaware/integrations/flask.py +29 -0
- policyaware/integrations/langchain.py +29 -0
- policyaware/integrations/llamaindex.py +26 -0
- policyaware/models.py +196 -0
- policyaware/observability.py +80 -0
- policyaware/policy.py +237 -0
- policyaware/policy_schema.py +136 -0
- policyaware/providers.py +443 -0
- policyaware/reason_codes.py +36 -0
- policyaware/risk.py +94 -0
- policyaware/routing.py +45 -0
- policyaware/tools.py +121 -0
- policyaware-0.2.0.dist-info/METADATA +170 -0
- policyaware-0.2.0.dist-info/RECORD +26 -0
- policyaware-0.2.0.dist-info/WHEEL +4 -0
- policyaware-0.2.0.dist-info/entry_points.txt +2 -0
- policyaware-0.2.0.dist-info/licenses/LICENSE +18 -0
policyaware/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from policyaware.gateway import Gateway
|
|
2
|
+
from policyaware.models import (
|
|
3
|
+
GatewayRequest,
|
|
4
|
+
GatewayResponse,
|
|
5
|
+
ModelCandidate,
|
|
6
|
+
PolicyDecision,
|
|
7
|
+
RiskAssessment,
|
|
8
|
+
ToolDecision,
|
|
9
|
+
)
|
|
10
|
+
from policyaware.providers import (
|
|
11
|
+
AnthropicProvider,
|
|
12
|
+
AzureOpenAIProvider,
|
|
13
|
+
BedrockProvider,
|
|
14
|
+
OllamaProvider,
|
|
15
|
+
OpenAICompatibleProvider,
|
|
16
|
+
ProviderRegistry,
|
|
17
|
+
SimulatedProvider,
|
|
18
|
+
VLLMProvider,
|
|
19
|
+
VertexAIProvider,
|
|
20
|
+
default_provider_registry,
|
|
21
|
+
)
|
|
22
|
+
from policyaware.tools import ToolPolicyEngine, ToolRegistry
|
|
23
|
+
from policyaware.risk import RiskClassifier
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Gateway",
|
|
27
|
+
"GatewayRequest",
|
|
28
|
+
"GatewayResponse",
|
|
29
|
+
"ModelCandidate",
|
|
30
|
+
"PolicyDecision",
|
|
31
|
+
"RiskAssessment",
|
|
32
|
+
"RiskClassifier",
|
|
33
|
+
"OpenAICompatibleProvider",
|
|
34
|
+
"AzureOpenAIProvider",
|
|
35
|
+
"AnthropicProvider",
|
|
36
|
+
"BedrockProvider",
|
|
37
|
+
"VertexAIProvider",
|
|
38
|
+
"OllamaProvider",
|
|
39
|
+
"VLLMProvider",
|
|
40
|
+
"ProviderRegistry",
|
|
41
|
+
"default_provider_registry",
|
|
42
|
+
"SimulatedProvider",
|
|
43
|
+
"ToolDecision",
|
|
44
|
+
"ToolPolicyEngine",
|
|
45
|
+
"ToolRegistry",
|
|
46
|
+
]
|
policyaware/approvals.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib import request as urlrequest
|
|
7
|
+
|
|
8
|
+
from policyaware.models import ApprovalRequest, GatewayRequest, PolicyDecision
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApprovalClient(ABC):
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def submit(self, request: GatewayRequest, decision: PolicyDecision) -> ApprovalRequest:
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NoopApprovalClient(ApprovalClient):
|
|
18
|
+
def submit(self, request: GatewayRequest, decision: PolicyDecision) -> ApprovalRequest:
|
|
19
|
+
return ApprovalRequest(
|
|
20
|
+
tenant=request.tenant,
|
|
21
|
+
app=request.app,
|
|
22
|
+
user=request.user,
|
|
23
|
+
decision=decision,
|
|
24
|
+
request_snapshot=request.model_dump(mode="json"),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FileApprovalClient(ApprovalClient):
|
|
29
|
+
def __init__(self, path: str | Path = ".policyaware/approvals.jsonl"):
|
|
30
|
+
self.path = Path(path)
|
|
31
|
+
|
|
32
|
+
def submit(self, request: GatewayRequest, decision: PolicyDecision) -> ApprovalRequest:
|
|
33
|
+
approval = ApprovalRequest(
|
|
34
|
+
tenant=request.tenant,
|
|
35
|
+
app=request.app,
|
|
36
|
+
user=request.user,
|
|
37
|
+
decision=decision,
|
|
38
|
+
request_snapshot=request.model_dump(mode="json"),
|
|
39
|
+
)
|
|
40
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
42
|
+
handle.write(approval.model_dump_json() + "\n")
|
|
43
|
+
return approval
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WebhookApprovalClient(ApprovalClient):
|
|
47
|
+
def __init__(self, url: str, timeout_seconds: int = 15):
|
|
48
|
+
self.url = url
|
|
49
|
+
self.timeout_seconds = timeout_seconds
|
|
50
|
+
|
|
51
|
+
def submit(self, request: GatewayRequest, decision: PolicyDecision) -> ApprovalRequest:
|
|
52
|
+
approval = ApprovalRequest(
|
|
53
|
+
tenant=request.tenant,
|
|
54
|
+
app=request.app,
|
|
55
|
+
user=request.user,
|
|
56
|
+
decision=decision,
|
|
57
|
+
request_snapshot=request.model_dump(mode="json"),
|
|
58
|
+
)
|
|
59
|
+
body = json.dumps(approval.model_dump(mode="json")).encode("utf-8")
|
|
60
|
+
req = urlrequest.Request(
|
|
61
|
+
self.url,
|
|
62
|
+
data=body,
|
|
63
|
+
headers={"Content-Type": "application/json"},
|
|
64
|
+
method="POST",
|
|
65
|
+
)
|
|
66
|
+
with urlrequest.urlopen(req, timeout=self.timeout_seconds):
|
|
67
|
+
pass
|
|
68
|
+
return approval
|
policyaware/audit.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from policyaware.models import AuditTrace, GatewayRequest, GatewayResponse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def estimate_tokens(text: str) -> int:
|
|
13
|
+
return max(1, len(text.split()))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuditLogger:
|
|
17
|
+
def __init__(self, path: str | Path | None = None):
|
|
18
|
+
self.path = Path(path) if path else None
|
|
19
|
+
|
|
20
|
+
def record(self, request: GatewayRequest, response: GatewayResponse, started_at: float) -> AuditTrace:
|
|
21
|
+
input_tokens = estimate_tokens(request.prompt_text)
|
|
22
|
+
output_tokens = estimate_tokens(response.content)
|
|
23
|
+
model = response.route.model if response.route else None
|
|
24
|
+
trace = AuditTrace(
|
|
25
|
+
trace_id=response.trace_id,
|
|
26
|
+
request_id=request.request_id,
|
|
27
|
+
tenant=request.tenant,
|
|
28
|
+
app=request.app,
|
|
29
|
+
user_id=request.user.get("id"),
|
|
30
|
+
task_type=request.context.get("task_type"),
|
|
31
|
+
policy_decision=response.policy.decision.value,
|
|
32
|
+
matched_rules=response.policy.matched_rules,
|
|
33
|
+
reason_codes=response.policy.reason_codes,
|
|
34
|
+
actions=response.policy.actions,
|
|
35
|
+
risk_tier=response.policy.risk_tier.value,
|
|
36
|
+
model=model.name if model else None,
|
|
37
|
+
input_tokens=input_tokens,
|
|
38
|
+
output_tokens=output_tokens,
|
|
39
|
+
estimated_cost_usd=((input_tokens + output_tokens) / 1000)
|
|
40
|
+
* (model.cost_per_1k_tokens if model else 0),
|
|
41
|
+
latency_ms=int((time.perf_counter() - started_at) * 1000),
|
|
42
|
+
risk_score=response.policy.risk_score,
|
|
43
|
+
eval_scores={result.name: result.score for result in response.evals},
|
|
44
|
+
request_snapshot=request.model_dump(mode="json"),
|
|
45
|
+
response_snapshot={
|
|
46
|
+
"content": response.content,
|
|
47
|
+
"policy": response.policy.model_dump(mode="json"),
|
|
48
|
+
"route": response.route.model_dump(mode="json") if response.route else None,
|
|
49
|
+
"evals": [result.model_dump(mode="json") for result in response.evals],
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
if self.path:
|
|
53
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
55
|
+
handle.write(trace.model_dump_json() + "\n")
|
|
56
|
+
return trace
|
|
57
|
+
|
|
58
|
+
def export_jsonl(self, traces: list[AuditTrace], path: str | Path) -> None:
|
|
59
|
+
output = Path(path)
|
|
60
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
with output.open("w", encoding="utf-8") as handle:
|
|
62
|
+
for trace in traces:
|
|
63
|
+
handle.write(json.dumps(trace.model_dump(mode="json")) + "\n")
|
|
64
|
+
|
|
65
|
+
def read_traces(self) -> list[dict[str, Any]]:
|
|
66
|
+
if not self.path or not self.path.exists():
|
|
67
|
+
return []
|
|
68
|
+
traces: list[dict[str, Any]] = []
|
|
69
|
+
with self.path.open("r", encoding="utf-8") as handle:
|
|
70
|
+
for line in handle:
|
|
71
|
+
if line.strip():
|
|
72
|
+
traces.append(json.loads(line))
|
|
73
|
+
return traces
|
|
74
|
+
|
|
75
|
+
def find_trace(self, trace_id: str) -> dict[str, Any] | None:
|
|
76
|
+
for trace in self.read_traces():
|
|
77
|
+
if trace.get("trace_id") == trace_id:
|
|
78
|
+
return trace
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SQLiteAuditLogger(AuditLogger):
|
|
83
|
+
def __init__(self, path: str | Path = ".policyaware/audit.db"):
|
|
84
|
+
super().__init__(None)
|
|
85
|
+
self.db_path = Path(path)
|
|
86
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
self._init_db()
|
|
88
|
+
|
|
89
|
+
def _init_db(self) -> None:
|
|
90
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
91
|
+
conn.execute(
|
|
92
|
+
"""
|
|
93
|
+
CREATE TABLE IF NOT EXISTS traces (
|
|
94
|
+
trace_id TEXT PRIMARY KEY,
|
|
95
|
+
tenant TEXT NOT NULL,
|
|
96
|
+
app TEXT NOT NULL,
|
|
97
|
+
policy_decision TEXT NOT NULL,
|
|
98
|
+
risk_tier TEXT NOT NULL,
|
|
99
|
+
created_at TEXT NOT NULL,
|
|
100
|
+
trace_json TEXT NOT NULL
|
|
101
|
+
)
|
|
102
|
+
"""
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def record(self, request: GatewayRequest, response: GatewayResponse, started_at: float) -> AuditTrace:
|
|
106
|
+
trace = super().record(request, response, started_at)
|
|
107
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
108
|
+
conn.execute(
|
|
109
|
+
"""
|
|
110
|
+
INSERT OR REPLACE INTO traces
|
|
111
|
+
(trace_id, tenant, app, policy_decision, risk_tier, created_at, trace_json)
|
|
112
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
113
|
+
""",
|
|
114
|
+
(
|
|
115
|
+
trace.trace_id,
|
|
116
|
+
trace.tenant,
|
|
117
|
+
trace.app,
|
|
118
|
+
trace.policy_decision,
|
|
119
|
+
trace.risk_tier,
|
|
120
|
+
trace.created_at.isoformat(),
|
|
121
|
+
trace.model_dump_json(),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
return trace
|
|
125
|
+
|
|
126
|
+
def read_traces(self) -> list[dict[str, Any]]:
|
|
127
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
128
|
+
rows = conn.execute("SELECT trace_json FROM traces ORDER BY created_at DESC").fetchall()
|
|
129
|
+
return [json.loads(row[0]) for row in rows]
|
|
130
|
+
|
|
131
|
+
def find_trace(self, trace_id: str) -> dict[str, Any] | None:
|
|
132
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
133
|
+
row = conn.execute("SELECT trace_json FROM traces WHERE trace_id = ?", (trace_id,)).fetchone()
|
|
134
|
+
return json.loads(row[0]) if row else None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TraceViewer:
|
|
138
|
+
def write_html(self, traces: list[dict[str, Any]], path: str | Path) -> Path:
|
|
139
|
+
output = Path(path)
|
|
140
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
rows = "\n".join(
|
|
142
|
+
"<tr>"
|
|
143
|
+
f"<td>{trace.get('trace_id')}</td>"
|
|
144
|
+
f"<td>{trace.get('tenant')}</td>"
|
|
145
|
+
f"<td>{trace.get('app')}</td>"
|
|
146
|
+
f"<td>{trace.get('policy_decision')}</td>"
|
|
147
|
+
f"<td>{trace.get('risk_tier')}</td>"
|
|
148
|
+
f"<td>{trace.get('model') or '-'}</td>"
|
|
149
|
+
f"<td>{trace.get('latency_ms')}</td>"
|
|
150
|
+
f"<td>{', '.join(trace.get('reason_codes', []))}</td>"
|
|
151
|
+
"</tr>"
|
|
152
|
+
for trace in traces
|
|
153
|
+
)
|
|
154
|
+
html = f"""<!doctype html>
|
|
155
|
+
<html lang="en">
|
|
156
|
+
<head>
|
|
157
|
+
<meta charset="utf-8">
|
|
158
|
+
<title>PolicyAware Trace Viewer</title>
|
|
159
|
+
<style>
|
|
160
|
+
body {{ font-family: Arial, sans-serif; margin: 24px; color: #1f2933; }}
|
|
161
|
+
h1 {{ color: #1f4e79; }}
|
|
162
|
+
table {{ border-collapse: collapse; width: 100%; font-size: 13px; }}
|
|
163
|
+
th {{ background: #1f4e79; color: white; text-align: left; }}
|
|
164
|
+
th, td {{ border: 1px solid #d0d7de; padding: 8px; vertical-align: top; }}
|
|
165
|
+
tr:nth-child(even) {{ background: #f6f8fa; }}
|
|
166
|
+
</style>
|
|
167
|
+
</head>
|
|
168
|
+
<body>
|
|
169
|
+
<h1>PolicyAware Trace Viewer</h1>
|
|
170
|
+
<p>Static audit trace view generated from local audit storage.</p>
|
|
171
|
+
<table>
|
|
172
|
+
<thead>
|
|
173
|
+
<tr>
|
|
174
|
+
<th>Trace</th><th>Tenant</th><th>App</th><th>Decision</th>
|
|
175
|
+
<th>Risk</th><th>Model</th><th>Latency ms</th><th>Reason Codes</th>
|
|
176
|
+
</tr>
|
|
177
|
+
</thead>
|
|
178
|
+
<tbody>{rows}</tbody>
|
|
179
|
+
</table>
|
|
180
|
+
</body>
|
|
181
|
+
</html>
|
|
182
|
+
"""
|
|
183
|
+
output.write_text(html, encoding="utf-8")
|
|
184
|
+
return output
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class AuditBundleWriter:
|
|
188
|
+
def write(self, trace: dict[str, Any], output_dir: str | Path) -> list[Path]:
|
|
189
|
+
output = Path(output_dir)
|
|
190
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
files = {
|
|
192
|
+
"trace.json": trace,
|
|
193
|
+
"decision.json": trace.get("response_snapshot", {}).get("policy", {}),
|
|
194
|
+
"request.json": trace.get("request_snapshot", {}),
|
|
195
|
+
"eval_report.json": trace.get("response_snapshot", {}).get("evals", []),
|
|
196
|
+
"summary.md": self._summary(trace),
|
|
197
|
+
}
|
|
198
|
+
written: list[Path] = []
|
|
199
|
+
for name, content in files.items():
|
|
200
|
+
path = output / name
|
|
201
|
+
with path.open("w", encoding="utf-8") as handle:
|
|
202
|
+
if name.endswith(".md"):
|
|
203
|
+
handle.write(str(content))
|
|
204
|
+
else:
|
|
205
|
+
json.dump(content, handle, indent=2)
|
|
206
|
+
written.append(path)
|
|
207
|
+
return written
|
|
208
|
+
|
|
209
|
+
def _summary(self, trace: dict[str, Any]) -> str:
|
|
210
|
+
return "\n".join(
|
|
211
|
+
[
|
|
212
|
+
f"# PolicyAware Audit Summary",
|
|
213
|
+
"",
|
|
214
|
+
f"- Trace: `{trace.get('trace_id')}`",
|
|
215
|
+
f"- Tenant: `{trace.get('tenant')}`",
|
|
216
|
+
f"- Decision: `{trace.get('policy_decision')}`",
|
|
217
|
+
f"- Risk tier: `{trace.get('risk_tier')}`",
|
|
218
|
+
f"- Reason codes: `{', '.join(trace.get('reason_codes', [])) or '-'}`",
|
|
219
|
+
f"- Model: `{trace.get('model') or '-'}`",
|
|
220
|
+
f"- Latency: `{trace.get('latency_ms')} ms`",
|
|
221
|
+
]
|
|
222
|
+
)
|
policyaware/cli.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from policyaware.audit import AuditBundleWriter, AuditLogger, SQLiteAuditLogger, TraceViewer
|
|
10
|
+
from policyaware.data_protection import DataProtectionEngine
|
|
11
|
+
from policyaware.evals import EvalSuiteRunner
|
|
12
|
+
from policyaware.gateway import Gateway
|
|
13
|
+
from policyaware.models import GatewayRequest, ToolCallRequest
|
|
14
|
+
from policyaware.observability import OpenTelemetryJsonExporter, PrometheusExporter
|
|
15
|
+
from policyaware.policy import PolicyEngine
|
|
16
|
+
from policyaware.policy_schema import PolicySchemaValidator, PolicyValidationError
|
|
17
|
+
from policyaware.risk import RiskClassifier
|
|
18
|
+
from policyaware.tools import ToolPolicyEngine
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="PolicyAware AI Gateway CLI")
|
|
21
|
+
policy_app = typer.Typer(help="Policy testing commands")
|
|
22
|
+
eval_app = typer.Typer(help="Evaluation commands")
|
|
23
|
+
dev_app = typer.Typer(help="Local development commands")
|
|
24
|
+
tools_app = typer.Typer(help="MCP and tool governance commands")
|
|
25
|
+
audit_app = typer.Typer(help="Audit and replay commands")
|
|
26
|
+
risk_app = typer.Typer(help="Risk classification commands")
|
|
27
|
+
observability_app = typer.Typer(help="Metrics and trace export commands")
|
|
28
|
+
app.add_typer(policy_app, name="policy")
|
|
29
|
+
app.add_typer(eval_app, name="eval")
|
|
30
|
+
app.add_typer(dev_app, name="dev")
|
|
31
|
+
app.add_typer(tools_app, name="tools")
|
|
32
|
+
app.add_typer(audit_app, name="audit")
|
|
33
|
+
app.add_typer(risk_app, name="risk")
|
|
34
|
+
app.add_typer(observability_app, name="observability")
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@policy_app.command("validate")
|
|
39
|
+
def validate_policy(policy_file: Path) -> None:
|
|
40
|
+
"""Validate a YAML policy file and print clear schema errors."""
|
|
41
|
+
import yaml
|
|
42
|
+
|
|
43
|
+
with policy_file.open("r", encoding="utf-8") as handle:
|
|
44
|
+
policy = yaml.safe_load(handle) or {}
|
|
45
|
+
try:
|
|
46
|
+
PolicySchemaValidator().validate(policy)
|
|
47
|
+
except PolicyValidationError as exc:
|
|
48
|
+
console.print("[bold red]Policy validation failed[/bold red]")
|
|
49
|
+
for error in exc.errors:
|
|
50
|
+
console.print(f"- {error}")
|
|
51
|
+
raise typer.Exit(code=1) from exc
|
|
52
|
+
console.print("[bold green]Policy validation passed[/bold green]")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@policy_app.command("test")
|
|
56
|
+
def test_policy(
|
|
57
|
+
policy_file: Path,
|
|
58
|
+
role: str = "support_agent",
|
|
59
|
+
tenant: str = "acme",
|
|
60
|
+
region: str = "us",
|
|
61
|
+
risk: str = "low",
|
|
62
|
+
prompt: str = "Summarize this customer request.",
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Evaluate a sample request against a YAML policy file."""
|
|
65
|
+
engine = PolicyEngine.from_file(policy_file)
|
|
66
|
+
gateway = Gateway(policy_engine=engine)
|
|
67
|
+
response = gateway.chat(
|
|
68
|
+
GatewayRequest(
|
|
69
|
+
tenant=tenant,
|
|
70
|
+
app="cli-policy-test",
|
|
71
|
+
user={"id": "cli_user", "role": role},
|
|
72
|
+
context={"region": region, "risk": risk, "task_type": "policy_test"},
|
|
73
|
+
messages=[{"role": "user", "content": prompt}],
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
table = Table(title="Policy Decision")
|
|
78
|
+
table.add_column("Field")
|
|
79
|
+
table.add_column("Value")
|
|
80
|
+
table.add_row("decision", response.policy.decision.value)
|
|
81
|
+
table.add_row("risk_tier", response.policy.risk_tier.value)
|
|
82
|
+
table.add_row("reason", response.policy.reason)
|
|
83
|
+
table.add_row("reason_codes", ", ".join(response.policy.reason_codes) or "-")
|
|
84
|
+
table.add_row("matched_rules", ", ".join(response.policy.matched_rules) or "-")
|
|
85
|
+
table.add_row("actions", ", ".join(response.policy.actions) or "-")
|
|
86
|
+
table.add_row("trace_id", response.trace_id)
|
|
87
|
+
console.print(table)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@policy_app.command("explain")
|
|
91
|
+
def explain_policy(
|
|
92
|
+
policy_file: Path,
|
|
93
|
+
role: str = "support_agent",
|
|
94
|
+
tenant: str = "acme",
|
|
95
|
+
region: str = "us",
|
|
96
|
+
risk: str = "low",
|
|
97
|
+
prompt: str = "Summarize this customer request.",
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Render a machine-readable explanation for a sample policy decision."""
|
|
100
|
+
gateway = Gateway.from_policy_file(policy_file)
|
|
101
|
+
response = gateway.chat(
|
|
102
|
+
GatewayRequest(
|
|
103
|
+
tenant=tenant,
|
|
104
|
+
app="cli-policy-explain",
|
|
105
|
+
user={"id": "cli_user", "role": role},
|
|
106
|
+
context={"region": region, "risk": risk, "task_type": "policy_explain"},
|
|
107
|
+
messages=[{"role": "user", "content": prompt}],
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
console.print_json(data=response.policy.explanation.model_dump(mode="json"))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@eval_app.command("run")
|
|
114
|
+
def run_eval(eval_file: Path, policy_file: Path | None = None) -> None:
|
|
115
|
+
"""Parse an evaluation suite and report configured checks."""
|
|
116
|
+
gateway = Gateway.from_policy_file(policy_file) if policy_file else None
|
|
117
|
+
result = EvalSuiteRunner().run_file(eval_file, gateway=gateway)
|
|
118
|
+
console.print_json(data=result)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@risk_app.command("classify")
|
|
122
|
+
def classify_risk(
|
|
123
|
+
prompt: str,
|
|
124
|
+
role: str = "support_agent",
|
|
125
|
+
domain: str = "support",
|
|
126
|
+
autonomy: str = "assistive",
|
|
127
|
+
action_type: str = "read",
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Classify request risk without calling a model."""
|
|
130
|
+
request = GatewayRequest(
|
|
131
|
+
tenant="cli",
|
|
132
|
+
app="risk-classifier",
|
|
133
|
+
user={"id": "cli_user", "role": role},
|
|
134
|
+
context={"domain": domain, "autonomy": autonomy, "action_type": action_type},
|
|
135
|
+
messages=[{"role": "user", "content": prompt}],
|
|
136
|
+
)
|
|
137
|
+
findings = DataProtectionEngine().inspect(prompt)
|
|
138
|
+
risk = RiskClassifier().classify(request, findings)
|
|
139
|
+
console.print_json(data=risk.model_dump(mode="json"))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@tools_app.command("check")
|
|
143
|
+
def check_tool(
|
|
144
|
+
policy_file: Path,
|
|
145
|
+
agent: str,
|
|
146
|
+
connector: str,
|
|
147
|
+
action: str,
|
|
148
|
+
role: str = "developer",
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Check whether an agent can call a governed tool action."""
|
|
151
|
+
engine = ToolPolicyEngine.from_file(policy_file)
|
|
152
|
+
decision = engine.decide(
|
|
153
|
+
ToolCallRequest(
|
|
154
|
+
agent_id=agent,
|
|
155
|
+
connector_id=connector,
|
|
156
|
+
action=action,
|
|
157
|
+
user={"id": "cli_user", "role": role},
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
console.print_json(data=decision.model_dump(mode="json"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@audit_app.command("bundle")
|
|
164
|
+
def audit_bundle(
|
|
165
|
+
trace_id: str,
|
|
166
|
+
traces_file: Path = Path(".policyaware/traces.jsonl"),
|
|
167
|
+
out: Path = Path(".policyaware/audit-bundle"),
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Create JSON and Markdown evidence artifacts for a trace."""
|
|
170
|
+
logger = AuditLogger(traces_file)
|
|
171
|
+
trace = logger.find_trace(trace_id)
|
|
172
|
+
if trace is None:
|
|
173
|
+
raise typer.BadParameter(f"Trace not found: {trace_id}")
|
|
174
|
+
written = AuditBundleWriter().write(trace, out)
|
|
175
|
+
for path in written:
|
|
176
|
+
console.print(str(path))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@audit_app.command("view")
|
|
180
|
+
def audit_view(
|
|
181
|
+
traces_file: Path = Path(".policyaware/traces.jsonl"),
|
|
182
|
+
out: Path = Path(".policyaware/trace-viewer.html"),
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Generate a static HTML trace viewer from JSONL audit traces."""
|
|
185
|
+
traces = AuditLogger(traces_file).read_traces()
|
|
186
|
+
output = TraceViewer().write_html(traces, out)
|
|
187
|
+
console.print(str(output))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@audit_app.command("view-sqlite")
|
|
191
|
+
def audit_view_sqlite(
|
|
192
|
+
db: Path = Path(".policyaware/audit.db"),
|
|
193
|
+
out: Path = Path(".policyaware/trace-viewer.html"),
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Generate a static HTML trace viewer from SQLite audit storage."""
|
|
196
|
+
traces = SQLiteAuditLogger(db).read_traces()
|
|
197
|
+
output = TraceViewer().write_html(traces, out)
|
|
198
|
+
console.print(str(output))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@audit_app.command("replay")
|
|
202
|
+
def replay_trace(
|
|
203
|
+
trace_id: str,
|
|
204
|
+
policy_file: Path,
|
|
205
|
+
traces_file: Path = Path(".policyaware/traces.jsonl"),
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Replay a stored request snapshot against a policy file without external model calls."""
|
|
208
|
+
trace = AuditLogger(traces_file).find_trace(trace_id)
|
|
209
|
+
if trace is None:
|
|
210
|
+
raise typer.BadParameter(f"Trace not found: {trace_id}")
|
|
211
|
+
gateway = Gateway.from_policy_file(policy_file)
|
|
212
|
+
request = GatewayRequest(**trace["request_snapshot"])
|
|
213
|
+
response = gateway.chat(request)
|
|
214
|
+
console.print_json(
|
|
215
|
+
data={
|
|
216
|
+
"trace_id": trace_id,
|
|
217
|
+
"original_decision": trace.get("policy_decision"),
|
|
218
|
+
"replay_decision": response.policy.decision.value,
|
|
219
|
+
"replay_reason_codes": response.policy.reason_codes,
|
|
220
|
+
"changed": trace.get("policy_decision") != response.policy.decision.value,
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@observability_app.command("prometheus")
|
|
226
|
+
def export_prometheus(
|
|
227
|
+
traces_file: Path = Path(".policyaware/traces.jsonl"),
|
|
228
|
+
out: Path = Path(".policyaware/metrics.prom"),
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Export local audit traces as Prometheus text exposition metrics."""
|
|
231
|
+
traces = AuditLogger(traces_file).read_traces()
|
|
232
|
+
output = PrometheusExporter().write(traces, out)
|
|
233
|
+
console.print(str(output))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@observability_app.command("otel-json")
|
|
237
|
+
def export_otel_json(
|
|
238
|
+
traces_file: Path = Path(".policyaware/traces.jsonl"),
|
|
239
|
+
out: Path = Path(".policyaware/otel-spans.json"),
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Export local audit traces as OpenTelemetry-shaped JSON spans."""
|
|
242
|
+
traces = AuditLogger(traces_file).read_traces()
|
|
243
|
+
output = OpenTelemetryJsonExporter().write(traces, out)
|
|
244
|
+
console.print(str(output))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.command("chat")
|
|
248
|
+
def chat(
|
|
249
|
+
policy_file: Path,
|
|
250
|
+
prompt: str,
|
|
251
|
+
role: str = "support_agent",
|
|
252
|
+
tenant: str = "acme",
|
|
253
|
+
risk: str = "low",
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Send a prompt through the local simulated gateway."""
|
|
256
|
+
gateway = Gateway.from_policy_file(policy_file)
|
|
257
|
+
response = gateway.chat(
|
|
258
|
+
GatewayRequest(
|
|
259
|
+
tenant=tenant,
|
|
260
|
+
app="cli-chat",
|
|
261
|
+
user={"id": "cli_user", "role": role},
|
|
262
|
+
context={"region": "us", "risk": risk, "task_type": "chat"},
|
|
263
|
+
messages=[{"role": "user", "content": prompt}],
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
console.print(response.model_dump_json(indent=2))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dev_app.command("simulate")
|
|
270
|
+
def simulate(policy_file: Path = Path("examples/policies/basic.yaml")) -> None:
|
|
271
|
+
"""Run local policy scenarios without external model calls."""
|
|
272
|
+
scenarios = [
|
|
273
|
+
("low-risk allow", "support_agent", "low", "Summarize this ticket."),
|
|
274
|
+
("PII redaction", "support_agent", "low", "Email jane@example.com about the claim."),
|
|
275
|
+
("high-risk approval", "support_agent", "high", "Approve settlement without review."),
|
|
276
|
+
("deny unknown role", "intern", "low", "Summarize this ticket."),
|
|
277
|
+
]
|
|
278
|
+
gateway = Gateway.from_policy_file(policy_file)
|
|
279
|
+
table = Table(title="Local Simulation")
|
|
280
|
+
table.add_column("Scenario")
|
|
281
|
+
table.add_column("Decision")
|
|
282
|
+
table.add_column("Risk")
|
|
283
|
+
table.add_column("Actions")
|
|
284
|
+
table.add_column("Matched Rules")
|
|
285
|
+
for name, role, risk, prompt in scenarios:
|
|
286
|
+
response = gateway.chat(
|
|
287
|
+
GatewayRequest(
|
|
288
|
+
tenant="acme",
|
|
289
|
+
app="dev-sim",
|
|
290
|
+
user={"id": role, "role": role},
|
|
291
|
+
context={"region": "us", "risk": risk, "task_type": "simulation"},
|
|
292
|
+
messages=[{"role": "user", "content": prompt}],
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
table.add_row(
|
|
296
|
+
name,
|
|
297
|
+
response.policy.decision.value,
|
|
298
|
+
response.policy.risk_tier.value,
|
|
299
|
+
", ".join(response.policy.actions) or "-",
|
|
300
|
+
", ".join(response.policy.matched_rules) or "-",
|
|
301
|
+
)
|
|
302
|
+
console.print(table)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
app()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from policyaware.models import DataFindings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DataProtectionEngine:
|
|
9
|
+
"""Detects common sensitive data patterns and can redact them."""
|
|
10
|
+
|
|
11
|
+
PATTERNS: dict[str, re.Pattern[str]] = {
|
|
12
|
+
"email": re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"),
|
|
13
|
+
"phone": re.compile(r"\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b"),
|
|
14
|
+
"ssn": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
|
|
15
|
+
"credit_card": re.compile(r"\b(?:\d[ -]*?){13,16}\b"),
|
|
16
|
+
"api_key": re.compile(r"\b(?:sk|pk|api|secret|token)_[A-Za-z0-9_\-]{16,}\b", re.I),
|
|
17
|
+
"bearer_token": re.compile(r"\bBearer\s+[A-Za-z0-9._\-]{20,}\b", re.I),
|
|
18
|
+
"medical_record": re.compile(r"\b(?:MRN|medical record|patient id)[:#\s]+[A-Z0-9-]{5,}\b", re.I),
|
|
19
|
+
"diagnosis": re.compile(r"\b(?:diagnosis|icd-10|prescription|medication)[:\s]", re.I),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
PII = {"email", "phone", "ssn", "credit_card"}
|
|
23
|
+
PHI = {"medical_record", "diagnosis"}
|
|
24
|
+
SECRETS = {"api_key", "bearer_token"}
|
|
25
|
+
|
|
26
|
+
def inspect(self, text: str) -> DataFindings:
|
|
27
|
+
categories: list[str] = []
|
|
28
|
+
redactions = 0
|
|
29
|
+
for category, pattern in self.PATTERNS.items():
|
|
30
|
+
matches = pattern.findall(text)
|
|
31
|
+
if matches:
|
|
32
|
+
categories.append(category)
|
|
33
|
+
redactions += len(matches)
|
|
34
|
+
|
|
35
|
+
found = set(categories)
|
|
36
|
+
return DataFindings(
|
|
37
|
+
contains_pii=bool(found & self.PII),
|
|
38
|
+
contains_phi=bool(found & self.PHI),
|
|
39
|
+
contains_secrets=bool(found & self.SECRETS),
|
|
40
|
+
categories=categories,
|
|
41
|
+
redactions=redactions,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def redact(self, text: str) -> DataFindings:
|
|
45
|
+
findings = self.inspect(text)
|
|
46
|
+
redacted = text
|
|
47
|
+
for category, pattern in self.PATTERNS.items():
|
|
48
|
+
redacted = pattern.sub(f"[REDACTED_{category.upper()}]", redacted)
|
|
49
|
+
findings.redacted_text = redacted
|
|
50
|
+
return findings
|
|
51
|
+
|