veil-proxy 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- veil/__init__.py +0 -0
- veil/adopt.py +238 -0
- veil/anthropic.py +268 -0
- veil/cli.py +231 -0
- veil/cloud.py +150 -0
- veil/crypto.py +45 -0
- veil/data/README.md +4 -0
- veil/data/pg_anonymizer_identifier.csv +172 -0
- veil/decode.py +90 -0
- veil/detect.py +208 -0
- veil/engine.py +140 -0
- veil/guard.py +230 -0
- veil/hookclient.py +45 -0
- veil/hooks.py +37 -0
- veil/keys.py +139 -0
- veil/match.py +47 -0
- veil/ner.py +108 -0
- veil/openai.py +214 -0
- veil/proxy.py +375 -0
- veil/responses.py +194 -0
- veil/shell.py +91 -0
- veil/sources/__init__.py +70 -0
- veil/sources/core.py +378 -0
- veil/sources/files.py +88 -0
- veil/sources/kv.py +72 -0
- veil/sources/mongo.py +72 -0
- veil/sources/sql.py +233 -0
- veil/state.py +78 -0
- veil/stream.py +49 -0
- veil/team.py +74 -0
- veil/tokens.py +71 -0
- veil/vault.py +141 -0
- veil_proxy-0.1.1.dist-info/METADATA +75 -0
- veil_proxy-0.1.1.dist-info/RECORD +36 -0
- veil_proxy-0.1.1.dist-info/WHEEL +4 -0
- veil_proxy-0.1.1.dist-info/entry_points.txt +4 -0
veil/__init__.py
ADDED
|
File without changes
|
veil/adopt.py
ADDED
|
@@ -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"
|
veil/anthropic.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Anthropic Messages API transforms.
|
|
2
|
+
|
|
3
|
+
Direction model ("the lens"):
|
|
4
|
+
request (machine -> model): every string in system/messages is redacted,
|
|
5
|
+
including tool_use inputs that we rehydrated on an earlier turn, so
|
|
6
|
+
the model's view of history is byte-stable (prompt caching works).
|
|
7
|
+
response (model -> machine): tool_use inputs are rehydrated per *sink
|
|
8
|
+
policy* so commands run against real data; assistant text is
|
|
9
|
+
optionally rehydrated for display. Thinking blocks are never
|
|
10
|
+
touched (they are signed, and only ever contain tokens).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .engine import Veil
|
|
21
|
+
from .stream import TextHold
|
|
22
|
+
|
|
23
|
+
# Byte-level dumps print data in escaped forms that bypass pseudonymisation
|
|
24
|
+
# (found with a real model: `sed -n l app.log`). They are replaced by a notice.
|
|
25
|
+
RAW_DUMP = re.compile(
|
|
26
|
+
r"(?:^|[;&|(]\s*|\s)(?:od|xxd|hexdump|hd|strings|base64(?!\s+(?:-d|--decode)))\b"
|
|
27
|
+
r"|\bcat\s+-\w*[vAeEtT]"
|
|
28
|
+
r"|\bsed\b[^|;&]*?(?:\s|['\"])(?:-n\s+)?['\"]?(?:\d+(?:,\s*\d+)?|\$)?\s*l(?:\s*\d+)?['\"]?(?=\s|$|;)"
|
|
29
|
+
)
|
|
30
|
+
EGRESS = re.compile(
|
|
31
|
+
r"\b(curl|wget|nc|ncat|telnet|ssh|scp|sftp|rsync|ftp|http|httpie|aws\s+s3|gsutil|git\s+push)\b"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
CONTENT_FIELDS = {"new_string", "content", "new_source"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
PSEUDONYM_NOTICE = (
|
|
39
|
+
"Privacy note: personal data in this session (names, emails, usernames, phones, addresses, "
|
|
40
|
+
"home-directory names) has been replaced by consistent pseudonyms such as user_ab12, "
|
|
41
|
+
"user_ab12@example.test, First_Ab12 Last_Ab12, Ab12 Example Street. They work like the real values "
|
|
42
|
+
"when used whole and exactly (e.g. WHERE email = 'user_ab12@example.test', paths containing user_ab12). "
|
|
43
|
+
"Fragments of a pseudonym (LIKE '%ab12%', partial greps, character slicing) will not match real data, "
|
|
44
|
+
"so never search on parts of them. Do not try to recover the real values (byte dumps and escaped "
|
|
45
|
+
"output are blocked); the developer sees real values in your answers automatically."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Policy:
|
|
51
|
+
display_real: bool = True # rehydrate assistant text shown to the developer
|
|
52
|
+
notice: bool = True # tell the model pseudonyms are opaque (static text: cache-safe)
|
|
53
|
+
full_tools: set[str] = field(default_factory=lambda: {"Bash", "Read", "Grep", "Glob", "LS"})
|
|
54
|
+
preserve_tools: set[str] = field(default_factory=lambda: {"Edit", "MultiEdit", "Write", "NotebookEdit"})
|
|
55
|
+
full_tool_prefixes: tuple[str, ...] = () # e.g. ("mcp__veil_db__",)
|
|
56
|
+
|
|
57
|
+
def mode(self, tool: str, tool_input: dict) -> str:
|
|
58
|
+
if tool == "Bash" and EGRESS.search(str(tool_input.get("command", ""))):
|
|
59
|
+
return "none" # never put real values into a network-capable command
|
|
60
|
+
if tool in self.full_tools or tool.startswith(self.full_tool_prefixes):
|
|
61
|
+
return "full"
|
|
62
|
+
if tool in self.preserve_tools:
|
|
63
|
+
return "preserve"
|
|
64
|
+
return "none" # unknown sinks (WebFetch, Agent, new tools) fail safe
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_RX_META = set("\\.+*?()|[]{}^$#&-~")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rx_escape(s: str) -> str:
|
|
71
|
+
"""Escape for ripgrep's Rust regex (which rejects escaped spaces/letters)."""
|
|
72
|
+
return "".join("\\" + c if c in _RX_META else c for c in s)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fragment_notice(veil: Veil, codes: list[str]) -> str:
|
|
76
|
+
parts = []
|
|
77
|
+
for code in dict.fromkeys(codes):
|
|
78
|
+
toks = veil.code_tokens(code)[:4]
|
|
79
|
+
parts.append(f"'{code}' (use one of: {', '.join(toks)})" if toks else f"'{code}'")
|
|
80
|
+
msg = ("veil: " + "; ".join(parts) + " -- only part of a pseudonym. Pseudonyms match real data only "
|
|
81
|
+
"when used whole and exactly. This command was not run; repeat it with a full pseudonym.")
|
|
82
|
+
return "echo " + "'" + msg.replace("'", "") + "'"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def rehydrate_tool_input(veil: Veil, policy: Policy, tool: str, tool_input: dict) -> dict:
|
|
86
|
+
mode = policy.mode(tool, tool_input)
|
|
87
|
+
if mode == "full":
|
|
88
|
+
frags = veil.fragments(json.dumps(tool_input, ensure_ascii=False))
|
|
89
|
+
if frags and tool == "Grep" and isinstance(tool_input.get("pattern"), str):
|
|
90
|
+
# "lines about person mz5z": expand the fragment to that person's real values
|
|
91
|
+
pat, codes = tool_input["pattern"], veil.codes()
|
|
92
|
+
for start, end, code in reversed(veil.fragments(pat)):
|
|
93
|
+
pat = pat[:start] + "(?:" + "|".join(map(_rx_escape, codes[code])) + ")" + pat[end:]
|
|
94
|
+
return {**veil.rehydrate_json({k: v for k, v in tool_input.items() if k != "pattern"}),
|
|
95
|
+
"pattern": veil.rehydrate(pat)}
|
|
96
|
+
if frags and tool == "Bash":
|
|
97
|
+
return {**tool_input, "command": _fragment_notice(veil, [c for *_, c in frags])}
|
|
98
|
+
if tool == "Bash" and RAW_DUMP.search(str(tool_input.get("command", ""))):
|
|
99
|
+
return {**tool_input, "command": "echo 'veil: byte-level dumps (sed l, od, xxd, hexdump, strings, "
|
|
100
|
+
"cat -v, base64) are disabled: they bypass pseudonymisation. "
|
|
101
|
+
"Read files normally; personal values are pseudonymised on purpose.'"}
|
|
102
|
+
if tool == "Bash" and isinstance(tool_input.get("command"), str):
|
|
103
|
+
# data -> code boundary: escape each real value for its quoting context
|
|
104
|
+
from .shell import rehydrate_command
|
|
105
|
+
rest = veil.rehydrate_json({k: v for k, v in tool_input.items() if k != "command"})
|
|
106
|
+
return {**rest, "command": rehydrate_command(veil, tool_input["command"])}
|
|
107
|
+
return veil.rehydrate_json(tool_input)
|
|
108
|
+
if mode == "preserve":
|
|
109
|
+
# Locators (paths, old_string) must be real or the edit misses its
|
|
110
|
+
# target. Content fields rehydrate only tokens whose real value already
|
|
111
|
+
# exists in the target file: edits keep existing data intact, but the
|
|
112
|
+
# agent can never *introduce* real PII into source code or fixtures.
|
|
113
|
+
out = {k: (v if k in CONTENT_FIELDS else veil.rehydrate_json(v)) for k, v in tool_input.items()}
|
|
114
|
+
path = out.get("file_path") or out.get("notebook_path") or ""
|
|
115
|
+
try:
|
|
116
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
117
|
+
existing = f.read()
|
|
118
|
+
except OSError:
|
|
119
|
+
existing = ""
|
|
120
|
+
allowed = {t for t in veil.tokens_in(json.dumps(tool_input, ensure_ascii=False))
|
|
121
|
+
if (veil.vault.value_for(t) or "\0") in existing}
|
|
122
|
+
for k in CONTENT_FIELDS & out.keys():
|
|
123
|
+
out[k] = veil.rehydrate_json(out[k], only=allowed)
|
|
124
|
+
if isinstance(out.get("edits"), list): # MultiEdit
|
|
125
|
+
out["edits"] = [{**e, "old_string": veil.rehydrate(e.get("old_string", "")),
|
|
126
|
+
"new_string": veil.rehydrate(e.get("new_string", ""), only=allowed)}
|
|
127
|
+
for e in tool_input["edits"]]
|
|
128
|
+
return out
|
|
129
|
+
return tool_input
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# -- requests --------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def _redact_block(veil: Veil, block: Any, free_text: bool = False) -> Any:
|
|
135
|
+
if not isinstance(block, dict):
|
|
136
|
+
return veil.redact_json(block)
|
|
137
|
+
t = block.get("type")
|
|
138
|
+
if t in ("thinking", "redacted_thinking", "image"):
|
|
139
|
+
return block
|
|
140
|
+
if t == "text":
|
|
141
|
+
return {**block, "text": veil.redact(block["text"], free_text=free_text)}
|
|
142
|
+
if t == "tool_use":
|
|
143
|
+
return {**block, "input": veil.redact_json(block.get("input", {}))}
|
|
144
|
+
if t == "tool_result":
|
|
145
|
+
content = block.get("content")
|
|
146
|
+
if isinstance(content, str):
|
|
147
|
+
content = veil.redact(content, free_text=True)
|
|
148
|
+
elif isinstance(content, list):
|
|
149
|
+
content = [_redact_block(veil, b, free_text=True) for b in content]
|
|
150
|
+
return {**block, "content": content}
|
|
151
|
+
# documents, search results, unknown future block types: redact all strings
|
|
152
|
+
return veil.redact_json(block)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Top-level fields that are pure configuration; everything else is redacted.
|
|
156
|
+
# Default-deny matters: Claude Code 2.1.285 sends `safeguards[].classifier_context`
|
|
157
|
+
# (home_dir, user_identity, cwd, git state) next to system/messages.
|
|
158
|
+
CONFIG_FIELDS = {"model", "max_tokens", "stream", "temperature", "top_p", "top_k", "stop_sequences",
|
|
159
|
+
"thinking", "tool_choice", "output_config", "context_management", "service_tier",
|
|
160
|
+
"anthropic_version", "anthropic_beta"} # last two: Bedrock/Vertex body fields
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def redact_request(veil: Veil, body: dict, notice: bool = False) -> dict:
|
|
164
|
+
out = {}
|
|
165
|
+
for key, value in body.items():
|
|
166
|
+
if key in CONFIG_FIELDS:
|
|
167
|
+
out[key] = value
|
|
168
|
+
elif key == "system" and isinstance(value, list):
|
|
169
|
+
out[key] = [_redact_block(veil, b) for b in value]
|
|
170
|
+
elif key == "messages" and isinstance(value, list):
|
|
171
|
+
out[key] = [{**msg, "content": veil.redact(msg["content"], free_text=True)
|
|
172
|
+
if isinstance(msg.get("content"), str)
|
|
173
|
+
else [_redact_block(veil, b, free_text=True) for b in msg.get("content") or []]}
|
|
174
|
+
for msg in value]
|
|
175
|
+
else: # system as str, tools, metadata, safeguards, unknown future fields
|
|
176
|
+
out[key] = veil.redact_json(value)
|
|
177
|
+
if notice and out.get("messages") and out.get("tools"): # agent requests only
|
|
178
|
+
system = out.get("system")
|
|
179
|
+
if isinstance(system, list):
|
|
180
|
+
out["system"] = system + [{"type": "text", "text": PSEUDONYM_NOTICE}]
|
|
181
|
+
else:
|
|
182
|
+
out["system"] = (system + "\n\n" if system else "") + PSEUDONYM_NOTICE
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# -- non-streaming responses -----------------------------------------------
|
|
187
|
+
|
|
188
|
+
def rehydrate_response(veil: Veil, policy: Policy, body: dict) -> dict:
|
|
189
|
+
content = []
|
|
190
|
+
for block in body.get("content", []):
|
|
191
|
+
if block.get("type") == "text" and policy.display_real:
|
|
192
|
+
block = {**block, "text": veil.rehydrate(block["text"])}
|
|
193
|
+
elif block.get("type") == "tool_use":
|
|
194
|
+
block = {**block, "input": rehydrate_tool_input(veil, policy, block["name"], block.get("input", {}))}
|
|
195
|
+
content.append(block)
|
|
196
|
+
return {**body, "content": content}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# -- streaming responses ---------------------------------------------------
|
|
200
|
+
|
|
201
|
+
class StreamRewriter:
|
|
202
|
+
"""Rewrites SSE events from /v1/messages with stream=true.
|
|
203
|
+
|
|
204
|
+
* text_delta: rehydrated incrementally. A token may be split across
|
|
205
|
+
deltas, so we hold back up to max-token-length chars until it is safe.
|
|
206
|
+
* input_json_delta: buffered until content_block_stop, then the whole
|
|
207
|
+
input is rehydrated by policy and emitted as one delta.
|
|
208
|
+
* everything else (pings, thinking, signatures, usage) passes through
|
|
209
|
+
untouched and immediately, so idle watchdogs never fire.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(self, veil: Veil, policy: Policy):
|
|
213
|
+
self.veil, self.policy = veil, policy
|
|
214
|
+
self.blocks: dict[int, dict] = {}
|
|
215
|
+
|
|
216
|
+
def feed(self, event: str, data: dict) -> list[tuple[str, dict]]:
|
|
217
|
+
t = data.get("type", event)
|
|
218
|
+
if t == "content_block_start":
|
|
219
|
+
cb = data["content_block"]
|
|
220
|
+
st = {"type": cb.get("type"), "hold": TextHold(self.veil), "json": "", "name": cb.get("name")}
|
|
221
|
+
self.blocks[data["index"]] = st
|
|
222
|
+
if st["type"] == "text" and cb.get("text"):
|
|
223
|
+
text, cb = cb["text"], {**cb, "text": ""}
|
|
224
|
+
return [(event, {**data, "content_block": cb})] + self._text(data["index"], st["hold"].feed(text))
|
|
225
|
+
return [(event, data)]
|
|
226
|
+
if t == "content_block_delta":
|
|
227
|
+
idx, delta = data["index"], data["delta"]
|
|
228
|
+
st = self.blocks.get(idx)
|
|
229
|
+
if st and st["type"] == "text" and delta.get("type") == "text_delta" and self.policy.display_real:
|
|
230
|
+
return self._text(idx, st["hold"].feed(delta["text"]))
|
|
231
|
+
if st and delta.get("type") == "input_json_delta" and st["type"] == "tool_use":
|
|
232
|
+
st["json"] += delta.get("partial_json", "")
|
|
233
|
+
return []
|
|
234
|
+
return [(event, data)]
|
|
235
|
+
if t == "content_block_stop":
|
|
236
|
+
idx = data["index"]
|
|
237
|
+
st = self.blocks.pop(idx, None)
|
|
238
|
+
out: list[tuple[str, dict]] = []
|
|
239
|
+
if st and st["type"] == "text":
|
|
240
|
+
out = self._text(idx, st["hold"].flush())
|
|
241
|
+
elif st and st["type"] == "tool_use":
|
|
242
|
+
try:
|
|
243
|
+
tool_input = json.loads(st["json"]) if st["json"].strip() else {}
|
|
244
|
+
except json.JSONDecodeError:
|
|
245
|
+
tool_input = None
|
|
246
|
+
if tool_input is None: # malformed: pass through verbatim
|
|
247
|
+
partial = st["json"]
|
|
248
|
+
else:
|
|
249
|
+
partial = json.dumps(
|
|
250
|
+
rehydrate_tool_input(self.veil, self.policy, st["name"], tool_input),
|
|
251
|
+
ensure_ascii=False,
|
|
252
|
+
)
|
|
253
|
+
if partial:
|
|
254
|
+
out.append(("content_block_delta", {
|
|
255
|
+
"type": "content_block_delta", "index": idx,
|
|
256
|
+
"delta": {"type": "input_json_delta", "partial_json": partial},
|
|
257
|
+
}))
|
|
258
|
+
return out + [(event, data)]
|
|
259
|
+
return [(event, data)]
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def _text(idx: int, text: str) -> list[tuple[str, dict]]:
|
|
263
|
+
if not text:
|
|
264
|
+
return []
|
|
265
|
+
return [("content_block_delta", {
|
|
266
|
+
"type": "content_block_delta", "index": idx,
|
|
267
|
+
"delta": {"type": "text_delta", "text": text},
|
|
268
|
+
})]
|