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/sla.py ADDED
@@ -0,0 +1,234 @@
1
+ """SLAs: every pending approval gets a deadline; remind before it,
2
+ escalate at breach. Escalation is notification, not auto-decision — the
3
+ human still owns the call (auto-deny is an explicit opt-in that writes an
4
+ ordinary approval_denied by "system:sla").
5
+
6
+ Notification reach, honestly stated: reminders and escalations post to
7
+ Slack (plus the escalation webhook); an auto-DENY is a decision, so it is
8
+ delivered like one: the agent's callback_url is called and the card is
9
+ reflected on every configured chat surface, Teams included.
10
+
11
+ Reminders/escalations are idempotent on the log itself: the append happens
12
+ in a transaction that holds the append lock and re-checks for an existing
13
+ event, so overlapping scans and restarts can never double-fire. Their
14
+ notifications are outbox rows written in that same transaction (B10): the
15
+ marker and "will be announced" commit together, and a Slack outage delays
16
+ the message instead of losing it. Delivery is at-least-once, like a
17
+ webhook: a post whose success was not recorded is posted again.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import logging
23
+ import time
24
+
25
+ from .ledger import AlreadyDecided, Ledger, UnknownApproval
26
+ from .ledger.store import APPEND_LOCK
27
+
28
+ SLACK_SENDER = "chat:slack"
29
+
30
+ log = logging.getLogger("primarypath.sla")
31
+
32
+
33
+ def resolve_deadline_ms(settings, tool: str, requested_deadline_ms: int | None,
34
+ now_ms: int | None = None) -> int | None:
35
+ if requested_deadline_ms is not None:
36
+ return requested_deadline_ms
37
+ sla_s = settings.sla_for_tool(tool)
38
+ if sla_s is None:
39
+ return None
40
+ return (now_ms if now_ms is not None else int(time.time() * 1000)) + sla_s * 1000
41
+
42
+
43
+ class SLAMonitor:
44
+ def __init__(self, ledger: Ledger, pool, settings, slack=None,
45
+ webhooks=None, chat_routers=None, now=time.time):
46
+ self.ledger = ledger
47
+ self.pool = pool
48
+ self.settings = settings
49
+ self.slack = slack
50
+ self.webhooks = webhooks
51
+ # every chat surface gets the auto-deny reflected (a Teams card
52
+ # stuck on Approve/Reject after the system already denied would
53
+ # invite decisions on a dead approval)
54
+ self.chat_routers = (chat_routers if chat_routers is not None
55
+ else [r for r in (slack,) if r is not None])
56
+ self.now = now
57
+ self._task: asyncio.Task | None = None
58
+ register = getattr(webhooks, "register_sender", None)
59
+ if slack is not None and register is not None:
60
+ register(SLACK_SENDER, self._deliver_chat)
61
+
62
+ # -- lifecycle ------------------------------------------------------------
63
+
64
+ def start(self) -> None:
65
+ if self.settings.sla_scan_interval_s > 0:
66
+ self._task = asyncio.get_running_loop().create_task(self._loop())
67
+
68
+ async def stop(self) -> None:
69
+ if self._task is not None:
70
+ self._task.cancel()
71
+ try:
72
+ await self._task
73
+ except asyncio.CancelledError:
74
+ pass
75
+
76
+ async def _loop(self) -> None:
77
+ while True:
78
+ try:
79
+ await self.scan()
80
+ except Exception:
81
+ log.exception("sla scan failed; will retry")
82
+ await asyncio.sleep(self.settings.sla_scan_interval_s)
83
+
84
+ # -- one scan pass -----------------------------------------------------------
85
+
86
+ async def scan(self) -> None:
87
+ now_ms = int(self.now() * 1000)
88
+ remind_ms = self.settings.sla_remind_before_seconds * 1000
89
+ async with self.pool.connection() as conn:
90
+ cur = await conn.execute(
91
+ "SELECT tenant_id, approval_id, run_id, tool, deadline_ms, "
92
+ "agent_id, unit FROM approvals_view "
93
+ "WHERE status = 'pending' AND deadline_ms IS NOT NULL "
94
+ "AND deadline_ms <= %s", (now_ms + remind_ms,))
95
+ rows = await cur.fetchall()
96
+
97
+ for tenant, approval_id, run_id, tool, deadline_ms, agent, unit in rows:
98
+ if now_ms >= deadline_ms:
99
+ await self._append_once("approval_escalated", approval_id, {
100
+ "approval_id": approval_id, "run_id": run_id,
101
+ "to": self.settings.sla_escalate_to,
102
+ "reason": "sla_breach"}, tenant, agent, unit,
103
+ notify=self._escalation_rows(
104
+ approval_id, run_id, tool, deadline_ms, tenant))
105
+ # the CONTROL on every scan: expiry must not hang off the
106
+ # one-shot marker or the notification. The row is still
107
+ # pending, so a scan that died after committing the marker
108
+ # is finished by the next one; expire() is idempotent on a
109
+ # decided case.
110
+ if self.settings.sla_breach_auto_deny:
111
+ await self._auto_deny(approval_id, deadline_ms, tenant)
112
+ else:
113
+ await self._append_once("approval_reminded", approval_id, {
114
+ "approval_id": approval_id, "run_id": run_id}, tenant,
115
+ agent, unit,
116
+ notify=self._reminder_rows(approval_id, tool,
117
+ deadline_ms, tenant))
118
+
119
+ async def _append_once(self, type: str, approval_id: str,
120
+ payload: dict, tenant: str, agent: str,
121
+ unit: str | None, notify=None) -> bool:
122
+ """Append unless an event of this type already exists for the
123
+ approval; check + append share the approval's per-chain append lock
124
+ (F24), so it is race-free. SLA events land on the agent's chain,
125
+ beside the approval they concern. `notify` is a list of outbox rows
126
+ (url, payload, kind) enqueued in the same transaction."""
127
+ async with self.pool.connection() as conn:
128
+ async with conn.transaction():
129
+ await conn.execute(
130
+ "SELECT pg_advisory_xact_lock(%s, hashtext(%s))",
131
+ (APPEND_LOCK, f"{tenant}:{agent}"))
132
+ cur = await conn.execute(
133
+ "SELECT 1 FROM events WHERE tenant_id = %s AND type = %s "
134
+ "AND payload_json->>'approval_id' = %s LIMIT 1",
135
+ (tenant, type, approval_id))
136
+ if await cur.fetchone() is not None:
137
+ return False
138
+ event = await self.ledger._append_in_tx(
139
+ conn, type, payload, tenant, agent_id=agent, unit=unit)
140
+ if self.webhooks is not None:
141
+ for url, body, kind in notify or ():
142
+ await self.webhooks.enqueue_in_tx(
143
+ conn, url, body, tenant=tenant, kind=kind,
144
+ approval_id=approval_id,
145
+ event_hash=event.get("hash"))
146
+ return True
147
+
148
+ # -- notifications ---------------------------------------------------------------
149
+
150
+ def _reminder_rows(self, approval_id: str, tool: str, deadline_ms: int,
151
+ tenant: str) -> list[tuple[str, dict, str]]:
152
+ if self.slack is None:
153
+ return []
154
+ return [(SLACK_SENDER, {
155
+ "message": "sla_reminder", "approval_id": approval_id,
156
+ "tool": tool, "deadline_ms": deadline_ms, "tenant": tenant,
157
+ "channel": self.slack.channel}, "sla_reminder")]
158
+
159
+ def _escalation_rows(self, approval_id: str, run_id: str, tool: str,
160
+ deadline_ms: int,
161
+ tenant: str) -> list[tuple[str, dict, str]]:
162
+ rows = []
163
+ if self.settings.sla_escalation_webhook:
164
+ # the page: signed, retried, dead-lettered, replayable
165
+ rows.append((self.settings.sla_escalation_webhook, {
166
+ "kind": "sla_escalation", "approval_id": approval_id,
167
+ "run_id": run_id, "tool": tool, "deadline_ms": deadline_ms,
168
+ "to": self.settings.sla_escalate_to}, "sla_escalation"))
169
+ if self.slack is not None:
170
+ rows.append((SLACK_SENDER, {
171
+ "message": "sla_escalation", "approval_id": approval_id,
172
+ "run_id": run_id, "tool": tool, "deadline_ms": deadline_ms,
173
+ "tenant": tenant, "to": self.settings.sla_escalate_to,
174
+ "channel": (self.settings.sla_escalation_channel
175
+ or self.slack.channel)}, "sla_escalation_chat"))
176
+ return rows
177
+
178
+ async def _deliver_chat(self, p: dict) -> None:
179
+ """The outbox's sender for chat:slack rows. Worded at delivery
180
+ time, from the case as it stands then: a message delayed by an
181
+ outage must not invite a decision the case can no longer take.
182
+ Raising leaves the row pending for a retry."""
183
+ a = await self.ledger.approval(p["approval_id"],
184
+ tenant=p.get("tenant", "default"))
185
+ status = a["status"] if a else None
186
+ aid, tool = p["approval_id"], p["tool"]
187
+ case = f"<{self.slack.public_url}/?approval={aid}|"
188
+ if p["message"] == "sla_reminder":
189
+ if status != "pending":
190
+ return # decided while Slack was down: nothing to remind
191
+ left_s = max(0, (p["deadline_ms"] - int(self.now() * 1000))
192
+ // 1000)
193
+ await self.slack.api.post_message(
194
+ p["channel"],
195
+ [{"type": "section", "text": {"type": "mrkdwn", "text":
196
+ f":hourglass_flowing_sand: *Reminder:* `{tool}` approval "
197
+ f"`{aid}` is still pending — {left_s}s to its SLA. "
198
+ f"{case}decide now>"}}],
199
+ text=f"Reminder: {aid} pending")
200
+ return
201
+ # a breach happened and is announced even if the case has since
202
+ # been decided; only the call to action follows the case
203
+ if status == "pending":
204
+ link = f"{case}decide now>"
205
+ elif status == "expired":
206
+ link = f"{case}open the case> (expired; the agent must re-propose)"
207
+ else:
208
+ link = f"{case}open the case> (now {status})"
209
+ await self.slack.api.post_message(
210
+ p["channel"],
211
+ [{"type": "section", "text": {"type": "mrkdwn", "text":
212
+ f":rotating_light: *SLA breached:* `{tool}` approval "
213
+ f"`{aid}` (run `{p['run_id']}`) blew its deadline and "
214
+ f"was escalated to *{p['to']}*. {link}"}}],
215
+ text=f"SLA breached: {aid}")
216
+
217
+ async def _auto_deny(self, approval_id: str, deadline_ms: int,
218
+ tenant: str) -> None:
219
+ """The deadline passed with no decision: the proposal EXPIRES
220
+ (status `expired`, event approval_expired) — final for the waiting
221
+ agent like a denial, but honest about what happened: nobody said
222
+ no, nobody was there."""
223
+ try:
224
+ a = await self.ledger.expire(
225
+ approval_id,
226
+ reason=f"SLA breached: no decision by deadline {deadline_ms}",
227
+ tenant=tenant)
228
+ except (AlreadyDecided, UnknownApproval):
229
+ return
230
+ # the agent waiting on its callback_url gets called back (outbox
231
+ # row, written by the ledger's hook in the expiry's own
232
+ # transaction), and every chat surface's card flips (not just Slack)
233
+ for chat in self.chat_routers:
234
+ chat.reflect(a)
app/slack.py ADDED
@@ -0,0 +1,282 @@
1
+ """Slack routing: post approvals to a channel, decide from Slack.
2
+
3
+ A Slack decision is an ordinary approval_granted/approval_denied in the
4
+ same chain as a web decision; the only Slack-specific trace in the audit
5
+ is the reviewer string carrying the Slack identity. Message refs live in
6
+ their own operational table, never in the chain.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ import logging
14
+ import time
15
+
16
+ import httpx
17
+
18
+ from .chat import ChatRouter
19
+ from .ledger import Ledger
20
+
21
+ log = logging.getLogger("primarypath.slack")
22
+
23
+ SIGNATURE_TOLERANCE_S = 300
24
+
25
+
26
+ def verify_slack_signature(secret: str, timestamp: str, body: bytes,
27
+ signature: str, now: float | None = None) -> bool:
28
+ """v0= HMAC over "v0:{timestamp}:{body}"; stale timestamps rejected."""
29
+ try:
30
+ ts = int(timestamp)
31
+ except (TypeError, ValueError):
32
+ return False
33
+ if abs((now if now is not None else time.time()) - ts) > SIGNATURE_TOLERANCE_S:
34
+ return False
35
+ base = b"v0:" + timestamp.encode() + b":" + body
36
+ expected = "v0=" + hmac.new(secret.encode(), base, hashlib.sha256).hexdigest()
37
+ return hmac.compare_digest(expected, signature or "")
38
+
39
+
40
+ def _args_summary(arguments: dict, max_items: int = 4) -> str:
41
+ items = list(arguments.items())
42
+ shown = ", ".join(f"{k}={json.dumps(v, ensure_ascii=False)}"
43
+ for k, v in items[:max_items])
44
+ return shown + (", …" if len(items) > max_items else "")
45
+
46
+
47
+ def case_blocks(approval: dict, public_url: str) -> list[dict]:
48
+ reasoning = approval.get("reasoning") or "(no reasoning recorded)"
49
+ if len(reasoning) > 600:
50
+ reasoning = reasoning[:600] + "…"
51
+ return [
52
+ {"type": "section", "text": {"type": "mrkdwn", "text":
53
+ f"*Approval requested:* `{approval['tool']}"
54
+ f"({_args_summary(approval.get('arguments', {}))})`"}},
55
+ {"type": "section", "text": {"type": "mrkdwn", "text": reasoning}},
56
+ {"type": "context", "elements": [{"type": "mrkdwn", "text":
57
+ f"run `{approval['run_id']}` · "
58
+ f"<{public_url}/?approval={approval['approval_id']}|open in inbox>"}]},
59
+ {"type": "actions", "block_id": "primarypath_decision", "elements": [
60
+ {"type": "button", "action_id": "primarypath_approve",
61
+ "text": {"type": "plain_text", "text": "Approve"},
62
+ "style": "primary", "value": approval["approval_id"]},
63
+ {"type": "button", "action_id": "primarypath_reject",
64
+ "text": {"type": "plain_text", "text": "Reject"},
65
+ "style": "danger", "value": approval["approval_id"]},
66
+ ]},
67
+ ]
68
+
69
+
70
+ def decided_blocks(approval: dict, public_url: str) -> list[dict]:
71
+ icon = "✅" if approval["status"] == "granted" else "⛔"
72
+ line = (f"{icon} *{approval['status']}* by {approval.get('reviewer')}"
73
+ + (f": {approval['reason']}" if approval.get("reason") else ""))
74
+ return [
75
+ {"type": "section", "text": {"type": "mrkdwn", "text":
76
+ f"`{approval['tool']}` — {line}"}},
77
+ {"type": "context", "elements": [{"type": "mrkdwn", "text":
78
+ f"run `{approval['run_id']}` · "
79
+ f"<{public_url}/?approval={approval['approval_id']}|audit in inbox>"}]},
80
+ ]
81
+
82
+
83
+ def reject_modal(approval_id: str) -> dict:
84
+ return {
85
+ "type": "modal",
86
+ "callback_id": "primarypath_reject_reason",
87
+ "private_metadata": approval_id,
88
+ "title": {"type": "plain_text", "text": "Reject approval"},
89
+ "submit": {"type": "plain_text", "text": "Reject"},
90
+ "blocks": [
91
+ {"type": "input", "block_id": "reason_block",
92
+ "label": {"type": "plain_text",
93
+ "text": "Reason (recorded in the audit trail)"},
94
+ "element": {"type": "plain_text_input", "action_id": "reason",
95
+ "multiline": True}},
96
+ ],
97
+ }
98
+
99
+
100
+ class SlackAPI:
101
+ """Thin real client; tests swap in a stub with the same surface."""
102
+
103
+ def __init__(self, token: str):
104
+ self._client = httpx.AsyncClient(
105
+ base_url="https://slack.com/api",
106
+ headers={"Authorization": f"Bearer {token}"}, timeout=10)
107
+
108
+ async def _call(self, method: str, payload: dict) -> dict:
109
+ r = await self._client.post(f"/{method}", json=payload)
110
+ out = r.json()
111
+ if not out.get("ok"):
112
+ log.warning("slack %s failed: %s", method, out.get("error"))
113
+ return out
114
+
115
+ async def post_message(self, channel: str, blocks: list, text: str) -> dict:
116
+ return await self._call("chat.postMessage",
117
+ {"channel": channel, "blocks": blocks, "text": text})
118
+
119
+ async def update_message(self, channel: str, ts: str, blocks: list,
120
+ text: str) -> dict:
121
+ return await self._call("chat.update", {"channel": channel, "ts": ts,
122
+ "blocks": blocks, "text": text})
123
+
124
+ async def open_view(self, trigger_id: str, view: dict) -> dict:
125
+ return await self._call("views.open",
126
+ {"trigger_id": trigger_id, "view": view})
127
+
128
+ async def post_ephemeral(self, channel: str, user: str, text: str) -> dict:
129
+ return await self._call("chat.postEphemeral",
130
+ {"channel": channel, "user": user, "text": text})
131
+
132
+
133
+ class SlackRouter(ChatRouter):
134
+ def __init__(self, api, pool, ledger: Ledger, channel: str, public_url: str,
135
+ approver_ids: set[str] | None = None,
136
+ tenant: str = "default"):
137
+ super().__init__(ledger, tenant=tenant)
138
+ self.api = api
139
+ self.pool = pool
140
+ self.channel = channel
141
+ self.public_url = public_url
142
+ # empty set = nobody may decide from Slack (fail closed): the HMAC
143
+ # proves the request came from Slack, not that the user is an approver
144
+ self.approver_ids = approver_ids or set()
145
+
146
+ # -- notify + reflect ------------------------------------------------------
147
+
148
+ def notify(self, approval: dict) -> None:
149
+ self._spawn(self._notify(approval))
150
+
151
+ async def _notify(self, approval: dict) -> None:
152
+ out = await self.api.post_message(
153
+ self.channel, case_blocks(approval, self.public_url),
154
+ text=f"Approval requested: {approval['tool']}")
155
+ if out.get("ok"):
156
+ async with self.pool.connection() as conn:
157
+ await conn.execute(
158
+ "INSERT INTO slack_messages (tenant_id, approval_id, "
159
+ "channel, ts) VALUES (%s, %s, %s, %s) "
160
+ "ON CONFLICT (tenant_id, approval_id) DO NOTHING",
161
+ (approval.get("tenant_id", "default"),
162
+ approval["approval_id"], out["channel"], out["ts"]))
163
+
164
+ def reflect(self, approval: dict) -> None:
165
+ """Update the Slack message after a decision from any surface."""
166
+ self._spawn(self._reflect(approval))
167
+
168
+ async def _reflect(self, approval: dict) -> None:
169
+ ref = await self._message_ref(approval["approval_id"],
170
+ approval.get("tenant_id", "default"))
171
+ if ref:
172
+ await self.api.update_message(
173
+ ref[0], ref[1], decided_blocks(approval, self.public_url),
174
+ text=f"{approval['status']}: {approval['tool']}")
175
+
176
+ async def _message_ref(self, approval_id: str,
177
+ tenant: str = "default") -> tuple[str, str] | None:
178
+ async with self.pool.connection() as conn:
179
+ cur = await conn.execute(
180
+ "SELECT channel, ts FROM slack_messages "
181
+ "WHERE tenant_id = %s AND approval_id = %s",
182
+ (tenant, approval_id))
183
+ return await cur.fetchone()
184
+
185
+ # -- interactions ----------------------------------------------------------
186
+
187
+ async def handle_interaction(self, payload: dict) -> dict | None:
188
+ kind = payload.get("type")
189
+ if kind == "block_actions":
190
+ return await self._handle_block_action(payload)
191
+ if kind == "view_submission":
192
+ return await self._handle_view_submission(payload)
193
+ return None
194
+
195
+ @staticmethod
196
+ def _reviewer(payload: dict) -> str:
197
+ user = payload.get("user", {})
198
+ display = user.get("name") or user.get("username") or "unknown"
199
+ return f"{display} (slack:{user.get('id', '?')})"
200
+
201
+ def _authorized(self, payload: dict) -> bool:
202
+ return payload.get("user", {}).get("id") in self.approver_ids
203
+
204
+ async def _refuse(self, payload: dict, approval_id: str, verb: str) -> None:
205
+ """Record the unauthorized attempt in the audit log and tell the
206
+ clicker why nothing happened. Signature-valid != authorized."""
207
+ reviewer = self._reviewer(payload)
208
+ await self.record_refusal(approval_id, reviewer, "Slack", verb)
209
+ channel = (payload.get("channel") or {}).get("id") or self.channel
210
+ user = payload.get("user", {}).get("id", "")
211
+ if user:
212
+ await self.api.post_ephemeral(
213
+ channel, user,
214
+ "You are not an authorized approver for this workspace; "
215
+ "the attempt was recorded in the audit log.")
216
+
217
+ async def _handle_block_action(self, payload: dict) -> dict | None:
218
+ actions = payload.get("actions") or []
219
+ if not actions:
220
+ return None
221
+ action = actions[0]
222
+ approval_id = action.get("value", "")
223
+ verb = {"primarypath_approve": "approve", "primarypath_reject": "reject"}.get(
224
+ action.get("action_id", ""))
225
+ if verb is None:
226
+ return None
227
+ if not self._authorized(payload):
228
+ await self._refuse(payload, approval_id, verb)
229
+ return None
230
+ if verb == "reject":
231
+ await self.api.open_view(payload.get("trigger_id", ""),
232
+ reject_modal(approval_id))
233
+ return None
234
+ await self._decide(approval_id, True, self._reviewer(payload), "",
235
+ channel=(payload.get("channel") or {}).get("id"),
236
+ user=payload.get("user", {}).get("id"))
237
+ return None
238
+
239
+ async def _handle_view_submission(self, payload: dict) -> dict | None:
240
+ view = payload.get("view", {})
241
+ if view.get("callback_id") != "primarypath_reject_reason":
242
+ return None
243
+ approval_id = view.get("private_metadata", "")
244
+ if not self._authorized(payload):
245
+ # the modal click was already gated, but submissions are forgeable
246
+ await self._refuse(payload, approval_id, "reject")
247
+ return {"response_action": "errors",
248
+ "errors": {"reason_block":
249
+ "You are not an authorized approver."}}
250
+ values = view.get("state", {}).get("values", {})
251
+ reason = (values.get("reason_block", {}).get("reason", {})
252
+ .get("value") or "").strip()
253
+ if not reason:
254
+ return {"response_action": "errors",
255
+ "errors": {"reason_block": "A reason is required to reject."}}
256
+ try:
257
+ await self.decide_from_chat(approval_id, False,
258
+ self._reviewer(payload), reason)
259
+ except self.ChatNotPermitted as e:
260
+ await self.record_refusal(approval_id, self._reviewer(payload),
261
+ "Slack", "reject")
262
+ return {"response_action": "errors",
263
+ "errors": {"reason_block": str(e)}}
264
+ approval = await self.ledger.approval(approval_id, tenant=self.tenant)
265
+ if approval is not None:
266
+ await self._reflect(approval)
267
+ return {"response_action": "clear"}
268
+
269
+ async def _decide(self, approval_id: str, approved: bool, reviewer: str,
270
+ reason: str, channel: str | None = None,
271
+ user: str | None = None) -> None:
272
+ try:
273
+ approval = await self.decide_from_chat(approval_id, approved,
274
+ reviewer, reason)
275
+ except self.ChatNotPermitted as e:
276
+ await self.record_refusal(approval_id, reviewer, "Slack",
277
+ "approve" if approved else "reject")
278
+ if channel and user:
279
+ await self.api.post_ephemeral(channel, user, str(e))
280
+ return
281
+ if approval is not None:
282
+ await self._reflect(approval)