privacyfence-sync 6.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.
@@ -0,0 +1,8 @@
1
+ """Run a PrivacyFence data-sync recipe as a plain MCP client."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("privacyfence-sync")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0.dev0"
@@ -0,0 +1,189 @@
1
+ """OAuth sign-in for organization servers: token storage, the loopback callback and the provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import tempfile
10
+ import time
11
+ import webbrowser
12
+ from http.server import BaseHTTPRequestHandler, HTTPServer
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import parse_qs, urlparse
16
+
17
+ from mcp.client.auth import OAuthClientProvider
18
+ from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
19
+
20
+ from .client import SignInError, SyncError
21
+
22
+ # The redirect URI must match the registered one exactly (PrivacyFence does no port-agnostic
23
+ # loopback matching), so the port is fixed and stored with the client registration.
24
+ DEFAULT_CALLBACK_PORT = 33418
25
+ CALLBACK_TIMEOUT_SECONDS = 300.0
26
+ _SIGNED_IN_PAGE = b"Signed in to PrivacyFence. You can close this tab."
27
+ _FAILED_PAGE = b"Sign-in failed. Return to the terminal."
28
+
29
+
30
+ def login_hint(server_url: str) -> str:
31
+ return f"Not signed in to {server_url}, or the sign-in expired. Run: privacyfence-sync login --server {server_url}"
32
+
33
+
34
+ def storage_path(server_url: str) -> Path:
35
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
36
+ digest = hashlib.sha256(server_url.encode("utf-8")).hexdigest()[:16]
37
+ return Path(base) / "privacyfence-sync" / f"{digest}.json"
38
+
39
+
40
+ class FileTokenStorage:
41
+ """The SDK's TokenStorage over one JSON file, written atomically with mode 0o600."""
42
+
43
+ def __init__(self, path: Path) -> None:
44
+ self._path = path
45
+
46
+ def _read(self) -> dict[str, Any]:
47
+ try:
48
+ data = json.loads(self._path.read_text(encoding="utf-8"))
49
+ except FileNotFoundError:
50
+ return {}
51
+ return data if isinstance(data, dict) else {}
52
+
53
+ def _write(self, data: dict[str, Any]) -> None:
54
+ self._path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
55
+ fd, tmp = tempfile.mkstemp(dir=self._path.parent, prefix=".tmp-", suffix=".json")
56
+ try:
57
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
58
+ handle.write(json.dumps(data))
59
+ os.chmod(tmp, 0o600)
60
+ os.replace(tmp, self._path)
61
+ except BaseException:
62
+ Path(tmp).unlink(missing_ok=True)
63
+ raise
64
+
65
+ def has_tokens(self) -> bool:
66
+ return bool(self._read().get("tokens"))
67
+
68
+ async def get_tokens(self) -> OAuthToken | None:
69
+ raw = self._read().get("tokens")
70
+ return OAuthToken.model_validate(raw) if raw else None
71
+
72
+ async def set_tokens(self, tokens: OAuthToken) -> None:
73
+ data = self._read()
74
+ data["tokens"] = tokens.model_dump(mode="json", exclude_none=True)
75
+ self._write(data)
76
+
77
+ async def get_client_info(self) -> OAuthClientInformationFull | None:
78
+ raw = self._read().get("client_info")
79
+ return OAuthClientInformationFull.model_validate(raw) if raw else None
80
+
81
+ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
82
+ data = self._read()
83
+ data["client_info"] = client_info.model_dump(mode="json", exclude_none=True)
84
+ self._write(data)
85
+
86
+
87
+ class _CallbackServer(HTTPServer):
88
+ result: AuthorizationCodeResult | None = None
89
+ error: str | None = None
90
+
91
+
92
+ class _CallbackHandler(BaseHTTPRequestHandler):
93
+ server: _CallbackServer
94
+
95
+ def do_GET(self) -> None: # noqa: N802 (the http.server hook name)
96
+ url = urlparse(self.path)
97
+ if url.path != "/callback":
98
+ self._reply(404, b"Not found")
99
+ return
100
+ query = {key: values[0] for key, values in parse_qs(url.query).items()}
101
+ if "code" not in query:
102
+ self.server.error = query.get("error_description") or query.get("error") or "no code in the callback"
103
+ self._reply(400, _FAILED_PAGE)
104
+ return
105
+ self.server.result = AuthorizationCodeResult(code=query["code"], state=query.get("state"), iss=query.get("iss"))
106
+ self._reply(200, _SIGNED_IN_PAGE)
107
+
108
+ def _reply(self, status: int, body: bytes) -> None:
109
+ self.send_response(status)
110
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
111
+ self.send_header("Content-Length", str(len(body)))
112
+ self.end_headers()
113
+ self.wfile.write(body)
114
+
115
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
116
+ return None
117
+
118
+
119
+ def _bind_callback_server(port: int) -> _CallbackServer:
120
+ try:
121
+ return _CallbackServer(("127.0.0.1", port), _CallbackHandler)
122
+ except OSError as exc:
123
+ raise SyncError(f"cannot listen on 127.0.0.1:{port} for the sign-in ({exc}); pass --callback-port") from exc
124
+
125
+
126
+ def _wait_for_callback(server: _CallbackServer, timeout: float) -> AuthorizationCodeResult:
127
+ deadline = time.monotonic() + timeout
128
+ try:
129
+ while server.result is None:
130
+ remaining = deadline - time.monotonic()
131
+ if remaining <= 0:
132
+ raise SyncError("No sign-in arrived within 5 minutes.")
133
+ server.timeout = remaining
134
+ server.handle_request()
135
+ if server.error is not None:
136
+ raise SyncError(f"Sign-in failed: {server.error}")
137
+ return server.result
138
+ finally:
139
+ server.server_close()
140
+
141
+
142
+ class _InteractiveSignIn:
143
+ def __init__(self, callback_port: int, timeout: float = CALLBACK_TIMEOUT_SECONDS) -> None:
144
+ self._port = callback_port
145
+ self._timeout = timeout
146
+ self._server: _CallbackServer | None = None
147
+
148
+ async def redirect_handler(self, url: str) -> None:
149
+ # Listen before the browser opens, so a fast redirect cannot beat the server.
150
+ self._server = _bind_callback_server(self._port)
151
+ print(f"Open this page to sign in: {url}", flush=True)
152
+ webbrowser.open(url)
153
+
154
+ async def callback_handler(self) -> AuthorizationCodeResult:
155
+ server, self._server = self._server, None
156
+ if server is None:
157
+ raise SyncError("the sign-in callback was requested before the sign-in page")
158
+ return await asyncio.to_thread(_wait_for_callback, server, self._timeout)
159
+
160
+
161
+ def oauth_provider(
162
+ server_url: str, storage: FileTokenStorage, *, interactive: bool, callback_port: int
163
+ ) -> OAuthClientProvider:
164
+ metadata = OAuthClientMetadata(
165
+ client_name="privacyfence-sync",
166
+ redirect_uris=[f"http://127.0.0.1:{callback_port}/callback"], # type: ignore[list-item]
167
+ grant_types=["authorization_code", "refresh_token"],
168
+ response_types=["code"],
169
+ token_endpoint_auth_method="none", # nosec B106 # a public loopback client holds no secret
170
+ )
171
+ if interactive:
172
+ flow = _InteractiveSignIn(callback_port)
173
+ return OAuthClientProvider(
174
+ server_url,
175
+ metadata,
176
+ storage,
177
+ redirect_handler=flow.redirect_handler,
178
+ callback_handler=flow.callback_handler,
179
+ )
180
+
181
+ async def refuse_redirect(url: str) -> None:
182
+ raise SignInError(login_hint(server_url))
183
+
184
+ async def refuse_callback() -> AuthorizationCodeResult:
185
+ raise SignInError(login_hint(server_url))
186
+
187
+ return OAuthClientProvider(
188
+ server_url, metadata, storage, redirect_handler=refuse_redirect, callback_handler=refuse_callback
189
+ )
@@ -0,0 +1,136 @@
1
+ """Command line entry point: ``privacyfence-sync run|login|check``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import httpx2
12
+ from mcp.shared.exceptions import MCPError
13
+
14
+ from .auth import DEFAULT_CALLBACK_PORT, FileTokenStorage, login_hint, storage_path
15
+ from .client import (
16
+ RecipeRuntimeError,
17
+ SignInError,
18
+ SyncClient,
19
+ SyncError,
20
+ ToolDenied,
21
+ connect_oauth,
22
+ connect_token,
23
+ )
24
+ from .recipe import RecipeError, load_recipe
25
+ from .runner import run_recipe
26
+ from .template import TemplateError
27
+ from .transform import ScriptError
28
+
29
+ DEFAULT_SERVER = "http://127.0.0.1:8765/mcp"
30
+ DEFAULT_TOKEN_ENV = "PRIVACYFENCE_TOKEN" # nosec B105 # the name of an environment variable, not a secret
31
+
32
+
33
+ def _parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(prog="privacyfence-sync", description=__doc__, allow_abbrev=False)
35
+ sub = parser.add_subparsers(dest="command", required=True)
36
+ run = sub.add_parser("run", help="run a recipe", allow_abbrev=False)
37
+ run.add_argument("recipe", type=Path)
38
+ run.add_argument("--server", help="the PrivacyFence MCP URL")
39
+ run.add_argument(
40
+ "--attended", action="store_true", help="wait for approvals instead of declaring the run unattended"
41
+ )
42
+ run.add_argument("--token-env", default=DEFAULT_TOKEN_ENV, help="environment variable holding the token")
43
+ login = sub.add_parser("login", help="sign in to an organization server", allow_abbrev=False)
44
+ login.add_argument("--server", required=True, help="the PrivacyFence MCP URL")
45
+ login.add_argument(
46
+ "--callback-port", type=int, default=DEFAULT_CALLBACK_PORT, help="loopback port for the browser's return"
47
+ )
48
+ check = sub.add_parser("check", help="validate a recipe without any network call")
49
+ check.add_argument("recipe", type=Path)
50
+ return parser
51
+
52
+
53
+ def _fail(message: str, code: int) -> int:
54
+ print(f"privacyfence-sync: {message}", file=sys.stderr)
55
+ return code
56
+
57
+
58
+ def _resolve_server(flag: str | None, recipe_server: str | None) -> str:
59
+ return flag or recipe_server or os.environ.get("PRIVACYFENCE_MCP_URL") or DEFAULT_SERVER
60
+
61
+
62
+ def _connect(server: str, token_env: str) -> SyncClient:
63
+ token = os.environ.get(token_env)
64
+ if token:
65
+ return connect_token(server, token)
66
+ storage = FileTokenStorage(storage_path(server))
67
+ if not storage.has_tokens():
68
+ raise SignInError(login_hint(server))
69
+ return connect_oauth(server, storage)
70
+
71
+
72
+ async def _run(recipe_path: Path, server_flag: str | None, attended: bool, token_env: str) -> None:
73
+ recipe = load_recipe(recipe_path)
74
+ client = _connect(_resolve_server(server_flag, recipe.server), token_env)
75
+ try:
76
+ async with client:
77
+ await run_recipe(recipe, client, attended=attended, out=sys.stdout)
78
+ except BaseExceptionGroup as group:
79
+ # The MCP transport runs in an anyio task group, which wraps the step's own error.
80
+ raise _first_leaf(group) from None
81
+
82
+
83
+ async def _login(server: str, callback_port: int) -> None:
84
+ client = connect_oauth(
85
+ server, FileTokenStorage(storage_path(server)), interactive=True, callback_port=callback_port
86
+ )
87
+ try:
88
+ async with client:
89
+ await client.call("privacyfence_status", {"reason": "privacyfence-sync login check"})
90
+ except BaseExceptionGroup as group:
91
+ raise _first_leaf(group) from None
92
+ print(f"Signed in to {server}.")
93
+
94
+
95
+ def _first_leaf(group: BaseExceptionGroup[BaseException]) -> BaseException:
96
+ first = group.exceptions[0]
97
+ return _first_leaf(first) if isinstance(first, BaseExceptionGroup) else first
98
+
99
+
100
+ def main(argv: list[str] | None = None) -> int:
101
+ try:
102
+ args = _parser().parse_args(argv)
103
+ except SystemExit as exc:
104
+ return 1 if exc.code else 0
105
+ try:
106
+ if args.command == "check":
107
+ recipe = load_recipe(args.recipe)
108
+ print(f"{recipe.name}: {len(recipe.steps)} step(s), OK")
109
+ return 0
110
+ if args.command == "login":
111
+ asyncio.run(_login(args.server, args.callback_port))
112
+ return 0
113
+ asyncio.run(_run(args.recipe, args.server, args.attended, args.token_env))
114
+ return 0
115
+ except (RecipeError, TemplateError, RecipeRuntimeError) as exc:
116
+ return _fail(str(exc), 1)
117
+ except ToolDenied as exc:
118
+ return _fail(str(exc), 3)
119
+ except SignInError as exc:
120
+ return _fail(str(exc), 4)
121
+ except (SyncError, ScriptError) as exc:
122
+ return _fail(str(exc), 2)
123
+ except httpx2.HTTPError as exc:
124
+ return _fail(f"cannot reach the server: {exc}", 2)
125
+ except MCPError as exc:
126
+ # The MCP client turns an HTTP error on the handshake (a bad or revoked token, a wrong URL,
127
+ # a server error) into this generic error, so name the usual causes.
128
+ return _fail(
129
+ f"the server refused the connection ({exc.message}); check that the token in "
130
+ "$PRIVACYFENCE_TOKEN (or --token-env) is valid and that the server URL is right",
131
+ 2,
132
+ )
133
+
134
+
135
+ if __name__ == "__main__":
136
+ raise SystemExit(main())
@@ -0,0 +1,125 @@
1
+ """MCP session, tool calls and file transfer: the only code that talks to the daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from contextlib import AsyncExitStack
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import httpx2
12
+ from mcp import ClientSession
13
+ from mcp.client.streamable_http import streamable_http_client
14
+ from mcp.types import TextContent
15
+
16
+ if TYPE_CHECKING:
17
+ from .auth import FileTokenStorage
18
+
19
+ _TRANSFER_TIMEOUT = 120.0
20
+ _DENIED_PREFIX = "Request denied"
21
+
22
+
23
+ class SyncError(Exception):
24
+ """Base of every error privacyfence-sync raises on purpose."""
25
+
26
+
27
+ class ToolFailed(SyncError):
28
+ """A tool call or file transfer failed."""
29
+
30
+
31
+ class ToolDenied(SyncError):
32
+ """The daemon denied the request (policy or the user said no)."""
33
+
34
+
35
+ class SignInError(SyncError):
36
+ """Not signed in, sign-in expired, or no token."""
37
+
38
+
39
+ class RecipeRuntimeError(SyncError):
40
+ """A reference in a recipe failed at run time."""
41
+
42
+
43
+ class SyncClient:
44
+ def __init__(self, server_url: str, auth: httpx2.Auth | None, headers: dict[str, str]) -> None:
45
+ self._server_url = server_url
46
+ self._auth = auth
47
+ self._headers = headers
48
+ self._stack: AsyncExitStack | None = None
49
+ self._session: ClientSession | None = None
50
+
51
+ async def __aenter__(self) -> SyncClient:
52
+ stack = AsyncExitStack()
53
+ try:
54
+ http = await stack.enter_async_context(httpx2.AsyncClient(auth=self._auth, headers=self._headers))
55
+ read, write = await stack.enter_async_context(streamable_http_client(self._server_url, http_client=http))
56
+ session = await stack.enter_async_context(ClientSession(read, write))
57
+ await session.initialize()
58
+ except BaseException:
59
+ await stack.aclose()
60
+ raise
61
+ self._stack = stack
62
+ self._session = session
63
+ return self
64
+
65
+ async def __aexit__(self, *exc: object) -> None:
66
+ stack, self._stack, self._session = self._stack, None, None
67
+ if stack is not None:
68
+ await stack.aclose()
69
+
70
+ def _require_session(self) -> ClientSession:
71
+ if self._session is None:
72
+ raise RuntimeError("SyncClient used outside `async with`")
73
+ return self._session
74
+
75
+ async def call(self, tool: str, args: dict[str, Any]) -> Any:
76
+ result = await self._require_session().call_tool(tool, args)
77
+ text = "\n".join(c.text for c in result.content if isinstance(c, TextContent))
78
+ if result.is_error:
79
+ if text.startswith(_DENIED_PREFIX):
80
+ raise ToolDenied(text)
81
+ raise ToolFailed(text)
82
+ if result.structured_content is not None:
83
+ return result.structured_content
84
+ try:
85
+ return json.loads(text)
86
+ except ValueError:
87
+ return text
88
+
89
+ async def download(self, url: str, dest: Path, expected_sha256: str | None) -> None:
90
+ # The URL is the credential (ADR 0028), so this client carries no Authorization header.
91
+ async with httpx2.AsyncClient(timeout=_TRANSFER_TIMEOUT) as http:
92
+ response = await http.get(url)
93
+ response.raise_for_status()
94
+ data = response.content
95
+ if expected_sha256 is not None and hashlib.sha256(data).hexdigest() != expected_sha256:
96
+ raise ToolFailed("downloaded file does not match the sha256 the tool reported")
97
+ dest.write_bytes(data)
98
+
99
+ async def upload(self, path: Path, reason: str) -> str:
100
+ slot = await self.call(
101
+ "privacyfence_create_upload_slot",
102
+ {"filename": path.name, "size_bytes": path.stat().st_size, "reason": reason},
103
+ )
104
+ async with httpx2.AsyncClient(timeout=_TRANSFER_TIMEOUT) as http:
105
+ response = await http.put(slot["upload_url"], content=path.read_bytes())
106
+ response.raise_for_status()
107
+ return str(slot["upload_id"])
108
+
109
+
110
+ def connect_token(server_url: str, token: str) -> SyncClient:
111
+ return SyncClient(server_url, None, {"Authorization": f"Bearer {token}"})
112
+
113
+
114
+ def connect_oauth(
115
+ server_url: str, storage: FileTokenStorage, *, interactive: bool = False, callback_port: int | None = None
116
+ ) -> SyncClient:
117
+ from .auth import DEFAULT_CALLBACK_PORT, oauth_provider # auth.py imports this module's errors
118
+
119
+ provider = oauth_provider(
120
+ server_url,
121
+ storage,
122
+ interactive=interactive,
123
+ callback_port=DEFAULT_CALLBACK_PORT if callback_port is None else callback_port,
124
+ )
125
+ return SyncClient(server_url, provider, {})