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.
Files changed (46) hide show
  1. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/PKG-INFO +22 -9
  2. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/README.md +19 -8
  3. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/pyproject.toml +2 -1
  4. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/adopt.py +83 -2
  5. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/anthropic.py +21 -2
  6. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/cli.py +171 -6
  7. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/cloud.py +69 -0
  8. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/data/README.md +4 -0
  9. veil_proxy-0.2.0/veil/data/words.txt.gz +0 -0
  10. veil_proxy-0.2.0/veil/decode.py +135 -0
  11. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/detect.py +72 -17
  12. veil_proxy-0.2.0/veil/display.py +58 -0
  13. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/engine.py +69 -13
  14. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/guard.py +108 -81
  15. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/hookclient.py +3 -1
  16. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/keys.py +105 -3
  17. veil_proxy-0.2.0/veil/match.py +143 -0
  18. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/proxy.py +57 -15
  19. veil_proxy-0.2.0/veil/quasi.py +53 -0
  20. veil_proxy-0.2.0/veil/reseed.py +185 -0
  21. veil_proxy-0.2.0/veil/rotate.py +116 -0
  22. veil_proxy-0.2.0/veil/shell.py +711 -0
  23. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/sources/__init__.py +8 -2
  24. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/sources/core.py +89 -10
  25. veil_proxy-0.2.0/veil/sources/elastic.py +112 -0
  26. veil_proxy-0.2.0/veil/sources/mssql.py +162 -0
  27. veil_proxy-0.2.0/veil/sources/schema.py +181 -0
  28. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/sources/sql.py +10 -5
  29. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/state.py +42 -1
  30. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/stream.py +1 -1
  31. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/team.py +4 -1
  32. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/vault.py +28 -0
  33. veil_proxy-0.1.2/veil/decode.py +0 -90
  34. veil_proxy-0.1.2/veil/match.py +0 -47
  35. veil_proxy-0.1.2/veil/shell.py +0 -91
  36. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/__init__.py +0 -0
  37. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/crypto.py +0 -0
  38. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/data/pg_anonymizer_identifier.csv +0 -0
  39. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/hooks.py +0 -0
  40. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/ner.py +0 -0
  41. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/openai.py +0 -0
  42. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/responses.py +0 -0
  43. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/sources/files.py +0 -0
  44. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/sources/kv.py +0 -0
  45. {veil_proxy-0.1.2 → veil_proxy-0.2.0}/veil/sources/mongo.py +0 -0
  46. {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.1.2
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
- - **20/20** one-command bypass transforms are contained, and restored values are escaped for their
59
- shell/SQL context (35/35 injection tests).
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, OpenAI Chat Completions (Aider, Continue, OpenAI SDK) and
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`), or `VEIL_VAULT_KEY` /
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
- - **20/20** one-command bypass transforms are contained, and restored values are escaped for their
23
- shell/SQL context (35/35 injection tests).
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, OpenAI Chat Completions (Aider, Continue, OpenAI SDK) and
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`), or `VEIL_VAULT_KEY` /
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.1.2"
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(f'"{c}"' if " " in c else c for c in [sys.executable, "-m", "veil.hookclient"])
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(json.dumps(tool_input, ensure_ascii=False))
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(json.dumps(tool_input, ensure_ascii=False))
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 {adopt.systemd_status() if sys.platform.startswith('linux') else adopt.service_status()}")
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
- for source in (args.sources if args.cmd == "seed" else [args.path]):
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.split('@')[-1]}", file=sys.stderr) # never echo credentials
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