patchr 0.1.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.
- apps/__init__.py +2 -0
- apps/api/__init__.py +2 -0
- apps/api/main.py +652 -0
- apps/benchmarks/__init__.py +1 -0
- apps/benchmarks/main.py +20 -0
- apps/sandbox/__init__.py +1 -0
- apps/sandbox/main.py +20 -0
- apps/worker/__init__.py +2 -0
- apps/worker/main.py +15 -0
- apps/worker/verify.py +14 -0
- patchr/__init__.py +12 -0
- patchr/sdk/__init__.py +20 -0
- patchr/sdk/client.py +12 -0
- patchr-0.1.0.dist-info/METADATA +137 -0
- patchr-0.1.0.dist-info/RECORD +116 -0
- patchr-0.1.0.dist-info/WHEEL +5 -0
- patchr-0.1.0.dist-info/entry_points.txt +5 -0
- patchr-0.1.0.dist-info/licenses/LICENSE +17 -0
- patchr-0.1.0.dist-info/top_level.txt +3 -0
- picux/__init__.py +6 -0
- picux/agents/__init__.py +5 -0
- picux/agents/registry.py +204 -0
- picux/api/__init__.py +5 -0
- picux/api/service.py +5075 -0
- picux/audit/__init__.py +31 -0
- picux/audit/activity.py +97 -0
- picux/audit/observability.py +55 -0
- picux/audit/verification/__init__.py +21 -0
- picux/audit/verification/ledger.py +633 -0
- picux/benchmarks/__init__.py +5 -0
- picux/benchmarks/local.py +286 -0
- picux/config.py +140 -0
- picux/contracts/__init__.py +22 -0
- picux/contracts/handshake.py +122 -0
- picux/contracts/integration.py +385 -0
- picux/contracts/openapi.py +187 -0
- picux/contracts/protocol_map.py +152 -0
- picux/contracts/routes.py +980 -0
- picux/contracts/schema_catalog.py +125 -0
- picux/core/__init__.py +17 -0
- picux/core/models.py +148 -0
- picux/core/router.py +131 -0
- picux/core/runtime.py +42 -0
- picux/core/state_machine.py +38 -0
- picux/domains/__init__.py +2 -0
- picux/domains/bridge/HostRun.py +1104 -0
- picux/domains/bridge/__init__.py +6 -0
- picux/domains/bridge/engine.py +345 -0
- picux/domains/hunt/__init__.py +6 -0
- picux/domains/hunt/engine.py +307 -0
- picux/domains/hunt/models.py +88 -0
- picux/domains/pay/__init__.py +16 -0
- picux/domains/pay/adapters.py +607 -0
- picux/domains/pay/engine.py +950 -0
- picux/domains/pay/models.py +95 -0
- picux/domains/proxy/__init__.py +5 -0
- picux/domains/proxy/engine.py +466 -0
- picux/domains/resolve/__init__.py +5 -0
- picux/domains/resolve/engine.py +546 -0
- picux/orchestrator/__init__.py +3 -0
- picux/orchestrator/engine.py +2840 -0
- picux/portals/__init__.py +17 -0
- picux/portals/templates.py +272 -0
- picux/protocols/__init__.py +1 -0
- picux/protocols/a2a/__init__.py +6 -0
- picux/protocols/a2a/client.py +51 -0
- picux/protocols/a2a/envelope.py +132 -0
- picux/protocols/mcp/__init__.py +7 -0
- picux/protocols/mcp/client.py +69 -0
- picux/protocols/mcp/contract.py +67 -0
- picux/protocols/mcp/server.py +76 -0
- picux/sandbox/__init__.py +6 -0
- picux/sandbox/midnight_arbitrage.py +215 -0
- picux/sandbox/models.py +90 -0
- picux/sdk/__init__.py +13 -0
- picux/sdk/client.py +768 -0
- picux/sdk/external.py +245 -0
- picux/security/__init__.py +18 -0
- picux/security/auth.py +86 -0
- picux/security/config_validator.py +58 -0
- picux/security/policy.py +158 -0
- picux/security/secrets.py +144 -0
- picux/signals/__init__.py +1 -0
- picux/signals/community/__init__.py +24 -0
- picux/signals/community/adapters/__init__.py +7 -0
- picux/signals/community/adapters/reddit.py +37 -0
- picux/signals/community/adapters/shopify.py +23 -0
- picux/signals/community/adapters/web.py +23 -0
- picux/signals/community/disambiguation.py +51 -0
- picux/signals/community/intake.py +227 -0
- picux/signals/community/models.py +102 -0
- picux/signals/community/rules.py +91 -0
- picux/signals/community/scoring.py +64 -0
- picux/storage/__init__.py +41 -0
- picux/storage/agents.py +50 -0
- picux/storage/cases.py +440 -0
- picux/storage/channels.py +476 -0
- picux/storage/connectors.py +411 -0
- picux/storage/envelopes.py +137 -0
- picux/storage/escrows.py +168 -0
- picux/storage/events.py +989 -0
- picux/storage/keyspace.py +60 -0
- picux/storage/mandates.py +107 -0
- picux/storage/portals.py +222 -0
- picux/storage/postgres.py +2049 -0
- picux/storage/providers.py +148 -0
- picux/storage/proxy.py +231 -0
- picux/storage/receipts.py +131 -0
- picux/storage/signals.py +147 -0
- picux/storage/tasks.py +179 -0
- picux/tools/__init__.py +11 -0
- picux/tools/shared.py +2048 -0
- picux/verification/__init__.py +5 -0
- picux/verification/rollout.py +183 -0
- picux/workflows/__init__.py +5 -0
- picux/workflows/templates.py +74 -0
picux/sdk/external.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import mimetypes
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from picux.sdk.client import PicuxClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ExternalPricing:
|
|
16
|
+
successUsd: float = 1.0
|
|
17
|
+
mgmtRate: float = 0.05
|
|
18
|
+
mgmtCapUsd: float = 5.0
|
|
19
|
+
|
|
20
|
+
def quote(self, *, valueUsd: float = 0.0, laborUsd: float = 0.0) -> dict[str, Any]:
|
|
21
|
+
value = max(0.0, float(valueUsd or 0.0))
|
|
22
|
+
labor = max(0.0, float(laborUsd or 0.0))
|
|
23
|
+
success = self.successUsd if value > 0 else 0.0
|
|
24
|
+
mgmt = min(labor * max(0.0, self.mgmtRate), max(0.0, self.mgmtCapUsd)) if labor > 0 else 0.0
|
|
25
|
+
return {
|
|
26
|
+
"currency": "USD",
|
|
27
|
+
"valueUsd": round(value, 2),
|
|
28
|
+
"laborUsd": round(labor, 2),
|
|
29
|
+
"successFeeUsd": round(success, 2),
|
|
30
|
+
"mgmtFeeUsd": round(mgmt, 2),
|
|
31
|
+
"totalFeeUsd": round(success + mgmt, 2),
|
|
32
|
+
"adapter": "external",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ExternalAppClient:
|
|
37
|
+
"""External app/service/agent adapter that calls Picux through the SDK boundary."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, client: PicuxClient, *, pricing: ExternalPricing | None = None, appId: str = "external") -> None:
|
|
40
|
+
self.client = client
|
|
41
|
+
self.pricing = pricing or ExternalPricing()
|
|
42
|
+
self.appId = appId
|
|
43
|
+
|
|
44
|
+
def deployAgent(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
goal: str,
|
|
48
|
+
userId: str = "external",
|
|
49
|
+
domain: str = "hunt",
|
|
50
|
+
needsApproval: bool = False,
|
|
51
|
+
meta: dict[str, Any] | None = None,
|
|
52
|
+
) -> dict[str, Any]:
|
|
53
|
+
payload = {
|
|
54
|
+
"userId": userId,
|
|
55
|
+
"domain": domain,
|
|
56
|
+
"channel": self.appId,
|
|
57
|
+
"goal": goal,
|
|
58
|
+
"needsApproval": needsApproval,
|
|
59
|
+
"meta": {"appId": self.appId, **(meta or {})},
|
|
60
|
+
}
|
|
61
|
+
return self.client.createTask(payload)
|
|
62
|
+
|
|
63
|
+
def publishSignal(
|
|
64
|
+
self,
|
|
65
|
+
signal: dict[str, Any],
|
|
66
|
+
*,
|
|
67
|
+
targetDomain: str = "hunt",
|
|
68
|
+
userId: str = "external",
|
|
69
|
+
approved: bool = False,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
return self.client.launchCommunityTask(
|
|
72
|
+
{
|
|
73
|
+
"userId": userId,
|
|
74
|
+
"targetDomain": targetDomain,
|
|
75
|
+
"approved": approved,
|
|
76
|
+
"signal": signal,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def requestResolve(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
text: str,
|
|
84
|
+
userId: str = "external",
|
|
85
|
+
requestId: str = "",
|
|
86
|
+
provider: str = "",
|
|
87
|
+
location: str = "",
|
|
88
|
+
issueType: str = "",
|
|
89
|
+
attachments: list[dict[str, Any]] | None = None,
|
|
90
|
+
filePaths: list[str] | None = None,
|
|
91
|
+
contact: dict[str, Any] | None = None,
|
|
92
|
+
meta: dict[str, Any] | None = None,
|
|
93
|
+
needsApproval: bool = False,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
clean = _redactText(text)
|
|
96
|
+
contactRefs = _contactRefs(contact or {})
|
|
97
|
+
encodedFiles = _fileAttachments(filePaths or [])
|
|
98
|
+
request = {
|
|
99
|
+
"kind": "resolveRequest",
|
|
100
|
+
"requestId": requestId,
|
|
101
|
+
"provider": provider,
|
|
102
|
+
"location": location,
|
|
103
|
+
"issueType": issueType,
|
|
104
|
+
"text": clean,
|
|
105
|
+
"attachments": [*(attachments or []), *encodedFiles],
|
|
106
|
+
"contact": contactRefs,
|
|
107
|
+
}
|
|
108
|
+
return self.client.createTask(
|
|
109
|
+
{
|
|
110
|
+
"userId": userId,
|
|
111
|
+
"domain": "resolve",
|
|
112
|
+
"channel": self.appId,
|
|
113
|
+
"goal": clean,
|
|
114
|
+
"extRef": requestId,
|
|
115
|
+
"needsApproval": needsApproval,
|
|
116
|
+
"inData": {"query": clean, "request": request},
|
|
117
|
+
"meta": {"appId": self.appId, "requestId": requestId, **(meta or {})},
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def requestProxy(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
task: str,
|
|
125
|
+
userId: str = "external",
|
|
126
|
+
taskId: str = "",
|
|
127
|
+
kind: str = "review",
|
|
128
|
+
reason: str = "humanIntervention",
|
|
129
|
+
context: dict[str, Any] | None = None,
|
|
130
|
+
requestId: str = "",
|
|
131
|
+
refs: dict[str, Any] | None = None,
|
|
132
|
+
proofReq: list[str] | None = None,
|
|
133
|
+
route: dict[str, Any] | None = None,
|
|
134
|
+
channelMsg: dict[str, Any] | None = None,
|
|
135
|
+
voiceCall: dict[str, Any] | None = None,
|
|
136
|
+
policy: dict[str, Any] | None = None,
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
payload: dict[str, Any] = {
|
|
139
|
+
"userId": userId,
|
|
140
|
+
"kind": kind,
|
|
141
|
+
"reason": reason,
|
|
142
|
+
"task": task,
|
|
143
|
+
"context": {"appId": self.appId, **(context or {})},
|
|
144
|
+
}
|
|
145
|
+
optional = {
|
|
146
|
+
"taskId": taskId,
|
|
147
|
+
"requestId": requestId,
|
|
148
|
+
"refs": refs,
|
|
149
|
+
"proofReq": proofReq,
|
|
150
|
+
"route": route,
|
|
151
|
+
"channelMsg": channelMsg,
|
|
152
|
+
"voiceCall": voiceCall,
|
|
153
|
+
"policy": policy,
|
|
154
|
+
}
|
|
155
|
+
payload.update({key: value for key, value in optional.items() if value})
|
|
156
|
+
return self.client.createProxyMission(payload)
|
|
157
|
+
|
|
158
|
+
def submitProxy(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
proxyId: str = "",
|
|
162
|
+
requestId: str = "",
|
|
163
|
+
note: str = "",
|
|
164
|
+
text: str = "",
|
|
165
|
+
proof: dict[str, Any] | None = None,
|
|
166
|
+
refs: dict[str, Any] | None = None,
|
|
167
|
+
requiresPhysicalProof: bool | None = None,
|
|
168
|
+
policy: dict[str, Any] | None = None,
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
payload: dict[str, Any] = {"appId": self.appId, **(proof or {})}
|
|
171
|
+
optional = {
|
|
172
|
+
"requestId": requestId,
|
|
173
|
+
"note": note,
|
|
174
|
+
"text": text,
|
|
175
|
+
"refs": refs,
|
|
176
|
+
"requiresPhysicalProof": requiresPhysicalProof,
|
|
177
|
+
"policy": policy,
|
|
178
|
+
}
|
|
179
|
+
payload.update({key: value for key, value in optional.items() if value is not None and value != ""})
|
|
180
|
+
if proxyId:
|
|
181
|
+
return self.client.submitProxyOutcome(proxyId, payload)
|
|
182
|
+
return self.client.submitProxyOutcomeByRef(payload)
|
|
183
|
+
|
|
184
|
+
def priceOutcome(self, *, valueUsd: float = 0.0, laborUsd: float = 0.0) -> dict[str, Any]:
|
|
185
|
+
return self.pricing.quote(valueUsd=valueUsd, laborUsd=laborUsd)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _redactText(text: str) -> str:
|
|
189
|
+
clean = re.sub(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", "[email]", str(text or ""), flags=re.IGNORECASE)
|
|
190
|
+
return re.sub(r"\+?\b(?:\d[\s().-]?){8,15}\d\b", "[phone]", clean)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _contactRefs(contact: dict[str, Any]) -> dict[str, Any]:
|
|
194
|
+
result: dict[str, Any] = {}
|
|
195
|
+
emailRef = str(contact.get("emailRef", "") or "")
|
|
196
|
+
phoneRef = str(contact.get("phoneRef", "") or "")
|
|
197
|
+
email = str(contact.get("email", "") or "")
|
|
198
|
+
phone = str(contact.get("phone", contact.get("phoneNumber", "")) or "")
|
|
199
|
+
if emailRef:
|
|
200
|
+
result["emailRef"] = emailRef
|
|
201
|
+
elif email:
|
|
202
|
+
result["emailRef"] = _hashedRef("email", email)
|
|
203
|
+
if phoneRef:
|
|
204
|
+
result["phoneRef"] = phoneRef
|
|
205
|
+
elif phone:
|
|
206
|
+
result["phoneRef"] = _hashedRef("phone", phone)
|
|
207
|
+
if result:
|
|
208
|
+
result["redacted"] = True
|
|
209
|
+
if contact.get("preferredChannel"):
|
|
210
|
+
result["preferredChannel"] = str(contact.get("preferredChannel") or "")
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _hashedRef(kind: str, value: str) -> str:
|
|
215
|
+
digest = hashlib.sha256(str(value or "").strip().lower().encode("utf-8")).hexdigest()[:16]
|
|
216
|
+
return f"external:{kind}:{digest}"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _fileAttachments(filePaths: list[str]) -> list[dict[str, Any]]:
|
|
220
|
+
attachments: list[dict[str, Any]] = []
|
|
221
|
+
for rawPath in filePaths:
|
|
222
|
+
path = Path(rawPath)
|
|
223
|
+
data = path.read_bytes()
|
|
224
|
+
attachments.append(
|
|
225
|
+
{
|
|
226
|
+
"kind": _kindFromPath(path),
|
|
227
|
+
"name": path.name,
|
|
228
|
+
"mime": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
|
|
229
|
+
"size": len(data),
|
|
230
|
+
"sha256": "sha256:" + hashlib.sha256(data).hexdigest(),
|
|
231
|
+
"dataBase64": base64.b64encode(data).decode("ascii"),
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
return attachments
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _kindFromPath(path: Path) -> str:
|
|
238
|
+
name = path.name.lower()
|
|
239
|
+
if "receipt" in name:
|
|
240
|
+
return "receipt"
|
|
241
|
+
if "damage" in name or "photo" in name:
|
|
242
|
+
return "damagePhoto"
|
|
243
|
+
if "screen" in name:
|
|
244
|
+
return "screenshot"
|
|
245
|
+
return "attachment"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Security and production readiness primitives."""
|
|
2
|
+
|
|
3
|
+
from .auth import AuthResult, PicuxAuthGate
|
|
4
|
+
from .config_validator import ProductionReadiness, assertProductionReady, validateProductionReady
|
|
5
|
+
from .policy import PolicyDecision, PolicyEngine
|
|
6
|
+
from .secrets import SecretResolution, SecretResolver
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AuthResult",
|
|
10
|
+
"PicuxAuthGate",
|
|
11
|
+
"PolicyDecision",
|
|
12
|
+
"PolicyEngine",
|
|
13
|
+
"ProductionReadiness",
|
|
14
|
+
"SecretResolution",
|
|
15
|
+
"SecretResolver",
|
|
16
|
+
"assertProductionReady",
|
|
17
|
+
"validateProductionReady",
|
|
18
|
+
]
|
picux/security/auth.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from picux.config import PicuxSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PUBLIC_ROUTES = {
|
|
11
|
+
("GET", "/healthz"),
|
|
12
|
+
("GET", "/runtime"),
|
|
13
|
+
("GET", "/v1/status"),
|
|
14
|
+
("GET", "/openapi.json"),
|
|
15
|
+
("GET", "/v1/contract"),
|
|
16
|
+
("GET", "/v1/manifest"),
|
|
17
|
+
("GET", "/v1/protocol-map"),
|
|
18
|
+
("GET", "/v1/a2a/contract"),
|
|
19
|
+
("GET", "/v1/pay/adapters/contract"),
|
|
20
|
+
("POST", "/v1/handshake"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class AuthResult:
|
|
26
|
+
ok: bool
|
|
27
|
+
error: str = ""
|
|
28
|
+
status: int = 200
|
|
29
|
+
transport: str = "api"
|
|
30
|
+
|
|
31
|
+
def toMap(self) -> dict[str, Any]:
|
|
32
|
+
if self.ok:
|
|
33
|
+
return {"ok": True, "transport": self.transport}
|
|
34
|
+
return {"ok": False, "error": self.error, "transport": self.transport}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PicuxAuthGate:
|
|
38
|
+
"""Bearer token gate for external API and MCP transports."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, *, apiToken: str = "", mcpToken: str = "", a2aToken: str = "") -> None:
|
|
41
|
+
self.apiToken = str(apiToken or "").strip()
|
|
42
|
+
self.mcpToken = str(mcpToken or "").strip()
|
|
43
|
+
self.a2aToken = str(a2aToken or "").strip()
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def fromEnv(cls) -> "PicuxAuthGate":
|
|
47
|
+
settings = PicuxSettings.fromEnv()
|
|
48
|
+
return cls(apiToken=settings.apiToken, mcpToken=settings.mcpToken, a2aToken=settings.a2aToken)
|
|
49
|
+
|
|
50
|
+
def authorize(self, method: str, path: str, headers: dict[str, Any] | None = None) -> AuthResult:
|
|
51
|
+
method = method.upper()
|
|
52
|
+
path = str(path or "/")
|
|
53
|
+
transport = self._transport(path)
|
|
54
|
+
if (method, path) in PUBLIC_ROUTES or (method == "GET" and path.startswith("/v1/schemas")):
|
|
55
|
+
return AuthResult(ok=True, transport=transport)
|
|
56
|
+
|
|
57
|
+
expected = self._expectedToken(transport)
|
|
58
|
+
if not expected:
|
|
59
|
+
return AuthResult(ok=True, transport=transport)
|
|
60
|
+
actual = self._bearer(headers or {})
|
|
61
|
+
if not hmac.compare_digest(actual, expected):
|
|
62
|
+
return AuthResult(ok=False, error="unauthorized", status=401, transport=transport)
|
|
63
|
+
return AuthResult(ok=True, transport=transport)
|
|
64
|
+
|
|
65
|
+
def _expectedToken(self, transport: str) -> str:
|
|
66
|
+
if transport == "mcp":
|
|
67
|
+
return self.mcpToken or self.apiToken
|
|
68
|
+
if transport == "a2a":
|
|
69
|
+
return self.a2aToken or self.apiToken
|
|
70
|
+
return self.apiToken
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _transport(path: str) -> str:
|
|
74
|
+
if path == "/mcp":
|
|
75
|
+
return "mcp"
|
|
76
|
+
if path.startswith("/v1/a2a"):
|
|
77
|
+
return "a2a"
|
|
78
|
+
return "api"
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _bearer(headers: dict[str, Any]) -> str:
|
|
82
|
+
normalized = {str(key).lower(): str(value) for key, value in headers.items()}
|
|
83
|
+
raw = normalized.get("authorization", "").strip()
|
|
84
|
+
if raw.lower().startswith("bearer "):
|
|
85
|
+
return raw[7:].strip()
|
|
86
|
+
return ""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from picux.config import PicuxSettings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
REQUIRED_PROD_FIELDS = {
|
|
10
|
+
"apiToken": "PICUX_API_TOKEN",
|
|
11
|
+
"redisUrl": "PICUX_REDIS_URL",
|
|
12
|
+
"postgresDsn": "PICUX_POSTGRES_DSN",
|
|
13
|
+
"mcpUrl": "PICUX_MCP_BASE_URL",
|
|
14
|
+
"mcpToken": "PICUX_MCP_TOKEN",
|
|
15
|
+
"a2aUrl": "PICUX_A2A_BASE_URL",
|
|
16
|
+
"a2aToken": "PICUX_A2A_TOKEN",
|
|
17
|
+
"bridgeUrl": "PICUX_BRIDGE_API_BASE",
|
|
18
|
+
"bridgeToken": "PICUX_BRIDGE_API_TOKEN",
|
|
19
|
+
"resolveToken": "PICUX_RESOLVE_OUTCOME_WEBHOOK_TOKEN",
|
|
20
|
+
"bridgeOutcomeToken": "PICUX_BRIDGE_OUTCOME_WEBHOOK_TOKEN",
|
|
21
|
+
"vaultKey": "PICUX_VAULT_KEY_B64",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ProductionReadiness:
|
|
27
|
+
ok: bool
|
|
28
|
+
prod: bool
|
|
29
|
+
issues: tuple[str, ...]
|
|
30
|
+
|
|
31
|
+
def toMap(self) -> dict[str, Any]:
|
|
32
|
+
return {"ok": self.ok, "prod": self.prod, "issues": list(self.issues)}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validateProductionReady(settings: PicuxSettings) -> ProductionReadiness:
|
|
36
|
+
if not settings.prod:
|
|
37
|
+
return ProductionReadiness(ok=True, prod=False, issues=())
|
|
38
|
+
|
|
39
|
+
issues: list[str] = []
|
|
40
|
+
for attr, envName in REQUIRED_PROD_FIELDS.items():
|
|
41
|
+
value = getattr(settings, attr)
|
|
42
|
+
if not str(value or "").strip():
|
|
43
|
+
issues.append(f"missing:{envName}")
|
|
44
|
+
|
|
45
|
+
if not settings.policyEnforcement:
|
|
46
|
+
issues.append("invalid:PICUX_POLICY_ENFORCEMENT")
|
|
47
|
+
if str(settings.apiUrl or "").startswith("http://127.0.0.1"):
|
|
48
|
+
issues.append("invalid:PICUX_API_BASE_URL")
|
|
49
|
+
if str(settings.redisUrl or "").startswith("redis://localhost"):
|
|
50
|
+
issues.append("invalid:PICUX_REDIS_URL")
|
|
51
|
+
|
|
52
|
+
return ProductionReadiness(ok=not issues, prod=True, issues=tuple(issues))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def assertProductionReady(settings: PicuxSettings) -> None:
|
|
56
|
+
readiness = validateProductionReady(settings)
|
|
57
|
+
if not readiness.ok:
|
|
58
|
+
raise RuntimeError("productionNotReady:" + ",".join(readiness.issues))
|
picux/security/policy.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class PolicyDecision:
|
|
11
|
+
allowed: bool
|
|
12
|
+
reasons: tuple[str, ...]
|
|
13
|
+
subjectId: str
|
|
14
|
+
action: str
|
|
15
|
+
resource: str
|
|
16
|
+
domain: str
|
|
17
|
+
mandateId: str = ""
|
|
18
|
+
|
|
19
|
+
def toMap(self) -> dict[str, Any]:
|
|
20
|
+
return {
|
|
21
|
+
"allowed": self.allowed,
|
|
22
|
+
"reasons": list(self.reasons),
|
|
23
|
+
"subjectId": self.subjectId,
|
|
24
|
+
"action": self.action,
|
|
25
|
+
"resource": self.resource,
|
|
26
|
+
"domain": self.domain,
|
|
27
|
+
"mandateId": self.mandateId,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PolicyEngine:
|
|
32
|
+
"""Least-privilege checks for mandate sessions and capability grants."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, *, clock: Callable[[], datetime] | None = None) -> None:
|
|
35
|
+
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
|
36
|
+
|
|
37
|
+
def evaluate(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
38
|
+
decision = self.decision(payload)
|
|
39
|
+
return {"ok": decision.allowed, "decision": decision.toMap()}
|
|
40
|
+
|
|
41
|
+
def decision(self, payload: dict[str, Any]) -> PolicyDecision:
|
|
42
|
+
subjectId = str(payload.get("subjectId", payload.get("agentId", payload.get("clientId", ""))) or "")
|
|
43
|
+
action = str(payload.get("action", "") or "")
|
|
44
|
+
resource = str(payload.get("resource", "") or "")
|
|
45
|
+
domain = str(payload.get("domain", "") or "")
|
|
46
|
+
mandateId = str(payload.get("mandateId", "") or "")
|
|
47
|
+
povRef = str(payload.get("povRef", payload.get("povId", "")) or "")
|
|
48
|
+
session = payload.get("session", {}) if isinstance(payload.get("session"), dict) else {}
|
|
49
|
+
grants = payload.get("grants", []) if isinstance(payload.get("grants"), list) else []
|
|
50
|
+
reasons: list[str] = []
|
|
51
|
+
|
|
52
|
+
if not subjectId:
|
|
53
|
+
reasons.append("missing:subjectId")
|
|
54
|
+
if not action:
|
|
55
|
+
reasons.append("missing:action")
|
|
56
|
+
if not resource:
|
|
57
|
+
reasons.append("missing:resource")
|
|
58
|
+
if not domain:
|
|
59
|
+
reasons.append("missing:domain")
|
|
60
|
+
reasons.extend(self._sessionReasons(session, subjectId=subjectId, action=action, domain=domain, mandateId=mandateId))
|
|
61
|
+
if action.startswith("pay.") and not mandateId:
|
|
62
|
+
reasons.append("missing:mandateId")
|
|
63
|
+
if action == "pay.settle" and not povRef:
|
|
64
|
+
reasons.append("missing:povRef")
|
|
65
|
+
if not self._grantAllowed(grants, subjectId=subjectId, action=action, resource=resource, domain=domain):
|
|
66
|
+
reasons.append("grantDenied")
|
|
67
|
+
|
|
68
|
+
cleanReasons = tuple(dict.fromkeys(reason for reason in reasons if reason))
|
|
69
|
+
return PolicyDecision(
|
|
70
|
+
allowed=not cleanReasons,
|
|
71
|
+
reasons=cleanReasons,
|
|
72
|
+
subjectId=subjectId,
|
|
73
|
+
action=action,
|
|
74
|
+
resource=resource,
|
|
75
|
+
domain=domain,
|
|
76
|
+
mandateId=mandateId,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _sessionReasons(
|
|
80
|
+
self,
|
|
81
|
+
session: dict[str, Any],
|
|
82
|
+
*,
|
|
83
|
+
subjectId: str,
|
|
84
|
+
action: str,
|
|
85
|
+
domain: str,
|
|
86
|
+
mandateId: str,
|
|
87
|
+
) -> list[str]:
|
|
88
|
+
reasons: list[str] = []
|
|
89
|
+
if not session:
|
|
90
|
+
return ["missing:session"]
|
|
91
|
+
if str(session.get("status", "active") or "active") != "active":
|
|
92
|
+
reasons.append("sessionInactive")
|
|
93
|
+
if self._expired(str(session.get("expiresAt", "") or "")):
|
|
94
|
+
reasons.append("sessionExpired")
|
|
95
|
+
sessionSubject = str(session.get("subjectId", "") or "")
|
|
96
|
+
if sessionSubject and subjectId and sessionSubject != subjectId:
|
|
97
|
+
reasons.append("subjectMismatch")
|
|
98
|
+
sessionMandate = str(session.get("mandateId", "") or "")
|
|
99
|
+
if sessionMandate and mandateId and sessionMandate != mandateId:
|
|
100
|
+
reasons.append("mandateMismatch")
|
|
101
|
+
domains = _cleanList(session.get("domains", []))
|
|
102
|
+
if domains and domain and domain not in domains:
|
|
103
|
+
reasons.append("domainDenied")
|
|
104
|
+
actions = _cleanList(session.get("actions", []))
|
|
105
|
+
if actions and action and action not in actions:
|
|
106
|
+
reasons.append("actionDenied")
|
|
107
|
+
return reasons
|
|
108
|
+
|
|
109
|
+
def _grantAllowed(self, grants: list[Any], *, subjectId: str, action: str, resource: str, domain: str) -> bool:
|
|
110
|
+
for grant in grants:
|
|
111
|
+
if not isinstance(grant, dict):
|
|
112
|
+
continue
|
|
113
|
+
if self._expired(str(grant.get("expiresAt", "") or "")):
|
|
114
|
+
continue
|
|
115
|
+
grantSubject = str(grant.get("subjectId", "") or "")
|
|
116
|
+
if grantSubject and subjectId and grantSubject != subjectId:
|
|
117
|
+
continue
|
|
118
|
+
domains = _cleanList(grant.get("domains", []))
|
|
119
|
+
if domains and domain not in domains:
|
|
120
|
+
continue
|
|
121
|
+
actions = _cleanList(grant.get("actions", []))
|
|
122
|
+
if action not in actions and "*" not in actions:
|
|
123
|
+
continue
|
|
124
|
+
resources = _cleanList(grant.get("resources", []))
|
|
125
|
+
if resource not in resources and "*" not in resources:
|
|
126
|
+
continue
|
|
127
|
+
return True
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
def _expired(self, value: str) -> bool:
|
|
131
|
+
parsed = _parseTime(value)
|
|
132
|
+
return bool(parsed and parsed <= self.clock())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cleanList(value: Any) -> list[str]:
|
|
136
|
+
if not isinstance(value, list):
|
|
137
|
+
return []
|
|
138
|
+
deduped: list[str] = []
|
|
139
|
+
for item in value:
|
|
140
|
+
clean = str(item or "").strip()
|
|
141
|
+
if clean and clean not in deduped:
|
|
142
|
+
deduped.append(clean)
|
|
143
|
+
return deduped
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _parseTime(value: str) -> datetime | None:
|
|
147
|
+
raw = str(value or "").strip()
|
|
148
|
+
if not raw:
|
|
149
|
+
return None
|
|
150
|
+
if raw.endswith("Z"):
|
|
151
|
+
raw = raw[:-1] + "+00:00"
|
|
152
|
+
try:
|
|
153
|
+
parsed = datetime.fromisoformat(raw)
|
|
154
|
+
except Exception:
|
|
155
|
+
return None
|
|
156
|
+
if parsed.tzinfo is None:
|
|
157
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
158
|
+
return parsed.astimezone(timezone.utc)
|