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.
- privacyfence/__init__.py +17 -0
- privacyfence/app_credentials.py +30 -0
- privacyfence/approval_icons.py +58 -0
- privacyfence/approval_list_html.py +285 -0
- privacyfence/approval_ui.py +167 -0
- privacyfence/approval_window_html.py +919 -0
- privacyfence/approvals.py +649 -0
- privacyfence/apps_script_client.py +337 -0
- privacyfence/atlassian_oauth.py +268 -0
- privacyfence/audit_forwarding.py +229 -0
- privacyfence/audit_log.py +855 -0
- privacyfence/auto_accept.py +1712 -0
- privacyfence/calendar_client.py +741 -0
- privacyfence/card_builder.py +138 -0
- privacyfence/confluence_client.py +630 -0
- privacyfence/connector.py +90 -0
- privacyfence/connector_host.py +60 -0
- privacyfence/connector_registry.py +156 -0
- privacyfence/connectors/__init__.py +2 -0
- privacyfence/connectors/apps_script.py +316 -0
- privacyfence/connectors/calendar.py +765 -0
- privacyfence/connectors/confluence.py +652 -0
- privacyfence/connectors/contacts.py +422 -0
- privacyfence/connectors/drive.py +1721 -0
- privacyfence/connectors/gmail.py +1421 -0
- privacyfence/connectors/jira.py +447 -0
- privacyfence/connectors/salesforce.py +434 -0
- privacyfence/connectors/slack.py +730 -0
- privacyfence/connectors/tasks.py +380 -0
- privacyfence/connectors/telegram.py +304 -0
- privacyfence/contacts_client.py +677 -0
- privacyfence/daemon_main.py +1718 -0
- privacyfence/dialog_window_html.py +267 -0
- privacyfence/download_staging.py +295 -0
- privacyfence/drive_client.py +2681 -0
- privacyfence/email_markdown.py +169 -0
- privacyfence/gate.py +1337 -0
- privacyfence/gmail_client.py +1261 -0
- privacyfence/google_oauth.py +183 -0
- privacyfence/html_to_text.py +250 -0
- privacyfence/jira_client.py +457 -0
- privacyfence/markdown_to_html.py +134 -0
- privacyfence/oauth_loopback.py +205 -0
- privacyfence/org_bundle_signing.py +255 -0
- privacyfence/org_identity.py +369 -0
- privacyfence/org_mode.py +404 -0
- privacyfence/paths.py +222 -0
- privacyfence/pii_detector.py +447 -0
- privacyfence/principal.py +191 -0
- privacyfence/privacy_filter.py +223 -0
- privacyfence/resource_grants.py +626 -0
- privacyfence/resource_names.py +134 -0
- privacyfence/resources/approval_window/fonts/OFL.txt +93 -0
- privacyfence/resources/approval_window/fonts/SourceSerif4-Italic.woff2 +0 -0
- privacyfence/resources/approval_window/fonts/SourceSerif4-Regular.woff2 +0 -0
- privacyfence/resources/approval_window/fonts/SourceSerif4-SemiBold.woff2 +0 -0
- privacyfence/resources/approval_window/styles.css +447 -0
- privacyfence/resources/connector_icons/README.md +47 -0
- privacyfence/resources/connector_icons/calendar.png +0 -0
- privacyfence/resources/connector_icons/confluence.png +0 -0
- privacyfence/resources/connector_icons/contacts.png +0 -0
- privacyfence/resources/connector_icons/drive.png +0 -0
- privacyfence/resources/connector_icons/gmail.png +0 -0
- privacyfence/resources/connector_icons/jira.png +0 -0
- privacyfence/resources/connector_icons/salesforce.png +0 -0
- privacyfence/resources/connector_icons/slack.png +0 -0
- privacyfence/resources/connector_icons/tasks.png +0 -0
- privacyfence/resources/connector_icons/telegram.png +0 -0
- privacyfence/resources/icon_32.png +0 -0
- privacyfence/resources/icon_512.png +0 -0
- privacyfence/resources/icon_64.png +0 -0
- privacyfence/resources/icon_menubar.png +0 -0
- privacyfence/resources/settings.yaml.example +290 -0
- privacyfence/resources/sw.js +43 -0
- privacyfence/resources/tokens.css +119 -0
- privacyfence/safe_errors.py +183 -0
- privacyfence/salesforce_client.py +427 -0
- privacyfence/secure_files.py +202 -0
- privacyfence/settings_controller.py +1892 -0
- privacyfence/settings_window_html.py +1270 -0
- privacyfence/slack_client.py +1778 -0
- privacyfence/std_streams.py +62 -0
- privacyfence/tasks_client.py +345 -0
- privacyfence/telegram_auth.py +73 -0
- privacyfence/telegram_client.py +596 -0
- privacyfence/text_extraction.py +450 -0
- privacyfence/update_checker.py +253 -0
- privacyfence/url_safety.py +34 -0
- privacyfence/web/__init__.py +13 -0
- privacyfence/web/csp.py +125 -0
- privacyfence/web/mcp_auth.py +99 -0
- privacyfence/web/mcp_dispatch.py +665 -0
- privacyfence/web/mcp_tools.py +377 -0
- privacyfence/web/oauth_provider.py +557 -0
- privacyfence/web/org_session.py +212 -0
- privacyfence/web/routes_approvals.py +335 -0
- privacyfence/web/routes_connect.py +672 -0
- privacyfence/web/routes_downloads.py +141 -0
- privacyfence/web/routes_mcp.py +509 -0
- privacyfence/web/routes_org_approvals.py +474 -0
- privacyfence/web/routes_org_identity.py +205 -0
- privacyfence/web/routes_security.py +316 -0
- privacyfence/web/routes_settings.py +392 -0
- privacyfence/web/server.py +993 -0
- privacyfence/web/session_auth.py +337 -0
- privacyfence/web/state_stream.py +191 -0
- privacyfence/web_approval_ui.py +205 -0
- privacyfence/web_prompt.py +76 -0
- privacyfence/web_shell.py +382 -0
- privacyfence/webauthn_stepup.py +417 -0
- privacyfence-4.0.0.dist-info/METADATA +487 -0
- privacyfence-4.0.0.dist-info/RECORD +117 -0
- privacyfence-4.0.0.dist-info/WHEEL +5 -0
- privacyfence-4.0.0.dist-info/entry_points.txt +2 -0
- privacyfence-4.0.0.dist-info/licenses/LICENSE +185 -0
- privacyfence-4.0.0.dist-info/licenses/NOTICE +36 -0
- privacyfence-4.0.0.dist-info/top_level.txt +1 -0
privacyfence/__init__.py
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|