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.
Files changed (94) hide show
  1. app/__init__.py +351 -0
  2. app/_version.py +8 -0
  3. app/agents.py +150 -0
  4. app/alembic.ini +37 -0
  5. app/auth.py +714 -0
  6. app/chat.py +122 -0
  7. app/config.py +258 -0
  8. app/costgov.py +345 -0
  9. app/db.py +56 -0
  10. app/faults.py +26 -0
  11. app/grants.py +122 -0
  12. app/idempotency.py +58 -0
  13. app/jcs.py +117 -0
  14. app/ledger/__init__.py +41 -0
  15. app/ledger/fold.py +74 -0
  16. app/ledger/hashing.py +19 -0
  17. app/ledger/projection.py +389 -0
  18. app/ledger/store.py +1042 -0
  19. app/ledger/verify.py +31 -0
  20. app/main.py +50 -0
  21. app/maintenance.py +56 -0
  22. app/metrics.py +89 -0
  23. app/migrations/env.py +20 -0
  24. app/migrations/script.py.mako +23 -0
  25. app/migrations/versions/0001_baseline.py +19 -0
  26. app/migrations/versions/0002_events_and_projection.py +78 -0
  27. app/migrations/versions/0003_slack_messages.py +27 -0
  28. app/migrations/versions/0004_sessions.py +31 -0
  29. app/migrations/versions/0005_siem_cursor.py +26 -0
  30. app/migrations/versions/0006_teams_messages.py +27 -0
  31. app/migrations/versions/0007_tenant_scoping.py +50 -0
  32. app/migrations/versions/0008_policy_view.py +39 -0
  33. app/migrations/versions/0009_api_tokens.py +37 -0
  34. app/migrations/versions/0010_session_groups.py +23 -0
  35. app/migrations/versions/0011_budgets_and_evals.py +75 -0
  36. app/migrations/versions/0012_run_event_ts_index.py +23 -0
  37. app/migrations/versions/0013_agent_chains.py +102 -0
  38. app/migrations/versions/0014_dual_control.py +33 -0
  39. app/migrations/versions/0015_sampled_mode.py +24 -0
  40. app/migrations/versions/0016_sampling_salt.py +25 -0
  41. app/migrations/versions/0017_live_tokens_index.py +25 -0
  42. app/migrations/versions/0018_webhook_outbox.py +47 -0
  43. app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
  44. app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
  45. app/migrations/versions/0021_execution_grants.py +59 -0
  46. app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
  47. app/migrations/versions/0023_idempotency_keys.py +38 -0
  48. app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
  49. app/netguard.py +73 -0
  50. app/obslog.py +105 -0
  51. app/policy.py +390 -0
  52. app/ratelimit.py +78 -0
  53. app/routes/__init__.py +0 -0
  54. app/routes/admin.py +312 -0
  55. app/routes/agents.py +111 -0
  56. app/routes/approvals.py +576 -0
  57. app/routes/audit.py +213 -0
  58. app/routes/costgov.py +150 -0
  59. app/routes/health.py +53 -0
  60. app/routes/policy.py +72 -0
  61. app/routes/runs.py +115 -0
  62. app/routes/slack.py +40 -0
  63. app/routes/teams.py +29 -0
  64. app/routes/traces.py +55 -0
  65. app/schemas.py +248 -0
  66. app/security.py +203 -0
  67. app/siem.py +248 -0
  68. app/signing.py +342 -0
  69. app/sla.py +234 -0
  70. app/slack.py +282 -0
  71. app/teams.py +276 -0
  72. app/tokens.py +117 -0
  73. app/traces.py +309 -0
  74. app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  75. app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  76. app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  77. app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  78. app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
  79. app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
  80. app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
  81. app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
  82. app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
  83. app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
  84. app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  85. app/web-dist/assets/index-B_JDe_G2.js +10 -0
  86. app/web-dist/assets/index-J7J-YtwM.css +1 -0
  87. app/web-dist/index.html +24 -0
  88. app/webhooks.py +405 -0
  89. primarypath_server-1.0.0.dist-info/METADATA +35 -0
  90. primarypath_server-1.0.0.dist-info/RECORD +94 -0
  91. primarypath_server-1.0.0.dist-info/WHEEL +5 -0
  92. primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
  93. primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
  94. 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())