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/approvals.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
4
|
+
from fastapi.responses import JSONResponse
|
|
5
|
+
|
|
6
|
+
from ..auth import (
|
|
7
|
+
activity_scope,
|
|
8
|
+
agent_credential,
|
|
9
|
+
agent_pin,
|
|
10
|
+
is_agent_plane,
|
|
11
|
+
require_agent,
|
|
12
|
+
require_reader,
|
|
13
|
+
require_reviewer,
|
|
14
|
+
resolve_agent,
|
|
15
|
+
resolve_tenant,
|
|
16
|
+
reviewer_role,
|
|
17
|
+
reviewer_string,
|
|
18
|
+
scope_units,
|
|
19
|
+
visible_to,
|
|
20
|
+
)
|
|
21
|
+
from ..costgov import COST_REVIEWER
|
|
22
|
+
from ..faults import fault_point
|
|
23
|
+
from ..idempotency import idempotency_of
|
|
24
|
+
from ..ledger import (
|
|
25
|
+
ActionMismatch,
|
|
26
|
+
AlreadyDecided,
|
|
27
|
+
GrantRefused,
|
|
28
|
+
IdempotencyMismatch,
|
|
29
|
+
Ledger,
|
|
30
|
+
RunOwnedByAnotherAgent,
|
|
31
|
+
SecondReviewerRequired,
|
|
32
|
+
UnknownApproval,
|
|
33
|
+
)
|
|
34
|
+
from ..policy import (
|
|
35
|
+
POLICY_REVIEWER,
|
|
36
|
+
NotAnApprover,
|
|
37
|
+
case_controls,
|
|
38
|
+
sampled_for_review,
|
|
39
|
+
)
|
|
40
|
+
from ..schemas import (
|
|
41
|
+
Annotation,
|
|
42
|
+
Cancel,
|
|
43
|
+
ChangesRequest,
|
|
44
|
+
CreateApproval,
|
|
45
|
+
Decision,
|
|
46
|
+
Incident,
|
|
47
|
+
Redeem,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
router = APIRouter(prefix="/api/approvals")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _ledger(request: Request) -> Ledger:
|
|
54
|
+
return request.app.state.ledger
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _existing(ledger: Ledger, approval_id: str, tenant: str) -> dict:
|
|
58
|
+
"""Re-read an approval this request already touched: it exists."""
|
|
59
|
+
a = await ledger.approval(approval_id, tenant)
|
|
60
|
+
assert a is not None
|
|
61
|
+
return a
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def redact_grant(a: dict) -> dict:
|
|
65
|
+
"""The approval as the human plane sees it: the execution grant's
|
|
66
|
+
metadata (issued, expires, redeemed by whom) stays — it is the case's
|
|
67
|
+
provenance — but the signature, the bearer half of a capability to
|
|
68
|
+
run one consequential action, is handed only to the agent plane."""
|
|
69
|
+
if a and a.get("grant") and "signature" in a["grant"]:
|
|
70
|
+
return {**a, "grant": {k: v for k, v in a["grant"].items()
|
|
71
|
+
if k != "signature"}}
|
|
72
|
+
return a
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _present(request: Request, a: dict) -> dict:
|
|
76
|
+
return a if await is_agent_plane(request) else redact_grant(a)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _reviewer_or_400(identity: dict | None, typed: str) -> str:
|
|
80
|
+
if identity is not None and identity.get("token_id"):
|
|
81
|
+
# issued-token operator: the typed name says WHO sat at the
|
|
82
|
+
# keyboard, the binding says WHICH revocable credential proved it
|
|
83
|
+
if not typed.strip():
|
|
84
|
+
raise HTTPException(400, "reviewer is required")
|
|
85
|
+
label = identity.get("token_label")
|
|
86
|
+
bind = f"token:{identity['token_id']}" + (f" {label}" if label else "")
|
|
87
|
+
return f"{typed.strip()} ({bind})"
|
|
88
|
+
if identity is not None:
|
|
89
|
+
return reviewer_string(identity)
|
|
90
|
+
if not typed.strip():
|
|
91
|
+
raise HTTPException(400, "reviewer is required")
|
|
92
|
+
return typed
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.post("", dependencies=[Depends(require_agent)])
|
|
96
|
+
async def create(request: Request, body: CreateApproval):
|
|
97
|
+
from ..netguard import UnsafeCallbackURL, validate_callback_url
|
|
98
|
+
from ..sla import resolve_deadline_ms
|
|
99
|
+
|
|
100
|
+
# SSRF guard: refuse a callback_url pointing at internal/metadata targets
|
|
101
|
+
# before we ever POST a decision to it.
|
|
102
|
+
if body.callback_url:
|
|
103
|
+
try:
|
|
104
|
+
validate_callback_url(
|
|
105
|
+
body.callback_url,
|
|
106
|
+
request.app.state.settings.webhook_allow_private)
|
|
107
|
+
except UnsafeCallbackURL as e:
|
|
108
|
+
raise HTTPException(400, str(e)) from None
|
|
109
|
+
|
|
110
|
+
tenant = await resolve_tenant(request)
|
|
111
|
+
# non-spoofable: agent + unit come from the service token record,
|
|
112
|
+
# resolved server-side (F24) — never from the request body
|
|
113
|
+
agent_id, unit = await resolve_agent(request)
|
|
114
|
+
ledger = _ledger(request)
|
|
115
|
+
|
|
116
|
+
# live policy supersedes static config: an action whose current policy
|
|
117
|
+
# does not require approval is auto-granted by system:policy so the
|
|
118
|
+
# agent's loop resolves immediately; unknown actions fall back to the
|
|
119
|
+
# configured fail-safe (require approval).
|
|
120
|
+
effective = await request.app.state.policy.effective(body.tool, tenant)
|
|
121
|
+
|
|
122
|
+
# F23: cost governance can only TIGHTEN the safety decision, never weaken
|
|
123
|
+
# it. Over budget with on_exceed=gate forces a human even if policy said
|
|
124
|
+
# autonomous; on_exceed=deny refuses the action outright. Under budget (or
|
|
125
|
+
# the open no-budget posture) it adds nothing.
|
|
126
|
+
cost = await request.app.state.cost.check(body.tool, tenant,
|
|
127
|
+
run_id=body.run_id)
|
|
128
|
+
cost_denies = cost["exceeded"] and cost["on_exceed"] == "deny"
|
|
129
|
+
cost_gates = cost["exceeded"] and cost["on_exceed"] == "gate"
|
|
130
|
+
# sampled mode: a deterministic 1-in-N goes to a human. Computed on
|
|
131
|
+
# run_id:call_id BEFORE anything is created, so a retry of the same
|
|
132
|
+
# call gets the same answer — and keyed by a server-side salt the
|
|
133
|
+
# agent plane cannot read, so the agent cannot grind call ids that
|
|
134
|
+
# are never sampled.
|
|
135
|
+
sampled_in = (effective["mode"] == "sampled" and sampled_for_review(
|
|
136
|
+
f"{body.run_id}:{body.call_id}", effective.get("sample_n") or 0,
|
|
137
|
+
await request.app.state.policy.sampling_salt(tenant)))
|
|
138
|
+
# dual control is the strongest gate: never auto-grantable
|
|
139
|
+
requires_approval = (effective["requires_approval"] or cost_gates
|
|
140
|
+
or sampled_in
|
|
141
|
+
or effective.get("require_second_approval", False))
|
|
142
|
+
# the evaluation is made ONCE, at request time, and stamped into the
|
|
143
|
+
# requested event: "deny" (over budget), "auto" (no gate), or "gated"
|
|
144
|
+
# (a human must decide)
|
|
145
|
+
disposition = ("deny" if cost_denies
|
|
146
|
+
else "gated" if requires_approval else "auto")
|
|
147
|
+
# the rule result, in words the reviewer reads: why this proposal is
|
|
148
|
+
# gated (or not), against which policy version
|
|
149
|
+
rule_result = []
|
|
150
|
+
if effective["source"] == "default":
|
|
151
|
+
rule_result.append("no policy rule: the configured default "
|
|
152
|
+
+ ("requires approval"
|
|
153
|
+
if effective["requires_approval"]
|
|
154
|
+
else "auto-grants"))
|
|
155
|
+
else:
|
|
156
|
+
rule_result.append(f"policy mode {effective['mode']}: "
|
|
157
|
+
+ ("requires approval"
|
|
158
|
+
if effective["requires_approval"]
|
|
159
|
+
else "no human gate"))
|
|
160
|
+
if effective.get("require_second_approval"):
|
|
161
|
+
rule_result.append("dual control: two different approvers")
|
|
162
|
+
if sampled_in:
|
|
163
|
+
rule_result.append(f"sampled: this 1-in-{effective.get('sample_n')} "
|
|
164
|
+
f"pick goes to a human")
|
|
165
|
+
if cost_gates:
|
|
166
|
+
rule_result.append(f"cost: over budget ({cost['scope']}:{cost['key']}, "
|
|
167
|
+
f"{cost['window']}) forces a human")
|
|
168
|
+
if cost_denies:
|
|
169
|
+
rule_result.append(f"cost: over budget ({cost['scope']}:{cost['key']}, "
|
|
170
|
+
f"{cost['window']}) denies")
|
|
171
|
+
policy_snapshot = request.app.state.policy.snapshot(effective)
|
|
172
|
+
|
|
173
|
+
deadline_ms = resolve_deadline_ms(request.app.state.settings, body.tool,
|
|
174
|
+
body.deadline_ms)
|
|
175
|
+
try:
|
|
176
|
+
approval, created = await ledger.create_approval(
|
|
177
|
+
run_id=body.run_id, call_id=body.call_id, tool=body.tool,
|
|
178
|
+
arguments=body.arguments, reasoning=body.reasoning,
|
|
179
|
+
framework=body.framework, deadline_ms=deadline_ms,
|
|
180
|
+
callback_url=body.callback_url, tenant=tenant,
|
|
181
|
+
agent_id=agent_id, unit=unit, disposition=disposition,
|
|
182
|
+
policy=policy_snapshot, rule_result=rule_result,
|
|
183
|
+
supersedes=body.supersedes,
|
|
184
|
+
pinned=(await agent_pin(request)) is not None)
|
|
185
|
+
except ActionMismatch as e:
|
|
186
|
+
# the id is taken by a DIFFERENT action: refuse rather than hand
|
|
187
|
+
# the caller the earlier decision. An accidental (or deliberate)
|
|
188
|
+
# id reuse must never attach an old approval to a new operation.
|
|
189
|
+
# (also: superseding another agent's proposal)
|
|
190
|
+
raise HTTPException(409, str(e)) from None
|
|
191
|
+
except RunOwnedByAnotherAgent as e:
|
|
192
|
+
# a pinned agent proposing into another agent's run (the same rule
|
|
193
|
+
# as run-event ingest: a run has one owner)
|
|
194
|
+
raise HTTPException(409, str(e)) from None
|
|
195
|
+
except UnknownApproval:
|
|
196
|
+
raise HTTPException(404, f"unknown approval to supersede: "
|
|
197
|
+
f"{body.supersedes}") from None
|
|
198
|
+
except AlreadyDecided as e:
|
|
199
|
+
raise HTTPException(409, f"cannot supersede {e.approval_id}: "
|
|
200
|
+
f"already {e.status}") from None
|
|
201
|
+
fault_point("after_append_commit_before_http")
|
|
202
|
+
|
|
203
|
+
# These auto-decisions are idempotent on the approval, NOT conditional on
|
|
204
|
+
# `created`: the requested-append and the decision are separate
|
|
205
|
+
# transactions, so a crash (or lost response) between them leaves the
|
|
206
|
+
# approval pending and the agent's idempotent retry (created=False) must
|
|
207
|
+
# still finish it. AlreadyDecided means a human or earlier retry won.
|
|
208
|
+
#
|
|
209
|
+
# A retry executes the STAMPED disposition, never a fresh evaluation:
|
|
210
|
+
# otherwise a policy relaxed (or a budget window rolled over) after an
|
|
211
|
+
# action was gated would let the agent re-POST the same create and turn
|
|
212
|
+
# a pending, human-queued action into an auto-approval. Events from
|
|
213
|
+
# before the stamp existed read as gated — the fail-safe: a human
|
|
214
|
+
# finishes what a crash left pending.
|
|
215
|
+
if not created:
|
|
216
|
+
disposition = (await ledger.requested_disposition(
|
|
217
|
+
approval["approval_id"], body.run_id, tenant)) or "gated"
|
|
218
|
+
if disposition == "deny" and approval["status"] == "pending":
|
|
219
|
+
if cost.get("exceeded"):
|
|
220
|
+
limit = cost.get("limit") or {}
|
|
221
|
+
spent = cost.get("spent") or {}
|
|
222
|
+
detail = (f"over budget: {cost['scope']}:{cost['key']} spent "
|
|
223
|
+
f"${(spent.get('usd') if spent else 0)} / "
|
|
224
|
+
f"{(spent.get('tokens') if spent else 0)} tok vs "
|
|
225
|
+
f"limit ${limit.get('usd')}/{limit.get('tokens')} tok "
|
|
226
|
+
f"({cost['window']})")
|
|
227
|
+
else:
|
|
228
|
+
# a retry after the window rolled over: the stamped deny still
|
|
229
|
+
# stands (the evaluation happened once, at request time)
|
|
230
|
+
detail = "over budget at request time (stamped disposition)"
|
|
231
|
+
try:
|
|
232
|
+
approval = await ledger.decide(
|
|
233
|
+
approval["approval_id"], approved=False,
|
|
234
|
+
reviewer=COST_REVIEWER, reason=detail, reviewer_role="cost",
|
|
235
|
+
tenant=tenant)
|
|
236
|
+
except AlreadyDecided:
|
|
237
|
+
approval = await _existing(ledger, approval["approval_id"], tenant)
|
|
238
|
+
elif disposition == "auto" and approval["status"] == "pending":
|
|
239
|
+
reason = f"auto-approved: policy mode={effective['mode']}"
|
|
240
|
+
if effective["mode"] == "sampled":
|
|
241
|
+
reason += (f" (passed 1-in-{effective.get('sample_n')} "
|
|
242
|
+
f"sampling; the pick is a salted HMAC over the "
|
|
243
|
+
f"approval id — the export discloses the salt)")
|
|
244
|
+
try:
|
|
245
|
+
approval = await ledger.decide(
|
|
246
|
+
approval["approval_id"], approved=True,
|
|
247
|
+
reviewer=POLICY_REVIEWER, reason=reason,
|
|
248
|
+
reviewer_role="policy", tenant=tenant)
|
|
249
|
+
except AlreadyDecided:
|
|
250
|
+
approval = await _existing(ledger, approval["approval_id"], tenant)
|
|
251
|
+
elif created and disposition == "gated":
|
|
252
|
+
# gated for a human. When sampling or cost forced the gate, put
|
|
253
|
+
# the why in front of the reviewer as an annotation.
|
|
254
|
+
if sampled_in:
|
|
255
|
+
await ledger.annotate(
|
|
256
|
+
approval["approval_id"], POLICY_REVIEWER,
|
|
257
|
+
f"sampled for human review: 1 in "
|
|
258
|
+
f"{effective.get('sample_n')} {body.tool} proposals is "
|
|
259
|
+
f"checked (policy mode=sampled; the pick is a salted HMAC "
|
|
260
|
+
f"over the approval id — the export discloses the salt)",
|
|
261
|
+
tenant=tenant)
|
|
262
|
+
approval = await _existing(ledger, approval["approval_id"], tenant)
|
|
263
|
+
if cost_gates:
|
|
264
|
+
limit = cost.get("limit") or {}
|
|
265
|
+
spent = cost.get("spent") or {}
|
|
266
|
+
await ledger.annotate(
|
|
267
|
+
approval["approval_id"], COST_REVIEWER,
|
|
268
|
+
f"over budget ({cost['scope']}:{cost['key']}, {cost['window']}): "
|
|
269
|
+
f"spent ${spent.get('usd')} / {spent.get('tokens')} tok vs "
|
|
270
|
+
f"limit ${limit.get('usd')}/{limit.get('tokens')} tok",
|
|
271
|
+
tenant=tenant)
|
|
272
|
+
approval = await _existing(ledger, approval["approval_id"], tenant)
|
|
273
|
+
for chat in getattr(request.app.state, "chat_routers", []):
|
|
274
|
+
chat.notify(approval)
|
|
275
|
+
return JSONResponse(approval, status_code=201 if created else 200)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# a client-supplied limit is clamped: an absurd value would fold the whole
|
|
279
|
+
# table per request. The web inbox pages with cursor at this size.
|
|
280
|
+
MAX_LIMIT = 500
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@router.get("", dependencies=[Depends(require_reader)])
|
|
284
|
+
async def list_approvals(request: Request, status: str | None = None,
|
|
285
|
+
limit: int = 100, cursor: int = 0,
|
|
286
|
+
order: str = "asc", after: str | None = None):
|
|
287
|
+
"""Unit-scoped, server-enforced (F24): the queue never shows — or
|
|
288
|
+
counts — another unit's work.
|
|
289
|
+
|
|
290
|
+
`order=desc` lists newest first. `after=<requested_ts_ms>:<approval_id>`
|
|
291
|
+
(the last row of the previous page) is a keyset cursor: stable while
|
|
292
|
+
rows are being decided, where the `cursor` offset skips or repeats."""
|
|
293
|
+
if order not in ("asc", "desc"):
|
|
294
|
+
raise HTTPException(400, "order must be asc or desc")
|
|
295
|
+
keyset = None
|
|
296
|
+
if after is not None:
|
|
297
|
+
ts, sep, last_id = after.partition(":")
|
|
298
|
+
if not sep or not last_id or not ts.lstrip("-").isdigit():
|
|
299
|
+
raise HTTPException(
|
|
300
|
+
400, "after must be <requested_ts_ms>:<approval_id>")
|
|
301
|
+
keyset = (int(ts), last_id)
|
|
302
|
+
scope = await activity_scope(request)
|
|
303
|
+
rows = await _ledger(request).approvals(
|
|
304
|
+
status=status, limit=max(1, min(limit, MAX_LIMIT)),
|
|
305
|
+
offset=max(0, cursor), newest_first=order == "desc", after=keyset,
|
|
306
|
+
tenant=await resolve_tenant(request),
|
|
307
|
+
units=scope_units(scope),
|
|
308
|
+
agent=scope.get("agent") if scope else None)
|
|
309
|
+
if await is_agent_plane(request):
|
|
310
|
+
return rows
|
|
311
|
+
return [redact_grant(a) for a in rows]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@router.get("/pending-count", dependencies=[Depends(require_reader)])
|
|
315
|
+
async def pending_count(request: Request):
|
|
316
|
+
"""The nav badge, unit-scoped: volume is information too."""
|
|
317
|
+
scope = await activity_scope(request)
|
|
318
|
+
n = await _ledger(request).pending_count(
|
|
319
|
+
tenant=await resolve_tenant(request), units=scope_units(scope),
|
|
320
|
+
agent=scope.get("agent") if scope else None)
|
|
321
|
+
return {"pending": n}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@router.get("/{approval_id}", dependencies=[Depends(require_reader)])
|
|
325
|
+
async def get_one(request: Request, approval_id: str):
|
|
326
|
+
a = await _ledger(request).approval(approval_id,
|
|
327
|
+
tenant=await resolve_tenant(request))
|
|
328
|
+
scope = await activity_scope(request)
|
|
329
|
+
if a is None or not visible_to(scope, a.get("agent_id"), a.get("unit")):
|
|
330
|
+
# 404, not 403: outside your units, even existence is need-to-know
|
|
331
|
+
raise HTTPException(404, f"unknown approval {approval_id}")
|
|
332
|
+
return await _present(request, a)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@router.post("/{approval_id}/decision")
|
|
336
|
+
async def decide(request: Request, approval_id: str, body: Decision,
|
|
337
|
+
identity: dict | None = Depends(require_reviewer)):
|
|
338
|
+
reviewer = _reviewer_or_400(identity, body.reviewer)
|
|
339
|
+
tenant = await resolve_tenant(request)
|
|
340
|
+
|
|
341
|
+
# unit scopes visibility BEFORE approver_groups scope the decision
|
|
342
|
+
# (F24): deciding outside your units is refused as unknown, because
|
|
343
|
+
# even existence is need-to-know.
|
|
344
|
+
existing = await _ledger(request).approval(approval_id, tenant=tenant)
|
|
345
|
+
if existing is not None and not visible_to(
|
|
346
|
+
await activity_scope(request), existing.get("agent_id"),
|
|
347
|
+
existing.get("unit")):
|
|
348
|
+
raise HTTPException(404, f"unknown approval {approval_id}")
|
|
349
|
+
|
|
350
|
+
# enforce "who may approve": if the action pins approver_groups, the
|
|
351
|
+
# reviewer's IdP groups must intersect (admins bypass). A control that is
|
|
352
|
+
# recorded in the chain but silently unenforced would be the sharp edge
|
|
353
|
+
# for a product whose pitch is provable governance. The case's own
|
|
354
|
+
# snapshot binds as well as the live rule (B6): relaxing a policy never
|
|
355
|
+
# weakens a case that was proposed under the stricter one.
|
|
356
|
+
require_second = False
|
|
357
|
+
if existing is not None:
|
|
358
|
+
rule = await request.app.state.policy.policy_for(existing["tool"], tenant)
|
|
359
|
+
try:
|
|
360
|
+
require_second = case_controls(existing.get("policy"), rule,
|
|
361
|
+
identity)
|
|
362
|
+
except NotAnApprover as e:
|
|
363
|
+
raise HTTPException(403, str(e)) from None
|
|
364
|
+
if body.bulk and body.approved:
|
|
365
|
+
# a bulk approval is a glance, not a review: irreversible,
|
|
366
|
+
# dual-control and high/critical-risk actions are decided one
|
|
367
|
+
# by one, on their own case — enforced here, not in the client
|
|
368
|
+
why = _individual_only(existing.get("policy") or {}, require_second)
|
|
369
|
+
if why:
|
|
370
|
+
raise HTTPException(
|
|
371
|
+
403, f"{approval_id} must be decided individually: {why}")
|
|
372
|
+
# dual control distinctness binds to the CREDENTIAL for issued
|
|
373
|
+
# tokens: the same token with a different typed name is still the
|
|
374
|
+
# same approver (the in-transaction check covers exact strings)
|
|
375
|
+
if (require_second and body.approved and identity
|
|
376
|
+
and identity.get("token_id")):
|
|
377
|
+
standing = existing.get("endorsed_by") or ""
|
|
378
|
+
if f"(token:{identity['token_id']}" in standing:
|
|
379
|
+
raise HTTPException(
|
|
380
|
+
403, "the second approval must come from a different "
|
|
381
|
+
"credential, not a different name on the same token")
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
a = await _ledger(request).decide(
|
|
385
|
+
approval_id, approved=body.approved, reviewer=reviewer,
|
|
386
|
+
reason=body.reason, reviewer_role=reviewer_role(identity),
|
|
387
|
+
require_second=require_second, tenant=tenant,
|
|
388
|
+
idem=await idempotency_of(request, body.model_dump()))
|
|
389
|
+
except UnknownApproval:
|
|
390
|
+
raise HTTPException(404, f"unknown approval {approval_id}") from None
|
|
391
|
+
except AlreadyDecided as e:
|
|
392
|
+
raise HTTPException(409, f"already {e.status}") from None
|
|
393
|
+
except IdempotencyMismatch:
|
|
394
|
+
raise HTTPException(422, "Idempotency-Key was already used for a "
|
|
395
|
+
"different request") from None
|
|
396
|
+
except SecondReviewerRequired as e:
|
|
397
|
+
# 403, not 409: the approval is NOT decided, this reviewer just
|
|
398
|
+
# cannot be both halves of the pair
|
|
399
|
+
raise HTTPException(403, str(e)) from None
|
|
400
|
+
fault_point("after_decision_commit_before_reflect")
|
|
401
|
+
if a["status"] in ("granted", "denied"):
|
|
402
|
+
# an endorsement is not a decision: no card flip (and no callback —
|
|
403
|
+
# the outbox row is written by the ledger's decision hook, inside
|
|
404
|
+
# the decision's own transaction, so it survives a crash here)
|
|
405
|
+
for chat in getattr(request.app.state, "chat_routers", []):
|
|
406
|
+
chat.reflect(a)
|
|
407
|
+
# the decider is the human plane: the grant goes to the executor
|
|
408
|
+
return redact_grant(a)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _individual_only(policy: dict, require_second: bool) -> str | None:
|
|
412
|
+
"""Why a bulk approval is refused for this approval (None = allowed)."""
|
|
413
|
+
if policy.get("reversible") is False:
|
|
414
|
+
return "irreversible action"
|
|
415
|
+
if require_second or policy.get("require_second_approval"):
|
|
416
|
+
return "dual control"
|
|
417
|
+
if policy.get("risk") in ("high", "critical"):
|
|
418
|
+
return f"{policy['risk']} risk"
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@router.post("/{approval_id}/request-changes")
|
|
423
|
+
async def request_changes(request: Request, approval_id: str,
|
|
424
|
+
body: ChangesRequest,
|
|
425
|
+
identity: dict | None = Depends(require_reviewer)):
|
|
426
|
+
"""Send the proposal back: neither approved nor refused. The agent
|
|
427
|
+
changes it and proposes again with `supersedes` set to this id."""
|
|
428
|
+
reviewer = _reviewer_or_400(identity, body.reviewer)
|
|
429
|
+
tenant = await resolve_tenant(request)
|
|
430
|
+
existing = await _ledger(request).approval(approval_id, tenant=tenant)
|
|
431
|
+
if existing is not None and not visible_to(
|
|
432
|
+
await activity_scope(request), existing.get("agent_id"),
|
|
433
|
+
existing.get("unit")):
|
|
434
|
+
raise HTTPException(404, f"unknown approval {approval_id}")
|
|
435
|
+
# sending a proposal back closes the case: it is a decision, bound by
|
|
436
|
+
# the same approver_groups as approve and deny (B11)
|
|
437
|
+
if existing is not None:
|
|
438
|
+
rule = await request.app.state.policy.policy_for(existing["tool"], tenant)
|
|
439
|
+
try:
|
|
440
|
+
case_controls(existing.get("policy"), rule, identity)
|
|
441
|
+
except NotAnApprover as e:
|
|
442
|
+
raise HTTPException(403, str(e)) from None
|
|
443
|
+
try:
|
|
444
|
+
a = await _ledger(request).request_changes(
|
|
445
|
+
approval_id, reviewer, body.reason,
|
|
446
|
+
reviewer_role=reviewer_role(identity), tenant=tenant,
|
|
447
|
+
idem=await idempotency_of(request, body.model_dump()))
|
|
448
|
+
except UnknownApproval:
|
|
449
|
+
raise HTTPException(404, f"unknown approval {approval_id}") from None
|
|
450
|
+
except AlreadyDecided as e:
|
|
451
|
+
raise HTTPException(409, f"already {e.status}") from None
|
|
452
|
+
except IdempotencyMismatch:
|
|
453
|
+
raise HTTPException(422, "Idempotency-Key was already used for a "
|
|
454
|
+
"different request") from None
|
|
455
|
+
for chat in getattr(request.app.state, "chat_routers", []):
|
|
456
|
+
chat.reflect(a)
|
|
457
|
+
return a
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@router.post("/{approval_id}/cancel", dependencies=[Depends(require_agent)])
|
|
461
|
+
async def cancel(request: Request, approval_id: str, body: Cancel):
|
|
462
|
+
"""The proposing agent withdraws its pending proposal (agent plane).
|
|
463
|
+
A pinned token cancels only its own agent's proposals."""
|
|
464
|
+
tenant = await resolve_tenant(request)
|
|
465
|
+
agent_id, _unit = await resolve_agent(request)
|
|
466
|
+
try:
|
|
467
|
+
a = await _ledger(request).cancel(
|
|
468
|
+
approval_id, agent_id, body.reason, tenant=tenant,
|
|
469
|
+
pinned=(await agent_pin(request)) is not None,
|
|
470
|
+
idem=await idempotency_of(request, body.model_dump()))
|
|
471
|
+
except UnknownApproval:
|
|
472
|
+
raise HTTPException(404, f"unknown approval {approval_id}") from None
|
|
473
|
+
except AlreadyDecided as e:
|
|
474
|
+
raise HTTPException(409, f"already {e.status}") from None
|
|
475
|
+
except ActionMismatch:
|
|
476
|
+
raise HTTPException(403, "a pinned token can only cancel its own "
|
|
477
|
+
"agent's proposals") from None
|
|
478
|
+
except IdempotencyMismatch:
|
|
479
|
+
raise HTTPException(422, "Idempotency-Key was already used for a "
|
|
480
|
+
"different request") from None
|
|
481
|
+
fault_point("after_write_commit_before_http")
|
|
482
|
+
for chat in getattr(request.app.state, "chat_routers", []):
|
|
483
|
+
chat.reflect(a)
|
|
484
|
+
return a
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@router.post("/{approval_id}/redeem", dependencies=[Depends(require_agent)])
|
|
488
|
+
async def redeem(request: Request, approval_id: str, body: Redeem):
|
|
489
|
+
"""The executor redeems the execution grant minted with the decision
|
|
490
|
+
— once. Agent plane: the gateway or tool proxy that holds the tool
|
|
491
|
+
credentials presents the grant's nonce right before dispatch. Under
|
|
492
|
+
the chain lock: 409 if the approval is not granted, carries no grant,
|
|
493
|
+
the nonce does not match, the grant expired, or it was already
|
|
494
|
+
redeemed. Success appends `grant_redeemed` to the approval's chain —
|
|
495
|
+
`executor` is the presenting credential's identity, written by the
|
|
496
|
+
server; `executor_instance` is whatever the caller called itself —
|
|
497
|
+
and returns that event's hash."""
|
|
498
|
+
tenant = await resolve_tenant(request)
|
|
499
|
+
agent_id, _unit = await resolve_agent(request)
|
|
500
|
+
# the attribution is the CREDENTIAL, resolved server-side (an issued
|
|
501
|
+
# token names its agent and its revocable id + label); the caller's
|
|
502
|
+
# name for itself is kept, but apart, as self-asserted context
|
|
503
|
+
executor = await agent_credential(request)
|
|
504
|
+
instance = (body.instance or body.executor).strip() or None
|
|
505
|
+
try:
|
|
506
|
+
a, event = await _ledger(request).redeem_grant(
|
|
507
|
+
approval_id, body.nonce, executor=executor, instance=instance,
|
|
508
|
+
tenant=tenant, only_agent=await agent_pin(request),
|
|
509
|
+
idem=await idempotency_of(request, body.model_dump()))
|
|
510
|
+
except UnknownApproval:
|
|
511
|
+
raise HTTPException(404, f"unknown approval {approval_id}") from None
|
|
512
|
+
except ActionMismatch:
|
|
513
|
+
# 404, not 403: another agent's approvals are need-to-know
|
|
514
|
+
raise HTTPException(404, f"unknown approval {approval_id}") from None
|
|
515
|
+
except GrantRefused as e:
|
|
516
|
+
raise HTTPException(409, str(e)) from None
|
|
517
|
+
except IdempotencyMismatch:
|
|
518
|
+
raise HTTPException(422, "Idempotency-Key was already used for a "
|
|
519
|
+
"different request") from None
|
|
520
|
+
fault_point("after_write_commit_before_http")
|
|
521
|
+
out = {"approval_id": approval_id, "status": a["status"],
|
|
522
|
+
"tool": a["tool"], "action_digest": a["grant"]["action_digest"],
|
|
523
|
+
"redeemed_ts_ms": event["ts_ms"], "redeemed_by": executor,
|
|
524
|
+
"event_hash": event["hash"]}
|
|
525
|
+
if instance:
|
|
526
|
+
out["redeemed_instance"] = instance
|
|
527
|
+
return out
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@router.post("/{approval_id}/incident")
|
|
531
|
+
async def report_incident(request: Request, approval_id: str, body: Incident,
|
|
532
|
+
identity: dict | None = Depends(require_reviewer)):
|
|
533
|
+
"""Record that this action's execution caused an incident: a chained
|
|
534
|
+
`incident_reported` event the rollout track record counts (and
|
|
535
|
+
demotes on), and a typed annotation on the case."""
|
|
536
|
+
reviewer = _reviewer_or_400(identity, body.reviewer)
|
|
537
|
+
tenant = await resolve_tenant(request)
|
|
538
|
+
existing = await _ledger(request).approval(approval_id, tenant=tenant)
|
|
539
|
+
if existing is not None and not visible_to(
|
|
540
|
+
await activity_scope(request), existing.get("agent_id"),
|
|
541
|
+
existing.get("unit")):
|
|
542
|
+
raise HTTPException(404, f"unknown approval {approval_id}")
|
|
543
|
+
try:
|
|
544
|
+
a = await _ledger(request).report_incident(
|
|
545
|
+
approval_id, reviewer, body.severity, body.summary, tenant=tenant,
|
|
546
|
+
idem=await idempotency_of(request, body.model_dump()))
|
|
547
|
+
except UnknownApproval:
|
|
548
|
+
raise HTTPException(404, f"unknown approval {approval_id}") from None
|
|
549
|
+
except IdempotencyMismatch:
|
|
550
|
+
raise HTTPException(422, "Idempotency-Key was already used for a "
|
|
551
|
+
"different request") from None
|
|
552
|
+
fault_point("after_write_commit_before_http")
|
|
553
|
+
return a
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
@router.post("/{approval_id}/annotate")
|
|
557
|
+
async def annotate(request: Request, approval_id: str, body: Annotation,
|
|
558
|
+
identity: dict | None = Depends(require_reviewer)):
|
|
559
|
+
reviewer = _reviewer_or_400(identity, body.reviewer)
|
|
560
|
+
tenant = await resolve_tenant(request)
|
|
561
|
+
existing = await _ledger(request).approval(approval_id, tenant=tenant)
|
|
562
|
+
if existing is not None and not visible_to(
|
|
563
|
+
await activity_scope(request), existing.get("agent_id"),
|
|
564
|
+
existing.get("unit")):
|
|
565
|
+
raise HTTPException(404, f"unknown approval {approval_id}")
|
|
566
|
+
try:
|
|
567
|
+
a = await _ledger(request).annotate(
|
|
568
|
+
approval_id, reviewer, body.note, tenant=tenant,
|
|
569
|
+
idem=await idempotency_of(request, body.model_dump()))
|
|
570
|
+
except UnknownApproval:
|
|
571
|
+
raise HTTPException(404, f"unknown approval {approval_id}") from None
|
|
572
|
+
except IdempotencyMismatch:
|
|
573
|
+
raise HTTPException(422, "Idempotency-Key was already used for a "
|
|
574
|
+
"different request") from None
|
|
575
|
+
fault_point("after_write_commit_before_http")
|
|
576
|
+
return a
|