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/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)
|