privacyfence 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. privacyfence/__init__.py +17 -0
  2. privacyfence/app_credentials.py +30 -0
  3. privacyfence/approval_icons.py +58 -0
  4. privacyfence/approval_list_html.py +285 -0
  5. privacyfence/approval_ui.py +167 -0
  6. privacyfence/approval_window_html.py +919 -0
  7. privacyfence/approvals.py +649 -0
  8. privacyfence/apps_script_client.py +337 -0
  9. privacyfence/atlassian_oauth.py +268 -0
  10. privacyfence/audit_forwarding.py +229 -0
  11. privacyfence/audit_log.py +855 -0
  12. privacyfence/auto_accept.py +1712 -0
  13. privacyfence/calendar_client.py +741 -0
  14. privacyfence/card_builder.py +138 -0
  15. privacyfence/confluence_client.py +630 -0
  16. privacyfence/connector.py +90 -0
  17. privacyfence/connector_host.py +60 -0
  18. privacyfence/connector_registry.py +156 -0
  19. privacyfence/connectors/__init__.py +2 -0
  20. privacyfence/connectors/apps_script.py +316 -0
  21. privacyfence/connectors/calendar.py +765 -0
  22. privacyfence/connectors/confluence.py +652 -0
  23. privacyfence/connectors/contacts.py +422 -0
  24. privacyfence/connectors/drive.py +1721 -0
  25. privacyfence/connectors/gmail.py +1421 -0
  26. privacyfence/connectors/jira.py +447 -0
  27. privacyfence/connectors/salesforce.py +434 -0
  28. privacyfence/connectors/slack.py +730 -0
  29. privacyfence/connectors/tasks.py +380 -0
  30. privacyfence/connectors/telegram.py +304 -0
  31. privacyfence/contacts_client.py +677 -0
  32. privacyfence/daemon_main.py +1718 -0
  33. privacyfence/dialog_window_html.py +267 -0
  34. privacyfence/download_staging.py +295 -0
  35. privacyfence/drive_client.py +2681 -0
  36. privacyfence/email_markdown.py +169 -0
  37. privacyfence/gate.py +1337 -0
  38. privacyfence/gmail_client.py +1261 -0
  39. privacyfence/google_oauth.py +183 -0
  40. privacyfence/html_to_text.py +250 -0
  41. privacyfence/jira_client.py +457 -0
  42. privacyfence/markdown_to_html.py +134 -0
  43. privacyfence/oauth_loopback.py +205 -0
  44. privacyfence/org_bundle_signing.py +255 -0
  45. privacyfence/org_identity.py +369 -0
  46. privacyfence/org_mode.py +404 -0
  47. privacyfence/paths.py +222 -0
  48. privacyfence/pii_detector.py +447 -0
  49. privacyfence/principal.py +191 -0
  50. privacyfence/privacy_filter.py +223 -0
  51. privacyfence/resource_grants.py +626 -0
  52. privacyfence/resource_names.py +134 -0
  53. privacyfence/resources/approval_window/fonts/OFL.txt +93 -0
  54. privacyfence/resources/approval_window/fonts/SourceSerif4-Italic.woff2 +0 -0
  55. privacyfence/resources/approval_window/fonts/SourceSerif4-Regular.woff2 +0 -0
  56. privacyfence/resources/approval_window/fonts/SourceSerif4-SemiBold.woff2 +0 -0
  57. privacyfence/resources/approval_window/styles.css +447 -0
  58. privacyfence/resources/connector_icons/README.md +47 -0
  59. privacyfence/resources/connector_icons/calendar.png +0 -0
  60. privacyfence/resources/connector_icons/confluence.png +0 -0
  61. privacyfence/resources/connector_icons/contacts.png +0 -0
  62. privacyfence/resources/connector_icons/drive.png +0 -0
  63. privacyfence/resources/connector_icons/gmail.png +0 -0
  64. privacyfence/resources/connector_icons/jira.png +0 -0
  65. privacyfence/resources/connector_icons/salesforce.png +0 -0
  66. privacyfence/resources/connector_icons/slack.png +0 -0
  67. privacyfence/resources/connector_icons/tasks.png +0 -0
  68. privacyfence/resources/connector_icons/telegram.png +0 -0
  69. privacyfence/resources/icon_32.png +0 -0
  70. privacyfence/resources/icon_512.png +0 -0
  71. privacyfence/resources/icon_64.png +0 -0
  72. privacyfence/resources/icon_menubar.png +0 -0
  73. privacyfence/resources/settings.yaml.example +290 -0
  74. privacyfence/resources/sw.js +43 -0
  75. privacyfence/resources/tokens.css +119 -0
  76. privacyfence/safe_errors.py +183 -0
  77. privacyfence/salesforce_client.py +427 -0
  78. privacyfence/secure_files.py +202 -0
  79. privacyfence/settings_controller.py +1892 -0
  80. privacyfence/settings_window_html.py +1270 -0
  81. privacyfence/slack_client.py +1778 -0
  82. privacyfence/std_streams.py +62 -0
  83. privacyfence/tasks_client.py +345 -0
  84. privacyfence/telegram_auth.py +73 -0
  85. privacyfence/telegram_client.py +596 -0
  86. privacyfence/text_extraction.py +450 -0
  87. privacyfence/update_checker.py +253 -0
  88. privacyfence/url_safety.py +34 -0
  89. privacyfence/web/__init__.py +13 -0
  90. privacyfence/web/csp.py +125 -0
  91. privacyfence/web/mcp_auth.py +99 -0
  92. privacyfence/web/mcp_dispatch.py +665 -0
  93. privacyfence/web/mcp_tools.py +377 -0
  94. privacyfence/web/oauth_provider.py +557 -0
  95. privacyfence/web/org_session.py +212 -0
  96. privacyfence/web/routes_approvals.py +335 -0
  97. privacyfence/web/routes_connect.py +672 -0
  98. privacyfence/web/routes_downloads.py +141 -0
  99. privacyfence/web/routes_mcp.py +509 -0
  100. privacyfence/web/routes_org_approvals.py +474 -0
  101. privacyfence/web/routes_org_identity.py +205 -0
  102. privacyfence/web/routes_security.py +316 -0
  103. privacyfence/web/routes_settings.py +392 -0
  104. privacyfence/web/server.py +993 -0
  105. privacyfence/web/session_auth.py +337 -0
  106. privacyfence/web/state_stream.py +191 -0
  107. privacyfence/web_approval_ui.py +205 -0
  108. privacyfence/web_prompt.py +76 -0
  109. privacyfence/web_shell.py +382 -0
  110. privacyfence/webauthn_stepup.py +417 -0
  111. privacyfence-4.0.0.dist-info/METADATA +487 -0
  112. privacyfence-4.0.0.dist-info/RECORD +117 -0
  113. privacyfence-4.0.0.dist-info/WHEEL +5 -0
  114. privacyfence-4.0.0.dist-info/entry_points.txt +2 -0
  115. privacyfence-4.0.0.dist-info/licenses/LICENSE +185 -0
  116. privacyfence-4.0.0.dist-info/licenses/NOTICE +36 -0
  117. privacyfence-4.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,17 @@
