primarypath-server 1.0.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.
- app/__init__.py +351 -0
- app/_version.py +8 -0
- app/agents.py +150 -0
- app/alembic.ini +37 -0
- app/auth.py +714 -0
- app/chat.py +122 -0
- app/config.py +258 -0
- app/costgov.py +345 -0
- app/db.py +56 -0
- app/faults.py +26 -0
- app/grants.py +122 -0
- app/idempotency.py +58 -0
- app/jcs.py +117 -0
- app/ledger/__init__.py +41 -0
- app/ledger/fold.py +74 -0
- app/ledger/hashing.py +19 -0
- app/ledger/projection.py +389 -0
- app/ledger/store.py +1042 -0
- app/ledger/verify.py +31 -0
- app/main.py +50 -0
- app/maintenance.py +56 -0
- app/metrics.py +89 -0
- app/migrations/env.py +20 -0
- app/migrations/script.py.mako +23 -0
- app/migrations/versions/0001_baseline.py +19 -0
- app/migrations/versions/0002_events_and_projection.py +78 -0
- app/migrations/versions/0003_slack_messages.py +27 -0
- app/migrations/versions/0004_sessions.py +31 -0
- app/migrations/versions/0005_siem_cursor.py +26 -0
- app/migrations/versions/0006_teams_messages.py +27 -0
- app/migrations/versions/0007_tenant_scoping.py +50 -0
- app/migrations/versions/0008_policy_view.py +39 -0
- app/migrations/versions/0009_api_tokens.py +37 -0
- app/migrations/versions/0010_session_groups.py +23 -0
- app/migrations/versions/0011_budgets_and_evals.py +75 -0
- app/migrations/versions/0012_run_event_ts_index.py +23 -0
- app/migrations/versions/0013_agent_chains.py +102 -0
- app/migrations/versions/0014_dual_control.py +33 -0
- app/migrations/versions/0015_sampled_mode.py +24 -0
- app/migrations/versions/0016_sampling_salt.py +25 -0
- app/migrations/versions/0017_live_tokens_index.py +25 -0
- app/migrations/versions/0018_webhook_outbox.py +47 -0
- app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
- app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
- app/migrations/versions/0021_execution_grants.py +59 -0
- app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
- app/migrations/versions/0023_idempotency_keys.py +38 -0
- app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
- app/netguard.py +73 -0
- app/obslog.py +105 -0
- app/policy.py +390 -0
- app/ratelimit.py +78 -0
- app/routes/__init__.py +0 -0
- app/routes/admin.py +312 -0
- app/routes/agents.py +111 -0
- app/routes/approvals.py +576 -0
- app/routes/audit.py +213 -0
- app/routes/costgov.py +150 -0
- app/routes/health.py +53 -0
- app/routes/policy.py +72 -0
- app/routes/runs.py +115 -0
- app/routes/slack.py +40 -0
- app/routes/teams.py +29 -0
- app/routes/traces.py +55 -0
- app/schemas.py +248 -0
- app/security.py +203 -0
- app/siem.py +248 -0
- app/signing.py +342 -0
- app/sla.py +234 -0
- app/slack.py +282 -0
- app/teams.py +276 -0
- app/tokens.py +117 -0
- app/traces.py +309 -0
- app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
- app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
- app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
- app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- app/web-dist/assets/index-B_JDe_G2.js +10 -0
- app/web-dist/assets/index-J7J-YtwM.css +1 -0
- app/web-dist/index.html +24 -0
- app/webhooks.py +405 -0
- primarypath_server-1.0.0.dist-info/METADATA +35 -0
- primarypath_server-1.0.0.dist-info/RECORD +94 -0
- primarypath_server-1.0.0.dist-info/WHEEL +5 -0
- primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
- primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
- primarypath_server-1.0.0.dist-info/top_level.txt +1 -0
app/costgov.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Cost governance (F23): event-sourced budgets/quotas, live spend
|
|
2
|
+
enforcement on the same seam as policy, and eval/gate decisions as chained
|
|
3
|
+
evidence. The governance of the governance is audited: loosening a spend
|
|
4
|
+
cap or overriding a failed gate leaves a provable record.
|
|
5
|
+
|
|
6
|
+
Budgets are a fold of `budget_set` events into a rebuildable `budget_view`
|
|
7
|
+
projection (same discipline as policy_view). Spend is NEVER stored — it is
|
|
8
|
+
folded live from `run_event` usage, so there is no cached counter that can
|
|
9
|
+
drift. Eval profiles and gate decisions are ingested (not re-scored) as
|
|
10
|
+
`eval_recorded` / `gate_decision` events; the latest gate verdict folds
|
|
11
|
+
into `gate_view` for CI.
|
|
12
|
+
|
|
13
|
+
The F21 safety gate stays independent and fail-safe: cost can only add a
|
|
14
|
+
spend control on top of a safety decision, never weaken one.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import datetime as dt
|
|
19
|
+
import json
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from .ledger.store import DEFAULT_TENANT, Ledger
|
|
23
|
+
from .traces import parse_prices
|
|
24
|
+
|
|
25
|
+
WINDOWS = ("per_run", "daily", "monthly")
|
|
26
|
+
ON_EXCEED = ("gate", "deny")
|
|
27
|
+
SCOPES = ("tenant", "action")
|
|
28
|
+
COST_REVIEWER = "system:cost"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GateAlreadyDecided(Exception):
|
|
32
|
+
"""A gate_decision already exists for this (agent, version); only an
|
|
33
|
+
admin override may supersede it (F23)."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, verdict: dict):
|
|
36
|
+
self.verdict = verdict
|
|
37
|
+
super().__init__(
|
|
38
|
+
f"gate for {verdict['agent']}@{verdict['version']} is already "
|
|
39
|
+
f"decided (passed={verdict['passed']})")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def window_start_ms(window: str, now: float | None = None) -> int | None:
|
|
43
|
+
"""Epoch-ms at which the active window began. per_run has no time window
|
|
44
|
+
(it is enforced per single run, not a rolling total)."""
|
|
45
|
+
now = now if now is not None else time.time()
|
|
46
|
+
d = dt.datetime.fromtimestamp(now, tz=dt.UTC)
|
|
47
|
+
if window == "daily":
|
|
48
|
+
start = d.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
49
|
+
elif window == "monthly":
|
|
50
|
+
start = d.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
51
|
+
else:
|
|
52
|
+
return None
|
|
53
|
+
return int(start.timestamp() * 1000)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# 'window' is a reserved SQL keyword, so the column is window_kind; the API
|
|
57
|
+
# and folded dicts keep 'window' as the key.
|
|
58
|
+
BUDGET_SELECT = ("scope, key, max_usd, max_tokens, window_kind, on_exceed, "
|
|
59
|
+
"updated_by, reason, updated_ts_ms")
|
|
60
|
+
_BUDGET_KEYS = ("scope", "key", "max_usd", "max_tokens", "window", "on_exceed",
|
|
61
|
+
"updated_by", "reason", "updated_ts_ms")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
HISTORY_MAX_LIMIT = 1000
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _row_to_budget(row: tuple) -> dict:
|
|
68
|
+
return dict(zip(_BUDGET_KEYS, row))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CostStore:
|
|
72
|
+
def __init__(self, pool, ledger: Ledger, settings):
|
|
73
|
+
self.pool = pool
|
|
74
|
+
self.ledger = ledger
|
|
75
|
+
self.settings = settings
|
|
76
|
+
self.prices = parse_prices(settings.model_prices)
|
|
77
|
+
|
|
78
|
+
# -- budgets: fold (read) ------------------------------------------------
|
|
79
|
+
|
|
80
|
+
async def current_budgets(self, tenant: str = DEFAULT_TENANT) -> list[dict]:
|
|
81
|
+
async with self.pool.connection() as conn:
|
|
82
|
+
cur = await conn.execute(
|
|
83
|
+
f"SELECT {BUDGET_SELECT} FROM budget_view WHERE tenant_id = %s "
|
|
84
|
+
f"ORDER BY scope, key", (tenant,))
|
|
85
|
+
return [_row_to_budget(r) for r in await cur.fetchall()]
|
|
86
|
+
|
|
87
|
+
async def budget_for(self, scope: str, key: str,
|
|
88
|
+
tenant: str = DEFAULT_TENANT) -> dict | None:
|
|
89
|
+
async with self.pool.connection() as conn:
|
|
90
|
+
cur = await conn.execute(
|
|
91
|
+
f"SELECT {BUDGET_SELECT} FROM budget_view "
|
|
92
|
+
f"WHERE tenant_id = %s AND scope = %s AND key = %s",
|
|
93
|
+
(tenant, scope, key))
|
|
94
|
+
row = await cur.fetchone()
|
|
95
|
+
return _row_to_budget(row) if row else None
|
|
96
|
+
|
|
97
|
+
# -- budgets: write ------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
async def set_budget(self, scope: str, key: str, *, window: str,
|
|
100
|
+
on_exceed: str, by: str, reason: str = "",
|
|
101
|
+
max_usd: float | None = None,
|
|
102
|
+
max_tokens: int | None = None,
|
|
103
|
+
tenant: str = DEFAULT_TENANT) -> dict:
|
|
104
|
+
if scope not in SCOPES:
|
|
105
|
+
raise ValueError(f"scope must be one of {SCOPES}")
|
|
106
|
+
if window not in WINDOWS:
|
|
107
|
+
raise ValueError(f"window must be one of {WINDOWS}")
|
|
108
|
+
if on_exceed not in ON_EXCEED:
|
|
109
|
+
raise ValueError(f"on_exceed must be one of {ON_EXCEED}")
|
|
110
|
+
await self.ledger.append("budget_set", {
|
|
111
|
+
"scope": scope, "key": key, "window": window,
|
|
112
|
+
"on_exceed": on_exceed, "max_usd": max_usd,
|
|
113
|
+
"max_tokens": max_tokens, "by": by, "reason": reason},
|
|
114
|
+
tenant=tenant)
|
|
115
|
+
budget = await self.budget_for(scope, key, tenant)
|
|
116
|
+
assert budget is not None # just appended
|
|
117
|
+
return budget
|
|
118
|
+
|
|
119
|
+
# -- spend: live fold from run_event usage -------------------------------
|
|
120
|
+
|
|
121
|
+
async def spend(self, scope: str, key: str, window: str,
|
|
122
|
+
tenant: str = DEFAULT_TENANT,
|
|
123
|
+
now: float | None = None,
|
|
124
|
+
run_id: str | None = None) -> dict:
|
|
125
|
+
"""Fold run_event model_decision usage into {usd, tokens} for the
|
|
126
|
+
scope over the active window.
|
|
127
|
+
|
|
128
|
+
- daily/monthly: usage since the window start (calendar-aligned, UTC).
|
|
129
|
+
- per_run: usage of a SINGLE run (`run_id`). At request time the run
|
|
130
|
+
has already spent on prior decisions, so a per_run cap bounds a
|
|
131
|
+
run's accumulated spend and gates the next gated action once it is
|
|
132
|
+
crossed. Without a run_id (e.g. the aggregate spend report), per_run
|
|
133
|
+
has no single run to total, so it returns zero — it is enforced
|
|
134
|
+
live on the seam, not aggregated across runs.
|
|
135
|
+
|
|
136
|
+
For an action scope, a decision counts if it calls the budgeted action
|
|
137
|
+
as ANY of its tool calls (not just the first), and the decision's full
|
|
138
|
+
usage is charged to it. Charging in full is conservative — a decision
|
|
139
|
+
proposing the budgeted action errs toward the cap, never past it.
|
|
140
|
+
"""
|
|
141
|
+
q = ("SELECT model, sum(input_tokens), sum(output_tokens) "
|
|
142
|
+
"FROM usage_view WHERE tenant_id = %s")
|
|
143
|
+
params: list = [tenant]
|
|
144
|
+
if window == "per_run":
|
|
145
|
+
if run_id is None:
|
|
146
|
+
return {"usd": 0.0, "tokens": 0}
|
|
147
|
+
q += " AND run_id = %s"
|
|
148
|
+
params.append(run_id)
|
|
149
|
+
else:
|
|
150
|
+
since = window_start_ms(window, now)
|
|
151
|
+
if since is not None:
|
|
152
|
+
q += " AND ts_ms >= %s"
|
|
153
|
+
params.append(since)
|
|
154
|
+
if scope == "action":
|
|
155
|
+
q += " AND %s = ANY(tools)"
|
|
156
|
+
params.append(key)
|
|
157
|
+
q += " GROUP BY model"
|
|
158
|
+
async with self.pool.connection() as conn:
|
|
159
|
+
cur = await conn.execute(q, params)
|
|
160
|
+
rows = await cur.fetchall()
|
|
161
|
+
return self._totals(rows)
|
|
162
|
+
|
|
163
|
+
def _totals(self, rows) -> dict:
|
|
164
|
+
"""{usd, tokens} from (model, input_tokens, output_tokens) rows:
|
|
165
|
+
the aggregate is SQL's; only the per-model price lookup is ours."""
|
|
166
|
+
usd = 0.0
|
|
167
|
+
tokens = 0
|
|
168
|
+
for model, inp, out in rows:
|
|
169
|
+
inp, out = int(inp or 0), int(out or 0)
|
|
170
|
+
tokens += inp + out
|
|
171
|
+
rate = self.prices.get(model)
|
|
172
|
+
if rate:
|
|
173
|
+
usd += (inp * rate[0] + out * rate[1]) / 1_000_000
|
|
174
|
+
return {"usd": round(usd, 6), "tokens": tokens}
|
|
175
|
+
|
|
176
|
+
# -- enforcement: consulted on the approval seam -------------------------
|
|
177
|
+
|
|
178
|
+
async def check(self, action: str, tenant: str = DEFAULT_TENANT,
|
|
179
|
+
now: float | None = None,
|
|
180
|
+
run_id: str | None = None) -> dict:
|
|
181
|
+
"""Does this request's action breach a budget? Returns
|
|
182
|
+
{exceeded, on_exceed, scope, key, spent, limit} — a decision the seam
|
|
183
|
+
turns into a gate or a deny.
|
|
184
|
+
|
|
185
|
+
BOTH the action budget and the tenant `*` cap are evaluated (not just
|
|
186
|
+
the most specific), so a tenant-wide backstop holds even when the
|
|
187
|
+
action has its own sub-budget. If either is exceeded, the strictest
|
|
188
|
+
wins: deny beats gate. The no-budget posture is the client's choice
|
|
189
|
+
(cost_default_posture)."""
|
|
190
|
+
verdicts = []
|
|
191
|
+
any_rule = False
|
|
192
|
+
for scope, key in (("action", action), ("tenant", "*")):
|
|
193
|
+
rule = await self.budget_for(scope, key, tenant)
|
|
194
|
+
if rule is None:
|
|
195
|
+
continue
|
|
196
|
+
any_rule = True
|
|
197
|
+
spent = await self.spend(scope, key, rule["window"], tenant, now,
|
|
198
|
+
run_id)
|
|
199
|
+
verdicts.append({
|
|
200
|
+
"exceeded": self._over(rule, spent),
|
|
201
|
+
"on_exceed": rule["on_exceed"], "scope": scope, "key": key,
|
|
202
|
+
"window": rule["window"], "spent": spent,
|
|
203
|
+
"limit": {"usd": rule["max_usd"],
|
|
204
|
+
"tokens": rule["max_tokens"]}})
|
|
205
|
+
|
|
206
|
+
exceeded = [v for v in verdicts if v["exceeded"]]
|
|
207
|
+
if exceeded:
|
|
208
|
+
# deny beats gate; among same on_exceed, the most specific reports
|
|
209
|
+
deny = [v for v in exceeded if v["on_exceed"] == "deny"]
|
|
210
|
+
return deny[0] if deny else exceeded[0]
|
|
211
|
+
if any_rule:
|
|
212
|
+
return verdicts[0] # rules exist, all under budget: passes
|
|
213
|
+
# no budget covers this action: the client posture decides
|
|
214
|
+
if self.settings.cost_default_posture == "closed":
|
|
215
|
+
return {"exceeded": True, "on_exceed":
|
|
216
|
+
self.settings.cost_default_on_exceed, "scope": "default",
|
|
217
|
+
"key": action, "window": None, "spent": None,
|
|
218
|
+
"limit": None}
|
|
219
|
+
return {"exceeded": False, "on_exceed": None, "scope": "none",
|
|
220
|
+
"key": action, "window": None, "spent": None, "limit": None}
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _over(rule: dict, spent: dict) -> bool:
|
|
224
|
+
if rule.get("max_usd") is not None and spent["usd"] >= rule["max_usd"]:
|
|
225
|
+
return True
|
|
226
|
+
if (rule.get("max_tokens") is not None
|
|
227
|
+
and spent["tokens"] >= rule["max_tokens"]):
|
|
228
|
+
return True
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
# -- spend report (console) ----------------------------------------------
|
|
232
|
+
|
|
233
|
+
async def spend_report(self, tenant: str = DEFAULT_TENANT,
|
|
234
|
+
now: float | None = None) -> dict:
|
|
235
|
+
"""Spend vs budget per scope, plus the routing mix (which model ran).
|
|
236
|
+
A read/fold, no cached counters."""
|
|
237
|
+
budgets = await self.current_budgets(tenant)
|
|
238
|
+
lines = []
|
|
239
|
+
for b in budgets:
|
|
240
|
+
spent = await self.spend(b["scope"], b["key"], b["window"],
|
|
241
|
+
tenant, now)
|
|
242
|
+
lines.append({**b, "spent": spent,
|
|
243
|
+
"over": self._over(b, spent)})
|
|
244
|
+
|
|
245
|
+
# routing mix across the tenant's ingested decisions: one GROUP BY
|
|
246
|
+
async with self.pool.connection() as conn:
|
|
247
|
+
cur = await conn.execute(
|
|
248
|
+
"SELECT model, count(*), sum(input_tokens), sum(output_tokens) "
|
|
249
|
+
"FROM usage_view WHERE tenant_id = %s GROUP BY model",
|
|
250
|
+
(tenant,))
|
|
251
|
+
rows = await cur.fetchall()
|
|
252
|
+
mix: dict[str, dict] = {}
|
|
253
|
+
for model, n, inp, out in rows:
|
|
254
|
+
t = self._totals([(model, inp, out)])
|
|
255
|
+
mix[model] = {"decisions": int(n), "tokens": t["tokens"],
|
|
256
|
+
"usd": t["usd"]}
|
|
257
|
+
return {"budgets": lines, "routing_mix": mix}
|
|
258
|
+
|
|
259
|
+
# -- evals & gate decisions: ingest (not re-score) -----------------------
|
|
260
|
+
|
|
261
|
+
async def record_eval(self, agent: str, version: str, *, profile: dict,
|
|
262
|
+
scenarios, by: str,
|
|
263
|
+
tenant: str = DEFAULT_TENANT) -> None:
|
|
264
|
+
await self.ledger.append("eval_recorded", {
|
|
265
|
+
"agent": agent, "version": version, "profile": profile,
|
|
266
|
+
"scenarios": scenarios, "by": by}, tenant=tenant)
|
|
267
|
+
|
|
268
|
+
async def record_gate(self, agent: str, version: str, *, passed: bool,
|
|
269
|
+
baseline_version: str | None, blocks,
|
|
270
|
+
by: str, reason: str = "", override: bool = False,
|
|
271
|
+
tenant: str = DEFAULT_TENANT) -> dict:
|
|
272
|
+
"""Append a gate decision. Immutability is decided INSIDE the append
|
|
273
|
+
transaction, under the governance chain's advisory lock: a
|
|
274
|
+
check-then-append across two transactions would let two racing CI
|
|
275
|
+
jobs both see "undecided" and the second (last-write-wins in
|
|
276
|
+
gate_view) silently flip the first — the exact bypass the admin
|
|
277
|
+
override exists to prevent. Raises GateAlreadyDecided unless
|
|
278
|
+
`override` (the admin path, itself audited)."""
|
|
279
|
+
from .ledger.store import APPEND_LOCK, GOVERNANCE_CHAIN
|
|
280
|
+
|
|
281
|
+
async with self.pool.connection() as conn:
|
|
282
|
+
async with conn.transaction():
|
|
283
|
+
await conn.execute(
|
|
284
|
+
"SELECT pg_advisory_xact_lock(%s, hashtext(%s))",
|
|
285
|
+
(APPEND_LOCK, f"{tenant}:{GOVERNANCE_CHAIN}"))
|
|
286
|
+
if not override:
|
|
287
|
+
existing = await self._gate_verdict_conn(
|
|
288
|
+
conn, agent, version, tenant)
|
|
289
|
+
if existing is not None:
|
|
290
|
+
raise GateAlreadyDecided(existing)
|
|
291
|
+
await self.ledger._append_in_tx(conn, "gate_decision", {
|
|
292
|
+
"agent": agent, "version": version, "passed": bool(passed),
|
|
293
|
+
"baseline_version": baseline_version,
|
|
294
|
+
"blocks": blocks or [], "override": bool(override),
|
|
295
|
+
"by": by, "reason": reason}, tenant=tenant)
|
|
296
|
+
verdict = await self.gate_verdict(agent, version, tenant)
|
|
297
|
+
assert verdict is not None # just appended
|
|
298
|
+
return verdict
|
|
299
|
+
|
|
300
|
+
async def _gate_verdict_conn(self, conn, agent: str, version: str,
|
|
301
|
+
tenant: str) -> dict | None:
|
|
302
|
+
cur = await conn.execute(
|
|
303
|
+
"SELECT passed, baseline_version, blocks, override, "
|
|
304
|
+
"decided_by, reason, decided_ts_ms FROM gate_view "
|
|
305
|
+
"WHERE tenant_id = %s AND agent = %s AND version = %s",
|
|
306
|
+
(tenant, agent, version))
|
|
307
|
+
row = await cur.fetchone()
|
|
308
|
+
if row is None:
|
|
309
|
+
return None
|
|
310
|
+
return {"agent": agent, "version": version, "passed": row[0],
|
|
311
|
+
"baseline_version": row[1], "blocks": row[2],
|
|
312
|
+
"override": row[3], "decided_by": row[4], "reason": row[5],
|
|
313
|
+
"decided_ts_ms": row[6]}
|
|
314
|
+
|
|
315
|
+
async def gate_verdict(self, agent: str, version: str,
|
|
316
|
+
tenant: str = DEFAULT_TENANT) -> dict | None:
|
|
317
|
+
async with self.pool.connection() as conn:
|
|
318
|
+
return await self._gate_verdict_conn(conn, agent, version, tenant)
|
|
319
|
+
|
|
320
|
+
async def history(self, agent: str | None = None,
|
|
321
|
+
version: str | None = None,
|
|
322
|
+
tenant: str = DEFAULT_TENANT, limit: int = 500,
|
|
323
|
+
before: int | None = None) -> list[dict]:
|
|
324
|
+
"""Newest first, cursor-paged: pass the last row's `id` as `before`
|
|
325
|
+
for the next page. A page, never a silent cap on the whole."""
|
|
326
|
+
q = ("SELECT kind, agent, version, detail, ts_ms, id FROM eval_history "
|
|
327
|
+
"WHERE tenant_id = %s")
|
|
328
|
+
params: list = [tenant]
|
|
329
|
+
if agent:
|
|
330
|
+
q += " AND agent = %s"
|
|
331
|
+
params.append(agent)
|
|
332
|
+
if version:
|
|
333
|
+
q += " AND version = %s"
|
|
334
|
+
params.append(version)
|
|
335
|
+
if before is not None:
|
|
336
|
+
q += " AND id < %s"
|
|
337
|
+
params.append(before)
|
|
338
|
+
q += " ORDER BY id DESC LIMIT %s"
|
|
339
|
+
params.append(max(1, min(limit, HISTORY_MAX_LIMIT)))
|
|
340
|
+
async with self.pool.connection() as conn:
|
|
341
|
+
cur = await conn.execute(q, params)
|
|
342
|
+
rows = await cur.fetchall()
|
|
343
|
+
return [{"id": r[5], "kind": r[0], "agent": r[1], "version": r[2],
|
|
344
|
+
"detail": r[3] if isinstance(r[3], dict) else json.loads(r[3]),
|
|
345
|
+
"ts_ms": r[4]} for r in rows]
|
app/db.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Async Postgres pool and migrations runner."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from psycopg_pool import AsyncConnectionPool
|
|
8
|
+
|
|
9
|
+
from .config import Settings
|
|
10
|
+
|
|
11
|
+
# the migrations ship INSIDE the package (app/migrations, app/alembic.ini),
|
|
12
|
+
# so an installed wheel migrates exactly like a source checkout
|
|
13
|
+
PKG_DIR = Path(__file__).resolve().parent
|
|
14
|
+
MIGRATIONS_DIR = PKG_DIR / "migrations"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _alembic_config(url: str | None = None):
|
|
18
|
+
from alembic.config import Config
|
|
19
|
+
|
|
20
|
+
cfg = Config(str(PKG_DIR / "alembic.ini"))
|
|
21
|
+
cfg.set_main_option("script_location", str(MIGRATIONS_DIR))
|
|
22
|
+
if url is not None:
|
|
23
|
+
cfg.set_main_option("sqlalchemy.url", url)
|
|
24
|
+
return cfg
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _upgrade_sync(settings: Settings) -> None:
|
|
28
|
+
from alembic import command
|
|
29
|
+
|
|
30
|
+
command.upgrade(_alembic_config(settings.alembic_url), "head")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def migrate_main() -> None:
|
|
34
|
+
"""`primarypath-migrate`: alembic upgrade head from PRIMARYPATH_* settings
|
|
35
|
+
(the Helm pre-install/pre-upgrade Job, or an operator's shell)."""
|
|
36
|
+
from .config import get_settings
|
|
37
|
+
|
|
38
|
+
settings = get_settings()
|
|
39
|
+
_upgrade_sync(settings)
|
|
40
|
+
print(f"migrated to {head_revision()}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def run_migrations(settings: Settings) -> None:
|
|
44
|
+
"""Idempotent: alembic upgrade head, run off the event loop."""
|
|
45
|
+
await asyncio.to_thread(_upgrade_sync, settings)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def head_revision() -> str | None:
|
|
49
|
+
"""The newest migration in the tree (what /readyz requires the DB to be at)."""
|
|
50
|
+
from alembic.script import ScriptDirectory
|
|
51
|
+
|
|
52
|
+
return ScriptDirectory.from_config(_alembic_config()).get_current_head()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def make_pool(settings: Settings) -> AsyncConnectionPool:
|
|
56
|
+
return AsyncConnectionPool(settings.database_url, min_size=1, max_size=10, open=False)
|
app/faults.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Test-only crash injection for the F12 hardening harness.
|
|
2
|
+
|
|
3
|
+
When PRIMARYPATH_FAULT names a fault point, reaching that point hard-crashes the
|
|
4
|
+
process (os._exit: no cleanup, no flushing — the closest thing to a power
|
|
5
|
+
cut we can do deterministically). A production deployment never sets
|
|
6
|
+
PRIMARYPATH_FAULT, making every hook a no-op comparison against None.
|
|
7
|
+
|
|
8
|
+
Fault points wired into the service:
|
|
9
|
+
- after_event_insert_before_commit inside the append tx, after INSERT
|
|
10
|
+
- before_projection_update inside the append tx, before the view write
|
|
11
|
+
- after_append_commit_before_http create committed, response never sent
|
|
12
|
+
- after_decision_commit_before_reflect decision committed, Slack/webhook not told
|
|
13
|
+
- after_write_commit_before_http an annotate / incident / cancel / redeem /
|
|
14
|
+
ingest committed, response never sent (the
|
|
15
|
+
Idempotency-Key replay tests)
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
_ACTIVE = os.environ.get("PRIMARYPATH_FAULT")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fault_point(name: str) -> None:
|
|
25
|
+
if _ACTIVE is not None and _ACTIVE == name:
|
|
26
|
+
os._exit(1)
|
app/grants.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Signed execution grants: the approve→execute seam.
|
|
2
|
+
|
|
3
|
+
The chain proves that an approval happened for a digest-bound action. A
|
|
4
|
+
grant lets the EXECUTOR — the MCP gateway or a tool proxy that holds the
|
|
5
|
+
tool credentials, never the agent — prove that the action it is about to
|
|
6
|
+
run is the one that was approved, and run it exactly once:
|
|
7
|
+
|
|
8
|
+
- minted inside the granting transaction and stamped into the
|
|
9
|
+
`approval_granted` payload, so the chain carries the grant itself;
|
|
10
|
+
- Ed25519-signed by the audit signer (the same key ring auditors pin
|
|
11
|
+
from `/api/audit/keys`), so an executor verifies it OFFLINE;
|
|
12
|
+
- bound to approval_id + tenant + agent + tool + action_digest + issue
|
|
13
|
+
and expiry time + a random nonce;
|
|
14
|
+
- redeemed ONLINE exactly once: `POST /api/approvals/{id}/redeem` under
|
|
15
|
+
the chain lock appends `grant_redeemed`; a second redemption, an
|
|
16
|
+
expired grant, or a nonce that does not match is refused.
|
|
17
|
+
|
|
18
|
+
The signed record is the RFC 8785 (JCS) form of SIGNED_FIELDS; `key_id`
|
|
19
|
+
and `signature` ride alongside. The SDKs (Python `verify_grant`, TypeScript
|
|
20
|
+
`verifyGrant`) recompute the record and the action digest from the
|
|
21
|
+
arguments the executor is about to dispatch, so a substituted argument
|
|
22
|
+
fails before any tool runs.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
import secrets
|
|
28
|
+
import time
|
|
29
|
+
|
|
30
|
+
from .jcs import jcs
|
|
31
|
+
from .ledger.store import action_digest
|
|
32
|
+
|
|
33
|
+
GRANT_VERSION = 1
|
|
34
|
+
|
|
35
|
+
# the fields the signature commits to, in the order they are documented
|
|
36
|
+
SIGNED_FIELDS = ("v", "approval_id", "tenant", "agent_id", "tool",
|
|
37
|
+
"action_digest", "issued_ms", "expires_ms", "nonce")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def grant_message(grant: dict) -> bytes:
|
|
41
|
+
"""The exact bytes that were (or must be) signed."""
|
|
42
|
+
return jcs({k: grant[k] for k in SIGNED_FIELDS}).encode("utf-8")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def digest_of(approval: dict) -> str:
|
|
46
|
+
"""The approval's action digest: the one stamped in the chain, or —
|
|
47
|
+
for approvals requested before the stamp existed — recomputed from
|
|
48
|
+
the same immutable fields, so a grant always binds a digest."""
|
|
49
|
+
return approval.get("action_digest") or action_digest(
|
|
50
|
+
approval["tool"], approval.get("arguments"), approval.get("reasoning"),
|
|
51
|
+
approval.get("framework"), approval.get("callback_url"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GrantMinter:
|
|
55
|
+
"""Mints grants with the deployment's audit signer. `ttl_s` is how
|
|
56
|
+
long an executor has to redeem after the decision: short, because a
|
|
57
|
+
grant is a bearer capability for one consequential action."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, signer, ttl_s: int):
|
|
60
|
+
self.signer = signer
|
|
61
|
+
self.ttl_s = ttl_s
|
|
62
|
+
|
|
63
|
+
def mint(self, approval: dict, tenant: str,
|
|
64
|
+
now_ms: int | None = None) -> dict:
|
|
65
|
+
now_ms = int(time.time() * 1000) if now_ms is None else now_ms
|
|
66
|
+
grant = {
|
|
67
|
+
"v": GRANT_VERSION,
|
|
68
|
+
"approval_id": approval["approval_id"],
|
|
69
|
+
"tenant": tenant,
|
|
70
|
+
"agent_id": approval.get("agent_id") or "default",
|
|
71
|
+
"tool": approval["tool"],
|
|
72
|
+
"action_digest": digest_of(approval),
|
|
73
|
+
"issued_ms": now_ms,
|
|
74
|
+
"expires_ms": now_ms + self.ttl_s * 1000,
|
|
75
|
+
"nonce": secrets.token_hex(16),
|
|
76
|
+
}
|
|
77
|
+
sig = self.signer.backend.sign(grant_message(grant))
|
|
78
|
+
grant["key_id"] = self.signer.key_id
|
|
79
|
+
grant["signature"] = base64.b64encode(sig).decode()
|
|
80
|
+
return grant
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class GrantInvalid(Exception):
|
|
84
|
+
"""A grant that must not be honored, and why."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def verify_grant(grant: dict, keys: dict[str, str], *,
|
|
88
|
+
now_ms: int | None = None,
|
|
89
|
+
approval_id: str | None = None,
|
|
90
|
+
tool: str | None = None,
|
|
91
|
+
expected_digest: str | None = None) -> None:
|
|
92
|
+
"""Offline verification against a public-key ring (`key_id` ->
|
|
93
|
+
base64 raw Ed25519 public key). Raises GrantInvalid. The server uses
|
|
94
|
+
it only as a self-check; the SDKs carry the executor-side twin."""
|
|
95
|
+
from cryptography.exceptions import InvalidSignature
|
|
96
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
97
|
+
|
|
98
|
+
if not isinstance(grant, dict) or grant.get("v") != GRANT_VERSION:
|
|
99
|
+
raise GrantInvalid("unsupported grant version")
|
|
100
|
+
missing = [k for k in SIGNED_FIELDS + ("key_id", "signature")
|
|
101
|
+
if k not in grant]
|
|
102
|
+
if missing:
|
|
103
|
+
raise GrantInvalid(f"grant is missing {', '.join(missing)}")
|
|
104
|
+
pub = keys.get(grant["key_id"])
|
|
105
|
+
if pub is None:
|
|
106
|
+
raise GrantInvalid(f"unknown signing key {grant['key_id']}")
|
|
107
|
+
try:
|
|
108
|
+
ed25519.Ed25519PublicKey.from_public_bytes(
|
|
109
|
+
base64.b64decode(pub)).verify(
|
|
110
|
+
base64.b64decode(grant["signature"]), grant_message(grant))
|
|
111
|
+
except (InvalidSignature, ValueError):
|
|
112
|
+
raise GrantInvalid("signature does not verify") from None
|
|
113
|
+
now_ms = int(time.time() * 1000) if now_ms is None else now_ms
|
|
114
|
+
if now_ms > int(grant["expires_ms"]):
|
|
115
|
+
raise GrantInvalid("grant has expired")
|
|
116
|
+
if approval_id is not None and grant["approval_id"] != approval_id:
|
|
117
|
+
raise GrantInvalid("grant is for a different approval")
|
|
118
|
+
if tool is not None and grant["tool"] != tool:
|
|
119
|
+
raise GrantInvalid(f"grant is for tool {grant['tool']}, not {tool}")
|
|
120
|
+
if expected_digest is not None and grant["action_digest"] != expected_digest:
|
|
121
|
+
raise GrantInvalid("grant binds a different action digest than the "
|
|
122
|
+
"one about to execute")
|
app/idempotency.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Operation idempotency keys: retry any write safely.
|
|
2
|
+
|
|
3
|
+
A client that may lose a response (an agent pausing for days behind a
|
|
4
|
+
flaky network) sends `Idempotency-Key: <its own unique key>` on a
|
|
5
|
+
mutating request and REPEATS the same key on every retry of that one
|
|
6
|
+
logical operation. The server records the key inside the transaction
|
|
7
|
+
that appends the operation's events, together with a fingerprint of
|
|
8
|
+
who asked for what, and the result it returned. A retry with the same
|
|
9
|
+
key and the same fingerprint gets the recorded result back — the same
|
|
10
|
+
annotation, the same decision, the same redeemed grant — and writes
|
|
11
|
+
nothing; the same key with a different fingerprint is refused, because
|
|
12
|
+
a key names one operation.
|
|
13
|
+
|
|
14
|
+
Approval creation is idempotent by its own id and needs no key; every
|
|
15
|
+
other write accepts one. The SDKs generate a key per call.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from fastapi import HTTPException, Request
|
|
23
|
+
|
|
24
|
+
from .auth import credential_id, resolve_tenant
|
|
25
|
+
from .jcs import jcs
|
|
26
|
+
|
|
27
|
+
HEADER = "Idempotency-Key"
|
|
28
|
+
KEY_MAX = 200
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IdempotencyMismatch(Exception):
|
|
32
|
+
"""The key was already used for a different request."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Idem:
|
|
37
|
+
key: str
|
|
38
|
+
fingerprint: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def idempotency_of(request: Request, body) -> Idem | None:
|
|
42
|
+
"""The request's Idem, or None when it carries no key. The
|
|
43
|
+
fingerprint binds the key to tenant, credential, method, path and the
|
|
44
|
+
canonical body, so a key cannot be replayed across any of them."""
|
|
45
|
+
key = request.headers.get(HEADER)
|
|
46
|
+
if key is None:
|
|
47
|
+
return None
|
|
48
|
+
key = key.strip()
|
|
49
|
+
if not key or len(key) > KEY_MAX:
|
|
50
|
+
raise HTTPException(400, f"{HEADER} must be 1-{KEY_MAX} characters")
|
|
51
|
+
record = {
|
|
52
|
+
"tenant": await resolve_tenant(request),
|
|
53
|
+
"credential": await credential_id(request),
|
|
54
|
+
"method": request.method,
|
|
55
|
+
"path": request.url.path,
|
|
56
|
+
"body": body,
|
|
57
|
+
}
|
|
58
|
+
return Idem(key, hashlib.sha256(jcs(record).encode("utf-8")).hexdigest())
|