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.
- flowstate/__init__.py +34 -0
- flowstate/__main__.py +7 -0
- flowstate/activity.py +224 -0
- flowstate/agent_guide.py +251 -0
- flowstate/app_keys.py +141 -0
- flowstate/approvals.py +974 -0
- flowstate/approve_page.py +565 -0
- flowstate/ask.py +107 -0
- flowstate/autosession.py +480 -0
- flowstate/backup.py +201 -0
- flowstate/brief.py +420 -0
- flowstate/card_offer.py +208 -0
- flowstate/card_taps.py +165 -0
- flowstate/card_trust.py +127 -0
- flowstate/claims.py +461 -0
- flowstate/cleanup.py +619 -0
- flowstate/cli.py +728 -0
- flowstate/cli_data.py +434 -0
- flowstate/cli_more.py +302 -0
- flowstate/cli_review.py +459 -0
- flowstate/cloudflared_check.py +49 -0
- flowstate/conflict_card_ui.py +286 -0
- flowstate/conflict_cards.py +1193 -0
- flowstate/conflict_reopen.py +320 -0
- flowstate/conflicts.py +420 -0
- flowstate/connections.py +33 -0
- flowstate/database.py +2191 -0
- flowstate/dbconn.py +308 -0
- flowstate/deletes.py +860 -0
- flowstate/embeddings.py +354 -0
- flowstate/erase.py +1047 -0
- flowstate/extras.py +71 -0
- flowstate/git_capture.py +157 -0
- flowstate/guard.py +224 -0
- flowstate/handoffs.py +168 -0
- flowstate/history.py +755 -0
- flowstate/history_page.py +276 -0
- flowstate/http_app.py +204 -0
- flowstate/identity.py +387 -0
- flowstate/install_name.py +140 -0
- flowstate/ledger.py +272 -0
- flowstate/legacy_args.py +65 -0
- flowstate/links.py +609 -0
- flowstate/markdown_io.py +242 -0
- flowstate/meaning.py +398 -0
- flowstate/memory_import.py +92 -0
- flowstate/migrations/001_auto_sessions.sql +16 -0
- flowstate/migrations/002_review_queue.sql +33 -0
- flowstate/migrations/003_links.sql +47 -0
- flowstate/migrations/004_review_mode.sql +24 -0
- flowstate/migrations/005_history.sql +21 -0
- flowstate/migrations/006_activity.sql +26 -0
- flowstate/migrations/007_meaning_index.sql +16 -0
- flowstate/migrations/008_git_capture.sql +12 -0
- flowstate/migrations/009_hard_rules.sql +13 -0
- flowstate/migrations/010_handoffs.sql +14 -0
- flowstate/migrations/011_record_meta.sql +13 -0
- flowstate/migrations/012_accountability.sql +71 -0
- flowstate/migrations/013_todo_claims.sql +36 -0
- flowstate/migrations/014_decision_conflicts.sql +41 -0
- flowstate/migrations/015_session_runs.sql +17 -0
- flowstate/migrations/016_write_receipts.sql +35 -0
- flowstate/migrations/017_run_views.sql +26 -0
- flowstate/migrations/018_run_views_upto.sql +4 -0
- flowstate/migrations/019_approvals.sql +47 -0
- flowstate/migrations/020_source_stamps.sql +10 -0
- flowstate/migrations/021_review_links.sql +11 -0
- flowstate/migrations/022_review_start_tickets.sql +10 -0
- flowstate/migrations/023_conflict_cards.sql +68 -0
- flowstate/migrations/024_rule_confirmations.sql +7 -0
- flowstate/migrations/025_confirmed_hash.sql +7 -0
- flowstate/migrations/__init__.py +149 -0
- flowstate/models.py +307 -0
- flowstate/names.py +157 -0
- flowstate/offsite.py +386 -0
- flowstate/offsite_crypto.py +272 -0
- flowstate/paths.py +265 -0
- flowstate/popup.py +238 -0
- flowstate/priority.py +66 -0
- flowstate/privatefs.py +476 -0
- flowstate/project_history.py +710 -0
- flowstate/provenance.py +696 -0
- flowstate/receipts.py +377 -0
- flowstate/relay/__init__.py +72 -0
- flowstate/relay/acme.py +271 -0
- flowstate/relay/api.py +164 -0
- flowstate/relay/cli.py +344 -0
- flowstate/relay/crypto.py +137 -0
- flowstate/relay/ct.py +118 -0
- flowstate/relay/runner.py +177 -0
- flowstate/relay/store.py +191 -0
- flowstate/relay/tunnel.py +413 -0
- flowstate/release_signers +14 -0
- flowstate/remote_auth.py +1219 -0
- flowstate/review.py +320 -0
- flowstate/review_apply.py +140 -0
- flowstate/review_card_ui.py +241 -0
- flowstate/review_cards.py +564 -0
- flowstate/review_mode.py +274 -0
- flowstate/review_page.py +1361 -0
- flowstate/review_phone.py +465 -0
- flowstate/review_tunnel.py +338 -0
- flowstate/routing.py +392 -0
- flowstate/rules.py +418 -0
- flowstate/safety.py +98 -0
- flowstate/saves.py +83 -0
- flowstate/scheduler.py +160 -0
- flowstate/schema.sql +713 -0
- flowstate/search.py +355 -0
- flowstate/secret_store.py +271 -0
- flowstate/secrets_scan.py +135 -0
- flowstate/selfupdate.py +1020 -0
- flowstate/server.py +1299 -0
- flowstate/session_contents.py +382 -0
- flowstate/session_records.py +629 -0
- flowstate/site_style.py +46 -0
- flowstate/stored.py +158 -0
- flowstate/story.py +442 -0
- flowstate/surface.py +173 -0
- flowstate/tasks.py +231 -0
- flowstate/term.py +40 -0
- flowstate/tool_labels.py +104 -0
- flowstate/tooldefs.py +383 -0
- flowstate/tools/__init__.py +118 -0
- flowstate/tools/core.py +545 -0
- flowstate/tools/files.py +416 -0
- flowstate/tools/git_ops.py +1081 -0
- flowstate/tools/intelligence.py +376 -0
- flowstate/tools/learning.py +282 -0
- flowstate/tools/problems.py +159 -0
- flowstate/tools/sessions.py +266 -0
- flowstate/tools/story.py +48 -0
- flowstate/tools/todos.py +79 -0
- flowstate/tools/utils.py +71 -0
- flowstate/tools/variables.py +345 -0
- flowstate/updates.py +482 -0
- flowstate/urls.py +7 -0
- flowstate/waiting.py +828 -0
- flowstate/who.py +302 -0
- flowstatemcp-2.5.0.dist-info/METADATA +88 -0
- flowstatemcp-2.5.0.dist-info/RECORD +143 -0
- flowstatemcp-2.5.0.dist-info/WHEEL +4 -0
- 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
|