veil-proxy 0.1.1__tar.gz

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 (36) hide show
  1. veil_proxy-0.1.1/PKG-INFO +75 -0
  2. veil_proxy-0.1.1/README.md +39 -0
  3. veil_proxy-0.1.1/pyproject.toml +94 -0
  4. veil_proxy-0.1.1/pyproject.toml.orig +66 -0
  5. veil_proxy-0.1.1/veil/__init__.py +0 -0
  6. veil_proxy-0.1.1/veil/adopt.py +238 -0
  7. veil_proxy-0.1.1/veil/anthropic.py +268 -0
  8. veil_proxy-0.1.1/veil/cli.py +231 -0
  9. veil_proxy-0.1.1/veil/cloud.py +150 -0
  10. veil_proxy-0.1.1/veil/crypto.py +45 -0
  11. veil_proxy-0.1.1/veil/data/README.md +4 -0
  12. veil_proxy-0.1.1/veil/data/pg_anonymizer_identifier.csv +172 -0
  13. veil_proxy-0.1.1/veil/decode.py +90 -0
  14. veil_proxy-0.1.1/veil/detect.py +208 -0
  15. veil_proxy-0.1.1/veil/engine.py +140 -0
  16. veil_proxy-0.1.1/veil/guard.py +230 -0
  17. veil_proxy-0.1.1/veil/hookclient.py +45 -0
  18. veil_proxy-0.1.1/veil/hooks.py +37 -0
  19. veil_proxy-0.1.1/veil/keys.py +139 -0
  20. veil_proxy-0.1.1/veil/match.py +47 -0
  21. veil_proxy-0.1.1/veil/ner.py +108 -0
  22. veil_proxy-0.1.1/veil/openai.py +214 -0
  23. veil_proxy-0.1.1/veil/proxy.py +375 -0
  24. veil_proxy-0.1.1/veil/responses.py +194 -0
  25. veil_proxy-0.1.1/veil/shell.py +91 -0
  26. veil_proxy-0.1.1/veil/sources/__init__.py +70 -0
  27. veil_proxy-0.1.1/veil/sources/core.py +378 -0
  28. veil_proxy-0.1.1/veil/sources/files.py +88 -0
  29. veil_proxy-0.1.1/veil/sources/kv.py +72 -0
  30. veil_proxy-0.1.1/veil/sources/mongo.py +72 -0
  31. veil_proxy-0.1.1/veil/sources/sql.py +233 -0
  32. veil_proxy-0.1.1/veil/state.py +78 -0
  33. veil_proxy-0.1.1/veil/stream.py +49 -0
  34. veil_proxy-0.1.1/veil/team.py +74 -0
  35. veil_proxy-0.1.1/veil/tokens.py +71 -0
  36. veil_proxy-0.1.1/veil/vault.py +141 -0
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.3
2
+ Name: veil-proxy
3
+ Version: 0.1.1
4
+ Summary: Privacy middleware for AI coding agents: the model sees consistent pseudonyms, your machine keeps the real values
5
+ Keywords: privacy,pii,llm,proxy,pseudonymization,claude-code,coding-agent
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Environment :: Console
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: MacOS
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Security
13
+ Requires-Dist: cryptography>=50.0.2
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: pyahocorasick>=2.3.1
16
+ Requires-Dist: veil-proxy[postgres,mysql,mongo,redis,bedrock] ; extra == 'all'
17
+ Requires-Dist: botocore>=1.43.115 ; extra == 'bedrock'
18
+ Requires-Dist: pymongo>=4.19.0 ; extra == 'mongo'
19
+ Requires-Dist: pymysql>=1.2.3 ; extra == 'mysql'
20
+ Requires-Dist: gliner>=0.2.29 ; extra == 'ner'
21
+ Requires-Dist: onnxruntime>=1.31.0 ; extra == 'ner'
22
+ Requires-Dist: psycopg[binary]>=3.3.6 ; extra == 'postgres'
23
+ Requires-Dist: redis>=8.1.0 ; extra == 'redis'
24
+ Requires-Python: >=3.11
25
+ Project-URL: Homepage, https://github.com/lemonadesaltbagel/veil
26
+ Project-URL: Repository, https://github.com/lemonadesaltbagel/veil
27
+ Project-URL: Issues, https://github.com/lemonadesaltbagel/veil/issues
28
+ Provides-Extra: all
29
+ Provides-Extra: bedrock
30
+ Provides-Extra: mongo
31
+ Provides-Extra: mysql
32
+ Provides-Extra: ner
33
+ Provides-Extra: postgres
34
+ Provides-Extra: redis
35
+ Description-Content-Type: text/markdown
36
+
37
+ # veil
38
+
39
+ Privacy middleware for AI coding agents. The model sees consistent pseudonyms
40
+ (`user_ab12@example.test`, `First_Ab12 Last_Ab12`); your machine keeps the real values,
41
+ and commands the agent runs locally get the real values back (shell- and SQL-safe).
42
+
43
+ ```sh
44
+ pip install "veil-proxy[postgres]" # extras: postgres, mysql, mongo, redis, bedrock, ner, all
45
+ # (installs the `veil` and `veil-hook` commands; import name `veil`)
46
+ veil init ~/code/myapp # seed from .env DB URLs, SQLite, fixtures; wire Claude Code
47
+ veil service install # proxy as a LaunchAgent (macOS) / systemd user unit (Linux)
48
+ veil status ~/code/myapp
49
+ ```
50
+
51
+ Evidence from our own test runs (details and the harness to re-run them are on GitHub:
52
+ https://github.com/lemonadesaltbagel/veil):
53
+
54
+ - 39 requests from real agent sessions against a live model leaked **0** real values (checked with
55
+ a scanner that also decodes escaped and transformed text), and every session still found the bug.
56
+ - Seeding from your own data lets **1.8%** of names and contact details through, versus 80.4% for
57
+ pattern matching (1,000 labelled records).
58
+ - **20/20** one-command bypass transforms are contained, and restored values are escaped for their
59
+ shell/SQL context (35/35 injection tests).
60
+ - Prompt caching keeps working (74–89% cache hits); about 19 ms overhead per request at 40k seeded
61
+ values.
62
+
63
+ One local proxy (default `127.0.0.1:8787`) serves every client, routed by path:
64
+ Anthropic Messages (Claude Code, Agent SDK), Amazon Bedrock InvokeModel (binary eventstream),
65
+ Google Vertex rawPredict, OpenAI Chat Completions (Aider, Continue, OpenAI SDK) and
66
+ OpenAI Responses (Codex CLI).
67
+
68
+ Teams: `veil team export | join` shares the pseudonym secret (never the vault key);
69
+ `veil team managed-settings` writes a Claude Code `managed-settings.d/` drop-in that pins the
70
+ proxy with `allowedProviders: ["customEndpoint"]`.
71
+
72
+ Optional NER for free text: `pip install "veil-proxy[ner]"` then `veil ner-model fetch`.
73
+
74
+ Status: alpha. Keys: macOS Keychain / Linux Secret Service (`secret-tool`), or `VEIL_VAULT_KEY` /
75
+ `VEIL_TOKEN_SECRET` (base64). State: `~/.veil` (encrypted vault).
@@ -0,0 +1,39 @@
1
+ # veil
2
+
3
+ Privacy middleware for AI coding agents. The model sees consistent pseudonyms
4
+ (`user_ab12@example.test`, `First_Ab12 Last_Ab12`); your machine keeps the real values,
5
+ and commands the agent runs locally get the real values back (shell- and SQL-safe).
6
+
7
+ ```sh
8
+ pip install "veil-proxy[postgres]" # extras: postgres, mysql, mongo, redis, bedrock, ner, all
9
+ # (installs the `veil` and `veil-hook` commands; import name `veil`)
10
+ veil init ~/code/myapp # seed from .env DB URLs, SQLite, fixtures; wire Claude Code
11
+ veil service install # proxy as a LaunchAgent (macOS) / systemd user unit (Linux)
12
+ veil status ~/code/myapp
13
+ ```
14
+
15
+ Evidence from our own test runs (details and the harness to re-run them are on GitHub:
16
+ https://github.com/lemonadesaltbagel/veil):
17
+
18
+ - 39 requests from real agent sessions against a live model leaked **0** real values (checked with
19
+ a scanner that also decodes escaped and transformed text), and every session still found the bug.
20
+ - Seeding from your own data lets **1.8%** of names and contact details through, versus 80.4% for
21
+ pattern matching (1,000 labelled records).
22
+ - **20/20** one-command bypass transforms are contained, and restored values are escaped for their
23
+ shell/SQL context (35/35 injection tests).
24
+ - Prompt caching keeps working (74–89% cache hits); about 19 ms overhead per request at 40k seeded
25
+ values.
26
+
27
+ One local proxy (default `127.0.0.1:8787`) serves every client, routed by path:
28
+ Anthropic Messages (Claude Code, Agent SDK), Amazon Bedrock InvokeModel (binary eventstream),
29
+ Google Vertex rawPredict, OpenAI Chat Completions (Aider, Continue, OpenAI SDK) and
30
+ OpenAI Responses (Codex CLI).
31
+
32
+ Teams: `veil team export | join` shares the pseudonym secret (never the vault key);
33
+ `veil team managed-settings` writes a Claude Code `managed-settings.d/` drop-in that pins the
34
+ proxy with `allowedProviders: ["customEndpoint"]`.
35
+
36
+ Optional NER for free text: `pip install "veil-proxy[ner]"` then `veil ner-model fetch`.
37
+
38
+ Status: alpha. Keys: macOS Keychain / Linux Secret Service (`secret-tool`), or `VEIL_VAULT_KEY` /
39
+ `VEIL_TOKEN_SECRET` (base64). State: `~/.veil` (encrypted vault).
@@ -0,0 +1,94 @@
1
+ [project]
2
+ name = "veil-proxy"
3
+ version = "0.1.1"
4
+ description = "Privacy middleware for AI coding agents: the model sees consistent pseudonyms, your machine keeps the real values"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ keywords = [
8
+ "privacy",
9
+ "pii",
10
+ "llm",
11
+ "proxy",
12
+ "pseudonymization",
13
+ "claude-code",
14
+ "coding-agent",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: MacOS",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Programming Language :: Python :: 3",
23
+ "Topic :: Security",
24
+ ]
25
+ dependencies = [
26
+ "cryptography>=50.0.2",
27
+ "httpx>=0.27",
28
+ "pyahocorasick>=2.3.1",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/lemonadesaltbagel/veil"
33
+ Repository = "https://github.com/lemonadesaltbagel/veil"
34
+ Issues = "https://github.com/lemonadesaltbagel/veil/issues"
35
+
36
+ [project.optional-dependencies]
37
+ postgres = ["psycopg[binary]>=3.3.6"]
38
+ mysql = ["pymysql>=1.2.3"]
39
+ mongo = ["pymongo>=4.19.0"]
40
+ redis = ["redis>=8.1.0"]
41
+ bedrock = ["botocore>=1.43.115"]
42
+ ner = [
43
+ "gliner>=0.2.29",
44
+ "onnxruntime>=1.31.0",
45
+ ]
46
+ all = ["veil-proxy[postgres,mysql,mongo,redis,bedrock]"]
47
+
48
+ [project.scripts]
49
+ veil = "veil.cli:main"
50
+ veil-hook = "veil.hookclient:cli"
51
+
52
+ [dependency-groups]
53
+ dev = [
54
+ "anthropic>=1.15.0",
55
+ "botocore>=1.43.115",
56
+ "mongomock>=4.3.0",
57
+ "openai>=3.35.1",
58
+ "pytest>=8",
59
+ "veil-proxy[all]",
60
+ ]
61
+
62
+ [tool.uv.sources.veil-proxy]
63
+ workspace = true
64
+
65
+ [tool.uv.build-backend]
66
+ module-name = "veil"
67
+ module-root = ""
68
+ source-include = ["README.md"]
69
+ source-exclude = [
70
+ "tests",
71
+ "scripts",
72
+ "datasets",
73
+ "captures",
74
+ "models",
75
+ "packaging",
76
+ "demo",
77
+ "dist",
78
+ "veil/testing.py",
79
+ "veil/audit.py",
80
+ "veil/redteam.py",
81
+ "**/__pycache__",
82
+ "**/.DS_Store",
83
+ ]
84
+ wheel-exclude = [
85
+ "veil/testing.py",
86
+ "veil/audit.py",
87
+ "veil/redteam.py",
88
+ "**/__pycache__",
89
+ "**/.DS_Store",
90
+ ]
91
+
92
+ [build-system]
93
+ requires = ["uv_build>=0.12,<0.13"]
94
+ build-backend = "uv_build"
@@ -0,0 +1,66 @@
1
+ [project]
2
+ name = "veil-proxy"
3
+ version = "0.1.1"
4
+ description = "Privacy middleware for AI coding agents: the model sees consistent pseudonyms, your machine keeps the real values"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ keywords = ["privacy", "pii", "llm", "proxy", "pseudonymization", "claude-code", "coding-agent"]
8
+ classifiers = [
9
+ "Development Status :: 3 - Alpha",
10
+ "Environment :: Console",
11
+ "Intended Audience :: Developers",
12
+ "Operating System :: MacOS",
13
+ "Operating System :: POSIX :: Linux",
14
+ "Programming Language :: Python :: 3",
15
+ "Topic :: Security",
16
+ ]
17
+ dependencies = [
18
+ "cryptography>=50.0.2",
19
+ "httpx>=0.27",
20
+ "pyahocorasick>=2.3.1",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/lemonadesaltbagel/veil"
25
+ Repository = "https://github.com/lemonadesaltbagel/veil"
26
+ Issues = "https://github.com/lemonadesaltbagel/veil/issues"
27
+
28
+ [project.optional-dependencies]
29
+ postgres = ["psycopg[binary]>=3.3.6"]
30
+ mysql = ["pymysql>=1.2.3"]
31
+ mongo = ["pymongo>=4.19.0"]
32
+ redis = ["redis>=8.1.0"]
33
+ bedrock = ["botocore>=1.43.115"]
34
+ ner = ["gliner>=0.2.29", "onnxruntime>=1.31.0"]
35
+ all = ["veil-proxy[postgres,mysql,mongo,redis,bedrock]"]
36
+
37
+ [project.scripts]
38
+ veil = "veil.cli:main"
39
+ veil-hook = "veil.hookclient:cli"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "anthropic>=1.15.0",
44
+ "botocore>=1.43.115",
45
+ "mongomock>=4.3.0",
46
+ "openai>=3.35.1",
47
+ "pytest>=8",
48
+ "veil-proxy[all]",
49
+ ]
50
+
51
+ [tool.uv.sources]
52
+ veil-proxy = { workspace = true }
53
+
54
+ # Ship only the runtime package. Not shipped: tests, scripts, datasets, captures, models, and the
55
+ # dev-only harness modules (audit/testing/redteam need the sample datasets from a source checkout).
56
+ [tool.uv.build-backend]
57
+ module-name = "veil"
58
+ module-root = ""
59
+ source-include = ["README.md"]
60
+ source-exclude = ["tests", "scripts", "datasets", "captures", "models", "packaging", "demo", "dist",
61
+ "veil/testing.py", "veil/audit.py", "veil/redteam.py", "**/__pycache__", "**/.DS_Store"]
62
+ wheel-exclude = ["veil/testing.py", "veil/audit.py", "veil/redteam.py", "**/__pycache__", "**/.DS_Store"]
63
+
64
+ [build-system]
65
+ requires = ["uv_build>=0.12,<0.13"]
66
+ build-backend = "uv_build"
File without changes
@@ -0,0 +1,238 @@
1
+ """Adoption: `veil init`, `veil status`, `veil service` (macOS LaunchAgent).
2
+
3
+ `veil init` finds a project's data sources, seeds them, and writes a project-local
4
+ `.claude/settings.local.json` (merged, never clobbered; git-ignored) that routes
5
+ Claude Code through the proxy and adds veil's hooks as a backup layer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import plistlib
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ import urllib.request
19
+
20
+ URL_VARS = re.compile(r"^\s*(?:export\s+)?([A-Z0-9_]*(?:DATABASE|DB|POSTGRES|PG|MYSQL|MARIADB|MONGO|MONGODB|REDIS)"
21
+ r"[A-Z0-9_]*(?:URL|URI|DSN))\s*=\s*['\"]?([^'\"\s#]+)", re.M)
22
+ SCHEMES = ("postgres://", "postgresql://", "mysql://", "mariadb://", "mongodb://", "mongodb+srv://",
23
+ "redis://", "rediss://", "sqlite:", "file:")
24
+ FIXTURE_DIRS = re.compile(r"^(fixtures?|seeds?|seed_data|testdata|test_data|sample[-_]?data|mock[-_]?data)$", re.I)
25
+ SKIP = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".veil", ".next", "target"}
26
+ LABEL = os.environ.get("VEIL_SERVICE_LABEL", "dev.veil.proxy")
27
+
28
+
29
+ def detect_sources(project: str) -> list[tuple[str, str]]:
30
+ """[(source, where it was found)] -- credentials stay in the source string, never printed."""
31
+ found: dict[str, str] = {}
32
+ for root, dirs, files in os.walk(project):
33
+ dirs[:] = [d for d in dirs if d not in SKIP]
34
+ depth = root[len(project):].count(os.sep)
35
+ for f in files:
36
+ path = os.path.join(root, f)
37
+ rel = os.path.relpath(path, project)
38
+ if f.startswith(".env") and depth <= 2:
39
+ try:
40
+ for var, url in URL_VARS.findall(open(path, errors="ignore").read()):
41
+ if url.startswith(SCHEMES):
42
+ found.setdefault(url, f"{rel}:{var}")
43
+ except OSError:
44
+ pass
45
+ elif f.endswith((".sqlite", ".sqlite3", ".db")) and os.path.getsize(path) < 2_000_000_000:
46
+ found.setdefault(path, rel)
47
+ for d in list(dirs):
48
+ if FIXTURE_DIRS.match(d):
49
+ found.setdefault(os.path.join(root, d), os.path.relpath(os.path.join(root, d), project) + "/")
50
+ return list(found.items())
51
+
52
+
53
+ def redact_uri(uri: str) -> str:
54
+ return re.sub(r"//([^:@/]+)(:[^@/]*)?@", r"//\1:***@", uri)
55
+
56
+
57
+ def veil_command() -> list[str]:
58
+ """How hooks and the service invoke veil (absolute; independent of cwd/venv activation)."""
59
+ return [sys.executable, "-m", "veil.cli"]
60
+
61
+
62
+ def write_claude_settings(project: str, port: int, hooks: bool = True, deny_env: bool = False) -> str:
63
+ d = os.path.join(project, ".claude")
64
+ os.makedirs(d, exist_ok=True)
65
+ path = os.path.join(d, "settings.local.json")
66
+ settings = {}
67
+ if os.path.exists(path):
68
+ try:
69
+ settings = json.load(open(path))
70
+ except ValueError:
71
+ raise SystemExit(f"{path} is not valid JSON; fix it before running veil init")
72
+ env = settings.setdefault("env", {})
73
+ env["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}"
74
+ no_proxy = {h for h in env.get("NO_PROXY", "").split(",") if h} | {"127.0.0.1", "localhost"}
75
+ env["NO_PROXY"] = ",".join(sorted(no_proxy))
76
+ if hooks:
77
+ cmd = " ".join(f'"{c}"' if " " in c else c for c in veil_command())
78
+ all_hooks = settings.setdefault("hooks", {})
79
+ for event, kind in (("PreToolUse", "pre"), ("PostToolUse", "post")):
80
+ entries = [e for e in all_hooks.get(event, [])
81
+ if "veil.cli hook" not in json.dumps(e) and "veil.hookclient" not in json.dumps(e)]
82
+ fast = " ".join(f'"{c}"' if " " in c else c for c in [sys.executable, "-m", "veil.hookclient"])
83
+ entries.append({"matcher": "*", "hooks": [{"type": "command",
84
+ "command": f"{fast} {kind} --port {port}"}]})
85
+ all_hooks[event] = entries
86
+ if deny_env:
87
+ deny = settings.setdefault("permissions", {}).setdefault("deny", [])
88
+ for rule in ("Read(./.env)", "Read(./.env.*)"):
89
+ if rule not in deny:
90
+ deny.append(rule)
91
+ tmp = path + ".tmp"
92
+ with open(tmp, "w") as f:
93
+ json.dump(settings, f, indent=2)
94
+ f.write("\n")
95
+ os.replace(tmp, path)
96
+ gi = os.path.join(project, ".gitignore")
97
+ if os.path.isdir(os.path.join(project, ".git")):
98
+ lines = open(gi).read().splitlines() if os.path.exists(gi) else []
99
+ if ".claude/settings.local.json" not in lines:
100
+ with open(gi, "a") as f:
101
+ f.write(("\n" if lines and lines[-1] else "") + ".claude/settings.local.json\n")
102
+ return path
103
+
104
+
105
+ def health(port: int, timeout: float = 2.0) -> dict | None:
106
+ try:
107
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) # bypass system proxies
108
+ with opener.open(f"http://127.0.0.1:{port}/veil/health", timeout=timeout) as r:
109
+ return json.loads(r.read())
110
+ except Exception:
111
+ return None
112
+
113
+
114
+ # -- macOS LaunchAgent ----------------------------------------------------------
115
+
116
+ def agents_dir() -> str:
117
+ return os.environ.get("VEIL_LAUNCH_AGENTS_DIR", os.path.expanduser("~/Library/LaunchAgents"))
118
+
119
+
120
+ def plist_path() -> str:
121
+ return os.path.join(agents_dir(), f"{LABEL}.plist")
122
+
123
+
124
+ def service_plist(port: int, upstream: str, ner: bool, home: str, capture: str | None = None) -> dict:
125
+ args = veil_command() + ["proxy", "--port", str(port), "--upstream", upstream] + (["--ner"] if ner else []) \
126
+ + (["--capture", capture] if capture else [])
127
+ env = {"VEIL_HOME": home, "NO_PROXY": "127.0.0.1,localhost"}
128
+ for k in ("VEIL_VAULT_KEY", "VEIL_TOKEN_SECRET", "VEIL_KEYCHAIN"): # only if the caller uses them
129
+ if os.environ.get(k):
130
+ env[k] = os.environ[k]
131
+ return {"Label": LABEL, "ProgramArguments": args, "RunAtLoad": True, "KeepAlive": True,
132
+ "EnvironmentVariables": env, "WorkingDirectory": home,
133
+ "StandardOutPath": os.path.join(home, "proxy.log"), "StandardErrorPath": os.path.join(home, "proxy.log")}
134
+
135
+
136
+ def service_install(port: int, upstream: str, ner: bool, home: str, load: bool = True,
137
+ capture: str | None = None) -> str:
138
+ os.makedirs(agents_dir(), exist_ok=True)
139
+ path = plist_path()
140
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) # may carry env keys: owner-only
141
+ with os.fdopen(fd, "wb") as f:
142
+ plistlib.dump(service_plist(port, upstream, ner, home, capture), f)
143
+ if load:
144
+ uid = os.getuid()
145
+ subprocess.run(["launchctl", "bootout", f"gui/{uid}/{LABEL}"], capture_output=True)
146
+ # bootout is asynchronous: wait for the old instance to unload, then (re)try bootstrap
147
+ for _ in range(50):
148
+ if service_status() == "not loaded":
149
+ break
150
+ time.sleep(0.1)
151
+ for attempt in range(5):
152
+ r = subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", path], capture_output=True, text=True)
153
+ if r.returncode == 0:
154
+ break
155
+ time.sleep(0.5 * (attempt + 1))
156
+ else:
157
+ raise SystemExit(f"launchctl bootstrap failed: {r.stderr.strip()}")
158
+ return path
159
+
160
+
161
+ def service_uninstall() -> bool:
162
+ subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}/{LABEL}"], capture_output=True)
163
+ if os.path.exists(plist_path()):
164
+ os.remove(plist_path())
165
+ return True
166
+ return False
167
+
168
+
169
+ def service_status() -> str:
170
+ r = subprocess.run(["launchctl", "print", f"gui/{os.getuid()}/{LABEL}"], capture_output=True, text=True)
171
+ if r.returncode != 0:
172
+ return "not loaded"
173
+ m = re.search(r"state = (\w+)", r.stdout)
174
+ pid = re.search(r"pid = (\d+)", r.stdout)
175
+ return f"{m.group(1) if m else 'loaded'}" + (f" (pid {pid.group(1)})" if pid else "")
176
+
177
+
178
+ # -- Linux: systemd user unit ---------------------------------------------------------
179
+
180
+ UNIT = "veil-proxy.service"
181
+
182
+
183
+ def systemd_dir() -> str:
184
+ return os.environ.get("VEIL_SYSTEMD_DIR", os.path.expanduser("~/.config/systemd/user"))
185
+
186
+
187
+ def systemd_unit(port: int, upstream: str, ner: bool, home: str, capture: str | None = None) -> str:
188
+ plist = service_plist(port, upstream, ner, home, capture)
189
+ q = lambda a: '"' + a.replace("\\", "\\\\").replace('"', '\\"') + '"' if any(c in a for c in ' "\'') else a
190
+ env = "\n".join(f'Environment="{k}={v}"' for k, v in plist["EnvironmentVariables"].items())
191
+ return f"""[Unit]
192
+ Description=veil privacy proxy for AI coding agents
193
+ After=network-online.target
194
+
195
+ [Service]
196
+ ExecStart={" ".join(q(a) for a in plist["ProgramArguments"])}
197
+ {env}
198
+ Restart=on-failure
199
+ RestartSec=2
200
+ NoNewPrivileges=yes
201
+ PrivateTmp=yes
202
+ ProtectSystem=strict
203
+ ProtectHome=read-only
204
+ ReadWritePaths={home}
205
+
206
+ [Install]
207
+ WantedBy=default.target
208
+ """
209
+
210
+
211
+ def systemd_install(port: int, upstream: str, ner: bool, home: str, load: bool = True,
212
+ capture: str | None = None) -> str:
213
+ os.makedirs(systemd_dir(), exist_ok=True)
214
+ path = os.path.join(systemd_dir(), UNIT)
215
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) # may carry env keys: owner-only
216
+ with os.fdopen(fd, "w") as f:
217
+ f.write(systemd_unit(port, upstream, ner, home, capture))
218
+ if load:
219
+ for args in (["daemon-reload"], ["enable", "--now", UNIT], ["restart", UNIT]):
220
+ r = subprocess.run(["systemctl", "--user", *args], capture_output=True, text=True)
221
+ if r.returncode != 0:
222
+ raise SystemExit(f"systemctl --user {' '.join(args)} failed: {r.stderr.strip()}")
223
+ return path
224
+
225
+
226
+ def systemd_uninstall() -> bool:
227
+ subprocess.run(["systemctl", "--user", "disable", "--now", UNIT], capture_output=True)
228
+ path = os.path.join(systemd_dir(), UNIT)
229
+ if os.path.exists(path):
230
+ os.remove(path)
231
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
232
+ return True
233
+ return False
234
+
235
+
236
+ def systemd_status() -> str:
237
+ r = subprocess.run(["systemctl", "--user", "is-active", UNIT], capture_output=True, text=True)
238
+ return r.stdout.strip() or "not loaded"