flowstatemcp 2.5.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 (143) hide show
  1. flowstate/__init__.py +34 -0
  2. flowstate/__main__.py +7 -0
  3. flowstate/activity.py +224 -0
  4. flowstate/agent_guide.py +251 -0
  5. flowstate/app_keys.py +141 -0
  6. flowstate/approvals.py +974 -0
  7. flowstate/approve_page.py +565 -0
  8. flowstate/ask.py +107 -0
  9. flowstate/autosession.py +480 -0
  10. flowstate/backup.py +201 -0
  11. flowstate/brief.py +420 -0
  12. flowstate/card_offer.py +208 -0
  13. flowstate/card_taps.py +165 -0
  14. flowstate/card_trust.py +127 -0
  15. flowstate/claims.py +461 -0
  16. flowstate/cleanup.py +619 -0
  17. flowstate/cli.py +728 -0
  18. flowstate/cli_data.py +434 -0
  19. flowstate/cli_more.py +302 -0
  20. flowstate/cli_review.py +459 -0
  21. flowstate/cloudflared_check.py +49 -0
  22. flowstate/conflict_card_ui.py +286 -0
  23. flowstate/conflict_cards.py +1193 -0
  24. flowstate/conflict_reopen.py +320 -0
  25. flowstate/conflicts.py +420 -0
  26. flowstate/connections.py +33 -0
  27. flowstate/database.py +2191 -0
  28. flowstate/dbconn.py +308 -0
  29. flowstate/deletes.py +860 -0
  30. flowstate/embeddings.py +354 -0
  31. flowstate/erase.py +1047 -0
  32. flowstate/extras.py +71 -0
  33. flowstate/git_capture.py +157 -0
  34. flowstate/guard.py +224 -0
  35. flowstate/handoffs.py +168 -0
  36. flowstate/history.py +755 -0
  37. flowstate/history_page.py +276 -0
  38. flowstate/http_app.py +204 -0
  39. flowstate/identity.py +387 -0
  40. flowstate/install_name.py +140 -0
  41. flowstate/ledger.py +272 -0
  42. flowstate/legacy_args.py +65 -0
  43. flowstate/links.py +609 -0
  44. flowstate/markdown_io.py +242 -0
  45. flowstate/meaning.py +398 -0
  46. flowstate/memory_import.py +92 -0
  47. flowstate/migrations/001_auto_sessions.sql +16 -0
  48. flowstate/migrations/002_review_queue.sql +33 -0
  49. flowstate/migrations/003_links.sql +47 -0
  50. flowstate/migrations/004_review_mode.sql +24 -0
  51. flowstate/migrations/005_history.sql +21 -0
  52. flowstate/migrations/006_activity.sql +26 -0
  53. flowstate/migrations/007_meaning_index.sql +16 -0
  54. flowstate/migrations/008_git_capture.sql +12 -0
  55. flowstate/migrations/009_hard_rules.sql +13 -0
  56. flowstate/migrations/010_handoffs.sql +14 -0
  57. flowstate/migrations/011_record_meta.sql +13 -0
  58. flowstate/migrations/012_accountability.sql +71 -0
  59. flowstate/migrations/013_todo_claims.sql +36 -0
  60. flowstate/migrations/014_decision_conflicts.sql +41 -0
  61. flowstate/migrations/015_session_runs.sql +17 -0
  62. flowstate/migrations/016_write_receipts.sql +35 -0
  63. flowstate/migrations/017_run_views.sql +26 -0
  64. flowstate/migrations/018_run_views_upto.sql +4 -0
  65. flowstate/migrations/019_approvals.sql +47 -0
  66. flowstate/migrations/020_source_stamps.sql +10 -0
  67. flowstate/migrations/021_review_links.sql +11 -0
  68. flowstate/migrations/022_review_start_tickets.sql +10 -0
  69. flowstate/migrations/023_conflict_cards.sql +68 -0
  70. flowstate/migrations/024_rule_confirmations.sql +7 -0
  71. flowstate/migrations/025_confirmed_hash.sql +7 -0
  72. flowstate/migrations/__init__.py +149 -0
  73. flowstate/models.py +307 -0
  74. flowstate/names.py +157 -0
  75. flowstate/offsite.py +386 -0
  76. flowstate/offsite_crypto.py +272 -0
  77. flowstate/paths.py +265 -0
  78. flowstate/popup.py +238 -0
  79. flowstate/priority.py +66 -0
  80. flowstate/privatefs.py +476 -0
  81. flowstate/project_history.py +710 -0
  82. flowstate/provenance.py +696 -0
  83. flowstate/receipts.py +377 -0
  84. flowstate/relay/__init__.py +72 -0
  85. flowstate/relay/acme.py +271 -0
  86. flowstate/relay/api.py +164 -0
  87. flowstate/relay/cli.py +344 -0
  88. flowstate/relay/crypto.py +137 -0
  89. flowstate/relay/ct.py +118 -0
  90. flowstate/relay/runner.py +177 -0
  91. flowstate/relay/store.py +191 -0
  92. flowstate/relay/tunnel.py +413 -0
  93. flowstate/release_signers +14 -0
  94. flowstate/remote_auth.py +1219 -0
  95. flowstate/review.py +320 -0
  96. flowstate/review_apply.py +140 -0
  97. flowstate/review_card_ui.py +241 -0
  98. flowstate/review_cards.py +564 -0
  99. flowstate/review_mode.py +274 -0
  100. flowstate/review_page.py +1361 -0
  101. flowstate/review_phone.py +465 -0
  102. flowstate/review_tunnel.py +338 -0
  103. flowstate/routing.py +392 -0
  104. flowstate/rules.py +418 -0
  105. flowstate/safety.py +98 -0
  106. flowstate/saves.py +83 -0
  107. flowstate/scheduler.py +160 -0
  108. flowstate/schema.sql +713 -0
  109. flowstate/search.py +355 -0
  110. flowstate/secret_store.py +271 -0
  111. flowstate/secrets_scan.py +135 -0
  112. flowstate/selfupdate.py +1020 -0
  113. flowstate/server.py +1299 -0
  114. flowstate/session_contents.py +382 -0
  115. flowstate/session_records.py +629 -0
  116. flowstate/site_style.py +46 -0
  117. flowstate/stored.py +158 -0
  118. flowstate/story.py +442 -0
  119. flowstate/surface.py +173 -0
  120. flowstate/tasks.py +231 -0
  121. flowstate/term.py +40 -0
  122. flowstate/tool_labels.py +104 -0
  123. flowstate/tooldefs.py +383 -0
  124. flowstate/tools/__init__.py +118 -0
  125. flowstate/tools/core.py +545 -0
  126. flowstate/tools/files.py +416 -0
  127. flowstate/tools/git_ops.py +1081 -0
  128. flowstate/tools/intelligence.py +376 -0
  129. flowstate/tools/learning.py +282 -0
  130. flowstate/tools/problems.py +159 -0
  131. flowstate/tools/sessions.py +266 -0
  132. flowstate/tools/story.py +48 -0
  133. flowstate/tools/todos.py +79 -0
  134. flowstate/tools/utils.py +71 -0
  135. flowstate/tools/variables.py +345 -0
  136. flowstate/updates.py +482 -0
  137. flowstate/urls.py +7 -0
  138. flowstate/waiting.py +828 -0
  139. flowstate/who.py +302 -0
  140. flowstatemcp-2.5.0.dist-info/METADATA +88 -0
  141. flowstatemcp-2.5.0.dist-info/RECORD +143 -0
  142. flowstatemcp-2.5.0.dist-info/WHEEL +4 -0
  143. flowstatemcp-2.5.0.dist-info/entry_points.txt +3 -0
