veil-proxy 0.1.2__tar.gz → 0.2.0__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.2 → veil_proxy-0.2.0}/PKG-INFO +22 -9
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/README.md +19 -8
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/pyproject.toml +2 -1
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/adopt.py +83 -2
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/anthropic.py +21 -2
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/cli.py +171 -6
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/cloud.py +69 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/data/README.md +4 -0
- veil_proxy-0.2.0/veil/data/words.txt.gz +0 -0
- veil_proxy-0.2.0/veil/decode.py +135 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/detect.py +72 -17
- veil_proxy-0.2.0/veil/display.py +58 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/engine.py +69 -13
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/guard.py +108 -81
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/hookclient.py +3 -1
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/keys.py +105 -3
- veil_proxy-0.2.0/veil/match.py +143 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/proxy.py +57 -15
- veil_proxy-0.2.0/veil/quasi.py +53 -0
- veil_proxy-0.2.0/veil/reseed.py +185 -0
- veil_proxy-0.2.0/veil/rotate.py +116 -0
- veil_proxy-0.2.0/veil/shell.py +711 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/sources/__init__.py +8 -2
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/sources/core.py +89 -10
- veil_proxy-0.2.0/veil/sources/elastic.py +112 -0
- veil_proxy-0.2.0/veil/sources/mssql.py +162 -0
- veil_proxy-0.2.0/veil/sources/schema.py +181 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/sources/sql.py +10 -5
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/state.py +42 -1
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/stream.py +1 -1
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/team.py +4 -1
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/vault.py +28 -0
- veil_proxy-0.1.2/veil/decode.py +0 -90
- veil_proxy-0.1.2/veil/match.py +0 -47
- veil_proxy-0.1.2/veil/shell.py +0 -91
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/__init__.py +0 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/crypto.py +0 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/data/pg_anonymizer_identifier.csv +0 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/hooks.py +0 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/ner.py +0 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/openai.py +0 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/responses.py +0 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/sources/files.py +0 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/sources/kv.py +0 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/sources/mongo.py +0 -0
- {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/tokens.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: veil-proxy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Privacy middleware for AI coding agents: the model sees consistent pseudonyms, your machine keeps the real values
|
|
5
5
|
Keywords: privacy,pii,llm,proxy,pseudonymization,claude-code,coding-agent
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -18,6 +18,7 @@ Requires-Dist: pyahocorasick>=2.3.1
|
|
|
18
18
|
Requires-Dist: veil-proxy[postgres, mysql, mongo, redis, bedrock] ; extra == "all"
|
|
19
19
|
Requires-Dist: botocore>=1.43.115 ; extra == "bedrock"
|
|
20
20
|
Requires-Dist: pymongo>=4.19.0 ; extra == "mongo"
|
|
21
|
+
Requires-Dist: pymssql>=2.3 ; extra == "mssql"
|
|
21
22
|
Requires-Dist: pymysql>=1.2.3 ; extra == "mysql"
|
|
22
23
|
Requires-Dist: gliner>=0.2.29 ; extra == "ner"
|
|
23
24
|
Requires-Dist: onnxruntime>=1.31.0 ; extra == "ner"
|
|
@@ -29,6 +30,7 @@ Project-URL: Repository, https://github.com/lemonadesaltbagel/veil
|
|
|
29
30
|
Provides-Extra: all
|
|
30
31
|
Provides-Extra: bedrock
|
|
31
32
|
Provides-Extra: mongo
|
|
33
|
+
Provides-Extra: mssql
|
|
32
34
|
Provides-Extra: mysql
|
|
33
35
|
Provides-Extra: ner
|
|
34
36
|
Provides-Extra: postgres
|
|
@@ -41,7 +43,7 @@ Privacy middleware for AI coding agents. The model sees consistent pseudonyms
|
|
|
41
43
|
and commands the agent runs locally get the real values back (shell- and SQL-safe).
|
|
42
44
|
|
|
43
45
|
```sh
|
|
44
|
-
pip install "veil-proxy[postgres]" # extras: postgres, mysql, mongo, redis, bedrock, ner, all
|
|
46
|
+
pip install "veil-proxy[postgres]" # extras: postgres, mysql, mongo, redis, bedrock, ner, mssql, all
|
|
45
47
|
# (installs the `veil` and `veil-hook` commands; import name `veil`)
|
|
46
48
|
veil init ~/code/myapp # seed from .env DB URLs, SQLite, fixtures; wire Claude Code
|
|
47
49
|
veil service install # proxy as a LaunchAgent (macOS) / systemd user unit (Linux)
|
|
@@ -55,15 +57,26 @@ https://github.com/lemonadesaltbagel/veil):
|
|
|
55
57
|
a scanner that also decodes escaped and transformed text), and every session still found the bug.
|
|
56
58
|
- Seeding from your own data lets **1.8%** of names and contact details through, versus 80.4% for
|
|
57
59
|
pattern matching (1,000 labelled records).
|
|
58
|
-
- **
|
|
59
|
-
|
|
60
|
+
- **21/21** one-command bypass transforms are contained. Restored values are escaped for every
|
|
61
|
+
language layer they pass through (shell, heredocs, `python -c`, `node -e`, SQL, `bash -c`...):
|
|
62
|
+
35/35 injection tests, and 55,000 fuzzed nested commands executed with 0 code executions.
|
|
60
63
|
- Prompt caching keeps working (74–89% cache hits); about 19 ms overhead per request at 40k seeded
|
|
61
|
-
values.
|
|
64
|
+
values, and 37 + 68 ms per 234 KB request at 1.2 million seeded values (1.1 GB).
|
|
62
65
|
|
|
63
66
|
One local proxy (default `127.0.0.1:8787`) serves every client, routed by path:
|
|
64
67
|
Anthropic Messages (Claude Code, Agent SDK), Amazon Bedrock InvokeModel (binary eventstream),
|
|
65
|
-
Google Vertex rawPredict,
|
|
66
|
-
OpenAI Responses (Codex CLI).
|
|
68
|
+
Google Vertex rawPredict, Microsoft Foundry (`/foundry`), Claude Platform on AWS (`/anthropic-aws`),
|
|
69
|
+
OpenAI Chat Completions (Aider, Continue, OpenAI SDK) and OpenAI Responses (Codex CLI).
|
|
70
|
+
|
|
71
|
+
Seeds from Postgres, MySQL/MariaDB, SQLite, SQL Server (native sensitivity labels), MongoDB, Redis,
|
|
72
|
+
Elasticsearch/OpenSearch and fixture files, plus personal fields declared in code (Prisma, Django,
|
|
73
|
+
Rails, SQLAlchemy, OpenAPI, protobuf), and re-seeds automatically when the data changes.
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
veil display tokens # screen sharing: answers keep pseudonyms (veil display real to undo)
|
|
77
|
+
veil quasi redact # also pseudonymise rare cities, postal codes, birth dates, employers
|
|
78
|
+
veil keys rotate # re-encrypt the vault; --token-secret for new pseudonyms
|
|
79
|
+
```
|
|
67
80
|
|
|
68
81
|
Teams: `veil team export | join` shares the pseudonym secret (never the vault key);
|
|
69
82
|
`veil team managed-settings` writes a Claude Code `managed-settings.d/` drop-in that pins the
|
|
@@ -71,6 +84,6 @@ proxy with `allowedProviders: ["customEndpoint"]`.
|
|
|
71
84
|
|
|
72
85
|
Optional NER for free text: `pip install "veil-proxy[ner]"` then `veil ner-model fetch`.
|
|
73
86
|
|
|
74
|
-
Status: alpha. Keys: macOS Keychain / Linux Secret Service (`secret-tool`)
|
|
75
|
-
`VEIL_TOKEN_SECRET` (base64). State: `~/.veil` (encrypted vault).
|
|
87
|
+
Status: alpha. Keys: macOS Keychain / Linux Secret Service (`secret-tool`) / Windows Credential
|
|
88
|
+
Manager, or `VEIL_VAULT_KEY` / `VEIL_TOKEN_SECRET` (base64). State: `~/.veil` (encrypted vault).
|
|
76
89
|
|
|
@@ -5,7 +5,7 @@ Privacy middleware for AI coding agents. The model sees consistent pseudonyms
|
|
|
5
5
|
and commands the agent runs locally get the real values back (shell- and SQL-safe).
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
pip install "veil-proxy[postgres]" # extras: postgres, mysql, mongo, redis, bedrock, ner, all
|
|
8
|
+
pip install "veil-proxy[postgres]" # extras: postgres, mysql, mongo, redis, bedrock, ner, mssql, all
|
|
9
9
|
# (installs the `veil` and `veil-hook` commands; import name `veil`)
|
|
10
10
|
veil init ~/code/myapp # seed from .env DB URLs, SQLite, fixtures; wire Claude Code
|
|
11
11
|
veil service install # proxy as a LaunchAgent (macOS) / systemd user unit (Linux)
|
|
@@ -19,15 +19,26 @@ https://github.com/lemonadesaltbagel/veil):
|
|
|
19
19
|
a scanner that also decodes escaped and transformed text), and every session still found the bug.
|
|
20
20
|
- Seeding from your own data lets **1.8%** of names and contact details through, versus 80.4% for
|
|
21
21
|
pattern matching (1,000 labelled records).
|
|
22
|
-
- **
|
|
23
|
-
|
|
22
|
+
- **21/21** one-command bypass transforms are contained. Restored values are escaped for every
|
|
23
|
+
language layer they pass through (shell, heredocs, `python -c`, `node -e`, SQL, `bash -c`...):
|
|
24
|
+
35/35 injection tests, and 55,000 fuzzed nested commands executed with 0 code executions.
|
|
24
25
|
- Prompt caching keeps working (74–89% cache hits); about 19 ms overhead per request at 40k seeded
|
|
25
|
-
values.
|
|
26
|
+
values, and 37 + 68 ms per 234 KB request at 1.2 million seeded values (1.1 GB).
|
|
26
27
|
|
|
27
28
|
One local proxy (default `127.0.0.1:8787`) serves every client, routed by path:
|
|
28
29
|
Anthropic Messages (Claude Code, Agent SDK), Amazon Bedrock InvokeModel (binary eventstream),
|
|
29
|
-
Google Vertex rawPredict,
|
|
30
|
-
OpenAI Responses (Codex CLI).
|
|
30
|
+
Google Vertex rawPredict, Microsoft Foundry (`/foundry`), Claude Platform on AWS (`/anthropic-aws`),
|
|
31
|
+
OpenAI Chat Completions (Aider, Continue, OpenAI SDK) and OpenAI Responses (Codex CLI).
|
|
32
|
+
|
|
33
|
+
Seeds from Postgres, MySQL/MariaDB, SQLite, SQL Server (native sensitivity labels), MongoDB, Redis,
|
|
34
|
+
Elasticsearch/OpenSearch and fixture files, plus personal fields declared in code (Prisma, Django,
|
|
35
|
+
Rails, SQLAlchemy, OpenAPI, protobuf), and re-seeds automatically when the data changes.
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
veil display tokens # screen sharing: answers keep pseudonyms (veil display real to undo)
|
|
39
|
+
veil quasi redact # also pseudonymise rare cities, postal codes, birth dates, employers
|
|
40
|
+
veil keys rotate # re-encrypt the vault; --token-secret for new pseudonyms
|
|
41
|
+
```
|
|
31
42
|
|
|
32
43
|
Teams: `veil team export | join` shares the pseudonym secret (never the vault key);
|
|
33
44
|
`veil team managed-settings` writes a Claude Code `managed-settings.d/` drop-in that pins the
|
|
@@ -35,5 +46,5 @@ proxy with `allowedProviders: ["customEndpoint"]`.
|
|
|
35
46
|
|
|
36
47
|
Optional NER for free text: `pip install "veil-proxy[ner]"` then `veil ner-model fetch`.
|
|
37
48
|
|
|
38
|
-
Status: alpha. Keys: macOS Keychain / Linux Secret Service (`secret-tool`)
|
|
39
|
-
`VEIL_TOKEN_SECRET` (base64). State: `~/.veil` (encrypted vault).
|
|
49
|
+
Status: alpha. Keys: macOS Keychain / Linux Secret Service (`secret-tool`) / Windows Credential
|
|
50
|
+
Manager, or `VEIL_VAULT_KEY` / `VEIL_TOKEN_SECRET` (base64). State: `~/.veil` (encrypted vault).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "veil-proxy"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Privacy middleware for AI coding agents: the model sees consistent pseudonyms, your machine keeps the real values"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -32,6 +32,7 @@ mongo = ["pymongo>=4.19.0"]
|
|
|
32
32
|
redis = ["redis>=8.1.0"]
|
|
33
33
|
bedrock = ["botocore>=1.43.115"]
|
|
34
34
|
ner = ["gliner>=0.2.29", "onnxruntime>=1.31.0"]
|
|
35
|
+
mssql = ["pymssql>=2.3"] # not in `all`: building it from source needs FreeTDS
|
|
35
36
|
all = ["veil-proxy[postgres,mysql,mongo,redis,bedrock]"]
|
|
36
37
|
|
|
37
38
|
[project.scripts]
|
|
@@ -54,6 +54,17 @@ def redact_uri(uri: str) -> str:
|
|
|
54
54
|
return re.sub(r"//([^:@/]+)(:[^@/]*)?@", r"//\1:***@", uri)
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def _q(arg: str) -> str:
|
|
58
|
+
"""Quote for the hook shell (sh on macOS/Linux, Git Bash or cmd on Windows): double
|
|
59
|
+
quotes keep spaces and Windows backslashes (C:\\Users\\...) intact in all of them."""
|
|
60
|
+
return f'"{arg}"' if " " in arg or "\\" in arg else arg
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def state_home() -> str:
|
|
64
|
+
from .state import home
|
|
65
|
+
return home()
|
|
66
|
+
|
|
67
|
+
|
|
57
68
|
def veil_command() -> list[str]:
|
|
58
69
|
"""How hooks and the service invoke veil (absolute; independent of cwd/venv activation)."""
|
|
59
70
|
return [sys.executable, "-m", "veil.cli"]
|
|
@@ -79,9 +90,11 @@ def write_claude_settings(project: str, port: int, hooks: bool = True, deny_env:
|
|
|
79
90
|
for event, kind in (("PreToolUse", "pre"), ("PostToolUse", "post")):
|
|
80
91
|
entries = [e for e in all_hooks.get(event, [])
|
|
81
92
|
if "veil.cli hook" not in json.dumps(e) and "veil.hookclient" not in json.dumps(e)]
|
|
82
|
-
fast = " ".join(
|
|
93
|
+
fast = " ".join(_q(c) for c in [sys.executable, "-m", "veil.hookclient"])
|
|
94
|
+
home = state_home()
|
|
95
|
+
home_arg = f" --home {_q(home)}"
|
|
83
96
|
entries.append({"matcher": "*", "hooks": [{"type": "command",
|
|
84
|
-
"command": f"{fast} {kind} --port {port}"}]})
|
|
97
|
+
"command": f"{fast} {kind} --port {port}{home_arg}"}]})
|
|
85
98
|
all_hooks[event] = entries
|
|
86
99
|
if deny_env:
|
|
87
100
|
deny = settings.setdefault("permissions", {}).setdefault("deny", [])
|
|
@@ -236,3 +249,71 @@ def systemd_uninstall() -> bool:
|
|
|
236
249
|
def systemd_status() -> str:
|
|
237
250
|
r = subprocess.run(["systemctl", "--user", "is-active", UNIT], capture_output=True, text=True)
|
|
238
251
|
return r.stdout.strip() or "not loaded"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# -- Windows: a launcher in the user's Startup folder (no admin rights needed) --------
|
|
255
|
+
|
|
256
|
+
def startup_dir() -> str:
|
|
257
|
+
return os.environ.get("VEIL_STARTUP_DIR") or os.path.join(
|
|
258
|
+
os.environ.get("APPDATA", os.path.expanduser("~")), "Microsoft", "Windows", "Start Menu", "Programs", "Startup")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def windows_launcher_path() -> str:
|
|
262
|
+
return os.path.join(startup_dir(), f"{LABEL}.vbs")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _windows_args(port: int, upstream: str, ner: bool, home: str, capture: str | None) -> list[str]:
|
|
266
|
+
exe = sys.executable
|
|
267
|
+
pyw = os.path.join(os.path.dirname(exe), "pythonw.exe")
|
|
268
|
+
exe = pyw if os.path.exists(pyw) else exe # no console window
|
|
269
|
+
return [exe, "-m", "veil.cli", "proxy", "--port", str(port), "--upstream", upstream,
|
|
270
|
+
"--pid-file", os.path.join(home, "proxy.pid")] + (["--ner"] if ner else []) \
|
|
271
|
+
+ (["--capture", capture] if capture else [])
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def windows_launcher(port: int, upstream: str, ner: bool, home: str, capture: str | None = None) -> str:
|
|
275
|
+
"""VBScript: sets VEIL_HOME and starts the proxy hidden at logon. Keys come from the
|
|
276
|
+
Credential Manager; environment keys are deliberately not written into the script."""
|
|
277
|
+
vbs = lambda s: '"' + s.replace('"', '""') + '"'
|
|
278
|
+
cmdline = " ".join('"' + a + '"' for a in _windows_args(port, upstream, ner, home, capture))
|
|
279
|
+
return "\r\n".join([
|
|
280
|
+
"' veil proxy launcher (veil service install); remove with: veil service uninstall",
|
|
281
|
+
'Set sh = CreateObject("WScript.Shell")',
|
|
282
|
+
f'sh.Environment("Process")("VEIL_HOME") = {vbs(home)}',
|
|
283
|
+
'sh.Environment("Process")("NO_PROXY") = "127.0.0.1,localhost"',
|
|
284
|
+
f"sh.Run {vbs(cmdline)}, 0, False", ""])
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def windows_install(port: int, upstream: str, ner: bool, home: str, start: bool = True,
|
|
288
|
+
capture: str | None = None) -> str:
|
|
289
|
+
os.makedirs(startup_dir(), exist_ok=True)
|
|
290
|
+
path = windows_launcher_path()
|
|
291
|
+
with open(path, "w", encoding="utf-8", newline="") as f:
|
|
292
|
+
f.write(windows_launcher(port, upstream, ner, home, capture))
|
|
293
|
+
if start and health(port) is None:
|
|
294
|
+
flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
295
|
+
subprocess.Popen(_windows_args(port, upstream, ner, home, capture), creationflags=flags, close_fds=True,
|
|
296
|
+
env={**os.environ, "VEIL_HOME": home, "NO_PROXY": "127.0.0.1,localhost"},
|
|
297
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
|
|
298
|
+
return path
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def windows_uninstall(home: str) -> bool:
|
|
302
|
+
import signal
|
|
303
|
+
path, found = windows_launcher_path(), False
|
|
304
|
+
if os.path.exists(path):
|
|
305
|
+
os.remove(path)
|
|
306
|
+
found = True
|
|
307
|
+
pid_file = os.path.join(home, "proxy.pid")
|
|
308
|
+
try:
|
|
309
|
+
os.kill(int(open(pid_file).read().strip()), signal.SIGTERM) # TerminateProcess on Windows
|
|
310
|
+
found = True
|
|
311
|
+
except (OSError, ValueError):
|
|
312
|
+
pass
|
|
313
|
+
return found
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def windows_status(port: int = 8787) -> str:
|
|
317
|
+
installed = os.path.exists(windows_launcher_path())
|
|
318
|
+
running = health(port) is not None
|
|
319
|
+
return f"{'installed' if installed else 'not installed'} (Startup folder), {'running' if running else 'not running'}"
|
|
@@ -82,10 +82,29 @@ def _fragment_notice(veil: Veil, codes: list[str]) -> str:
|
|
|
82
82
|
return "echo " + "'" + msg.replace("'", "") + "'"
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
def _texts(obj) -> str:
|
|
86
|
+
"""Every string in a tool input, NUL-separated. (Scanning json.dumps() output glued
|
|
87
|
+
the `n` of an escaped newline onto a token after it, so the token went unseen.)"""
|
|
88
|
+
out: list[str] = []
|
|
89
|
+
|
|
90
|
+
def walk(o):
|
|
91
|
+
if isinstance(o, str):
|
|
92
|
+
out.append(o)
|
|
93
|
+
elif isinstance(o, dict):
|
|
94
|
+
for k, v in o.items():
|
|
95
|
+
walk(k)
|
|
96
|
+
walk(v)
|
|
97
|
+
elif isinstance(o, list):
|
|
98
|
+
for v in o:
|
|
99
|
+
walk(v)
|
|
100
|
+
walk(obj)
|
|
101
|
+
return "\0".join(out)
|
|
102
|
+
|
|
103
|
+
|
|
85
104
|
def rehydrate_tool_input(veil: Veil, policy: Policy, tool: str, tool_input: dict) -> dict:
|
|
86
105
|
mode = policy.mode(tool, tool_input)
|
|
87
106
|
if mode == "full":
|
|
88
|
-
frags = veil.fragments(
|
|
107
|
+
frags = veil.fragments(_texts(tool_input))
|
|
89
108
|
if frags and tool == "Grep" and isinstance(tool_input.get("pattern"), str):
|
|
90
109
|
# "lines about person mz5z": expand the fragment to that person's real values
|
|
91
110
|
pat, codes = tool_input["pattern"], veil.codes()
|
|
@@ -117,7 +136,7 @@ def rehydrate_tool_input(veil: Veil, policy: Policy, tool: str, tool_input: dict
|
|
|
117
136
|
existing = f.read()
|
|
118
137
|
except OSError:
|
|
119
138
|
existing = ""
|
|
120
|
-
allowed = {t for t in veil.tokens_in(
|
|
139
|
+
allowed = {t for t in veil.tokens_in(_texts(tool_input))
|
|
121
140
|
if (veil.vault.value_for(t) or "\0") in existing}
|
|
122
141
|
for k in CONTENT_FIELDS & out.keys():
|
|
123
142
|
out[k] = veil.rehydrate_json(out[k], only=allowed)
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
veil seed-env learn the developer's own identity (OS user, git author)
|
|
6
6
|
veil redact < file print the LLM's view of a file
|
|
7
7
|
veil rehydrate < file map tokens back to real values
|
|
8
|
+
veil display [real|tokens] what you see in answers (default real; tokens for screen shares)
|
|
9
|
+
veil reveal [FILE] pseudonyms -> real values (alias of rehydrate)
|
|
10
|
+
veil share [FILE] real values -> pseudonyms, safe to paste into tickets/chat
|
|
8
11
|
veil proxy [--port 8787] run the ANTHROPIC_BASE_URL proxy
|
|
9
12
|
veil hook pre|post Claude Code PreToolUse/PostToolUse hook
|
|
10
13
|
veil audit [--driver ...] run an agent session through veil, report what the LLM received
|
|
@@ -34,6 +37,26 @@ def _hook(veil, kind: str) -> int:
|
|
|
34
37
|
return 0
|
|
35
38
|
|
|
36
39
|
|
|
40
|
+
def _learn_schema(project: str, save: bool = True) -> dict:
|
|
41
|
+
"""Field hints from the project's ORM schema / annotations, remembered in config.json
|
|
42
|
+
(names only) so every later seed and re-seed applies them."""
|
|
43
|
+
from .sources import core, schema
|
|
44
|
+
hints = schema.scan(project)
|
|
45
|
+
if hints and not save:
|
|
46
|
+
print(f" schema {sum(map(len, hints.values()))} personal field(s) declared in code", file=sys.stderr)
|
|
47
|
+
elif hints:
|
|
48
|
+
cfg = state.config()
|
|
49
|
+
merged = cfg.get("schema_hints", {})
|
|
50
|
+
for table, fields in hints.items():
|
|
51
|
+
merged.setdefault(table, {}).update(fields)
|
|
52
|
+
cfg["schema_hints"] = merged
|
|
53
|
+
state.save_config(cfg)
|
|
54
|
+
core.set_hints(merged)
|
|
55
|
+
n = sum(map(len, hints.values()))
|
|
56
|
+
print(f" schema {n} personal field(s) declared in code across {len(hints)} model(s)", file=sys.stderr)
|
|
57
|
+
return hints
|
|
58
|
+
|
|
59
|
+
|
|
37
60
|
def _init(veil, args) -> int:
|
|
38
61
|
import os
|
|
39
62
|
from . import adopt
|
|
@@ -44,6 +67,8 @@ def _init(veil, args) -> int:
|
|
|
44
67
|
for src, where in sources:
|
|
45
68
|
print(f" source {adopt.redact_uri(src) if '://' in src else os.path.relpath(src, project)} ({where})",
|
|
46
69
|
file=sys.stderr)
|
|
70
|
+
if not args.no_detect:
|
|
71
|
+
_learn_schema(project, save=not args.dry_run)
|
|
47
72
|
if args.dry_run:
|
|
48
73
|
print(" (dry run: nothing seeded or written)", file=sys.stderr)
|
|
49
74
|
return 0
|
|
@@ -56,6 +81,8 @@ def _init(veil, args) -> int:
|
|
|
56
81
|
print(f" skipped {adopt.redact_uri(src)}: {type(e).__name__}: {str(e)[:120]}", file=sys.stderr)
|
|
57
82
|
seed_environment(veil.known)
|
|
58
83
|
state.save_known(veil.known, veil.vault.cipher)
|
|
84
|
+
if sources:
|
|
85
|
+
state.remember_sources([src for src, _ in sources], veil.vault.cipher)
|
|
59
86
|
path = adopt.write_claude_settings(project, args.port, hooks=not args.no_hooks, deny_env=args.deny_env)
|
|
60
87
|
print(f" wrote {os.path.relpath(path, project)} (ANTHROPIC_BASE_URL=http://127.0.0.1:{args.port})",
|
|
61
88
|
file=sys.stderr)
|
|
@@ -71,7 +98,9 @@ def _status(veil, args) -> int:
|
|
|
71
98
|
from . import adopt
|
|
72
99
|
h = adopt.health(args.port)
|
|
73
100
|
print(f"proxy {'up ' + json.dumps(h) if h else 'DOWN on :' + str(args.port)}")
|
|
74
|
-
print(f"service {
|
|
101
|
+
print(f"service {_service_status(args.port)}")
|
|
102
|
+
from . import display
|
|
103
|
+
print(f"display {display.configured_mode(state.home())} (answers show {'real values' if display.configured_mode(state.home()) == 'real' else 'pseudonyms'})")
|
|
75
104
|
print(f"keys {'env' if os.environ.get('VEIL_VAULT_KEY') else 'keychain/file'}; vault {len(veil.vault.tokens())} tokens, "
|
|
76
105
|
f"{len(veil.known)} known values")
|
|
77
106
|
path = os.path.join(os.path.abspath(args.project), ".claude", "settings.local.json")
|
|
@@ -80,6 +109,32 @@ def _status(veil, args) -> int:
|
|
|
80
109
|
return 0 if h and wired else 1
|
|
81
110
|
|
|
82
111
|
|
|
112
|
+
def _keys(args) -> int:
|
|
113
|
+
from . import rotate
|
|
114
|
+
try:
|
|
115
|
+
import httpx
|
|
116
|
+
httpx.get(f"http://127.0.0.1:{args.port}/veil/health", timeout=1, trust_env=False)
|
|
117
|
+
raise SystemExit(f"a veil proxy is running on :{args.port}; stop it first "
|
|
118
|
+
"(`veil service uninstall` or Ctrl-C), rotate, then start it again")
|
|
119
|
+
except httpx.HTTPError:
|
|
120
|
+
pass
|
|
121
|
+
h = state.home()
|
|
122
|
+
if args.token_secret:
|
|
123
|
+
vault = state.load().vault
|
|
124
|
+
r = rotate.rotate_token_secret(h, vault, drop_old=args.drop_old)
|
|
125
|
+
vault.close()
|
|
126
|
+
print(f"token secret rotated: new pseudonyms from now on; "
|
|
127
|
+
+ (f"{r['dropped']} old pseudonyms forgotten" if args.drop_old else
|
|
128
|
+
f"{r['retired']} old pseudonyms retired (still restored in existing conversations)")
|
|
129
|
+
+ ". Teams: `veil team export` again and have everyone join.")
|
|
130
|
+
else:
|
|
131
|
+
state.load().vault.close() # finishes an interrupted rotation, checks the current key works
|
|
132
|
+
r = rotate.rotate_vault_key(h)
|
|
133
|
+
print(f"vault key rotated: re-encrypted {r['vault_rows']} vault rows, {r['retired']} retired, "
|
|
134
|
+
f"{r['files']} files; pseudonyms unchanged")
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
83
138
|
def _team(args) -> int:
|
|
84
139
|
import os
|
|
85
140
|
from . import team
|
|
@@ -114,7 +169,8 @@ def _team(args) -> int:
|
|
|
114
169
|
state.save_config(cfg)
|
|
115
170
|
print(f"allowlist: {cfg['allow']}", file=sys.stderr)
|
|
116
171
|
else:
|
|
117
|
-
doc = team.managed_settings(args.port, hooks=not args.no_hooks, lock_hooks=args.lock_hooks
|
|
172
|
+
doc = team.managed_settings(args.port, hooks=not args.no_hooks, lock_hooks=args.lock_hooks,
|
|
173
|
+
display=args.display)
|
|
118
174
|
with open(args.out, "w") as f:
|
|
119
175
|
json.dump(doc, f, indent=2)
|
|
120
176
|
f.write("\n")
|
|
@@ -122,9 +178,25 @@ def _team(args) -> int:
|
|
|
122
178
|
return 0
|
|
123
179
|
|
|
124
180
|
|
|
181
|
+
def _service_status(port: int) -> str:
|
|
182
|
+
from . import adopt
|
|
183
|
+
if sys.platform == "win32":
|
|
184
|
+
return adopt.windows_status(port)
|
|
185
|
+
return adopt.systemd_status() if sys.platform.startswith("linux") else adopt.service_status()
|
|
186
|
+
|
|
187
|
+
|
|
125
188
|
def _service(args) -> int:
|
|
126
189
|
from . import adopt
|
|
127
190
|
linux = sys.platform.startswith("linux")
|
|
191
|
+
if sys.platform == "win32":
|
|
192
|
+
if args.action == "install":
|
|
193
|
+
path = adopt.windows_install(args.port, args.upstream, args.ner, state.home(), capture=args.capture)
|
|
194
|
+
print(f"installed {path}; status: {adopt.windows_status(args.port)}")
|
|
195
|
+
elif args.action == "uninstall":
|
|
196
|
+
print("removed" if adopt.windows_uninstall(state.home()) else "not installed")
|
|
197
|
+
else:
|
|
198
|
+
print(adopt.windows_status(args.port))
|
|
199
|
+
return 0
|
|
128
200
|
if args.action == "install":
|
|
129
201
|
if linux:
|
|
130
202
|
path = adopt.systemd_install(args.port, args.upstream, args.ner, state.home(), capture=args.capture)
|
|
@@ -157,10 +229,25 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
157
229
|
return 0
|
|
158
230
|
p = argparse.ArgumentParser(prog="veil")
|
|
159
231
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
160
|
-
s = sub.add_parser("seed"); s.add_argument("sources", nargs="
|
|
232
|
+
s = sub.add_parser("seed"); s.add_argument("sources", nargs="*")
|
|
233
|
+
s.add_argument("--no-remember", action="store_true", help="don't re-seed this source automatically")
|
|
234
|
+
s.add_argument("--refresh", action="store_true", help="re-seed every remembered source now")
|
|
235
|
+
s.add_argument("--schema", metavar="DIR", help="learn personal fields from ORM schemas / annotations in DIR first")
|
|
236
|
+
s = sub.add_parser("sources"); s.add_argument("action", nargs="?", choices=["list", "remove"], default="list")
|
|
237
|
+
s.add_argument("which", nargs="?", help="index (from `veil sources`) or the source itself")
|
|
161
238
|
s = sub.add_parser("seed-sqlite"); s.add_argument("path") # back-compat alias
|
|
162
239
|
sub.add_parser("seed-env")
|
|
163
240
|
sub.add_parser("redact"); sub.add_parser("rehydrate")
|
|
241
|
+
s = sub.add_parser("display"); s.add_argument("mode", nargs="?", choices=["real", "tokens"])
|
|
242
|
+
s = sub.add_parser("keys", help="rotate the vault key (re-encrypt) or the token secret (new pseudonyms)")
|
|
243
|
+
s.add_argument("action", choices=["rotate"])
|
|
244
|
+
s.add_argument("--token-secret", action="store_true", help="new pseudonyms instead of a new vault key")
|
|
245
|
+
s.add_argument("--drop-old", action="store_true", help="with --token-secret: forget old pseudonyms")
|
|
246
|
+
s.add_argument("--port", type=int, default=8787, help="refuse while a proxy listens here")
|
|
247
|
+
s = sub.add_parser("quasi", help="quasi-identifiers (city, zip, birth date, company, job title): warn|redact|off")
|
|
248
|
+
s.add_argument("mode", nargs="?", choices=["warn", "redact", "off"])
|
|
249
|
+
for name in ("reveal", "share"):
|
|
250
|
+
s = sub.add_parser(name); s.add_argument("file", nargs="?", default="-")
|
|
164
251
|
s = sub.add_parser("proxy"); s.add_argument("--port", type=int, default=8787)
|
|
165
252
|
s.add_argument("--upstream", default="https://api.anthropic.com")
|
|
166
253
|
s.add_argument("--show-tokens", action="store_true", help="don't rehydrate assistant text")
|
|
@@ -169,7 +256,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
169
256
|
s.add_argument("--openai-upstream", metavar="URL",
|
|
170
257
|
help="OpenAI-format upstream for /chat/completions (e.g. https://api.deepseek.com)")
|
|
171
258
|
s.add_argument("--bedrock-upstream", metavar="URL", help="e.g. https://bedrock-runtime.us-east-1.amazonaws.com")
|
|
259
|
+
s.add_argument("--pid-file", metavar="PATH", help="write the process id here (removed on exit)")
|
|
260
|
+
s.add_argument("--reseed-interval", type=float, default=30,
|
|
261
|
+
help="seconds between change checks of remembered sources (0 = off)")
|
|
172
262
|
s.add_argument("--vertex-upstream", metavar="URL", help="e.g. https://us-east5-aiplatform.googleapis.com")
|
|
263
|
+
s.add_argument("--foundry-upstream", metavar="URL",
|
|
264
|
+
help="Microsoft Foundry, served at /foundry: e.g. https://RESOURCE.services.ai.azure.com/anthropic")
|
|
265
|
+
s.add_argument("--anthropic-aws-upstream", metavar="URL",
|
|
266
|
+
help="Claude Platform on AWS, served at /anthropic-aws: e.g. https://aws-external-anthropic.us-east-1.api.aws")
|
|
173
267
|
s = sub.add_parser("hook"); s.add_argument("kind", choices=["pre", "post"])
|
|
174
268
|
s = sub.add_parser("init"); s.add_argument("project", nargs="?", default=".")
|
|
175
269
|
s.add_argument("--port", type=int, default=8787); s.add_argument("--source", action="append", default=[])
|
|
@@ -181,6 +275,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
181
275
|
s.add_argument("value", nargs="*"); s.add_argument("--passphrase-env", metavar="VAR")
|
|
182
276
|
s.add_argument("--reset-vault", action="store_true"); s.add_argument("--port", type=int, default=8787)
|
|
183
277
|
s.add_argument("--no-hooks", action="store_true"); s.add_argument("--lock-hooks", action="store_true")
|
|
278
|
+
s.add_argument("--display", choices=["real", "tokens"], help="managed-settings: enforce pseudonyms in answers")
|
|
184
279
|
s.add_argument("--out", default="50-veil.json")
|
|
185
280
|
s = sub.add_parser("service"); s.add_argument("action", choices=["install", "uninstall", "status"])
|
|
186
281
|
s.add_argument("--port", type=int, default=8787); s.add_argument("--upstream", default="https://api.anthropic.com")
|
|
@@ -189,6 +284,28 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
189
284
|
|
|
190
285
|
if args.cmd == "service":
|
|
191
286
|
return _service(args)
|
|
287
|
+
if args.cmd == "display":
|
|
288
|
+
from . import display
|
|
289
|
+
if args.mode:
|
|
290
|
+
cfg = state.config(); cfg["display"] = args.mode; state.save_config(cfg)
|
|
291
|
+
mode = display.configured_mode(state.home())
|
|
292
|
+
print(f"display: {mode}" + (" (VEIL_DISPLAY overrides config)" if __import__("os").environ.get("VEIL_DISPLAY") else "")
|
|
293
|
+
+ ("\nnote: a managed `x-veil-display: tokens` header still forces pseudonyms" if mode == "real" else ""))
|
|
294
|
+
return 0
|
|
295
|
+
if args.cmd == "keys":
|
|
296
|
+
return _keys(args)
|
|
297
|
+
if args.cmd == "quasi":
|
|
298
|
+
from . import quasi
|
|
299
|
+
if args.mode:
|
|
300
|
+
cfg = state.config(); cfg["quasi"] = args.mode; state.save_config(cfg)
|
|
301
|
+
known = state.load().known
|
|
302
|
+
rare = known.rare_quasi()
|
|
303
|
+
by_kind: dict[str, int] = {}
|
|
304
|
+
for kind, _, _ in rare.values():
|
|
305
|
+
by_kind[kind] = by_kind.get(kind, 0) + 1
|
|
306
|
+
print(f"quasi-identifiers: {quasi.mode(state.home())} ({len(rare)} rare of {len(known._quasi)} seeded"
|
|
307
|
+
+ (": " + ", ".join(f"{k} {n}" for k, n in sorted(by_kind.items())) if by_kind else "") + ")")
|
|
308
|
+
return 0
|
|
192
309
|
if args.cmd == "team":
|
|
193
310
|
return _team(args)
|
|
194
311
|
if args.cmd == "init" and args.dry_run:
|
|
@@ -199,31 +316,79 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
199
316
|
if args.cmd == "status":
|
|
200
317
|
return _status(veil, args)
|
|
201
318
|
if args.cmd in ("seed", "seed-sqlite"):
|
|
202
|
-
|
|
319
|
+
from .adopt import redact_uri
|
|
320
|
+
given = (args.sources if args.cmd == "seed" else [args.path])
|
|
321
|
+
if getattr(args, "schema", None):
|
|
322
|
+
_learn_schema(args.schema)
|
|
323
|
+
todo = given + (state.load_sources(veil.vault.cipher) if getattr(args, "refresh", False) else [])
|
|
324
|
+
if not todo:
|
|
325
|
+
raise SystemExit("usage: veil seed SOURCE... | veil seed --refresh")
|
|
326
|
+
for source in dict.fromkeys(todo):
|
|
203
327
|
report = seed(veil.known, source)
|
|
204
|
-
print(f"# {source
|
|
328
|
+
print(f"# {redact_uri(source) if '://' in source else source}", file=sys.stderr) # never echo credentials
|
|
205
329
|
print(report.table(), file=sys.stderr)
|
|
206
330
|
state.save_known(veil.known, veil.vault.cipher)
|
|
331
|
+
if given and not getattr(args, "no_remember", False):
|
|
332
|
+
n = len(state.remember_sources(given, veil.vault.cipher))
|
|
333
|
+
print(f"{n} source(s) remembered: the proxy re-seeds them when they change", file=sys.stderr)
|
|
207
334
|
print(f"{len(veil.known)} known values in total", file=sys.stderr)
|
|
335
|
+
elif args.cmd == "sources":
|
|
336
|
+
from .adopt import redact_uri
|
|
337
|
+
current = state.load_sources(veil.vault.cipher)
|
|
338
|
+
if args.action == "remove":
|
|
339
|
+
if args.which is None:
|
|
340
|
+
raise SystemExit("usage: veil sources remove INDEX|SOURCE")
|
|
341
|
+
drop = current[int(args.which)] if args.which.isdigit() and int(args.which) < len(current) else args.which
|
|
342
|
+
current = [c for c in current if c != drop]
|
|
343
|
+
state.save_sources(current, veil.vault.cipher)
|
|
344
|
+
for i, src in enumerate(current):
|
|
345
|
+
print(f"{i} {redact_uri(src) if '://' in src else src}")
|
|
346
|
+
if not current:
|
|
347
|
+
print("(no remembered sources: `veil seed SOURCE` adds one)")
|
|
208
348
|
elif args.cmd == "seed-env":
|
|
209
349
|
found = seed_environment(veil.known)
|
|
210
350
|
state.save_known(veil.known, veil.vault.cipher)
|
|
211
351
|
print(f"{len(found)} identity values learned", file=sys.stderr)
|
|
352
|
+
elif args.cmd in ("reveal", "share"):
|
|
353
|
+
text = sys.stdin.read() if args.file == "-" else open(args.file, encoding="utf-8").read()
|
|
354
|
+
sys.stdout.write(veil.rehydrate(text) if args.cmd == "reveal" else veil.redact(text, free_text=True))
|
|
212
355
|
elif args.cmd == "redact":
|
|
213
356
|
sys.stdout.write(veil.redact(sys.stdin.read()))
|
|
214
357
|
elif args.cmd == "rehydrate":
|
|
215
358
|
sys.stdout.write(veil.rehydrate(sys.stdin.read()))
|
|
216
359
|
elif args.cmd == "proxy":
|
|
217
360
|
from .proxy import serve
|
|
361
|
+
from .reseed import warm
|
|
218
362
|
if args.ner:
|
|
219
363
|
from .ner import GlinerDetector
|
|
220
364
|
veil.ner = GlinerDetector()
|
|
365
|
+
warm(veil.known)
|
|
221
366
|
server = serve(veil, Policy(display_real=not args.show_tokens), args.port, args.upstream, args.capture,
|
|
222
|
-
args.openai_upstream, args.bedrock_upstream, args.vertex_upstream
|
|
367
|
+
args.openai_upstream, args.bedrock_upstream, args.vertex_upstream,
|
|
368
|
+
args.foundry_upstream, args.anthropic_aws_upstream)
|
|
369
|
+
sources = state.load_sources(veil.vault.cipher)
|
|
370
|
+
if sources and args.reseed_interval > 0:
|
|
371
|
+
from .reseed import Reseeder
|
|
372
|
+
veil.reseeder = Reseeder(veil, sources, interval=args.reseed_interval,
|
|
373
|
+
save=lambda known: state.save_known(known, veil.vault.cipher))
|
|
374
|
+
veil.reseeder.start()
|
|
375
|
+
print(f"[veil] re-seeding {len(sources)} source(s) on change (checked every {args.reseed_interval:g}s)",
|
|
376
|
+
file=sys.stderr)
|
|
377
|
+
if args.pid_file:
|
|
378
|
+
import os
|
|
379
|
+
with open(args.pid_file, "w") as f:
|
|
380
|
+
f.write(str(os.getpid()))
|
|
223
381
|
try:
|
|
224
382
|
server.serve_forever()
|
|
225
383
|
except KeyboardInterrupt:
|
|
226
384
|
pass
|
|
385
|
+
finally:
|
|
386
|
+
if args.pid_file:
|
|
387
|
+
import os
|
|
388
|
+
try:
|
|
389
|
+
os.remove(args.pid_file)
|
|
390
|
+
except OSError:
|
|
391
|
+
pass
|
|
227
392
|
elif args.cmd == "hook":
|
|
228
393
|
return _hook(veil, args.kind)
|
|
229
394
|
return 0
|