1
+ """PrivacyFence: privacy proxy between Claude (MCP) and your personal data, with an embedded web
2
+ approval/settings UI."""
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("privacyfence")
9
+ except PackageNotFoundError:
10
+ # Not installed with metadata at all -- e.g. run straight out of a
11
+ # source checkout without `pip install -e .`/`pip install .` first, or a
12
+ # frozen build missing --copy-metadata (see scripts/build_dmg.sh). Never
13
+ # crash the whole package over a display string: fall back to the same
14
+ # placeholder [tool.setuptools_scm]'s fallback_version in pyproject.toml
15
+ # uses for "no tag reachable yet", so update_checker.py's parse_version()
16
+ # still accepts it (and correctly never claims it's up to date).
17
+ __version__ = "0.0.0.dev0"
@@ -0,0 +1,30 @@
1
+ """PrivacyFence application-level credentials.
2
+
3
+ Telegram's api_id/api_hash identify the PrivacyFence *application* to
4
+ Telegram's API (MTProto has no concept of "organization" the way OAuth
5
+ does) — they are the same for every user and every phone number, and are
6
+ not a substitute for per-user auth (still phone + code + optional 2FA, see
7
+ menu_bar.py). Because this repo is public, the real values are never
8
+ committed: release builds bake them in from CI secrets (see
9
+ scripts/build_dmg.sh) into the git-ignored ``_telegram_credentials`` module
10
+ generated right before packaging. Local/dev builds fall back to the
11
+ PRIVACYFENCE_TELEGRAM_API_ID / PRIVACYFENCE_TELEGRAM_API_HASH env vars.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+
17
+
18
+ def telegram_app_credentials() -> tuple[int, str] | None:
19
+ try:
20
+ from . import _telegram_credentials # generated at build time; git-ignored
21
+ except ImportError:
22
+ pass
23
+ else:
24
+ return int(_telegram_credentials.API_ID), _telegram_credentials.API_HASH
25
+
26
+ api_id = os.environ.get("PRIVACYFENCE_TELEGRAM_API_ID")
27
+ api_hash = os.environ.get("PRIVACYFENCE_TELEGRAM_API_HASH")
28
+ if api_id and api_hash:
29
+ return int(api_id), api_hash
30
+ return None
@@ -0,0 +1,58 @@
1
+ """Shared icon-asset loading for approval surfaces.
2
+
3
+ Locates the bundled shield/connector PNGs (resources/icon_*.png,
4
+ resources/connector_icons/<name>.png) and returns them as base64 data URIs
5
+ for embedding directly into a card-stack HTML document -- see
6
+ approval_window_html.py's module docstring for why that document must never
7
+ trigger a network fetch to render.
8
+
9
+ Plain filesystem + base64, no AppKit/PyObjC dependency -- both
10
+ approval_window.py's native host (AppKit/WKWebView) and web_approval_ui.py's
11
+ browser host need the exact same data URIs, so this is factored out here
12
+ rather than duplicated, and importable on any platform. approval_window.py
13
+ keeps its own private _icon_path/_connector_icon_path/_icon_data_uri (not
14
+ migrated onto this module) so this change stays scoped to the new web
15
+ surface without touching the native path's own, separately-tested code.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ from pathlib import Path
21
+
22
+ _RESOURCES = Path(__file__).parent / "resources"
23
+
24
+ _icon_data_uri_cache: dict[str, str] = {}
25
+
26
+
27
+ def shield_icon_path() -> str | None:
28
+ """PrivacyFence's own shield mark, top-right of every card -- see
29
+ approval_window_html.py's _header_html. Same silent-skip fallback as
30
+ connector_icon_path(): no bundled asset just means no icon, never an
31
+ error."""
32
+ for name in ("icon_64.png", "icon_512.png", "icon_32.png"):
33
+ p = _RESOURCES / name
34
+ if p.exists():
35
+ return str(p)
36
+ return None
37
+
38
+
39
+ def connector_icon_path(connector: str) -> str | None:
40
+ """Real per-service brand icon (Gmail/Drive/Slack/etc.), top-left,
41
+ alongside the "PrivacyFence" kicker -- see resources/connector_icons/README
42
+ for where the bundled assets come from."""
43
+ if not connector:
44
+ return None
45
+ p = _RESOURCES / "connector_icons" / f"{connector}.png"
46
+ return str(p) if p.exists() else None
47
+
48
+
49
+ def icon_data_uri(path: str | None) -> str:
50
+ """Base64 data: URI for a vendored PNG icon, or "" if missing. Cached
51
+ (these are a fixed, small set of bundled resources, not user data) so
52
+ repeated approvals don't re-read/re-encode the same file."""
53
+ if not path:
54
+ return ""
55
+ if path not in _icon_data_uri_cache:
56
+ data = base64.b64encode(Path(path).read_bytes()).decode("ascii")
57
+ _icon_data_uri_cache[path] = f"data:image/png;base64,{data}"
58
+ return _icon_data_uri_cache[path]
@@ -0,0 +1,285 @@
1
+ """Pure-function HTML for the ``/approvals`` list page
2
+ (docs/approval-list-ui-ux.md §2, the P1-compatible slice its own §6 says
3
+ can land ahead of P3's full design -- the row shape, the empty state, and
4
+ the central asymmetry of §2.2: **Deny is on the row; Allow is never on the
5
+ row.** Denying without reading the card cannot leak anything; approving
6
+ from a one-line summary is exactly the habituation failure the card exists
7
+ to prevent, so there is no "Allow" button here at all -- only "Review",
8
+ which opens the real card.
9
+
10
+ ``build_list_html(rows)`` is the first paint (web/routes_approvals.py, given
11
+ ``approvals.PendingApproval`` objects, each with a real connector icon --
12
+ see ``row_from_approval`` below); ``window.__pfRenderApprovals(state)`` is
13
+ the live re-render web_shell.py's SSE dispatch calls with
14
+ web/state_stream.py's own "approvals" event payload
15
+ (``PendingApproval.to_summary_dict()``, which carries no icon -- building
16
+ one needs approval_icons.py, a filesystem read this module deliberately
17
+ doesn't do on every SSE tick). A live-updated row therefore renders with a
18
+ plain connector-initial badge instead of the real icon; a decided row
19
+ leaves the list within one poll interval regardless (web/state_stream.py's
20
+ ``_APPROVALS_POLL_SECONDS``), so the visual gap is real but short-lived --
21
+ a documented simplification of this phase's own P1-compatible scope, not
22
+ an oversight.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import secrets
28
+ from datetime import datetime, timezone
29
+ from html import escape as _html_escape
30
+ from typing import Any
31
+
32
+ from . import approval_icons
33
+
34
+ _EMPTY_STATE = (
35
+ '<div class="pf-approvals-empty">'
36
+ '<div class="pf-approvals-empty-title">Nothing is waiting.</div>'
37
+ '<div class="pf-approvals-empty-sub">PrivacyFence is watching.</div>'
38
+ "</div>"
39
+ )
40
+
41
+ _CSS = """
42
+ .pf-approvals-page { max-width: 720px; margin: 0 auto; padding: 24px 20px 60px; width: 100%; }
43
+ .pf-approvals-heading { font-size: 13px; color: var(--color-neutral-600); margin-bottom: 14px; }
44
+ .pf-approvals-empty {
45
+ text-align: center; padding: 80px 20px; color: var(--color-neutral-600);
46
+ }
47
+ .pf-approvals-empty-title { font-size: 16px; font-weight: 600; color: var(--color-text); margin-bottom: 4px; }
48
+ .pf-approvals-empty-sub { font-size: 13px; }
49
+ .pf-approval-row {
50
+ display: flex; align-items: center; gap: 14px; padding: 14px 16px;
51
+ background: var(--color-surface); border-radius: var(--radius-lg); margin-bottom: 10px;
52
+ }
53
+ .pf-approval-icon {
54
+ width: 28px; height: 28px; border-radius: var(--radius-md); flex-shrink: 0; object-fit: contain;
55
+ background: var(--color-neutral-200);
56
+ }
57
+ .pf-approval-icon-fallback {
58
+ display: flex; align-items: center; justify-content: center;
59
+ font-size: 12px; font-weight: 700; color: var(--color-neutral-600);
60
+ }
61
+ .pf-approval-main { flex: 1; min-width: 0; }
62
+ .pf-approval-title {
63
+ font-size: 14px; font-weight: 600; color: var(--color-text);
64
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
65
+ }
66
+ .pf-approval-kicker { font-size: 12px; color: var(--color-neutral-600); margin-top: 2px; }
67
+ .pf-approval-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
68
+ .pf-btn-deny, .pf-btn-review {
69
+ font-size: 12.5px; font-weight: 600; padding: 7px 12px; border-radius: var(--radius-md);
70
+ border: none; cursor: pointer; text-decoration: none; white-space: nowrap;
71
+ }
72
+ .pf-btn-deny { background: transparent; color: var(--color-danger); border: 1px solid var(--color-divider); }
73
+ .pf-btn-review { background: var(--color-accent); color: #fff; }
74
+ """
75
+
76
+ # Runtime dispatch: sessionStorage's pending toast (left by the card page's
77
+ # own shim right before it navigates back here -- see
78
+ # web/routes_approvals.py's _bridge_shim), the empty-state/row re-render on
79
+ # every "approvals" SSE event, and the row-level Deny button (a direct POST
80
+ # to the decide endpoint, no navigation to the card at all -- §2.2's own
81
+ # point: denying needs no context).
82
+ _JS = """
83
+ (function () {
84
+ function relAge(iso) {
85
+ if (!iso) return '';
86
+ var then = new Date(iso).getTime();
87
+ if (isNaN(then)) return '';
88
+ var s = Math.max(0, Math.floor((Date.now() - then) / 1000));
89
+ if (s < 60) return 'just now';
90
+ var m = Math.floor(s / 60);
91
+ if (m < 60) return m + 'm ago';
92
+ var h = Math.floor(m / 60);
93
+ if (h < 24) return h + 'h ago';
94
+ return Math.floor(h / 24) + 'd ago';
95
+ }
96
+
97
+ function esc(s) {
98
+ return String(s == null ? '' : s)
99
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
100
+ }
101
+
102
+ function rowHtml(row) {
103
+ var title = esc(row.tool_name || row.summary || (row.kind === 'card' ? 'Approval' : 'Confirmation'));
104
+ var kicker = [row.connector ? row.connector.charAt(0).toUpperCase() + row.connector.slice(1) : '',
105
+ row.tool || '', relAge(row.created_at)].filter(Boolean).join(' · ');
106
+ var initial = (row.connector || '?').charAt(0).toUpperCase();
107
+ return '<div class="pf-approval-row" data-approval-id="' + esc(row.id) + '">' +
108
+ '<div class="pf-approval-icon pf-approval-icon-fallback">' + esc(initial) + '</div>' +
109
+ '<div class="pf-approval-main"><div class="pf-approval-title">' + title + '</div>' +
110
+ '<div class="pf-approval-kicker">' + esc(kicker) + '</div></div>' +
111
+ '<div class="pf-approval-actions">' +
112
+ '<button type="button" class="pf-btn-deny" data-deny="' + esc(row.id) + '">Deny</button>' +
113
+ '<a class="pf-btn-review" href="/approvals/' + esc(row.id) + '">Review →</a></div></div>';
114
+ }
115
+
116
+ function render(rows) {
117
+ var container = document.getElementById('pf-approvals-list');
118
+ if (!container) return;
119
+ if (!rows || !rows.length) {
120
+ container.innerHTML = %(empty)s;
121
+ return;
122
+ }
123
+ container.innerHTML = rows.map(rowHtml).join('');
124
+ }
125
+ window.__pfRenderApprovals = render;
126
+
127
+ function denyRow(id) {
128
+ fetch('/api/approvals/' + encodeURIComponent(id) + '/decide', {
129
+ method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
130
+ body: JSON.stringify({result: 'deny', csrf: %(csrf)s}),
131
+ }).then(function (r) {
132
+ var row = document.querySelector('[data-approval-id="' + id + '"]');
133
+ if (row) { row.remove(); }
134
+ // §4.4's "offer it after the first successful decision" isn't only
135
+ // the card page's own decision (whose own return-to-list flow
136
+ // triggers this same prompt via the DOMContentLoaded handler below)
137
+ // -- row-level Deny is a first-class decision path (§2.2's whole
138
+ // point: denying needs no card) and never goes through that flow at
139
+ // all, so without this, a workflow that only ever denies from the
140
+ // list would never trigger the notification permission pre-prompt.
141
+ if (r.ok && window.__pfNotifPrompt) { window.__pfNotifPrompt(); }
142
+ });
143
+ }
144
+
145
+ document.addEventListener('click', function (e) {
146
+ var btn = e.target.closest('[data-deny]');
147
+ if (btn) { denyRow(btn.getAttribute('data-deny')); }
148
+ });
149
+
150
+ // The card page's own return-to-list flow (web/routes_approvals.py's
151
+ // _bridge_shim) stashes a toast message here right before navigating
152
+ // back -- shown once, then cleared, so a page refresh never re-shows it.
153
+ // Deferred to DOMContentLoaded: #pf-shell-toast and window.__pfNotifPrompt
154
+ // are both defined by web_shell.py's own markup/script, which come
155
+ // *after* this <script> in document order (this module's body_html is
156
+ // wrapped inside <main>, ahead of the shell's own footer) -- running
157
+ // this inline, synchronously, would find neither yet.
158
+ document.addEventListener('DOMContentLoaded', function () {
159
+ try {
160
+ var raw = sessionStorage.getItem('pf_toast');
161
+ if (raw) {
162
+ sessionStorage.removeItem('pf_toast');
163
+ var toast = JSON.parse(raw);
164
+ var el = document.getElementById('pf-shell-toast');
165
+ if (el && toast && toast.msg) {
166
+ el.textContent = toast.msg;
167
+ el.classList.add('shown');
168
+ setTimeout(function () { el.classList.remove('shown'); }, 4000);
169
+ }
170
+ // §4.4: offer the notification permission pre-prompt right after
171
+ // the first successful decision, when the value is concrete --
172
+ // never on page load. window.__pfNotifPrompt (web_shell.py) itself
173
+ // no-ops past the first time (localStorage) and past a non-default
174
+ // permission state.
175
+ if (window.__pfNotifPrompt) { window.__pfNotifPrompt(); }
176
+ }
177
+ } catch (e) { /* sessionStorage unavailable -- toast just doesn't show */ }
178
+ });
179
+
180
+ // §3 point 4: focus moves to the next pending row's Review control, not
181
+ // into a re-opened card -- never auto-advance into a decision.
182
+ var firstReview = document.querySelector('.pf-btn-review');
183
+ if (firstReview) { firstReview.focus({preventScroll: true}); }
184
+ })();
185
+ """
186
+
187
+
188
+ def row_from_approval(card: Any) -> dict[str, Any]:
189
+ """``PendingApproval`` -> the same summary shape
190
+ ``PendingApproval.to_summary_dict()`` already produces (approvals.py) --
191
+ used for the server-rendered first paint here rather than calling that
192
+ method directly, only so this module stays the one place that decides
193
+ what a row needs to render (kept in sync with to_summary_dict() by the
194
+ field names below, not by importing it, since the two shapes need to
195
+ stay decoupled: an SSE payload field this module doesn't use yet
196
+ shouldn't have to be added here too)."""
197
+ return {
198
+ "id": card.id,
199
+ "kind": card.kind,
200
+ "connector": card.connector,
201
+ "tool": card.tool,
202
+ "tool_name": card.tool_name,
203
+ "summary": card.summary,
204
+ "created_at": _iso(card.created_at),
205
+ }
206
+
207
+
208
+ def _iso(ts: float) -> str:
209
+ return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
210
+
211
+
212
+ def _row_html(row: dict[str, Any]) -> str:
213
+ label = "Confirmation" if row.get("kind") != "card" else "Approval"
214
+ title = row.get("tool_name") or row.get("summary") or label
215
+ connector = (row.get("connector") or "").capitalize()
216
+ kicker = " · ".join(p for p in (connector, row.get("tool") or "", _relative_age(row.get("created_at", ""))) if p)
217
+ rid = row["id"]
218
+ icon_uri = approval_icons.icon_data_uri(approval_icons.connector_icon_path(row.get("connector", "")))
219
+ icon_html = (
220
+ f'<img class="pf-approval-icon" src="{_html_escape(icon_uri)}" alt="">' if icon_uri
221
+ else f'<div class="pf-approval-icon pf-approval-icon-fallback">{_html_escape(connector[:1])}</div>'
222
+ )
223
+ return (
224
+ f'<div class="pf-approval-row" data-approval-id="{_html_escape(rid)}">'
225
+ f"{icon_html}"
226
+ '<div class="pf-approval-main">'
227
+ f'<div class="pf-approval-title">{_html_escape(title)}</div>'
228
+ f'<div class="pf-approval-kicker">{_html_escape(kicker)}</div>'
229
+ "</div>"
230
+ '<div class="pf-approval-actions">'
231
+ f'<button type="button" class="pf-btn-deny" data-deny="{_html_escape(rid)}">Deny</button>'
232
+ f'<a class="pf-btn-review" href="/approvals/{_html_escape(rid)}">Review →</a>'
233
+ "</div>"
234
+ "</div>"
235
+ )
236
+
237
+
238
+ def _relative_age(iso_ts: str) -> str:
239
+ if not iso_ts:
240
+ return ""
241
+ try:
242
+ dt = datetime.fromisoformat(iso_ts)
243
+ except ValueError:
244
+ return ""
245
+ seconds = max(0, int((datetime.now(timezone.utc) - dt).total_seconds()))
246
+ if seconds < 60:
247
+ return "just now"
248
+ minutes = seconds // 60
249
+ if minutes < 60:
250
+ return f"{minutes}m ago"
251
+ hours = minutes // 60
252
+ if hours < 24:
253
+ return f"{hours}h ago"
254
+ return f"{hours // 24}d ago"
255
+
256
+
257
+ def build_list_html(rows: list[dict[str, Any]], *, csrf: str, nonce: str | None = None) -> str:
258
+ """The ``/approvals`` page body (dropped into web_shell.wrap's
259
+ ``<main>``) -- ``rows`` is a list of row_from_approval()'s shape,
260
+ newest first (same order approvals.PendingApprovalRegistry.
261
+ list_pending() already returns).
262
+
263
+ ``nonce``: the caller's current per-response CSP nonce (web/server.py's
264
+ ``_SecurityHeadersMiddleware``, via ``request.state.csp_nonce``) --
265
+ unlike approval_window_html.py's card documents, this fragment is
266
+ rendered fresh on every ``GET /approvals``, so it takes the request's
267
+ own nonce rather than minting one itself. Must be the same value
268
+ web_shell.wrap() is given for the rest of this same document, since
269
+ only one Content-Security-Policy header covers both. Defaults to a
270
+ fresh one when omitted (every caller outside this module's own tests
271
+ always passes the real per-request value explicitly)."""
272
+ nonce = nonce or secrets.token_urlsafe(18)
273
+ body = "".join(_row_html(r) for r in rows) if rows else _EMPTY_STATE
274
+ heading = (
275
+ f"{len(rows)} approval{'s' if len(rows) != 1 else ''} pending" if rows else ""
276
+ )
277
+ js = _JS % {"empty": json.dumps(_EMPTY_STATE), "csrf": json.dumps(csrf)}
278
+ return (
279
+ f'<style nonce="{nonce}">{_CSS}</style>'
280
+ '<div class="pf-approvals-page">'
281
+ + (f'<div class="pf-approvals-heading">{_html_escape(heading)}</div>' if heading else "")
282
+ + f'<div id="pf-approvals-list">{body}</div>'
283
+ "</div>"
284
+ f'<script nonce="{nonce}">{js}</script>'
285
+ )
@@ -0,0 +1,167 @@
1
+ """Approval UI seam: the interface gate.py depends on instead of importing
2
+ a concrete approval-surface implementation directly.
3
+
4
+ gate.py is the policy engine: auto-accept check -> block on a human decision
5
+ -> audit log. The policy loop itself has no reason to know how a human
6
+ decision actually gets shown -- it just needs something that can show the
7
+ write-gate popup, the review-gate popup, and the two smaller confirmation
8
+ dialogs, and return a decision. ApprovalUI is that something.
9
+
10
+ Through P9 this had two implementations: NativeApprovalUI (macOS AppKit/
11
+ WKWebView dialogs, via approval_popup.py) and WebApprovalUI (the same card
12
+ stack, served over HTTP). P10 deleted the native one -- "two approval
13
+ surfaces means two places for a security fix to land" -- leaving
14
+ WebApprovalUI (web_approval_ui.py) as the sole implementation. The ABC
15
+ stays here, and gate.py still reaches it through get_approval_ui() rather
16
+ than importing WebApprovalUI directly, on purpose: that decision's own
17
+ reasoning was "the ApprovalUI seam lets it come back if that proves
18
+ wrong", so a future implementation (e.g. a Windows-native dialog for #121,
19
+ once that's revisited) only needs to implement this interface and call
20
+ init_approval_ui() with an
21
+ instance of it -- gate.py's own call sites never change.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from abc import ABC, abstractmethod
26
+
27
+
28
+ class ApprovalUI(ABC):
29
+ """One blocking human-approval surface. Every method mirrors one of
30
+ WebApprovalUI's own (see web_approval_ui.py), which in turn mirrors
31
+ approval_popup.py's pre-P10 free functions -- these signatures were kept
32
+ identical across that transition so nothing calling through this ABC had
33
+ to change shape.
34
+ """
35
+
36
+ @abstractmethod
37
+ def show_popup(
38
+ self,
39
+ title: str,
40
+ preview: dict[str, str],
41
+ details_text: str,
42
+ temp_accept_eligible: bool = False,
43
+ claude_reason: str = "",
44
+ write_content_flags: list[str] | None = None,
45
+ seen_count: int = 0,
46
+ connector: str = "",
47
+ accept_all_choices: list[tuple[str, str]] | None = None,
48
+ preview_bytes: bytes = b"",
49
+ preview_mime_type: str = "",
50
+ preview_tables: list[dict] | None = None,
51
+ preview_blocks: list[dict] | None = None,
52
+ table_only: bool = False,
53
+ upload_forced: bool = False,
54
+ layout: str = "narrow",
55
+ ) -> tuple[str, int | None]:
56
+ """Approval popup for write tools. Returns (decision, chosen_index)
57
+ -- decision is 'accept', 'deny', or 'accept_all'; chosen_index is
58
+ the clicked button's index into accept_all_choices when decision is
59
+ 'accept_all', else None. See web_approval_ui.WebApprovalUI.show_popup's
60
+ docstring."""
61
+
62
+ @abstractmethod
63
+ def show_read_popup(
64
+ self,
65
+ title: str,
66
+ preview: dict[str, str],
67
+ details_text: str,
68
+ accept_all_choices: list[tuple[str, str]] | None,
69
+ pii_categories: list[str] | None = None,
70
+ visibility: dict[str, str] | None = None,
71
+ claude_reason: str = "",
72
+ seen_count: int = 0,
73
+ content_kind: str = "generic",
74
+ pdf_bytes: bytes = b"",
75
+ connector: str = "",
76
+ preview_bytes: bytes = b"",
77
+ preview_mime_type: str = "",
78
+ new_info: dict[str, str] | None = None,
79
+ preview_tables: list[dict] | None = None,
80
+ preview_blocks: list[dict] | None = None,
81
+ table_only: bool = False,
82
+ layout: str = "narrow",
83
+ ) -> tuple[str, int | None]:
84
+ """Approval popup for read tools. Returns (decision, chosen_index)
85
+ -- decision is 'accept', 'deny', or 'accept_all'; chosen_index is
86
+ the clicked button's index into accept_all_choices when decision is
87
+ 'accept_all', else None. See web_approval_ui.WebApprovalUI.
88
+ show_read_popup's docstring."""
89
+
90
+ @abstractmethod
91
+ def show_pii_confirmation_popup(self, categories: list[str]) -> bool:
92
+ """Second-step confirmation for content the PII detector flagged.
93
+ See web_approval_ui.WebApprovalUI.show_pii_confirmation_popup's
94
+ docstring."""
95
+
96
+ @abstractmethod
97
+ def show_rule_confirmation_popup(self, description: str) -> bool:
98
+ """Second-step confirmation after a specific "Always allow" button
99
+ is clicked. See web_approval_ui.WebApprovalUI.
100
+ show_rule_confirmation_popup's docstring."""
101
+
102
+ @property
103
+ def deferred_registry(self): # -> approvals.PendingApprovalRegistry | None
104
+ """A ``PendingApprovalRegistry`` (approvals.py) this backend is
105
+ registered with, if it supports the deferred/hold-window protocol --
106
+ ``None`` (the default) means this backend only ever blocks until a
107
+ human decides.
108
+ WebApprovalUI (the only implementation since P10) always overrides
109
+ this with a real registry; the default stays here for whatever
110
+ future implementation the seam's own docstring anticipates, in case
111
+ it has nowhere to send a human a reviewable link either.
112
+ gate.py checks this property, not the concrete class, to decide
113
+ whether to apply the deferred protocol -- see that module's
114
+ docstring."""
115
+ return None
116
+
117
+
118
+ class _UnconfiguredApprovalUI(ApprovalUI):
119
+ """The bare fallback get_approval_ui() constructs when nothing has
120
+ called init_approval_ui() yet. Deliberately not WebApprovalUI: this
121
+ plays the same "inert, no-registry default" role NativeApprovalUI
122
+ played before P10 -- gate.py's own test suite mostly monkeypatches its
123
+ module-level show_popup/show_read_popup/etc. wrappers directly, relying
124
+ on the default ApprovalUI having no deferred_registry (so gated_call()
125
+ takes the plain blocking path, not the deferred/hold-window one) rather
126
+ than installing a real ApprovalUI itself. Every method here raises if
127
+ actually invoked without being monkeypatched or replaced first -- a
128
+ misconfigured daemon should fail loudly, not silently deny (or block
129
+ forever on) a real gated call. daemon_main.py always calls
130
+ init_approval_ui() with a real, config-driven WebApprovalUI before any
131
+ gated call could reach this."""
132
+
133
+ def _unconfigured(self) -> None:
134
+ raise RuntimeError(
135
+ "No ApprovalUI configured -- call approval_ui.init_approval_ui() first "
136
+ "(daemon_main.py always does this at startup)."
137
+ )
138
+
139
+ def show_popup(self, *args, **kwargs):
140
+ self._unconfigured()
141
+
142
+ def show_read_popup(self, *args, **kwargs):
143
+ self._unconfigured()
144
+
145
+ def show_pii_confirmation_popup(self, categories: list[str]) -> bool:
146
+ self._unconfigured()
147
+
148
+ def show_rule_confirmation_popup(self, description: str) -> bool:
149
+ self._unconfigured()
150
+
151
+
152
+ _INSTANCE: ApprovalUI | None = None
153
+
154
+
155
+ def get_approval_ui() -> ApprovalUI:
156
+ """Lazily-constructed singleton -- see _UnconfiguredApprovalUI's own
157
+ docstring for what the default is and why."""
158
+ global _INSTANCE
159
+ if _INSTANCE is None:
160
+ _INSTANCE = _UnconfiguredApprovalUI()
161
+ return _INSTANCE
162
+
163
+
164
+ def init_approval_ui(ui: ApprovalUI) -> ApprovalUI:
165
+ global _INSTANCE
166
+ _INSTANCE = ui
167
+ return _INSTANCE