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 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
+ })]