flowstate/approvals.py ADDED
@@ -0,0 +1,974 @@
1
+ """Opt-in user-only approvals (2.4.0, migration 019).
2
+
3
+ Off by default (also for upgraded installs): FlowState never asks the user anything. An
4
+ agent's delete then goes through at once, after a safety copy, and stays fully undoable
5
+ (deletes.py, history.py). Review-mode changes just happen, logged and shown in the brief.
6
+
7
+ `flowstate approvals on` (per install, or --project for one project) turns them on. Then an
8
+ agent's delete request and turning review off wait for the user's own approval, and no app
9
+ can pass the user's OK along: only a human click or keypress decides, in this order:
10
+
11
+ 1. popup the app shows an MCP elicitation form with one required checkbox `approve`.
12
+ Only for apps on POPUP_CLIENTS (they show a real dialog), only when the
13
+ connection declared the `elicitation` capability, never to a client that calls
14
+ itself claude.ai / Anthropic. FlowState waits at most POPUP_SECONDS, holding no
15
+ database lock. Decline, cancel, approve=false or no answer: not approved, the
16
+ item stays queued. The popup is only as trustworthy as the app showing it.
17
+ 2. phone relay users: a link to flowstatemcp.com, which signs the user in and sends the
18
+ browser to https://<handle>.fsmcp.com/approve/<item> with a short signed grant
19
+ (approve_page.py checks it here with FSA_PUBLIC_KEYS). The phone review list
20
+ of everything waiting (review_phone.py) takes the same grants with action
21
+ open_review and item 0; its bookmarkable link (review_link) has no code in it:
22
+ the site signs the owner in and sends the browser to /review/start?t=<ticket>
23
+ here (a grant with action start_review, verify_start_ticket), which makes a
24
+ fresh one-use code (start_nonce, limited per site user) and sends it back to
25
+ the site to be signed.
26
+ 3. terminal `flowstate approve <id>` asks for the user's `y` on their own terminal.
27
+
28
+ Through the relay (2.4.2), an AI can't approve or reject any review item in the chat by
29
+ default: the answer tells the user to decide on their phone, from their saved review link
30
+ (2.4.4: no link in it, it holds the relay handle). `flowstate relay chat-approvals on`
31
+ (the user's terminal only; relay_chat_on) lets it quote the user as before, except for the
32
+ user-only items above.
33
+
34
+ Turning approvals on needs nothing; turning them off needs the user's terminal
35
+ (`flowstate approvals off` asks for `y` there). Every user-only decision is logged
36
+ (approval_log; manage_history list shows it). Unanswered requests never approve themselves.
37
+
38
+ Honest limit: an agent that controls the user's own logged-in browser or terminal could still
39
+ get through. This stops agents approving their own work through FlowState.
40
+ """
41
+
42
+ import base64
43
+ import hashlib
44
+ import hmac
45
+ import json
46
+ import re
47
+ import secrets
48
+ import sqlite3
49
+ from datetime import datetime, timedelta, timezone
50
+ from pathlib import Path
51
+ from typing import Optional
52
+
53
+ from . import dbconn
54
+
55
+ # Apps known to show the elicitation form as a real dialog to the user (clientInfo name,
56
+ # lower case). `flowstate approvals popup add|remove NAME` changes this per install.
57
+ POPUP_CLIENTS = ("claude-code", "cursor", "cursor-vscode", "visual studio code",
58
+ "visual studio code - insiders", "vscode")
59
+ # Apps known to draw the in-chat cards (MCP Apps) in a frame the model can't read or click,
60
+ # and to keep app-only tools from the model. `flowstate approvals cards add|remove NAME`
61
+ # changes this per install. claude-ai is Claude Desktop's chat (the MCP client name it sends
62
+ # on stdio); Claude Desktop's Cowork (agent) mode sends "local-agent-mode-<server name>", e.g.
63
+ # local-agent-mode-flowstate (task 65): an entry ending in "*" matches every name it starts
64
+ # (card_allowed; stdio only, never a sign-in). An app on a key, token or OAuth sign-in is matched by that sign-in's own name, and
65
+ # only one the user added to the separate sign-in list (`approvals cards add --sign-in NAME`;
66
+ # card_allowed_credential, card_trust.py), so an app name on this list never trusts a network
67
+ # sign-in that happens to have the same name (task 57). Popups the same: `approvals popup add
68
+ # --sign-in NAME`, and only for a LAN app on this computer (popup.no_box).
69
+ CARD_CLIENTS = ("claude-ai", "local-agent-mode-*")
70
+ WILDCARD_MIN = 4 # an entry ending in "*" needs this many characters before it
71
+ SIGN_IN_LISTS = ("popup_signins", "card_signins")
72
+ # Never a popup for these, whatever they declare: claude.ai, Claude Desktop / mobile and the
73
+ # Anthropic connector proxy don't show one (and the proxy drops the connection).
74
+ CONNECTOR_MARKS = ("claude-ai", "claude.ai", "claudeai", "anthropic")
75
+ POPUP_SECONDS = 120
76
+ GRANT_SECONDS = 300 # a phone grant lives 5 minutes
77
+ CLOCK_SKEW = 60
78
+ NONCE_DAYS = 7 # the phone link's one-use code, then a fresh one is made
79
+ START_MINUTES = 15 # a review-list code made by /review/start (review_phone.py)
80
+ START_OPEN = 10 # at most this many of those open at once (the oldest expire)
81
+ START_PER_HOUR = 30 # and at most this many made per hour per site user (then 429)
82
+ # All users together, as a backstop only: every code needs a ticket only the site can sign, so
83
+ # this can only be reached with a leaked signing key, and then it still keeps the table small.
84
+ START_PER_HOUR_ALL = 120
85
+ SITE = "https://flowstatemcp.com"
86
+ ACTION = "approve_review"
87
+ REVIEW_ACTION = "open_review" # the phone review list (review_phone.py): item 0
88
+ START_ACTION = "start_review" # the site's ticket for /review/start (review_phone.py): item 0
89
+ USER_ONLY_KINDS = ("delete", "setting", "source", "rule", "card_offer", "reopen_conflict")
90
+
91
+ # The flowstatemcp.com approval-grant public keys (ES256 / P-256 JWKs), pinned per release:
92
+ # fsa-1 active, fsa-2 backup so the site can rotate without a forced update.
93
+ # Published at https://flowstatemcp.com/.well-known/flowstate-approval-keys.json (Oct 21, 2026).
94
+ # A kid that maps to None is refused; if every kid is None, phone approvals are off entirely.
95
+ FSA_PUBLIC_KEYS: dict = {
96
+ "fsa-1": {"kty": "EC", "crv": "P-256", "x": "eMo3tUoz8rNtqO1UA32Dji_2Zi1gKpZVoGnrj4VZ_Wk",
97
+ "y": "CW7xH5MZy-iL5ZbCQ2BKDo7okUZtaABnMvcTCYTFhPA"},
98
+ "fsa-2": {"kty": "EC", "crv": "P-256", "x": "ZFiKJNndjt35qWI3AjmXhKkIQRNlmxhsq8sBlJT-340",
99
+ "y": "1LTTIZEiA3ehSiDaspCt-hqDETvjktMnZrkGahXPf4o"},
100
+ }
101
+
102
+ _NONCE_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$")
103
+ _TICKET_NONCE_RE = re.compile(r"^[A-Za-z0-9_-]{22,43}$")
104
+ # a compact ES256 JWS: header.payload.signature (64 bytes = 86 base64url characters); checked
105
+ # before any crypto so a junk request costs nothing
106
+ TICKET_RE = re.compile(r"^[A-Za-z0-9_-]{16,512}\.[A-Za-z0-9_-]{16,2048}\.[A-Za-z0-9_-]{86}$")
107
+
108
+
109
+ class ApprovalError(Exception):
110
+ def __init__(self, code: str, message: str):
111
+ super().__init__(message)
112
+ self.code = code
113
+ self.payload = {"error": code, "message": message}
114
+
115
+
116
+ def _now() -> datetime:
117
+ return datetime.now(timezone.utc)
118
+
119
+
120
+ def _ts(dt: Optional[datetime] = None) -> str:
121
+ return (dt or _now()).strftime("%Y-%m-%d %H:%M:%S")
122
+
123
+
124
+ def _conn(db_path: Path) -> sqlite3.Connection:
125
+ conn = dbconn.connect(str(db_path), timeout=5.0)
126
+ conn.row_factory = sqlite3.Row
127
+ conn.execute("PRAGMA busy_timeout = 5000")
128
+ return conn
129
+
130
+
131
+ def available(conn) -> bool:
132
+ return conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' "
133
+ "AND name='approval_settings'").fetchone() is not None
134
+
135
+
136
+ # ---------------------------------------------------------------- settings
137
+ TERMINAL_WORDS = "set in the user's terminal"
138
+
139
+
140
+ def _get(conn, key: str) -> Optional[str]:
141
+ row = conn.execute("SELECT value FROM approval_settings WHERE key = ?", (key,)).fetchone()
142
+ return row[0] if row else None
143
+
144
+
145
+ def _put(conn, key: str, value: Optional[str], who: str) -> None:
146
+ if value is None:
147
+ conn.execute("DELETE FROM approval_settings WHERE key = ?", (key,))
148
+ else:
149
+ conn.execute("""INSERT INTO approval_settings (key, value, updated_at, updated_by)
150
+ VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value,
151
+ updated_at = excluded.updated_at, updated_by = excluded.updated_by""",
152
+ (key, value, _ts(), (who or "")[:80]))
153
+
154
+
155
+ def is_on(db_path: Path, project_id: Optional[int] = None) -> bool:
156
+ """Approvals on for this project (its own setting, else the install's; default off)."""
157
+ try:
158
+ conn = _conn(db_path)
159
+ except sqlite3.Error:
160
+ return False
161
+ try:
162
+ return on_with(conn, project_id)
163
+ finally:
164
+ conn.close()
165
+
166
+
167
+ def on_with(conn, project_id: Optional[int] = None) -> bool:
168
+ if not available(conn):
169
+ return False
170
+ v = _get(conn, f"approvals:project:{int(project_id)}") if project_id is not None else None
171
+ return (v or _get(conn, "approvals") or "off") == "on"
172
+
173
+
174
+ def set_on(db_path: Path, on: Optional[bool], project_id: Optional[int] = None,
175
+ who: str = "command-line", how: str = TERMINAL_WORDS) -> dict:
176
+ """on True/False, or None to drop a project's own setting. Logged with review-mode changes.
177
+ Only the user's terminal (cli_review.py) and the local review page (review_page.py) call
178
+ this; `who` / `how` say which."""
179
+ key = "approvals" if project_id is None else f"approvals:project:{int(project_id)}"
180
+ conn = _conn(db_path)
181
+ try:
182
+ if not available(conn):
183
+ raise ApprovalError("approvals_unavailable", "Approvals aren't set up in this database "
184
+ "yet (migration 019); restart FlowState.")
185
+ conn.execute("BEGIN IMMEDIATE")
186
+ old = _get(conn, key)
187
+ new = None if on is None else ("on" if on else "off")
188
+ _put(conn, key, new, who)
189
+ from . import identity
190
+ identity.insert(conn, "review_mode_log", {
191
+ "scope": key, "old_mode": old, "new_mode": new, "client": who[:80],
192
+ "caller": who[:80], "user_request": how,
193
+ "changed_at": _ts()}, who)
194
+ conn.commit()
195
+ finally:
196
+ conn.close()
197
+ return {"scope": key, "old": old, "now": "on" if is_on(db_path, project_id) else "off"}
198
+
199
+
200
+ def phone_on(db_path: Path) -> bool:
201
+ conn = _conn(db_path)
202
+ try:
203
+ return not available(conn) or (_get(conn, "phone_approvals") or "on") == "on"
204
+ finally:
205
+ conn.close()
206
+
207
+
208
+ # ---------------------------------------------------------------- chat approvals via the relay
209
+ RELAY_CHAT_KEY = "relay_chat_approvals"
210
+
211
+
212
+ def relay_chat_on(db_path: Optional[Path]) -> bool:
213
+ """May an AI connected through the relay approve or reject review items in the chat
214
+ (manage_review approve / reject, quoting the user in user_request)? Off by default: the
215
+ user decides on their phone (or on this computer). Only the user changes it, on this
216
+ computer: `flowstate relay chat-approvals on|off`; no tool reads or writes this key.
217
+ User-only items (user_only) stay the user's own click or key either way."""
218
+ if db_path is None:
219
+ return False
220
+ try:
221
+ conn = _conn(db_path)
222
+ except sqlite3.Error:
223
+ return False
224
+ try:
225
+ return available(conn) and _get(conn, RELAY_CHAT_KEY) == "on"
226
+ except sqlite3.Error:
227
+ return False
228
+ finally:
229
+ conn.close()
230
+
231
+
232
+ def set_relay_chat(db_path: Path, on: bool, who: str = "command-line",
233
+ how: str = TERMINAL_WORDS) -> dict:
234
+ """The user's terminal (relay/cli.py) or the local review page (review_page.py) only.
235
+ Logged with the review-mode changes."""
236
+ conn = _conn(db_path)
237
+ try:
238
+ if not available(conn):
239
+ raise ApprovalError("approvals_unavailable", "Approvals aren't set up in this database "
240
+ "yet (migration 019); restart FlowState.")
241
+ conn.execute("BEGIN IMMEDIATE")
242
+ old = _get(conn, RELAY_CHAT_KEY)
243
+ new = "on" if on else "off"
244
+ _put(conn, RELAY_CHAT_KEY, new, who)
245
+ from . import identity
246
+ identity.insert(conn, "review_mode_log", {
247
+ "scope": RELAY_CHAT_KEY, "old_mode": old, "new_mode": new, "client": who[:80],
248
+ "caller": who[:80], "user_request": how,
249
+ "changed_at": _ts()}, who)
250
+ conn.commit()
251
+ finally:
252
+ conn.close()
253
+ return {"old": old or "off", "now": "on" if relay_chat_on(db_path) else "off"}
254
+
255
+
256
+ def log_setting(db_path: Optional[Path], scope: str, old: Optional[str], new: Optional[str],
257
+ who: str = "command-line", how: Optional[str] = None) -> None:
258
+ """One review_mode_log row ("Recent changes" on the review page, manage_history) for a
259
+ setting the user changed, the same way from the terminal and the page: `who` changed it,
260
+ `how` (TERMINAL_WORDS by default). Nothing when the database or the table isn't there."""
261
+ if db_path is None or not Path(db_path).is_file():
262
+ return
263
+ import time
264
+ from . import identity, review_mode
265
+ conn = dbconn.connect(str(db_path), timeout=5.0)
266
+ try:
267
+ conn.execute("PRAGMA busy_timeout = 5000")
268
+ if review_mode.available(conn):
269
+ identity.insert(conn, "review_mode_log", {
270
+ "scope": scope, "old_mode": old, "new_mode": new, "client": who[:80],
271
+ "caller": who[:80], "user_request": how or TERMINAL_WORDS,
272
+ "changed_at": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())}, who)
273
+ conn.commit()
274
+ finally:
275
+ conn.close()
276
+
277
+
278
+ def clean_client_name(name) -> str:
279
+ """An app's MCP client name as the popup and card lists keep it: spaces tidied, lower case."""
280
+ return " ".join(str(name or "").split())[:80].lower()
281
+
282
+
283
+ def set_value(db_path: Path, key: str, value: Optional[str], who: str = "command-line") -> None:
284
+ conn = _conn(db_path)
285
+ try:
286
+ _put(conn, key, value, who)
287
+ conn.commit()
288
+ finally:
289
+ conn.close()
290
+
291
+
292
+ def _listed(db_path: Optional[Path], base: tuple, key: str) -> set:
293
+ names = set(base)
294
+ if db_path is None:
295
+ return names
296
+ conn = _conn(db_path)
297
+ try:
298
+ if not available(conn):
299
+ return names
300
+ add = json.loads(_get(conn, f"{key}_add") or "[]")
301
+ drop = json.loads(_get(conn, f"{key}_remove") or "[]")
302
+ except (ValueError, sqlite3.Error):
303
+ return names
304
+ finally:
305
+ conn.close()
306
+ return (names | {str(n).lower() for n in add}) - {str(n).lower() for n in drop}
307
+
308
+
309
+ def _change_list(db_path: Path, base: tuple, key: str, name: str, add: bool, who: str) -> set:
310
+ name = clean_client_name(name)
311
+ if not name:
312
+ raise ApprovalError("name_required", "Give the app's name as it connects (clientInfo name).")
313
+ conn = _conn(db_path)
314
+ try:
315
+ lists = {k: set(json.loads(_get(conn, k) or "[]")) for k in (f"{key}_add", f"{key}_remove")}
316
+ (lists[f"{key}_add"] if add else lists[f"{key}_remove"]).add(name)
317
+ (lists[f"{key}_remove"] if add else lists[f"{key}_add"]).discard(name)
318
+ for k, v in lists.items():
319
+ _put(conn, k, json.dumps(sorted(v)) if v else None, who)
320
+ conn.commit()
321
+ finally:
322
+ conn.close()
323
+ return _listed(db_path, base, key)
324
+
325
+
326
+ def popup_clients(db_path: Optional[Path]) -> set:
327
+ return _listed(db_path, POPUP_CLIENTS, "popup_clients")
328
+
329
+
330
+ def change_popup_clients(db_path: Path, name: str, add: bool, who: str = "command-line") -> set:
331
+ return _change_list(db_path, POPUP_CLIENTS, "popup_clients", name, add, who)
332
+
333
+
334
+ def card_clients(db_path: Optional[Path]) -> set:
335
+ """Apps whose in-chat card taps count as the user's (conflict_cards.client_has_ui)."""
336
+ return _listed(db_path, CARD_CLIENTS, "card_clients")
337
+
338
+
339
+ def change_card_clients(db_path: Path, name: str, add: bool, who: str = "command-line") -> set:
340
+ return _change_list(db_path, CARD_CLIENTS, "card_clients", name, add, who)
341
+
342
+
343
+ def card_allowed(db_path: Optional[Path], client_name: str) -> bool:
344
+ """May this connection get the in-chat cards (and their app-only tap tools)? Only apps on
345
+ the card list: the MCP Apps flag an app sends is its own claim, like a popup's."""
346
+ name = clean_client_name(client_name)
347
+ return bool(name) and name_listed(name, card_clients(db_path))
348
+
349
+
350
+ def name_listed(name: str, listed) -> bool:
351
+ """`name` (cleaned) is on the list: the same name, or an entry ending in "*" (at least
352
+ WILDCARD_MIN characters before it) that the name starts with and goes beyond."""
353
+ for entry in listed:
354
+ if entry == name:
355
+ return True
356
+ if entry.endswith("*") and len(entry) > WILDCARD_MIN:
357
+ stem = entry[:-1]
358
+ if "*" not in stem and len(name) > len(stem) and name.startswith(stem):
359
+ return True
360
+ return False
361
+
362
+
363
+ def signins(db_path: Optional[Path], what: str) -> set:
364
+ """The sign-in names (a key's, token's or OAuth app's own name, lower case) the user put on
365
+ the popup ("popup") or card ("card") list themselves; there is no built-in default."""
366
+ return _listed(db_path, (), f"{what}_signins")
367
+
368
+
369
+ def change_signins(db_path: Path, what: str, name: str, add: bool, who: str = "command-line") -> set:
370
+ """Add or remove a sign-in. A card sign-in taken off the list is also marked as offered
371
+ (card_offer.py), so FlowState doesn't offer the cards to it again by itself."""
372
+ out = _change_list(db_path, (), f"{what}_signins", name, add, who)
373
+ if what == "card" and not add:
374
+ _change_list(db_path, (), "card_offered", name, True, who)
375
+ return out
376
+
377
+
378
+ def _signin_listed(db_path: Optional[Path], what: str, credential: Optional[str],
379
+ source: Optional[str]) -> bool:
380
+ name = clean_client_name(credential)
381
+ if not name or db_path is None:
382
+ return False
383
+ listed = signins(db_path, what)
384
+ if name in listed:
385
+ return True
386
+ # an OAuth app (approved with the owner passphrase) that registered again is given a
387
+ # number, "Claude (2)": it keeps the place its first registration had on the list
388
+ m = re.fullmatch(r"(.+) \((\d{1,3})\)", name)
389
+ return source == "oauth_client" and m is not None and m.group(1) in listed
390
+
391
+
392
+ def card_allowed_credential(db_path: Optional[Path], credential: Optional[str],
393
+ source: Optional[str] = None) -> bool:
394
+ """The same for an app that signed in over the network (card_trust.py): its key's,
395
+ token's or OAuth app's own name, and only a name the user added to the card sign-in list
396
+ themselves (signins). The built-in default (claude-ai) is a stdio client name, and an
397
+ OAuth app picks its own name when it registers."""
398
+ return _signin_listed(db_path, "card", credential, source)
399
+
400
+
401
+ def popup_allowed(db_path: Optional[Path], client_name: str, declared: bool) -> bool:
402
+ """May FlowState show this connection a popup? Declared the capability, on the allowlist,
403
+ and not claude.ai / the Anthropic connector. stdio only: the name is the app's own say-so
404
+ (popup.no_box checks a LAN app's sign-in with popup_allowed_credential)."""
405
+ name = (client_name or "").strip().lower()
406
+ if not declared or not name or any(m in name for m in CONNECTOR_MARKS):
407
+ return False
408
+ return name in popup_clients(db_path)
409
+
410
+
411
+ def popup_allowed_credential(db_path: Optional[Path], credential: Optional[str],
412
+ declared: bool, source: Optional[str] = None) -> bool:
413
+ """A LAN app on this computer: its key's or token's own name on the popup sign-in list the
414
+ user made (`approvals popup add --sign-in NAME`), never the name it sends (task 57, H-1)."""
415
+ return declared and _signin_listed(db_path, "popup", credential, source)
416
+
417
+
418
+ # ---------------------------------------------------------------- user-only items
419
+ def user_only(item: Optional[dict]) -> bool:
420
+ """Only a human click or keypress decides this item: a delete or setting queued while
421
+ approvals were on, and every "did the user say this?" item (provenance.py)."""
422
+ if not item or item.get("kind") not in USER_ONLY_KINDS:
423
+ return False
424
+ if item["kind"] in ("setting", "source", "rule", "card_offer", "reopen_conflict"): # always
425
+ return True
426
+ try:
427
+ return bool(json.loads(item.get("payload") or "{}").get("user_only"))
428
+ except ValueError:
429
+ return False
430
+
431
+
432
+ def nonce_for(db_path: Path, item_id: int) -> Optional[str]:
433
+ """The item's open one-use code; a fresh random one if it has none or it expired."""
434
+ conn = _conn(db_path)
435
+ try:
436
+ if not available(conn):
437
+ return None
438
+ conn.execute("BEGIN IMMEDIATE")
439
+ row = conn.execute("SELECT nonce, expires_at, used_at FROM approval_requests "
440
+ "WHERE item_id = ?", (item_id,)).fetchone()
441
+ if row and row["used_at"] is None and row["expires_at"] > _ts():
442
+ conn.commit()
443
+ return row["nonce"]
444
+ nonce = secrets.token_urlsafe(32) # 43 url-safe characters, never reused
445
+ conn.execute("""INSERT INTO approval_requests (item_id, nonce, action, created_at,
446
+ expires_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(item_id) DO UPDATE SET
447
+ nonce = excluded.nonce, created_at = excluded.created_at,
448
+ expires_at = excluded.expires_at, used_at = NULL""",
449
+ (item_id, nonce, ACTION, _ts(), _ts(_now() + timedelta(days=NONCE_DAYS))))
450
+ conn.commit()
451
+ return nonce
452
+ finally:
453
+ conn.close()
454
+
455
+
456
+ def consume_nonce(conn, item_id: int) -> None:
457
+ conn.execute("UPDATE approval_requests SET used_at = ? WHERE item_id = ? AND used_at IS NULL",
458
+ (_ts(), item_id))
459
+
460
+
461
+ def relay_handle() -> Optional[str]:
462
+ """This device's relay handle, if the relay agent is enrolled and on. Without relay state
463
+ on disk this is one file check: no relay module is imported."""
464
+ try:
465
+ import os
466
+ from . import paths
467
+ env = os.environ.get("FLOWSTATE_RELAY_DIR")
468
+ root = Path(env).expanduser() if env else paths.data_root() / "relay"
469
+ if not (root / "relay.json").exists():
470
+ return None
471
+ from .relay.store import RelayStore
472
+ store = RelayStore()
473
+ cfg = store.config()
474
+ return cfg.get("handle") if cfg.get("enabled") and store.enrolled() else None
475
+ except Exception: # noqa: BLE001 - no relay means no phone link, nothing more
476
+ return None
477
+
478
+
479
+ def relay_set_up() -> bool:
480
+ """Is the relay agent enrolled on this computer (switched on or not)? Without relay state
481
+ on disk this is one file check. The review page's relay settings are greyed out if not."""
482
+ try:
483
+ import os
484
+ from . import paths
485
+ env = os.environ.get("FLOWSTATE_RELAY_DIR")
486
+ root = Path(env).expanduser() if env else paths.data_root() / "relay"
487
+ if not (root / "relay.json").exists():
488
+ return False
489
+ from .relay.store import RelayStore
490
+ return bool(RelayStore().enrolled())
491
+ except Exception: # noqa: BLE001 - unreadable relay state: treated as not set up
492
+ return False
493
+
494
+
495
+ # For callers elsewhere (relay, tunnel, LAN from another computer), instead of a phone link:
496
+ # the links carry the relay handle, which then lands in cloud chat logs (audit D19, task 54).
497
+ PHONE_ELSEWHERE = ("Open the review list on your phone from the FlowState link you saved, or run "
498
+ "`flowstate review-page` on your computer.")
499
+
500
+
501
+ def _elsewhere() -> bool:
502
+ """Is this request from an app that isn't on this computer? (popup.on_this_computer: no
503
+ request at all, stdio, or the LAN listener with a loopback peer count as here.)"""
504
+ from .popup import on_this_computer
505
+ return not on_this_computer()
506
+
507
+
508
+ def phone_hidden(db_path: Path) -> bool:
509
+ """The phone links exist here but this caller is elsewhere, so it gets PHONE_ELSEWHERE."""
510
+ return _elsewhere() and review_handle(db_path) is not None
511
+
512
+
513
+ def _phone_labels(db_path: Optional[Path]) -> set:
514
+ """Every "phone (<handle>)" label FlowState itself saved here: this device's handle now,
515
+ and any earlier one (the relay turned off, or enrolled again under a new handle: task 56,
516
+ B), read from where 2.4.3 wrote them (decided_by, approval_log, history clients)."""
517
+ out = set()
518
+ handle = relay_handle()
519
+ if handle:
520
+ out.add(f"phone ({handle})")
521
+ if db_path is None:
522
+ return out
523
+ try:
524
+ conn = _conn(db_path)
525
+ except sqlite3.Error:
526
+ return out
527
+ try:
528
+ for table, col in (("review_items", "decided_by"), ("approval_log", "client"),
529
+ ("record_history", "client"), ("review_mode_log", "client")):
530
+ try:
531
+ out |= {r[0] for r in conn.execute(
532
+ f'SELECT DISTINCT "{col}" FROM "{table}" WHERE "{col}" LIKE \'phone (%)\'')
533
+ if _PHONE_LABEL.fullmatch(r[0] or "")}
534
+ except sqlite3.Error:
535
+ continue
536
+ finally:
537
+ conn.close()
538
+ return out
539
+
540
+
541
+ _PHONE_LABEL = re.compile(r"phone \([A-Za-z0-9][A-Za-z0-9._-]{1,62}\)")
542
+
543
+
544
+ def scrub_handle(result, db_path: Optional[Path] = None):
545
+ """For an app elsewhere: "phone (<handle>)", the label 2.4.3 saved with phone decisions
546
+ (history, who did what, the brief), becomes "phone" in its answer, with this device's
547
+ handle and any earlier one. Only those exact labels: record text is left alone."""
548
+ if not _elsewhere():
549
+ return result
550
+ if db_path is None:
551
+ try:
552
+ from . import server as S
553
+ db_path = S.backend.db_path
554
+ except Exception: # noqa: BLE001
555
+ db_path = None
556
+ labels = _phone_labels(db_path)
557
+ if not labels:
558
+ return result
559
+
560
+ def walk(v):
561
+ if isinstance(v, str):
562
+ for old in labels:
563
+ if old in v:
564
+ v = v.replace(old, "phone")
565
+ return v
566
+ if isinstance(v, dict):
567
+ return {k: walk(x) for k, x in v.items()}
568
+ if isinstance(v, list):
569
+ return [walk(x) for x in v]
570
+ return v
571
+ return walk(result)
572
+
573
+
574
+ def phone_link(db_path: Path, item_id: int) -> Optional[str]:
575
+ """The item's phone link, only for apps on this computer (never with the handle to an app
576
+ elsewhere: phone_hidden)."""
577
+ handle = relay_handle()
578
+ if not handle or not phone_on(db_path) or not keys_ready() or _elsewhere():
579
+ return None
580
+ nonce = nonce_for(db_path, item_id)
581
+ return f"{SITE}/approve/{handle}/{int(item_id)}?n={nonce}" if nonce else None
582
+
583
+
584
+ def review_handle(db_path: Path) -> Optional[str]:
585
+ """This device's handle when the phone review list is available here: relay enrolled,
586
+ keys pinned, phone links not off. Else None."""
587
+ handle = relay_handle()
588
+ if not handle or not phone_on(db_path) or not keys_ready():
589
+ return None
590
+ return handle
591
+
592
+
593
+ def review_link(db_path: Path) -> Optional[str]:
594
+ """The bookmarkable phone link to the whole waiting list (no code in it, so nothing secret
595
+ lands in chat history): https://flowstatemcp.com/review/<handle>. The site signs the user
596
+ in and sends the browser to this device's /review/start, which makes a fresh code. Only for
597
+ apps on this computer, like phone_link."""
598
+ handle = None if _elsewhere() else review_handle(db_path)
599
+ return f"{SITE}/review/{handle}" if handle else None
600
+
601
+
602
+ def _start_ready(conn) -> bool:
603
+ """review_open_nonces exists with the ticket columns (021 and 022)."""
604
+ cols = {r[1] for r in conn.execute("PRAGMA table_info(review_open_nonces)")}
605
+ return {"nonce", "sub", "ticket"} <= cols
606
+
607
+
608
+ def ticket_key(ticket_nonce: str) -> str:
609
+ """What is stored for a start ticket: a hash of its nonce, never the ticket."""
610
+ return hashlib.sha256(("start_review:" + ticket_nonce).encode()).hexdigest()
611
+
612
+
613
+ def verify_start_ticket(db_path: Path, ticket: str, handle: Optional[str] = None,
614
+ now: Optional[datetime] = None, keys: Optional[dict] = None) -> dict:
615
+ """Check the site's ticket for /review/start: the same ES256 grant checks as every phone
616
+ grant (pinned key, signature, times with the 5-minute lifetime and clock skew, issuer,
617
+ audience and handle from this device's relay.json, action start_review, item 0), plus a
618
+ 22-43 character nonce not used here before. Returns its claims; raises ApprovalError.
619
+ Nothing is stored: start_nonce remembers the nonce when it makes the code."""
620
+ if not isinstance(ticket, str) or not TICKET_RE.match(ticket):
621
+ raise ApprovalError("bad_grant", "The link is malformed.")
622
+ claims = _claims(ticket, handle, now, keys, START_ACTION, 0)
623
+ if not _TICKET_NONCE_RE.match(claims["nonce"]):
624
+ raise ApprovalError("bad_grant", "The approval grant's code is malformed.")
625
+ conn = _conn(db_path)
626
+ try:
627
+ if _start_ready(conn) and conn.execute(
628
+ "SELECT 1 FROM review_open_nonces WHERE ticket = ?",
629
+ (ticket_key(claims["nonce"]),)).fetchone():
630
+ raise ApprovalError("ticket_used", "This link was already used.")
631
+ finally:
632
+ conn.close()
633
+ return claims
634
+
635
+
636
+ def start_nonce(db_path: Path, sub: str, ticket_nonce: Optional[str] = None) -> Optional[str]:
637
+ """A fresh one-use code for the review list, made by /review/start (review_phone.py) for
638
+ the site user `sub` of a verified start ticket: 43 url-safe characters, valid
639
+ START_MINUTES. At most START_OPEN open at once (the oldest are expired), START_PER_HOUR
640
+ made per hour per user and START_PER_HOUR_ALL for everyone (else ApprovalError
641
+ rate_limited). `ticket_nonce`: the ticket's nonce, remembered (hashed) with the code, so
642
+ each ticket makes at most one code (a replay raises ticket_used and counts nothing).
643
+ Codes made here are told apart from the 7-day codes older releases put in chat links by
644
+ their lifetime (under a day). None if the database has no review_open_nonces table with
645
+ the 022 columns."""
646
+ if not isinstance(sub, str) or not sub:
647
+ raise ApprovalError("bad_grant", "The link doesn't say who signed in.")
648
+ conn = _conn(db_path)
649
+ try:
650
+ if not _start_ready(conn):
651
+ return None
652
+ now = _ts()
653
+ hour_ago = _ts(_now() - timedelta(hours=1))
654
+ short = "julianday(expires_at) - julianday(created_at) < 1"
655
+ key = ticket_key(ticket_nonce) if ticket_nonce else None
656
+ conn.execute("BEGIN IMMEDIATE")
657
+ if key and conn.execute("SELECT 1 FROM review_open_nonces WHERE ticket = ?",
658
+ (key,)).fetchone():
659
+ conn.rollback()
660
+ raise ApprovalError("ticket_used", "This link was already used.")
661
+ mine = conn.execute(f"SELECT COUNT(*) FROM review_open_nonces WHERE {short} AND sub = ? "
662
+ "AND created_at > ?", (sub, hour_ago)).fetchone()[0]
663
+ everyone = conn.execute(f"SELECT COUNT(*) FROM review_open_nonces WHERE {short} AND "
664
+ "created_at > ?", (hour_ago,)).fetchone()[0]
665
+ if mine >= START_PER_HOUR or everyone >= START_PER_HOUR_ALL:
666
+ conn.rollback()
667
+ raise ApprovalError("rate_limited", "Too many review links were opened in the last "
668
+ "hour. Try again later.")
669
+ open_ = conn.execute(f"SELECT nonce FROM review_open_nonces WHERE {short} AND used_at "
670
+ "IS NULL AND expires_at > ? ORDER BY created_at, rowid", (now,)).fetchall()
671
+ for row in open_[:max(0, len(open_) - START_OPEN + 1)]: # make room: oldest expire now
672
+ conn.execute("UPDATE review_open_nonces SET expires_at = ? WHERE nonce = ?",
673
+ (now, row["nonce"]))
674
+ nonce = secrets.token_urlsafe(32) # 43 url-safe characters, never reused
675
+ conn.execute("INSERT INTO review_open_nonces (nonce, action, created_at, expires_at, sub, "
676
+ "ticket) VALUES (?, ?, ?, ?, ?, ?)",
677
+ (nonce, REVIEW_ACTION, now, _ts(_now() + timedelta(minutes=START_MINUTES)),
678
+ sub[:200], key))
679
+ conn.commit()
680
+ return nonce
681
+ finally:
682
+ conn.close()
683
+
684
+
685
+ def use_review_nonce(db_path: Path, nonce: str) -> bool:
686
+ """Use up the review list's code. False if it was used, expired or never made here."""
687
+ conn = _conn(db_path)
688
+ try:
689
+ cur = conn.execute("UPDATE review_open_nonces SET used_at = ? WHERE nonce = ? AND "
690
+ "used_at IS NULL AND expires_at > ? AND action = ?",
691
+ (_ts(), nonce, _ts(), REVIEW_ACTION))
692
+ conn.commit()
693
+ return cur.rowcount == 1
694
+ except sqlite3.OperationalError: # no table (database older than 021)
695
+ return False
696
+ finally:
697
+ conn.close()
698
+
699
+
700
+ def ask_line(db_path: Path, item_id: int, what: str = "approve it") -> str:
701
+ """The one line an agent passes on when the user must decide (no popup)."""
702
+ link = phone_link(db_path, item_id)
703
+ if link:
704
+ return f"Waiting for the user: ask them to {what} on their phone: {link}"
705
+ from .review_tunnel import page_url
706
+ tunnel = page_url()
707
+ if tunnel: # this app came in through the user's own tunnel: its review page exists
708
+ return (f"Waiting for the user: ask them to {what} on their tunnel review page ({tunnel}) "
709
+ f"or with the FlowState review shortcut on their desktop (or run "
710
+ f"`flowstate approve {item_id}` in a terminal).")
711
+ if phone_hidden(db_path): # an app elsewhere: no link with the handle in it
712
+ return (f"Waiting for the user: ask them to {what} themselves. Tell them: "
713
+ f"\"{PHONE_ELSEWHERE}\" (or `flowstate approve {item_id}` in a terminal).")
714
+ return (f"Waiting for the user: ask them to open the FlowState review shortcut on their "
715
+ f"desktop and {what} there (or run `flowstate approve {item_id}` in a terminal).")
716
+
717
+
718
+ def describe(item: dict, project: str, db_path: Optional[Path] = None) -> str:
719
+ """What the user is asked, in plain words (without the app's name)."""
720
+ try:
721
+ p = json.loads(item.get("payload") or "{}")
722
+ except ValueError:
723
+ p = {}
724
+ if item["kind"] == "cleanup":
725
+ from . import cleanup
726
+ return cleanup.ask(p, project, db_path)
727
+ if item["kind"] == "card_offer":
728
+ from . import card_offer
729
+ return card_offer.describe(item)
730
+ if item["kind"] == "reopen_conflict":
731
+ from . import conflict_reopen
732
+ return conflict_reopen.describe(item)
733
+ if item["kind"] == "setting":
734
+ where = "the whole install" if p.get("scope") == "install" else project
735
+ return f"turn review off for {where}"
736
+ if item["kind"] == "source":
737
+ title = " ".join(str(p.get("title") or "").split())
738
+ title = title if len(title) <= 60 else title[:57] + "..."
739
+ return (f'mark the {p.get("record_type") or "record"} "{title}" in {project} as '
740
+ f"something you said yourself (user_stated)")
741
+ if item["kind"] == "rule":
742
+ rule = " ".join(str(p.get("rule") or "").split())
743
+ rule = rule if len(rule) <= 120 else rule[:117] + "..."
744
+ if p.get("action") == "remove":
745
+ return f'remove the hard rule R{p.get("rule_id")} "{rule}" from {project}'
746
+ return f'add the hard rule "{rule}" to {project} (every AI there is told to never break it)'
747
+ if item["kind"] == "delete":
748
+ if db_path is not None:
749
+ try:
750
+ from . import deletes
751
+ live = deletes.question(db_path, item, project)
752
+ if live:
753
+ return live
754
+ except Exception: # noqa: BLE001 - the stored counts still describe it
755
+ pass
756
+ rtype, title = p.get("record_type") or "record", " ".join(str(p.get("title") or "").split())
757
+ title = title if len(title) <= 60 else title[:57] + "..."
758
+ if rtype == "project":
759
+ what = f'delete the whole project "{project}"'
760
+ else:
761
+ what = f'delete the {rtype} "{title}"' if title else f"delete {rtype} #{p.get('record_id')}"
762
+ extra = ", ".join(f"{n} {k}" for k, n in (p.get("counts") or {}).items())
763
+ return what + (f" (with {extra})" if extra else "") + \
764
+ ("" if rtype == "project" else f" from {project}")
765
+ return f"decide review item #{item['id']} in {project}"
766
+
767
+
768
+ def ask_payload(db_path: Path, item_id: int, app: str) -> dict:
769
+ """What the popup shows and the one-line fallback, for a queued user-only item."""
770
+ conn = _conn(db_path)
771
+ try:
772
+ item = dict(conn.execute("SELECT * FROM review_items WHERE id = ?", (item_id,)).fetchone())
773
+ row = conn.execute("SELECT name FROM projects WHERE id = ?", (item["project_id"],)).fetchone()
774
+ finally:
775
+ conn.close()
776
+ project = row[0] if row else f"project #{item['project_id']}"
777
+ try:
778
+ p = json.loads(item.get("payload") or "{}")
779
+ except ValueError:
780
+ p = {}
781
+ out = {"item_id": item_id, "message": f"{app} wants to {describe(item, project, db_path)}.",
782
+ "line": ask_line(db_path, item_id)}
783
+ if item["kind"] == "delete" and p.get("record_type") == "project":
784
+ out["project_name"] = p.get("project_name") or project
785
+ return out
786
+
787
+
788
+ def log_decision(db_path: Path, item: dict, path: str, client: str, decision: str,
789
+ conn=None) -> None:
790
+ own = conn is None
791
+ conn = conn or _conn(db_path)
792
+ try:
793
+ if not available(conn):
794
+ return
795
+ conn.execute("""INSERT INTO approval_log (item_id, project_id, kind, path, client, decision,
796
+ decided_at) VALUES (?, ?, ?, ?, ?, ?, ?)""",
797
+ (item["id"], item.get("project_id"), item.get("kind"), path,
798
+ (client or "")[:80], decision, _ts()))
799
+ if decision in ("approved", "rejected"):
800
+ consume_nonce(conn, item["id"])
801
+ if own:
802
+ conn.commit()
803
+ finally:
804
+ if own:
805
+ conn.close()
806
+
807
+
808
+ def path_of(client: str) -> str:
809
+ """How a user-only decision was made, from the decider's label."""
810
+ c = (client or "").lower()
811
+ for p in ("popup", "phone", "review-page", "tunnel page", "chat"):
812
+ if c.startswith(p):
813
+ return p.replace("-", " ")
814
+ return "terminal"
815
+
816
+
817
+ # Paths where every decision is logged, not only user-only items': the user went through the
818
+ # queue themselves (waiting.py's chat walk, review_phone.py's phone list, review_tunnel.py).
819
+ LOG_EVERY_DECISION = ("chat", "phone", "tunnel page")
820
+
821
+
822
+ def logs_every_decision(client: str, agent: bool) -> bool:
823
+ return not agent and path_of(client) in LOG_EVERY_DECISION
824
+
825
+
826
+ def log_rows(db_path: Path, project_id: int, limit: int = 20) -> list:
827
+ conn = _conn(db_path)
828
+ try:
829
+ if not available(conn):
830
+ return []
831
+ return [dict(r) for r in conn.execute(
832
+ """SELECT item_id, kind, path, client, decision, decided_at FROM approval_log
833
+ WHERE project_id = ? ORDER BY id DESC LIMIT ?""", (project_id, limit))]
834
+ finally:
835
+ conn.close()
836
+
837
+
838
+ # ---------------------------------------------------------------- phone grants (ES256 JWS)
839
+ def keys_ready(keys: Optional[dict] = None) -> bool:
840
+ keys = FSA_PUBLIC_KEYS if keys is None else keys
841
+ return any(isinstance(v, dict) for v in keys.values())
842
+
843
+
844
+ def _b64d(s: str) -> bytes:
845
+ if not isinstance(s, str) or not re.fullmatch(r"[A-Za-z0-9_-]*", s):
846
+ raise ApprovalError("bad_grant", "The approval grant is malformed.")
847
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
848
+
849
+
850
+ def _verify_es256(jwk: dict, signing_input: bytes, sig: bytes) -> bool:
851
+ try:
852
+ from cryptography.exceptions import InvalidSignature
853
+ from cryptography.hazmat.primitives import hashes
854
+ from cryptography.hazmat.primitives.asymmetric import ec
855
+ from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
856
+ except ImportError as e: # pragma: no cover - the relay needs it anyway
857
+ raise ApprovalError("crypto_missing", "Phone approvals need the Python package "
858
+ "'cryptography' (the relay uses it too).") from e
859
+ if len(sig) != 64 or jwk.get("kty") != "EC" or jwk.get("crv") != "P-256":
860
+ return False
861
+ try:
862
+ x = int.from_bytes(_b64d(jwk["x"]), "big")
863
+ y = int.from_bytes(_b64d(jwk["y"]), "big")
864
+ key = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key()
865
+ der = encode_dss_signature(int.from_bytes(sig[:32], "big"), int.from_bytes(sig[32:], "big"))
866
+ key.verify(der, signing_input, ec.ECDSA(hashes.SHA256()))
867
+ return True
868
+ except (InvalidSignature, ValueError, KeyError, ApprovalError):
869
+ return False
870
+
871
+
872
+ def grant_hash(grant: str) -> str:
873
+ return hashlib.sha256((grant or "").encode()).hexdigest()
874
+
875
+
876
+ def _claims(grant: str, handle: Optional[str], now: Optional[datetime], keys: Optional[dict],
877
+ action: str, item_id: int) -> dict:
878
+ """The checks every phone grant gets: pinned key and signature, times, issuer, device,
879
+ action, item and the code's format. Returns its claims; raises ApprovalError."""
880
+ from .relay import RELAY_DOMAIN
881
+ keys = FSA_PUBLIC_KEYS if keys is None else keys
882
+ if not keys_ready(keys):
883
+ raise ApprovalError("phone_approvals_unavailable",
884
+ "Phone approvals aren't available in this FlowState release yet. "
885
+ "Approve in your terminal: flowstate approve <id>.")
886
+ handle = handle or relay_handle()
887
+ if not handle:
888
+ raise ApprovalError("relay_not_enrolled", "This FlowState has no relay set up.")
889
+ parts = (grant or "").split(".")
890
+ if len(parts) != 3 or len(grant) > 4096:
891
+ raise ApprovalError("bad_grant", "The approval grant is malformed.")
892
+ try:
893
+ header = json.loads(_b64d(parts[0]))
894
+ claims = json.loads(_b64d(parts[1]))
895
+ sig = _b64d(parts[2])
896
+ except (ValueError, UnicodeDecodeError) as e:
897
+ raise ApprovalError("bad_grant", "The approval grant is malformed.") from e
898
+ if not isinstance(header, dict) or not isinstance(claims, dict):
899
+ raise ApprovalError("bad_grant", "The approval grant is malformed.")
900
+ if header.get("alg") != "ES256" or header.get("typ") != "fsa+jwt":
901
+ raise ApprovalError("bad_grant", "The approval grant uses an unexpected format.")
902
+ jwk = keys.get(header.get("kid"))
903
+ if not isinstance(jwk, dict):
904
+ raise ApprovalError("unknown_key", "The approval grant was signed with a key this "
905
+ "FlowState doesn't trust.")
906
+ if not _verify_es256(jwk, f"{parts[0]}.{parts[1]}".encode(), sig):
907
+ raise ApprovalError("bad_signature", "The approval grant's signature is not valid.")
908
+ t = int((now or _now()).timestamp())
909
+ iat, exp = claims.get("iat"), claims.get("exp")
910
+ if not all(isinstance(v, int) and not isinstance(v, bool) for v in (iat, exp)) \
911
+ or exp <= iat or exp - iat > GRANT_SECONDS or iat > t + CLOCK_SKEW:
912
+ raise ApprovalError("bad_grant", "The approval grant's times are not valid.")
913
+ if exp <= t:
914
+ raise ApprovalError("grant_expired", "This approval link has expired (5 minutes). "
915
+ "Open the link from the chat again.")
916
+ checks = (claims.get("iss") == SITE,
917
+ claims.get("aud") == f"{handle}.{RELAY_DOMAIN}",
918
+ claims.get("handle") == handle,
919
+ isinstance(claims.get("sub"), str) and bool(claims["sub"]),
920
+ claims.get("action") == action)
921
+ if not all(checks):
922
+ raise ApprovalError("wrong_device", "This approval grant is not for this FlowState.")
923
+ item = claims.get("item")
924
+ if not isinstance(item, int) or isinstance(item, bool) or item != int(item_id):
925
+ raise ApprovalError("wrong_item", "This approval grant is for another item.")
926
+ nonce = claims.get("nonce")
927
+ if not isinstance(nonce, str) or not _NONCE_RE.match(nonce):
928
+ raise ApprovalError("bad_grant", "The approval grant's code is malformed.")
929
+ return claims
930
+
931
+
932
+ def require_phone_on(db_path: Path) -> None:
933
+ """Every phone decision re-checks the setting (audit D13): `approvals phone off` also
934
+ closes phone pages that are already open."""
935
+ if not phone_on(db_path):
936
+ raise ApprovalError("phone_off", "Phone review is turned off on this FlowState, so "
937
+ "nothing was decided. Decide on the computer, or turn "
938
+ "it back on there: flowstate approvals phone on.")
939
+
940
+
941
+ def verify_grant(db_path: Path, grant: str, item_id: int, handle: Optional[str] = None,
942
+ now: Optional[datetime] = None, keys: Optional[dict] = None) -> dict:
943
+ """Check a phone grant for `item_id`. Returns its claims; raises ApprovalError."""
944
+ require_phone_on(db_path)
945
+ claims = _claims(grant, handle, now, keys, ACTION, item_id)
946
+ item, nonce = claims["item"], claims["nonce"]
947
+ conn = _conn(db_path)
948
+ try:
949
+ row = conn.execute("""SELECT q.nonce, q.expires_at, q.used_at, q.action, r.status, r.kind,
950
+ r.payload FROM approval_requests q JOIN review_items r
951
+ ON r.id = q.item_id WHERE q.item_id = ?""", (item,)).fetchone()
952
+ finally:
953
+ conn.close()
954
+ if row is None or row["used_at"] is not None or row["status"] != "pending" \
955
+ or not hmac.compare_digest(row["nonce"], nonce) or row["action"] != ACTION:
956
+ raise ApprovalError("not_open", "This request was already decided, or the link is "
957
+ "out of date.")
958
+ if row["expires_at"] <= _ts(now):
959
+ raise ApprovalError("grant_expired", "This approval link is out of date; ask for a new one.")
960
+ if not user_only({"kind": row["kind"], "payload": row["payload"]}):
961
+ raise ApprovalError("not_open", "This item isn't decided by phone.")
962
+ return claims
963
+
964
+
965
+ def verify_review_grant(db_path: Path, grant: str, handle: Optional[str] = None,
966
+ now: Optional[datetime] = None, keys: Optional[dict] = None) -> dict:
967
+ """Check a phone grant that opens the waiting list (action open_review, item 0) and use up
968
+ its one-use code. Returns its claims; raises ApprovalError."""
969
+ require_phone_on(db_path)
970
+ claims = _claims(grant, handle, now, keys, REVIEW_ACTION, 0)
971
+ if not use_review_nonce(db_path, claims["nonce"]):
972
+ raise ApprovalError("grant_used", "This link was already used or is out of date. "
973
+ "Open a new one from the chat.")
974
+ return claims