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/routes/audit.py ADDED
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import hashlib
5
+ import io
6
+ import json
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Request
9
+ from fastapi.responses import PlainTextResponse
10
+
11
+ from ..auth import (
12
+ activity_scope,
13
+ get_identity,
14
+ require_admin,
15
+ require_console_reader,
16
+ require_reader,
17
+ resolve_tenant,
18
+ scope_units,
19
+ visible_to,
20
+ )
21
+ from ..ledger.hashing import canonical
22
+ from ..ledger.store import DEFAULT_AGENT, GOVERNANCE_CHAIN
23
+ from ..signing import SigningUnavailable
24
+
25
+ router = APIRouter(prefix="/api/audit")
26
+
27
+ # SIEM CSV columns, stable and documented: one row per event. F24 appended
28
+ # chain_id/agent/unit; the original seven stay in place.
29
+ CSV_COLUMNS = ["seq", "ts_ms", "type", "run_id", "approval_id", "actor",
30
+ "hash", "chain_id", "agent", "unit"]
31
+
32
+
33
+ def _csv_rows(w, chain_id: str, events: list[dict]) -> None:
34
+ for e in events:
35
+ p = json.loads(e["payload"]) if isinstance(e["payload"], str) else e["payload"]
36
+ w.writerow([e["seq"], e["ts_ms"], e["type"],
37
+ p.get("run_id", ""), p.get("approval_id", ""),
38
+ p.get("reviewer", "") or p.get("by", ""), e["hash"],
39
+ chain_id, e.get("agent_id", ""), e.get("unit", "")])
40
+
41
+
42
+ def bundle_to_csv(bundle: dict) -> str:
43
+ out = io.StringIO()
44
+ w = csv.writer(out)
45
+ w.writerow(CSV_COLUMNS)
46
+ if "chains" in bundle:
47
+ for c in bundle["chains"]:
48
+ _csv_rows(w, c["chain_id"], c["events"])
49
+ else:
50
+ _csv_rows(w, bundle.get("chain_id", DEFAULT_AGENT), bundle["events"])
51
+ return out.getvalue()
52
+
53
+
54
+ async def _exported_by(request: Request) -> str:
55
+ identity = await get_identity(request)
56
+ if identity:
57
+ return identity.get("email") or identity["subject"]
58
+ return "service"
59
+
60
+
61
+ async def _chain_readable(request: Request, read_scope, agent: str,
62
+ tenant: str) -> bool:
63
+ """An agent's whole chain is readable when the agent's unit is in the
64
+ caller's scope and so is every unit stamped on the chain. An agent
65
+ moved from one unit to another holds both units' work; a bundle cannot
66
+ drop events and still verify, so it goes only to a reader of both (B23)."""
67
+ unit = await request.app.state.agents.unit_of(agent, tenant)
68
+ if not visible_to(read_scope, agent, unit):
69
+ return False
70
+ units = scope_units(read_scope)
71
+ if units is None:
72
+ return True
73
+ return not await request.app.state.ledger.chain_has_unit_outside(
74
+ tenant, agent, units)
75
+
76
+
77
+ @router.get("/export")
78
+ async def export(request: Request, format: str = "json",
79
+ agent: str | None = None, scope: str | None = None):
80
+ """The signed, portable audit artifact.
81
+
82
+ Auth is per shape (the brief makes audit-and-verify available to every
83
+ authenticated role, F24 scopes it): a single agent's chain is readable
84
+ by approver+ sessions and the admin token, gated by the caller's units
85
+ below; the governance chain and the whole log stay on the admin plane.
86
+ The bare agent service token can pull none of them.
87
+
88
+ Three shapes (F24):
89
+ - ?agent=A one agent's chain, signed under ITS evidence
90
+ profile (the auditor's unit of verification)
91
+ - ?scope=governance the control-plane chain (who changed which
92
+ control) — no agent activity lives there
93
+ - (no params) the whole log: every chain, each independently
94
+ verifiable; there is no single cross-chain hash
95
+
96
+ Deliberately side-effecting despite being a GET: every export appends an
97
+ audit_exported provenance event (who pulled the record, when, to what
98
+ head). Pulling the audit trail is itself an auditable act — the
99
+ provenance lands on the governance chain. Safe to repeat; each call is
100
+ its own provenance record. Documented in the API reference."""
101
+ if agent is not None and scope is not None:
102
+ raise HTTPException(400, "pass either agent or scope, not both")
103
+ if agent is not None:
104
+ await require_console_reader(request)
105
+ else:
106
+ await require_admin(request)
107
+
108
+ ledger = request.app.state.ledger
109
+ tenant = await resolve_tenant(request)
110
+
111
+ if scope == "governance":
112
+ bundle = await ledger.export_bundle(tenant, chain=GOVERNANCE_CHAIN)
113
+ bundle["scope"] = "governance"
114
+ # the sampled-mode pick is an HMAC keyed by this salt; disclosing
115
+ # it here (admin plane — the agent token cannot reach an export)
116
+ # lets an auditor recompute every routing decision offline
117
+ bundle["sampling_salt"] = \
118
+ await request.app.state.policy.sampling_salt(tenant)
119
+ signer = request.app.state.signer
120
+ if signer is not None:
121
+ bundle = signer.sign_bundle(bundle)
122
+ exported_scope, head_hash = "governance", bundle["head"]
123
+ elif agent is not None:
124
+ # an agent's chain is agent activity: the need-to-know gate applies
125
+ # to the operator here exactly as it does in the inbox
126
+ read_scope = await activity_scope(request)
127
+ if not await _chain_readable(request, read_scope, agent, tenant):
128
+ raise HTTPException(404, f"unknown agent {agent}")
129
+ profile = await request.app.state.agents.evidence_for(agent, tenant)
130
+ bundle = await ledger.export_bundle(tenant, chain=agent)
131
+ bundle["scope"] = f"agent:{agent}"
132
+ bundle["agent"] = agent
133
+ bundle["evidence"] = profile
134
+ if (profile["source"] == "default"
135
+ or profile["signing_backend"] == "default"):
136
+ # no explicit evidence demand, or an explicit "deployment
137
+ # default": the default signer
138
+ signer = request.app.state.signer
139
+ else:
140
+ try:
141
+ signer = request.app.state.signers.for_profile(profile)
142
+ except SigningUnavailable as e:
143
+ raise HTTPException(409, str(e)) from None
144
+ if signer is not None:
145
+ bundle = signer.sign_bundle(bundle)
146
+ exported_scope, head_hash = f"agent:{agent}", bundle["head"]
147
+ else:
148
+ bundle = await ledger.export_full(tenant)
149
+ bundle["sampling_salt"] = \
150
+ await request.app.state.policy.sampling_salt(tenant)
151
+ signer = request.app.state.signer
152
+ if signer is not None:
153
+ bundle = signer.sign_chains(bundle)
154
+ heads = {c["chain_id"]: c["head"] for c in bundle["chains"]}
155
+ # a single commitment to every chain head, for the provenance event
156
+ head_hash = hashlib.sha256(
157
+ canonical(heads).encode("utf-8")).hexdigest()
158
+ exported_scope = "full"
159
+
160
+ await ledger.append("audit_exported", {
161
+ "head_hash": head_hash, "by": await _exported_by(request),
162
+ "scope": exported_scope}, tenant=tenant)
163
+ if format == "csv":
164
+ return PlainTextResponse(bundle_to_csv(bundle), media_type="text/csv")
165
+ return bundle
166
+
167
+
168
+ @router.get("/chains", dependencies=[Depends(require_console_reader)])
169
+ async def chains(request: Request):
170
+ """Every chain the caller may verify, with its length and head: the
171
+ audit page's agent picker. Chain heads are metadata (verification
172
+ anchors), not activity content, but volume is information too: for a
173
+ non-admin the list is unit-scoped like the inbox, and the governance
174
+ chain (the admin's plane) is omitted."""
175
+ tenant = await resolve_tenant(request)
176
+ registry = request.app.state.agents
177
+ read_scope = await activity_scope(request)
178
+ is_admin = True
179
+ try:
180
+ await require_admin(request)
181
+ except HTTPException:
182
+ is_admin = False
183
+ out = []
184
+ for c in await request.app.state.ledger.chains(tenant):
185
+ entry = dict(c)
186
+ if c["chain_id"] == GOVERNANCE_CHAIN:
187
+ if not is_admin:
188
+ continue
189
+ entry["kind"] = "governance"
190
+ else:
191
+ unit = await registry.unit_of(c["chain_id"], tenant)
192
+ if not is_admin and not await _chain_readable(
193
+ request, read_scope, c["chain_id"], tenant):
194
+ continue
195
+ entry["kind"] = "agent"
196
+ entry["unit"] = unit
197
+ entry["evidence"] = await registry.evidence_for(
198
+ c["chain_id"], tenant)
199
+ out.append(entry)
200
+ return out
201
+
202
+
203
+ @router.get("/keys", dependencies=[Depends(require_reader)])
204
+ async def keys(request: Request):
205
+ """The public-key ring: pin these to verify old and new bundles. Spans
206
+ every configured backend (file and KMS/HSM)."""
207
+ signer = request.app.state.signer
208
+ ring = dict(request.app.state.signers.pubring())
209
+ if signer is not None:
210
+ ring.update(signer.pubring())
211
+ return {"keys": ring,
212
+ "current": signer.key_id if signer else None,
213
+ "backends": request.app.state.signers.backends()}
app/routes/costgov.py ADDED
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request
4
+
5
+ from ..auth import (
6
+ get_identity,
7
+ require_admin,
8
+ require_agent,
9
+ require_agent_binding,
10
+ require_console_reader,
11
+ require_reader,
12
+ resolve_tenant,
13
+ )
14
+ from ..costgov import ON_EXCEED, SCOPES, WINDOWS, GateAlreadyDecided
15
+ from ..schemas import BudgetRule, EvalProfile, GateDecision, GateOverride
16
+
17
+ router = APIRouter(prefix="/api")
18
+
19
+
20
+ def _actor(identity: dict | None) -> str:
21
+ if identity:
22
+ return identity.get("email") or identity["subject"]
23
+ return "admin-token"
24
+
25
+
26
+ async def _agent_actor(request: Request) -> str:
27
+ identity = await get_identity(request)
28
+ if identity:
29
+ return identity.get("email") or identity["subject"]
30
+ return "service-token"
31
+
32
+
33
+ # -- budgets & spend ----------------------------------------------------------
34
+
35
+
36
+ @router.get("/budgets", dependencies=[Depends(require_reader)])
37
+ async def get_budgets(request: Request):
38
+ """Current folded budgets for the tenant (approver+)."""
39
+ return await request.app.state.cost.current_budgets(
40
+ tenant=await resolve_tenant(request))
41
+
42
+
43
+ @router.put("/budgets/{scope}/{key}")
44
+ async def set_budget(request: Request, scope: str, key: str, body: BudgetRule,
45
+ identity: dict | None = Depends(require_admin)):
46
+ """Set a budget/quota (admin). Appends an audited budget_set."""
47
+ if scope not in SCOPES:
48
+ raise HTTPException(400, f"scope must be one of {SCOPES}")
49
+ if body.window not in WINDOWS:
50
+ raise HTTPException(400, f"window must be one of {WINDOWS}")
51
+ if body.on_exceed not in ON_EXCEED:
52
+ raise HTTPException(400, f"on_exceed must be one of {ON_EXCEED}")
53
+ if body.max_usd is None and body.max_tokens is None:
54
+ raise HTTPException(400, "set max_usd and/or max_tokens")
55
+ return await request.app.state.cost.set_budget(
56
+ scope, key, window=body.window, on_exceed=body.on_exceed,
57
+ max_usd=body.max_usd, max_tokens=body.max_tokens,
58
+ by=_actor(identity), reason=body.reason,
59
+ tenant=await resolve_tenant(request))
60
+
61
+
62
+ @router.get("/spend", dependencies=[Depends(require_reader)])
63
+ async def get_spend(request: Request):
64
+ """Spend vs budget per scope, in-window, plus the routing mix (approver+)."""
65
+ return await request.app.state.cost.spend_report(
66
+ tenant=await resolve_tenant(request))
67
+
68
+
69
+ # -- evals & deploy gates -----------------------------------------------------
70
+
71
+
72
+ @router.post("/evals", dependencies=[Depends(require_agent)])
73
+ async def record_eval(request: Request, body: EvalProfile):
74
+ """Ingest an eval profile as chained evidence (agent/service token).
75
+ Not re-scored — drangue's evaluate owns the maths."""
76
+ await require_agent_binding(request, body.agent)
77
+ tenant = await resolve_tenant(request)
78
+ await request.app.state.cost.record_eval(
79
+ body.agent, body.version, profile=body.profile,
80
+ scenarios=body.scenarios, by=await _agent_actor(request), tenant=tenant)
81
+ return {"ok": True}
82
+
83
+
84
+ @router.post("/gate", dependencies=[Depends(require_agent)])
85
+ async def record_gate(request: Request, body: GateDecision):
86
+ """Record a deploy-gate decision as a chained event (agent/service token,
87
+ e.g. the CI job). Returns the resulting verdict.
88
+
89
+ A decided (agent, version) is immutable on this plane: gate_view folds
90
+ last-write-wins, so re-recording would let any service-token holder
91
+ flip a failed gate to pass and bypass the admin override entirely. The
92
+ only way past a decided gate is POST .../override (admin, audited).
93
+ The check lives INSIDE the store's append transaction, so two racing
94
+ recorders cannot both slip past it."""
95
+ await require_agent_binding(request, body.agent)
96
+ tenant = await resolve_tenant(request)
97
+ try:
98
+ verdict = await request.app.state.cost.record_gate(
99
+ body.agent, body.version, passed=body.passed,
100
+ baseline_version=body.baseline_version, blocks=body.blocks,
101
+ by=await _agent_actor(request), reason=body.reason, tenant=tenant)
102
+ except GateAlreadyDecided as e:
103
+ raise HTTPException(
104
+ 409, f"gate for {body.agent}@{body.version} is already decided "
105
+ f"(passed={e.verdict['passed']}); an admin override is the "
106
+ f"only way to change it") from None
107
+ return verdict
108
+
109
+
110
+ @router.get("/gate/{agent}/{version}", dependencies=[Depends(require_reader)])
111
+ async def gate_verdict(request: Request, agent: str, version: str):
112
+ """The latest gate verdict for CI (approver+ or service). 404 until a
113
+ gate decision has been recorded for this agent+version."""
114
+ verdict = await request.app.state.cost.gate_verdict(
115
+ agent, version, tenant=await resolve_tenant(request))
116
+ if verdict is None:
117
+ raise HTTPException(404, f"no gate decision for {agent}@{version}")
118
+ return verdict
119
+
120
+
121
+ @router.post("/gate/{agent}/{version}/override")
122
+ async def override_gate(request: Request, agent: str, version: str,
123
+ body: GateOverride,
124
+ identity: dict | None = Depends(require_admin)):
125
+ """Override a gate for a deploy (admin only). Appends a NEW gate_decision
126
+ with override=true and the required reason — who overrode what and why is
127
+ provable in the signed export."""
128
+ tenant = await resolve_tenant(request)
129
+ current = await request.app.state.cost.gate_verdict(agent, version, tenant)
130
+ if current is None:
131
+ # never fabricate a pass: there must be a recorded gate to override
132
+ raise HTTPException(404, f"no gate decision to override for "
133
+ f"{agent}@{version}")
134
+ return await request.app.state.cost.record_gate(
135
+ agent, version, passed=True,
136
+ baseline_version=current["baseline_version"],
137
+ blocks=current["blocks"],
138
+ by=_actor(identity), reason=body.reason, override=True, tenant=tenant)
139
+
140
+
141
+ @router.get("/evals", dependencies=[Depends(require_console_reader)])
142
+ async def eval_history(request: Request, agent: str | None = None,
143
+ version: str | None = None, limit: int = 500,
144
+ before: int | None = None):
145
+ """Eval + gate history (approver+ console; excludes the bare service
146
+ token — it can post evidence but not read the whole history back).
147
+ Newest first, cursor-paged: `before` = the last row's `id`."""
148
+ return await request.app.state.cost.history(
149
+ agent=agent, version=version, tenant=await resolve_tenant(request),
150
+ limit=limit, before=before)
app/routes/health.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Request, Response
4
+ from fastapi.responses import JSONResponse
5
+
6
+ router = APIRouter()
7
+
8
+
9
+ @router.get("/livez")
10
+ async def livez() -> dict:
11
+ """Liveness: is the process serving? Deliberately touches nothing
12
+ else — a database outage must show up as pods going UNREADY
13
+ (/readyz), never as kubelet restarting healthy processes."""
14
+ from .. import __version__
15
+
16
+ return {"status": "ok", "version": __version__}
17
+
18
+
19
+ @router.get("/healthz")
20
+ async def healthz(request: Request) -> dict:
21
+ """The operator's one-line check: process + database ping. Not a
22
+ liveness probe (see /livez); readiness is /readyz."""
23
+ async with request.app.state.pool.connection() as conn:
24
+ await conn.execute("SELECT 1")
25
+ from .. import __version__
26
+
27
+ return {"status": "ok", "db": "ok", "version": __version__}
28
+
29
+
30
+ @router.get("/readyz")
31
+ async def readyz(request: Request):
32
+ async with request.app.state.pool.connection() as conn:
33
+ cur = await conn.execute("SELECT version_num FROM alembic_version")
34
+ row = await cur.fetchone()
35
+ current = row[0] if row else None
36
+ head = request.app.state.migration_head
37
+ signer = request.app.state.signer
38
+ body = {"status": "ok", "db": "ok", "migration": current,
39
+ "migration_head": head,
40
+ "signing": signer.key_id if signer else None}
41
+ if current != head:
42
+ # not ready until the schema is at head (the k8s migration Job's gate)
43
+ return JSONResponse({**body, "status": "waiting-for-migrations"},
44
+ status_code=503)
45
+ return body
46
+
47
+
48
+ @router.get("/metrics")
49
+ async def metrics(request: Request):
50
+ from ..metrics import CONTENT_TYPE
51
+
52
+ body = await request.app.state.metrics.scrape(request.app.state.pool)
53
+ return Response(content=body, media_type=CONTENT_TYPE)
app/routes/policy.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request
4
+
5
+ from ..auth import require_admin, require_reader, resolve_tenant
6
+ from ..policy import MODES
7
+ from ..schemas import PolicyRule, Promote
8
+
9
+ router = APIRouter(prefix="/api")
10
+
11
+
12
+ def _actor(identity: dict | None) -> str:
13
+ if identity:
14
+ return identity.get("email") or identity["subject"]
15
+ return "admin-token"
16
+
17
+
18
+ @router.get("/policy", dependencies=[Depends(require_reader)])
19
+ async def get_policy(request: Request):
20
+ """The current folded policy for the tenant (approver+ read)."""
21
+ return await request.app.state.policy.current_policy(
22
+ tenant=await resolve_tenant(request))
23
+
24
+
25
+ @router.put("/policy/{action}")
26
+ async def set_rule(request: Request, action: str, body: PolicyRule,
27
+ identity: dict | None = Depends(require_admin)):
28
+ """Author a per-action rule (admin). Appends an audited policy_set."""
29
+ if body.mode not in MODES:
30
+ raise HTTPException(400, f"mode must be one of {MODES}")
31
+ return await request.app.state.policy.set_policy(
32
+ action, mode=body.mode, reversible=body.reversible,
33
+ requires_approval=body.requires_approval, allow=body.allow,
34
+ deny=body.deny, approver_groups=body.approver_groups,
35
+ require_second_approval=body.require_second_approval,
36
+ sample_n=body.sample_n, risk=body.risk,
37
+ blast_radius=body.blast_radius,
38
+ by=_actor(identity), reason=body.reason,
39
+ tenant=await resolve_tenant(request))
40
+
41
+
42
+ @router.get("/rollout", dependencies=[Depends(require_reader)])
43
+ async def get_rollout(request: Request):
44
+ """Per-action mode + track record + graduation recommendation."""
45
+ return await request.app.state.policy.rollout(
46
+ tenant=await resolve_tenant(request))
47
+
48
+
49
+ @router.post("/rollout/{action}/promote")
50
+ async def promote(request: Request, action: str, body: Promote,
51
+ identity: dict | None = Depends(require_admin)):
52
+ """Promote/demote an action's mode (admin). Appends an audited
53
+ policy_set; never automatic. Carries the existing rule's other fields so
54
+ only the mode moves."""
55
+ if body.mode not in MODES:
56
+ raise HTTPException(400, f"mode must be one of {MODES}")
57
+ tenant = await resolve_tenant(request)
58
+ existing = await request.app.state.policy.policy_for(action, tenant)
59
+ return await request.app.state.policy.set_policy(
60
+ action, mode=body.mode,
61
+ reversible=existing["reversible"] if existing else False,
62
+ requires_approval=None, # derive from the new mode
63
+ allow=existing["allow"] if existing else [],
64
+ deny=existing["deny"] if existing else [],
65
+ approver_groups=existing["approver_groups"] if existing else [],
66
+ # a mode move must never silently drop dual control or the rate
67
+ require_second_approval=(existing["require_second_approval"]
68
+ if existing else False),
69
+ sample_n=(existing.get("sample_n") or None) if existing else None,
70
+ risk=existing.get("risk") if existing else None,
71
+ blast_radius=existing.get("blast_radius") if existing else None,
72
+ by=_actor(identity), reason=body.reason, tenant=tenant)
app/routes/runs.py ADDED
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, Request
6
+
7
+ from ..auth import (
8
+ activity_scope,
9
+ agent_pin,
10
+ require_agent,
11
+ require_reader,
12
+ resolve_agent,
13
+ resolve_tenant,
14
+ visible_to,
15
+ )
16
+ from ..faults import fault_point
17
+ from ..idempotency import idempotency_of
18
+ from ..ledger import IdempotencyMismatch, RunOwnedByAnotherAgent
19
+ from ..schemas import RunEvents
20
+
21
+ router = APIRouter(prefix="/api/runs")
22
+
23
+
24
+ def _captured(events: list[dict], capture: str) -> list[dict]:
25
+ """The agent's evidence profile decides how deep run traces go (F24):
26
+ 'full' keeps everything, 'decisions' keeps model decisions and run
27
+ lifecycle (arguments/results of ordinary tool chatter are dropped),
28
+ 'none' records no run trace at all. Approvals are never affected —
29
+ the decision record is the product."""
30
+ if capture == "full":
31
+ return events
32
+ if capture == "none":
33
+ return []
34
+ keep = {"model_decision", "run_started", "run_finished"}
35
+ return [e for e in events if (e.get("type") or e.get("kind")) in keep]
36
+
37
+
38
+ @router.post("/{run_id}/events", dependencies=[Depends(require_agent)])
39
+ async def ingest(request: Request, run_id: str, body: RunEvents):
40
+ ledger = request.app.state.ledger
41
+ tenant = await resolve_tenant(request)
42
+ # non-spoofable: stamped from the token record, never the body (F24)
43
+ agent_id, unit = await resolve_agent(request)
44
+ profile = await request.app.state.agents.evidence_for(agent_id, tenant)
45
+ events = _captured(body.events, profile["capture"])
46
+ # one atomic batch: a crash mid-ingest rolls back the whole POST, so
47
+ # the client's retry semantics stay simple (all or nothing); with an
48
+ # Idempotency-Key a retry after a lost response appends nothing
49
+ try:
50
+ await ledger.append_batch(
51
+ "run_event", [{"run_id": run_id, "event": e} for e in events],
52
+ tenant=tenant, agent_id=agent_id, unit=unit,
53
+ idem=await idempotency_of(request, body.model_dump()),
54
+ run_id=run_id, pinned=(await agent_pin(request)) is not None)
55
+ except IdempotencyMismatch:
56
+ raise HTTPException(422, "Idempotency-Key was already used for a "
57
+ "different request") from None
58
+ except RunOwnedByAnotherAgent as e:
59
+ # a run belongs to the agent that started it: run reads are
60
+ # authorized per run, so a shared id would leak the other stream
61
+ raise HTTPException(409, str(e)) from None
62
+ fault_point("after_write_commit_before_http")
63
+ out = {"ingested": len(events)}
64
+ if len(events) != len(body.events):
65
+ # honest about the evidence profile: dropped by capture depth,
66
+ # not lost
67
+ out["dropped"] = len(body.events) - len(events)
68
+ out["capture"] = profile["capture"]
69
+ return out
70
+
71
+
72
+ async def _visible_run_events(request: Request, run_id: str) -> list[dict]:
73
+ events = await request.app.state.ledger.events_for_run(
74
+ run_id, tenant=await resolve_tenant(request))
75
+ scope = await activity_scope(request)
76
+ # per EVENT, not per run: ingest refuses a second pinned agent on a
77
+ # run_id, but a stream mixed before that rule (or by an unpinned
78
+ # credential) must still never hand one agent another's events.
79
+ # Outside your units, even existence is need-to-know.
80
+ return [e for e in events
81
+ if visible_to(scope, e.get("agent_id", "default"), e.get("unit"))]
82
+
83
+
84
+ @router.get("/{run_id}/audit", dependencies=[Depends(require_reader)])
85
+ async def audit(request: Request, run_id: str):
86
+ events = await _visible_run_events(request, run_id)
87
+ return [{"seq": e["seq"], "ts_ms": e["ts_ms"], "type": e["type"],
88
+ "detail": json.loads(e["payload"])} for e in events]
89
+
90
+
91
+ @router.get("/{run_id}/export", dependencies=[Depends(require_reader)])
92
+ async def run_export(request: Request, run_id: str):
93
+ """A signed bundle scoped to one run. Each event's hash still verifies
94
+ standalone; chain continuity applies only to chain exports."""
95
+ from ..ledger.hashing import GENESIS
96
+ from .audit import _exported_by
97
+
98
+ ledger = request.app.state.ledger
99
+ tenant = await resolve_tenant(request)
100
+ events = await _visible_run_events(request, run_id)
101
+ if not events:
102
+ raise HTTPException(404, f"unknown run {run_id}")
103
+ bundle = {"events": events,
104
+ "head": events[-1]["hash"] if events else GENESIS,
105
+ "scope": f"run:{run_id}"}
106
+ signer = request.app.state.signer
107
+ if signer is not None:
108
+ # a run bundle is a non-contiguous slice: bind the signature to
109
+ # every event hash, or an edited event with a recomputed hash
110
+ # would still verify (head/count/seqs unchanged)
111
+ bundle = signer.sign_bundle(bundle, bind_events=True)
112
+ await ledger.append("audit_exported", {
113
+ "head_hash": bundle["head"], "by": await _exported_by(request),
114
+ "scope": f"run:{run_id}"}, tenant=tenant)
115
+ return bundle
app/routes/slack.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.parse
5
+
6
+ from fastapi import APIRouter, HTTPException, Request
7
+ from fastapi.responses import JSONResponse, Response
8
+
9
+ from ..slack import verify_slack_signature
10
+
11
+ router = APIRouter(prefix="/slack")
12
+
13
+
14
+ @router.post("/interactions")
15
+ async def interactions(request: Request):
16
+ slack = getattr(request.app.state, "slack", None)
17
+ if slack is None:
18
+ raise HTTPException(404, "slack routing not configured")
19
+
20
+ body = await request.body()
21
+ ok = verify_slack_signature(
22
+ request.app.state.settings.slack_signing_secret,
23
+ request.headers.get("X-Slack-Request-Timestamp", ""),
24
+ body,
25
+ request.headers.get("X-Slack-Signature", ""))
26
+ if not ok:
27
+ # unverified interactions are approval forgeries; write nothing
28
+ raise HTTPException(401, "bad slack signature")
29
+
30
+ # parse the urlencoded form from the raw body we already verified
31
+ form = urllib.parse.parse_qs(body.decode("utf-8"))
32
+ try:
33
+ payload = json.loads(form.get("payload", ["{}"])[0])
34
+ except json.JSONDecodeError:
35
+ raise HTTPException(400, "malformed payload") from None
36
+
37
+ out = await slack.handle_interaction(payload)
38
+ if out is None:
39
+ return Response(status_code=200)
40
+ return JSONResponse(out)
app/routes/teams.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from fastapi import APIRouter, HTTPException, Request
6
+ from fastapi.responses import JSONResponse, Response
7
+
8
+ router = APIRouter(prefix="/teams")
9
+
10
+
11
+ @router.post("/activities")
12
+ async def activities(request: Request):
13
+ teams = getattr(request.app.state, "teams", None)
14
+ if teams is None:
15
+ raise HTTPException(404, "teams routing not configured")
16
+
17
+ # unverified interactions are approval forgeries; write nothing
18
+ await request.app.state.teams_verifier.verify(
19
+ request.headers.get("Authorization", ""))
20
+
21
+ try:
22
+ activity = json.loads(await request.body())
23
+ except json.JSONDecodeError:
24
+ raise HTTPException(400, "malformed activity") from None
25
+
26
+ reply = await teams.handle_activity(activity)
27
+ if reply is None:
28
+ return Response(status_code=200)
29
+ return JSONResponse(reply)