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.
- veil_proxy-0.1.1/PKG-INFO +75 -0
- veil_proxy-0.1.1/README.md +39 -0
- veil_proxy-0.1.1/pyproject.toml +94 -0
- veil_proxy-0.1.1/pyproject.toml.orig +66 -0
- veil_proxy-0.1.1/veil/__init__.py +0 -0
- veil_proxy-0.1.1/veil/adopt.py +238 -0
- veil_proxy-0.1.1/veil/anthropic.py +268 -0
- veil_proxy-0.1.1/veil/cli.py +231 -0
- veil_proxy-0.1.1/veil/cloud.py +150 -0
- veil_proxy-0.1.1/veil/crypto.py +45 -0
- veil_proxy-0.1.1/veil/data/README.md +4 -0
- veil_proxy-0.1.1/veil/data/pg_anonymizer_identifier.csv +172 -0
- veil_proxy-0.1.1/veil/decode.py +90 -0
- veil_proxy-0.1.1/veil/detect.py +208 -0
- veil_proxy-0.1.1/veil/engine.py +140 -0
- veil_proxy-0.1.1/veil/guard.py +230 -0
- veil_proxy-0.1.1/veil/hookclient.py +45 -0
- veil_proxy-0.1.1/veil/hooks.py +37 -0
- veil_proxy-0.1.1/veil/keys.py +139 -0
- veil_proxy-0.1.1/veil/match.py +47 -0
- veil_proxy-0.1.1/veil/ner.py +108 -0
- veil_proxy-0.1.1/veil/openai.py +214 -0
- veil_proxy-0.1.1/veil/proxy.py +375 -0
- veil_proxy-0.1.1/veil/responses.py +194 -0
- veil_proxy-0.1.1/veil/shell.py +91 -0
- veil_proxy-0.1.1/veil/sources/__init__.py +70 -0
- veil_proxy-0.1.1/veil/sources/core.py +378 -0
- veil_proxy-0.1.1/veil/sources/files.py +88 -0
- veil_proxy-0.1.1/veil/sources/kv.py +72 -0
- veil_proxy-0.1.1/veil/sources/mongo.py +72 -0
- veil_proxy-0.1.1/veil/sources/sql.py +233 -0
- veil_proxy-0.1.1/veil/state.py +78 -0
- veil_proxy-0.1.1/veil/stream.py +49 -0
- veil_proxy-0.1.1/veil/team.py +74 -0
- veil_proxy-0.1.1/veil/tokens.py +71 -0
- 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"
|