Local privacy firewall: masks secrets and personal data before Claude Code sends them, and restores them locally.

<h1 align="center"> <img alt="Latet" src=".github/assets/latet_card.png" width="420"> </h1>
AI agents read for you: your code and its .env, databases, logs, mailboxes, the web. Coding agents such as Claude Code and Codex do it at your request. Autonomous agents such as Hermes Agent, OpenClaw, Goose and OpenHands do it on their own, for hours, with no one reading each step. Everything they read is sent to a model in the cloud.
Latet stands between the agent and the model. It masks secrets and personal data before a request leaves your machine and restores them locally in the answer, so the agent keeps working normally and the provider never sees the real values.
The best models for your sensitive work too. Without it, work on customer data, health records or credentials stays away from cloud models, or goes to a weaker local one. With it, the cloud model gets the work, not the values.
At the office too. When your company reaches Claude or GPT through its own gateway, Amazon Bedrock, Google Vertex, Microsoft Foundry or Azure OpenAI, Latet reads that route from the agent's settings and goes in front of it: Company endpoints.
See it at work first. Once it is installed, latet tutorial opens a sandbox in your browser: a pretend agent sends requests full of invented secrets and personal data through the real Latet, and chapter by chapter the dashboard shows what Latet did with each value. Nothing leaves your computer, and none of your settings change: Tutorial.
Website: <https://latet.ai/> · Documentation: <https://docs.latet.ai/>, also in Italiano, Deutsch, Français and Español
<img alt="Latet's product video: an AI agent's request carries real values toward a cloud model; at the line between your machine and the cloud, Latet turns each one into a fake of the same shape, and puts the real values back when the answer returns" src=".github/assets/latet-product.gif" width="960">
Every request is checked before it leaves, whatever put the text in it: you, a file, a tool, the agent's own instructions.
| What leaves your machine | For example | What Latet does |
|---|---|---|
| Your prompts | "Send the IBAN GB82 WEST 1234 5698 7654 32 to Emily Carter" | Each value becomes a fake of the same shape, the name with the optional local name model; the answer comes back with the real ones |
| Files the agent reads | .env, config.yaml, test fixtures, a customer export | Keys, passwords, connection strings and personal data are masked; files the agent writes get the real values back |
| Command output and logs | docker logs, a stack trace, kubectl describe | Tokens, email addresses, public IP addresses and ID numbers in them are masked |
| Database queries | psql, or an MCP server answering SELECT * FROM customers | Secret columns always; with strict data mode every value, with fakes that keep joins and counts working |
| API responses | curl of an internal API, a webhook payload | Masked by value, and by field: a password or token field always |
| Web pages | An admin panel the agent opens with a browser tool | The personal data in the page's text is masked |
| Instructions | CLAUDE.md, AGENTS.md, the system prompt | Your name, user name, host name and company domain are masked |
| History | A session resumed ten days later | The same value gets the same fake, every time |
| Screenshots | A screenshot of an invoice | With the optional text recognition, the text in it is read on your machine and each value is covered with its fake; PDFs are flagged, or not sent (images: block) |
| What no pattern finds | "Mark from sales is quietly interviewing at a competitor" | The optional local reviewer, a language model on your computer, finds it before it leaves, following Latet's instructions and your own guidance: what counts as sensitive in your work, everywhere or in one project |
A key in .env, for example: the agent reads the file to learn the configuration, and the model receives pk_3a8d1f6b0e9c2a7d4b5e instead of pk_9f2c4e7a1b3d5f8e0a6c. It writes the code that reads the key from the environment, as it should; if a fake does come back, in a reply or in a command the agent runs, Latet puts the real key back on your machine. See How it works.
.env values, private keys, connection strings) and personal data (emails, phone numbers, IBANs, card numbers, VAT numbers of 30 European countries and national ID and tax numbers of 15 countries, each confirmed by its check digits or a nearby keyword, and names and street addresses with the optional local name model). Your own terms and formats too: a client's name, or every badge number written AB-123456 (custom recognizers, with a guide for AI agents to write them).latet.yaml in a repository adds the project's own terms, formats and stricter rules, shared with everyone who works on it, and can never turn protection down; your own settings for a project may also loosen it, or turn Latet off there (projects).latet dashboard).Everything runs on your machine. There is no Latet cloud service, no telemetry and no analytics.
Detectors find what has a shape: a key, an email address, an IBAN, an ID number with its check digits. A good deal of what matters has none. "Mark from sales is quietly interviewing at a competitor", a client named in passing in a log, a password written in a sentence, the details of a contract in a customer's email: no pattern recognises them, and an agent that reads files and tools for you brings plenty of them along. Latet has two more layers for these, and each adds to what the one before masked; neither can take anything away.
flowchart LR
T["A text about to leave:<br/>prompt, file, tool output"] --> D["Detectors<br/>secrets, personal data,<br/>your terms and formats"]
D --> R["Local reviewer<br/>a language model<br/>on your own computer"]
R --> L["Live Reviewer<br/>you, in the dashboard"]
L --> M["The model"]
The local reviewer is a language model that runs on your own computer, or on a server of yours (llama.cpp, Ollama, LM Studio). Before a text leaves, it reads what the detectors left in clear, with their replacements shown as placeholders, and points out the names, health details, addresses and secrets in prose. Latet masks them like any other value, wherever they appear from then on.
benchmarks/llm-review/README.md says how these were made, with scripts/llm_review_bench.py, and CONTRIBUTING.md how to send yours.The Live Reviewer is you, as the last check. Turn it on in the dashboard, and every new text waits on the Approvals page before it leaves: the original next to what will be sent, with what the detectors and the local reviewer masked, and what the reviewer only suggested.
Without the local reviewer, what has no shape is only as safe as the detectors and the local name model make it: turn the Live Reviewer on, or keep such text out of the agent's reach. See Local reviewer and Live Reviewer.
| Agent | How it goes through Latet | Signed in with |
|---|---|---|
| Claude Code: CLI, VS Code, JetBrains | Client mode: the Latet plugin, and the daemon checking each request | A Pro or Max subscription, or an API key |
| Claude Desktop's Code tab (local sessions) | Client mode, plugin only: the app ignores ANTHROPIC_BASE_URL | A subscription |
| Codex CLI and Codex Desktop | Gateway: openai_base_url in ~/.codex/config.toml | ChatGPT, or an API key |
| OpenCode | Gateway: each provider's baseURL | API keys (Anthropic, OpenAI, OpenRouter) |
| Gemini CLI | Gateway: latet gemini sets GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL | A Gemini API key, or Vertex AI |
| GitHub Copilot CLI | Gateway: latet copilot points COPILOT_PROVIDER_BASE_URL at Latet; signed in with GitHub, on a Copilot plan's models, it is not covered | A provider of your own: OpenAI, Anthropic or OpenRouter |
| Hermes Agent, OpenClaw, Goose, OpenHands, and any agent that takes an OpenAI-compatible base URL | Gateway: its base URL set to Latet (autonomous agents) | An OpenAI or OpenRouter API key; Hermes Agent, ChatGPT too |
| Scripts and SDKs: the OpenAI, Anthropic and Google GenAI SDKs, LangChain, LangGraph, AutoGen, CrewAI, Aider, etc. | Gateway: latet run <command> points OPENAI_BASE_URL, ANTHROPIC_BASE_URL and GOOGLE_GEMINI_BASE_URL at Latet | API keys |
| GitHub Copilot (VS Code chat), JetBrains Junie CLI and AI Assistant, Zed, Cline, Kilo Code, Continue, Qwen Code, Crush, Prime Agent | Gateway: the tool's own base URL or custom endpoint set to Latet (other tools); Copilot's inline suggestions are not covered | API keys |
Through the gateway, requests reach OpenAI (and ChatGPT, for Codex and Hermes Agent), OpenRouter (Claude, Gemini and hundreds of other models), Anthropic's API, and Google's Gemini API and Vertex AI. Not covered: Claude Desktop's Chat and Cowork signed in to Claude, cloud sessions (they run on the provider's infrastructure), and Cursor, Windsurf, Antigravity, Jules, Kiro, Warp and the ChatGPT app, whose requests leave from the vendor's servers (what Latet cannot protect).
127.0.0.1:5283 masks the request and restores the answer, streams included (SSE and WebSocket).flowchart LR
subgraph machine["Your machine"]
direction TB
CC["Claude Code<br/>+ Latet plugin<br/>(masks inside Claude Code)"]
GW["Codex, OpenCode, Gemini CLI,<br/>Hermes Agent, OpenClaw, Goose,<br/>OpenHands, scripts and SDKs"]
D["Latet daemon<br/>127.0.0.1:5283"]
V[("Vault<br/>original ↔ fake<br/>encrypted")]
UI["Dashboard"]
CC -- "already masked" --> D
GW -- "real values" --> D
D --- V
D --- UI
end
D -- "Claude Code's requests:<br/>checked, forwarded unchanged" --> P["Anthropic, OpenAI / ChatGPT,<br/>OpenRouter, Google Gemini"]
D -- "every other agent's:<br/>masked, answers restored" --> P
Tested for real. Each of these runs through a real Latet to a fake provider, set up as its guide says, and its test checks that the provider never sees the secrets and that the agent gets them back: Claude Code (on Bedrock and Vertex too), Codex, Gemini CLI, Hermes Agent, OpenClaw, Goose, OpenHands, Aider, Qwen Code, Crush, Prime Agent, the GitHub Copilot CLI, Junie CLI, and the CLIs of Cline, Kilo Code and Continue; the OpenAI, Anthropic and Google GenAI SDKs, LangChain, LangGraph, AutoGen and CrewAI. Tested live with real providers: the Claude Code CLI with a subscription and the Codex CLI signed in with ChatGPT (WebSocket), with local name detection and the dashboard, and the local reviewer with a model served by llama.cpp. GitHub Copilot in VS Code was tried by hand; Copilot in JetBrains IDEs, JetBrains AI Assistant, Zed, Android Studio and Claude Desktop on a gateway are documented, not tested. Every wire format is also tested in CI against a fake upstream. If an agent does not work through Latet, please open an issue.
Latet works with Claude Code signed in with a Pro or Max subscription: no API key is needed. Anthropic allows a subscription in Claude Code and its own apps (Legal and compliance), and that is the only place Latet uses one:
ANTHROPIC_BASE_URL, the setting Anthropic documents for gateways, a subscription login included.Latet takes no work away from your provider: it brings it the work you would otherwise keep from it.
This describes how Latet works; it is not legal advice.
Claude Code's auto mode: its reviewer is sent your OS user name and the paths of your home and working directories, which no plugin can mask. Latet blocks those reviews by default (auto mode then cannot approve actions by itself); auto_mode_review lets each detail through to the reviewer, or gives it a fake user name. See Claude Code.
The usual way is to start the agent through Latet, the session you want protected:
latet claude, latet codex or latet opencode runs that agent through Latet. It starts the daemon and gives the agent what it needs for that session only: none of the agent's settings change, and when the session ends the agent works as before. Every argument passes through: latet claude --resume.claude started on its own does not go through Latet. That is the price of changing nothing.For every session, once Latet fits your work: latet global-install points your agents at Latet for good, in the terminal and in the IDE, so no session can start without it. It is worth it once your terms, formats and projects are set and the false positives marked Not sensitive, and on a machine where a forgotten session would matter: a customer's data, an autonomous agent left alone. It shows everything it would change and asks once; it backs each agent's settings up first, and latet global-uninstall takes it all away, the settings restored byte for byte. latet agents apply routes one agent you install later.
Setting Latet up takes some time. The defaults protect at once what has a shape: keys, passwords, email addresses, IBANs, ID numbers. Making it fit you takes longer: your clients' names and internal codes as terms and recognizers (latet rules suggest finds the formats), the false positives marked Not sensitive from the dashboard, a project's own rules, and the local reviewer for what has no shape. The dashboard shows each request as it left, so you see what to add as you work.
latet run <command> is for scripts and SDKs: it runs the command with OPENAI_BASE_URL, ANTHROPIC_BASE_URL and GOOGLE_GEMINI_BASE_URL pointing at Latet, so a program that reads them goes through it (latet run python summarize.py). A program that does not read them runs as usual.
Requirements: Windows, macOS or Linux. The installers set up uv, which also provides a Python Latet is tested on (3.12 or 3.13).
On macOS and Linux:
curl -LsSf https://raw.githubusercontent.com/Latet-AI/latet/main/scripts/install.sh | sh
On Windows (PowerShell):
irm https://raw.githubusercontent.com/Latet-AI/latet/main/scripts/install.ps1 | iex
Both install the latest release from GitHub with uv, local name detection and text in images included, and change nothing else: start your agent with latet claude as above, and run latet global-install when it fits your work. latet upgrade installs the next release the same way.
Recommended: names and images from day one. Patterns find what has a shape, such as a key, an email address or an IBAN. A name in a sentence has none (Emily Carter lives at 42 Mill Lane): the local name model finds it. The text in a screenshot needs text recognition, or the image leaves as it is. The installers install what both run on; download their models once:
latet ner install # names and street addresses: about 1.6 GB
latet ocr install # the text in images: about 31 MB
Both run on this computer only: the models come once from huggingface.co, checked against pinned hashes, and nothing is sent anywhere. latet global-install --ner --ocr downloads them with the rest of its setup.
With an AI agent: point it at llms-install.md. It installs Latet, which changes nothing on its own, then tells you how you can go on (try it one session at a time, have it on for every session, set up your other tools, download the local models) and waits for your choice: it runs latet global-install only if you choose every session. Later, llms-config.md lets it change Latet's settings for you: what adds protection applies, what lowers it waits for your approval in the dashboard. That is a guardrail against mistakes, not a lock: an agent runs as you, so it cannot hold against one misled by a prompt injection (Agents and your settings).
With Docker, if you prefer: docker compose up -d with compose.yaml, then docker exec latet latet agents print shows how to point each agent at the container. See Docker.
From a checkout (also needs Node.js 22.12 or newer, to build the dashboard; installed from the repository's address, git+https://..., Latet would come without it):
git clone https://github.com/Latet-AI/latet.git
cd latet
cd dashboard && npm ci && npm run build && cd .. # the dashboard ships inside the package
uv tool install --force ".[ner,ocr]" # or "." without names and images
To work on Latet itself, see CONTRIBUTING.md.
latet tutorial shows it at work in about 30 minutes, in your browser: a pretend agent and a pretend model, the real Latet between them, and invented data. Nothing leaves your computer, and none of your settings change. See Tutorial..env file, start latet claude (or latet codex) there, and ask the agent to copy the key into another file. Then run latet dashboard: the request shows a fake, and the file on disk contains the original.latet claude, and look at the dashboard now and then. Add your own terms, mark the false positives Not sensitive, and turn on the local reviewer if you have a graphics card, or the Live Reviewer when the data matters.hooks/register.ts 883 lines1// Latet for Claude Code (client mode).
2//
3// Masks sensitive values inside Claude Code before a request is built, so the model
4// only ever reads surrogates; restores the real values for local tools and on screen; and checks
5// every request before it leaves. All detection happens in the local Latet daemon (the bridge).
6
7import { apiImages, copyId, copyPath, pastedImages, toolImages, type MaskedPicture, type Picture } from './images.ts'
8import { apiSlots, collectKeys, collectStrings, renameKeys, replaceStrings, rowTexts } from './walk.ts'
9import type { EngineInterface } from 'claude-code'
10
11const DEFAULT_BRIDGE = 'http://127.0.0.1:5283'
12// Kept equal to plugin.json and the Python package by the release check.
13const PLUGIN_VERSION = '0.9.10'
14const API = '/_latet/engine/v1/'
15// Tools whose arguments leave this machine (web, Anthropic-hosted connectors) or start another
16// model conversation: they keep the surrogates the model wrote.
17const KEEP_SURROGATES = /^(?:WebFetch|WebSearch|Task|Agent|SendMessage|mcp__claude_ai_.*)$/
18// Of those, the ones that run outside this computer: given a stand-in, they would search for someone
19// else, open the wrong page, or write to an address that does not exist. Subagents stay here.
20const OUTSIDE = /^(?:WebFetch|WebSearch|mcp__claude_ai_.*)$/
21// Fields of a tool.call event that are Claude Code's own, not the tool's arguments.
22const PINNED = new Set(['tool', 'tool_use_id', 'agentId', 'turnId'])
23// Rows the model produced, or that no request carries: nothing to mask.
24const SKIP_DOORS = new Set(['response', 'compaction', 'notice'])
25const WITHHELD = '[withheld by Latet: the local daemon did not answer, or failed on it, so this content was not sent]'
26const RENDER_CACHE_LIMIT = 2000
27const TOOL_NAMES_LIMIT = 2000
28const COPIES_LIMIT = 50
29
30let bridge = DEFAULT_BRIDGE
31let token = ''
32let session = ''
33let cwd = '' // the folder Claude Code runs in: the daemon applies that project's settings
34const verified = new Map<string, number>() // agent → messages already verified
35const rendered = new Map<string, string>() // surrogate text → restored text, for redraws
36// tool_use id → tool name: the daemon reads a tool's result by the tool (strict data mode leaves
37// what Read returns alone)
38const toolNames = new Map<string, string>()
39// tool_use id → the file the tool read or the address it fetched: strict data mode covers a file or an
40// address a rule names (`data_files`, `data_hosts`)
41const toolSources = new Map<string, string>()
42// The arguments of a tool call that say where its result comes from.
43const SOURCE_ARGUMENTS = ['file_path', 'notebook_path', 'path', 'url'] as const
44// Masked copies of images you pasted, by id: the Read tool gets them from here, never from a file.
45const copies = new Map<string, Picture>()
46// Whether this session's requests go through Latet (the daemon's verdict at start): when they do
47// not, prompts are stopped, since nothing would check what leaves.
48type Verdict = { ok: boolean; reason?: string | null; toast?: string | null }
49let route: Verdict | null = null
50// The session's route facts, while the daemon has not checked them (it was away at the start).
51let unchecked: Record<string, unknown> | null = null
52// Why Latet's own address cannot be trusted (a repository named it, or it is not on this computer):
53// then nothing at all is sent to it, since it would receive every text and the token.
54let untrusted: string | null = null
55// The session start got as far as a verdict, or as facts to check later: otherwise prompts are stopped.
56let routeKnown = false
57const LOOPBACK = /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d+\/?$/
58const SOURCES = ['user', 'project', 'local', 'flag', 'policy'] as const // rising precedence
59// Where each is set matters too, never its value: a proxy's address may hold a password.
60const SET_ONLY = ['LATET_BRIDGE_URL', 'CLAUDE_CODE_ENTRYPOINT', 'ANTHROPIC_FOUNDRY_RESOURCE'] as const
61const PROXIES = ['HTTPS_PROXY', 'HTTP_PROXY', 'ALL_PROXY', 'https_proxy', 'http_proxy', 'all_proxy'] as const
62// Why a connected computer's requests are withheld, each said with its cause (R6): what the person can do differs.
63const REMOTE_FAILURE = 'Latet could not use its server for Claude Code, so requests are withheld: `latet doctor` says why'
64const SURFACE = 'print mode (claude -p) and the other surfaces are not supported on a computer connected to a Latet server, so requests are withheld: use Claude Code\'s interactive terminal'
65const BINDING = 'Latet\'s helper binding for Claude Code is missing or out of date, so requests are withheld: run `latet agents apply claude`, or start Claude Code with `latet claude`'
66const UNANSWERED = 'the Latet server did not answer, so requests are withheld: `latet status` says why'
67const TOKEN_REFUSED = 'the server no longer accepts this computer\'s token (revoked, or your identity disabled): `latet disconnect`, then `latet connect <url>` with a new code from an administrator'
68const PLATFORM = 'Claude Code through a Latet server needs a Windows terminal in this version, so requests are withheld'
69const CODE_WORDS: Record<string, string> = {
70 bad_input: REMOTE_FAILURE, binding: BINDING, unsupported: PLATFORM, deadline: UNANSWERED,
71 unavailable: UNANSWERED, refused: TOKEN_REFUSED, response: REMOTE_FAILURE,
72}
73const WITHHELD_REMOTE = '[withheld by Latet: its server did not mask this content, so it was not sent]'
74const WITHHELD_DESCRIPTION = '[withheld by Latet: this tool\'s description could not be masked, so it was not sent]'
75const INPUT_LIMIT = 4 * 1024 * 1024
76const OUTPUT_LIMIT = INPUT_LIMIT - 1
77const OPERATIONS = new Set(['ping', 'route-check', 'mask', 'rehydrate', 'scope', 'verify', 'instructions', 'approvals/wait', 'outbound'])
78type Launch = { v: 1; python: string; cwd: string; descriptor: string; env: Record<string, string> }
79let remote = false
80let launch: Launch | null = null
81let inspected = true
82let helperSource: string | undefined
83let helperSetting: unknown
84let selectedBridge: unknown
85// Why the session start's remote check or its first call failed, from the error caught there (``causeOf``).
86let startCause = REMOTE_FAILURE
87
88class RemoteRefusal extends Error {}
89function refused(why: string = REMOTE_FAILURE): never { throw new RemoteRefusal(why) }
90/** The words of a remote failure, from the error caught: a refusal's own cause, else the generic one (never another
91 * hook's earlier cause). */
92function causeOf(error: unknown): string { return error instanceof RemoteRefusal ? error.message : REMOTE_FAILURE }
93/** Whether Claude Code runs on Windows, where its helper can be bound (E0c): Linux and macOS set neither variable. */
94async function onWindows($): Promise<boolean> {
95 try {
96 return (await $.env.get('OS')) === 'Windows_NT' || Boolean(await $.env.get('SystemRoot'))
97 } catch { return true } // unknown: the binding's own words, which name `latet agents apply claude`
98}
99function withheld(): string { return remote ? WITHHELD_REMOTE : WITHHELD }
100function object(value: unknown): value is Record<string, unknown> {
101 return value !== null && typeof value === 'object' && !Array.isArray(value)
102}
103function keys(value: Record<string, unknown>, names: string[]): boolean {
104 return Object.keys(value).length === names.length && names.every((name) => Object.prototype.hasOwnProperty.call(value, name))
105}
106function byteLength(text: string): number { return new TextEncoder().encode(text).byteLength }
107
108/** One bounded JSON value; duplicate decoded keys and nonfinite numbers are refused before use. */
109function protocolJSON(text: string, limit: number): unknown {
110 if (text.length > limit || byteLength(text) > limit) refused()
111 let at = 0, nodes = 0
112 const number = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y
113 function space() { while (at < text.length && ' \t\r\n'.includes(text.charAt(at))) at++ }
114 function string(): string {
115 if (text[at] !== '"') refused()
116 const begin = at++
117 while (at < text.length) {
118 if (text[at] === '\\') { at += 2; continue }
119 if (text[at++] === '"') return JSON.parse(text.slice(begin, at))
120 }
121 return refused()
122 }
123 function value(depth: number): unknown {
124 if (++nodes > 100000) refused()
125 space()
126 const first = text[at]
127 if (first === '"') return string()
128 if (first === '{' || first === '[') {
129 if (++depth > 64) refused() // Containers, with the root container at level one.
130 at++
131 space()
132 if (first === '[') {
133 const result: unknown[] = []
134 if (text[at] === ']') { at++; return result }
135 while (true) {
136 result.push(value(depth)); space()
137 if (text[at] === ']') { at++; return result }
138 if (text[at++] !== ',') refused()
139 }
140 }
141 const result: Record<string, unknown> = Object.create(null)
142 if (text[at] === '}') { at++; return result }
143 while (true) {
144 space()
145 const key = string()
146 if (Object.prototype.hasOwnProperty.call(result, key)) refused()
147 space()
148 if (text[at++] !== ':') refused()
149 result[key] = value(depth); space()
150 if (text[at] === '}') { at++; return result }
151 if (text[at++] !== ',') refused()
152 }
153 }
154 for (const [literal, result] of [['true', true], ['false', false], ['null', null]] as const) {
155 if (text.startsWith(literal, at)) { at += literal.length; return result }
156 }
157 number.lastIndex = at
158 const found = number.exec(text)
159 if (!found) return refused()
160 at = number.lastIndex
161 const result = Number(found[0])
162 if (!Number.isFinite(result)) refused()
163 return result
164 }
165 const result = value(0)
166 space()
167 if (at !== text.length) refused()
168 return result
169}
170
171/** Count before building the complete stdin string, including escaping and envelope overhead. */
172function protocolInput(input: unknown): string {
173 let bytes = 0, nodes = 0
174 function charge(text: string) {
175 if (text.length > INPUT_LIMIT || (bytes += byteLength(text)) > INPUT_LIMIT) refused()
176 }
177 function quoted(text: string) {
178 if (text.length > INPUT_LIMIT) refused()
179 charge(JSON.stringify(text))
180 }
181 function visit(value: unknown, depth: number) {
182 if (++nodes > 100000) refused()
183 if (typeof value === 'string') { quoted(value); return }
184 if (value === null || typeof value === 'boolean') { charge(JSON.stringify(value)); return }
185 if (typeof value === 'number') {
186 if (!Number.isFinite(value)) refused()
187 charge(JSON.stringify(value)); return
188 }
189 if (!object(value) && !Array.isArray(value)) refused()
190 if (++depth > 64) refused()
191 if (Array.isArray(value)) {
192 if (value.length > 100000) refused()
193 charge('[')
194 for (let index = 0; index < value.length; index++) {
195 if (index) charge(',')
196 visit(value[index], depth)
197 }
198 charge(']')
199 } else {
200 charge('{')
201 Object.entries(value).forEach(([key, item], index) => {
202 if (index) charge(',')
203 quoted(key); charge(':'); visit(item, depth)
204 })
205 charge('}')
206 }
207 }
208 visit(input, 0)
209 const text = JSON.stringify(input)
210 if (byteLength(text) > INPUT_LIMIT) refused()
211 return text
212}
213
214function drivePath(path: unknown): path is string {
215 return typeof path === 'string' && /^[A-Za-z]:\\/.test(path) &&
216 !/[\x00-\x1f/<>"|?*:]/.test(path.slice(2)) &&
217 path.slice(3).split('\\').every((part) => part !== '' && part !== '.' && part !== '..' && !/[. ]$/.test(part))
218}
219
220/** The initial candidate is the exact frozen Windows overlay, never an inherited env dump. */
221function boundLaunch(raw: unknown): Launch {
222 if (typeof raw !== 'string') return refused()
223 const parsed = protocolJSON(raw, 16384)
224 if (!object(parsed) || !keys(parsed, ['v', 'python', 'cwd', 'descriptor', 'env']) || parsed.v !== 1 ||
225 !drivePath(parsed.python) || !drivePath(parsed.cwd) || !drivePath(parsed.descriptor) || !object(parsed.env)) return refused()
226 const match = /^(.*\\connection\\claude-helper)\\(?:permanent\.json|session-[0-9a-f]{32}\.json)$/.exec(parsed.descriptor)
227 if (!match || !match[1] || parsed.cwd !== `${match[1]}\\cwd`) return refused()
228 const root = match[1], env = parsed.env
229 const windows = env.SystemRoot
230 if (!drivePath(windows)) return refused()
231 const expected: Record<string, string> = {
232 PATH: `${windows}\\System32`, SystemRoot: windows, WINDIR: windows,
233 HOME: `${root}\\homes\\user`, USERPROFILE: `${root}\\homes\\user`,
234 CODEX_HOME: `${root}\\homes\\codex`, CLAUDE_CONFIG_DIR: `${root}\\homes\\claude`,
235 XDG_CONFIG_HOME: `${root}\\homes\\xdg`, APPDATA: `${root}\\homes\\roaming`, LOCALAPPDATA: `${root}\\homes\\local`,
236 HOMEDRIVE: root.slice(0, 2), HOMEPATH: `${root.slice(2)}\\homes\\user`,
237 TEMP: `${root}\\temp`, TMP: `${root}\\temp`, TMPDIR: `${root}\\temp`,
238 LATET_KEYRING: 'off', PYTHON_KEYRING_BACKEND: 'keyring.backends.null.Keyring',
239 PYTHONHOME: '', PYTHONPATH: '', PYTHONSTARTUP: '', PYTHONUSERBASE: '', PYTHONEXECUTABLE: '',
240 __PYVENV_LAUNCHER__: '', __COMPAT_LAYER: '', SSL_CERT_FILE: '', SSL_CERT_DIR: '', SSLKEYLOGFILE: '',
241 OPENSSL_CONF: '', OPENSSL_CONF_INCLUDE: '', OPENSSL_ENGINES: '', OPENSSL_MODULES: '', OPENSSL_TRACE: '',
242 LATET_DATA_DIR: '', LATET_CONNECTION: '', LATET_CONFIG: '', LATET_SERVER_URL: '', LATET_SERVER_FINGERPRINT: '',
243 LATET_SERVER_ADMIN_TOKEN: '', LATET_BRIDGE_URL: '', LATET_BRIDGE_TOKEN: '', LATET_BRIDGE_HELPER: '',
244 LATET_TRUSTED_ACCOUNTS: '',
245 }
246 const folded = Object.keys(env).map((key) => key.toLowerCase())
247 if (new Set(folded).size !== folded.length || !keys(env, Object.keys(expected)) ||
248 Object.entries(expected).some(([key, value]) => env[key] !== value)) return refused()
249 return { v: 1, python: parsed.python, cwd: parsed.cwd, descriptor: parsed.descriptor, env: expected }
250}
251
252function remoteSpace(address: string): string | null {
253 if (/[\x00-\x20\x7f\\]/.test(address)) return null
254 try {
255 const parsed = new URL(address)
256 if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash ||
257 !/^\/u\/[a-z2-7]{26}\/?$/.test(parsed.pathname)) return null
258 return parsed.origin + parsed.pathname.replace(/\/$/, '')
259 } catch { return null }
260}
261
262async function helperCall($: EngineInterface, operation: string, body: Record<string, unknown> | null) {
263 if (untrusted || !launch || !OPERATIONS.has(operation) || (operation !== 'ping' && operation !== 'route-check' && route?.ok !== true)) refused()
264 const bound = launch
265 const stdin = protocolInput({ v: 1, op: operation, bridge, token, body })
266 try {
267 // Recheck the bound names; the helper independently checks the actual private snapshot and installation.
268 for (const [path, kind] of [[bound.python, 'file'], [bound.cwd, 'dir'], [bound.descriptor, 'file']] as const) {
269 const stat = await $.fs.stat(path, { resolve: true })
270 if (stat.kind !== kind || stat.isLink !== false || stat.realPath !== path ||
271 (kind === 'file' && (!Number.isSafeInteger(stat.size) || stat.size <= 0)) ||
272 (path === bound.descriptor && stat.size > 16384)) refused()
273 }
274 const result = await $.process.run([bound.python, '-I', '-B', '-m', 'latet.claude_bridge_helper', '--descriptor', bound.descriptor], {
275 cwd: bound.cwd, env: { ...bound.env }, stdin, timeoutMs: 35000,
276 })
277 if (!result || typeof result.stdout !== 'string' || result.stderr !== '' ||
278 result.isStdoutTruncated !== false || result.isStderrTruncated !== false) refused()
279 const answer = protocolJSON(result.stdout, OUTPUT_LIMIT)
280 if (!object(answer) || answer.v !== 1) refused()
281 if (answer.ok === false) {
282 if (!keys(answer, ['v', 'ok', 'code']) || result.exitCode !== 2 ||
283 typeof answer.code !== 'string' || !Object.prototype.hasOwnProperty.call(CODE_WORDS, answer.code)) refused()
284 return refused(CODE_WORDS[answer.code as string])
285 }
286 if (answer.ok !== true || result.exitCode !== 0 || !keys(answer, ['v', 'ok', 'response'])) refused()
287 const response = answer.response
288 if (operation === 'route-check') return response // checkRoute admits only the literal affirmative verdict.
289 if (!object(response)) refused()
290 if (operation === 'ping' && typeof response.version !== 'string') refused()
291 if (operation === 'instructions' && !Array.isArray(response.findings)) refused()
292 if (operation === 'verify' && (!Array.isArray(response.blocking) || !Array.isArray(response.flagged))) refused()
293 if (operation === 'outbound' && (!Array.isArray(response.found) || !(response.reason === null || typeof response.reason === 'string'))) refused()
294 if (operation === 'mask' || operation === 'approvals/wait') {
295 if (response.pending !== undefined) {
296 if (typeof response.pending !== 'string' || !response.pending ||
297 (response.viewers !== undefined && (typeof response.viewers !== 'number' || !Number.isSafeInteger(response.viewers) || response.viewers < 0))) refused()
298 } else if (!Array.isArray(response.items)) refused()
299 }
300 if (operation === 'rehydrate' && (!Array.isArray(response.items) || !Array.isArray(body?.items) ||
301 response.items.length !== body.items.length || response.items.some((item) => typeof item !== 'string'))) refused()
302 return response
303 } catch (error) {
304 if (error instanceof RemoteRefusal) throw error // its cause, said: never a raw child result or error
305 return refused() // No raw child result/error, retry, shell, host fetch or local fallback.
306 }
307}
308
309/** Claude Code's route as it runs: its variables (settings applied), and the settings file that set each. */
310async function routeFacts($, e) {
311 const env = {
312 ANTHROPIC_BASE_URL: await $.env.get('ANTHROPIC_BASE_URL'),
313 ANTHROPIC_BEDROCK_BASE_URL: await $.env.get('ANTHROPIC_BEDROCK_BASE_URL'),
314 ANTHROPIC_VERTEX_BASE_URL: await $.env.get('ANTHROPIC_VERTEX_BASE_URL'),
315 ANTHROPIC_FOUNDRY_BASE_URL: await $.env.get('ANTHROPIC_FOUNDRY_BASE_URL'),
316 CLAUDE_CODE_USE_BEDROCK: await $.env.get('CLAUDE_CODE_USE_BEDROCK'),
317 CLAUDE_CODE_USE_VERTEX: await $.env.get('CLAUDE_CODE_USE_VERTEX'),
318 CLAUDE_CODE_USE_FOUNDRY: await $.env.get('CLAUDE_CODE_USE_FOUNDRY'),
319 CLAUDE_CODE_USE_MANTLE: await $.env.get('CLAUDE_CODE_USE_MANTLE'),
320 }
321 const names = [...Object.keys(env), ...SET_ONLY, ...PROXIES]
322 const sources: Record<string, string> = {}
323 for (const source of SOURCES) {
324 try {
325 const settings = await $.settings.read({ source })
326 if (!object(settings)) throw new Error(REMOTE_FAILURE)
327 const block = settings.env === undefined ? {} : settings.env
328 if (!object(block)) throw new Error(REMOTE_FAILURE)
329 if (Object.values(block).some((value) => typeof value !== 'string')) inspected = false
330 for (const name of names) if (name in block) sources[name] = source
331 if (Object.prototype.hasOwnProperty.call(block, 'LATET_BRIDGE_URL')) selectedBridge = block.LATET_BRIDGE_URL
332 if (Object.prototype.hasOwnProperty.call(block, 'LATET_BRIDGE_HELPER')) {
333 helperSetting = block.LATET_BRIDGE_HELPER
334 helperSource = source
335 }
336 } catch {
337 inspected = false // Remote authority cannot be inferred from an unreadable or malformed layer.
338 }
339 }
340 const entrypoint = await $.env.get('CLAUDE_CODE_ENTRYPOINT')
341 const set = Object.fromEntries(Object.entries(env).filter(([, value]) => typeof value === 'string'))
342 return { bridge, env: set, sources, entrypoint: entrypoint ?? null, surface: e.surface ?? null }
343}
344
345/** Why Latet's own address cannot be trusted: named by a repository, or not on this computer. */
346function bridgeProblem(source: string | undefined): string | null {
347 if (source === 'project' || source === 'local') {
348 const file = source === 'project' ? '.claude/settings.json' : '.claude/settings.local.json'
349 return `this project's ${file} sets LATET_BRIDGE_URL: Latet never takes its address from a repository`
350 }
351 if (!LOOPBACK.test(bridge) && !remoteSpace(bridge)) return 'LATET_BRIDGE_URL is not an address on this computer'
352 return null
353}
354
355type Item = { text?: string; image?: Picture; kind: string; from_model?: boolean; json?: boolean; tool?: string }
356
357/** The daemon answered, with an error. */
358class BridgeError extends Error {
359 constructor(
360 readonly status: number,
361 path: string,
362 ) {
363 super(`Latet bridge ${path}: HTTP ${status}`)
364 }
365}
366
367async function bridgeCall($, path: string, body: Record<string, unknown>) {
368 // Rendering can precede session.start; never use defaults or a previous session's transport.
369 if (!routeKnown) refused()
370 if (untrusted) throw new Error(`Latet: ${untrusted}`)
371 if (remote) return helperCall($, path, { session, cwd, ...body })
372 const response = await $.http.fetch(bridge + API + path, {
373 method: 'POST',
374 headers: { 'content-type': 'application/json', 'x-latet-token': token },
375 body: JSON.stringify({ session, cwd, ...body }),
376 })
377 if (!response.ok) throw new BridgeError(response.status, path)
378 return JSON.parse(response.text)
379}
380
381/**
382 * The daemon's verdict on where the session's requests go. An older daemon (404) has no such check;
383 * any other answer that is not a verdict stops prompts, since nothing says where they would go. Throws
384 * while the daemon does not answer: the check is made again at the next prompt.
385 */
386async function checkRoute($, facts: Record<string, unknown>): Promise<Verdict | null> {
387 if (remote) {
388 const verdict = await bridgeCall($, 'route-check', facts)
389 return object(verdict) && verdict.ok === true ? { ok: true } : { ok: false, reason: REMOTE_FAILURE }
390 }
391 let verdict: Verdict | null
392 try {
393 verdict = await bridgeCall($, 'route-check', facts)
394 } catch (error) {
395 if (!(error instanceof BridgeError)) throw error
396 if (error.status === 404) return null
397 verdict = { ok: false, reason: 'Latet could not check where Claude Code sends its requests (`latet doctor` says why)' }
398 }
399 if (verdict?.toast) $.ui.toast(`Latet: ${verdict.toast}.`)
400 if (verdict?.ok === false) $.ui.toast(`Latet: ${verdict.reason}. Prompts are stopped.`)
401 return verdict
402}
403
404type Masked = { text: string; blocked?: string }
405
406const WAITING = 'Latet: waiting for your approval in the dashboard'
407const NO_DASHBOARD = 'Latet: a text waits for your approval, and no dashboard is open: run `latet dashboard`.'
408let toldNoDashboard = false
409
410/** The daemon's masked items: a text, and why it was stopped when it was (the local reviewer, or you).
411 *
412 * With the Live Reviewer on, new texts wait for you in the dashboard: the daemon answers `pending` at
413 * once, and is asked again until you decide (each call is held up to 20 s; a fetch gives up at 30). */
414async function maskItems($, items: Item[]): Promise<Masked[]> {
415 if (items.length === 0) return []
416 let result = await bridgeCall($, 'mask', { items })
417 if (result.pending) {
418 $.ui.status(WAITING)
419 try {
420 while (result.pending) {
421 if (!result.viewers && !toldNoDashboard) {
422 toldNoDashboard = true
423 $.ui.toast(NO_DASHBOARD)
424 }
425 if (result.viewers) toldNoDashboard = false
426 result = await bridgeCall($, 'approvals/wait', { id: result.pending })
427 }
428 } finally {
429 $.ui.status(undefined)
430 }
431 }
432 if (remote && (result.items.length !== items.length || result.items.some((item: unknown) => !object(item) ||
433 (typeof item.text !== 'string' && !Object.prototype.hasOwnProperty.call(item, 'image'))))) refused()
434 return result.items
435}
436
437async function mask($, items: Item[]): Promise<string[]> {
438 return (await maskItems($, items)).map((item) => item.text)
439}
440
441/** Images masked by the daemon: the text in them read, and painted over where values were. */
442async function maskPictures($, pictures: Picture[], kind: string, tool?: string): Promise<MaskedPicture[]> {
443 const items: Item[] = pictures.map((image) => (tool ? { image, kind, tool } : { image, kind }))
444 return (await maskItems($, items)) as unknown as MaskedPicture[]
445}
446
447/** A tool's result with its images masked; a Read of an image that was withheld is denied. */
448async function maskToolResult($, tool: string, answer) {
449 if (!answer || 'deny' in answer || answer.result === undefined) return answer
450 const found = toolImages(answer.result)
451 if (found === null) return answer
452 const masked = await maskPictures($, found.pictures, 'tool_result', tool)
453 if (masked.every((m, i) => m.image?.data === found.pictures[i].data)) return answer
454 return found.rebuild(masked)
455}
456
457async function digest(data: string): Promise<string> {
458 const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data))
459 return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 12)
460}
461
462/** Keep a masked copy of a pasted image for the Read tool, and say where it is. */
463async function offerCopy(image: Picture): Promise<string> {
464 const id = await digest(image.data)
465 copies.set(id, image)
466 if (copies.size > COPIES_LIMIT) copies.delete(copies.keys().next().value)
467 return copyPath(cwd || '.', id, image.media_type)
468}
469
470/** Texts with the real values. `model`: a tool call's input as the model wrote it, which the next request carries back
471 * as it is: the daemon's final check passes it as the model's own words. */
472async function restore($, texts: string[], json = false, model = false): Promise<string[]> {
473 if (texts.length === 0) return []
474 const result = await bridgeCall($, 'rehydrate', model ? { items: texts, json, model } : { items: texts, json })
475 return result.items
476}
477
478/** The daemon's version, or null when it does not answer. */
479async function ping($): Promise<string | null> {
480 if (untrusted) return null
481 try {
482 if (remote) {
483 const response = await helperCall($, 'ping', null)
484 return object(response) && typeof response.version === 'string' ? response.version : null
485 }
486 const response = await $.http.fetch(bridge + API + 'ping', { headers: { 'x-latet-token': token } })
487 if (!response.ok) return null
488 return String(JSON.parse(response.text).version ?? '')
489 } catch (error) {
490 startCause = causeOf(error)
491 return null
492 }
493}
494
495async function ensureDaemon($, autostart: boolean): Promise<string | null> {
496 const running = await ping($)
497 if (running !== null) return running
498 if (remote) return null
499 // Hooks have 10 s: launch without waiting, then watch for it briefly. A daemon in a container
500 // (LATET_AUTOSTART=0) is started there, not by the plugin.
501 if (autostart) {
502 try {
503 await $.process.run(['latet', 'start', '--no-wait'], { timeoutMs: 6000 })
504 } catch {
505 return null
506 }
507 }
508 for (let i = 0; i < (autostart ? 10 : 4); i++) {
509 const version = await ping($)
510 if (version !== null) return version
511 await $.clock.sleep(250)
512 }
513 return null
514}
515
516async function restoreForScreen($, text: string): Promise<string> {
517 const hit = rendered.get(text)
518 if (hit !== undefined) return hit
519 const [out] = await restore($, [text])
520 if (rendered.size >= RENDER_CACHE_LIMIT) rendered.clear()
521 rendered.set(text, out)
522 return out
523}
524
525/**
526 * A tool row's `input` and `output` (or a group's call) with the real values, for the screen only:
527 * the stored call and result keep what the model reads. A top-level string, such as the text of an
528 * errored call, is a value too.
529 */
530async function restoredForScreen<T extends Record<string, unknown>>($, props: T, keys: readonly string[]): Promise<T> {
531 const parts = keys.filter((key) => props[key] !== undefined && props[key] !== null)
532 const leaves = parts.flatMap((key) => {
533 const value = props[key]
534 return typeof value === 'string' ? [{ key, leaf: { path: [], value } }] : collectStrings(value).map((leaf) => ({ key, leaf }))
535 })
536 // The keys of the objects inside each part as well: a key can hold a value.
537 const named = parts.flatMap((key) => (typeof props[key] === 'string' ? [] : collectKeys(props[key]).map((leaf) => ({ key, leaf }))))
538 if (leaves.length === 0 && named.length === 0) return props
539 const restored = await restore($, [...leaves.map((x) => x.leaf.value), ...named.map((x) => x.leaf.key)])
540 const names = restored.slice(leaves.length)
541 if (restored.every((value, i) => value === (i < leaves.length ? leaves[i].leaf.value : named[i - leaves.length].leaf.key))) {
542 return props
543 }
544 const out: Record<string, unknown> = { ...props }
545 for (const key of parts) {
546 const mine = leaves.flatMap((x, i) => (x.key === key ? [i] : []))
547 const value = props[key]
548 if (typeof value === 'string') {
549 out[key] = restored[mine[0]]
550 continue
551 }
552 const keyed = named.flatMap((x, i) => (x.key === key ? [i] : []))
553 const replaced = replaceStrings(value, mine.map((i) => leaves[i].leaf), mine.map((i) => restored[i]))
554 out[key] = renameKeys(replaced, keyed.map((i) => named[i].leaf), keyed.map((i) => names[i]))
555 }
556 return out as T
557}
558
559function blockedAnswer(blocking: Array<{ type: string; kind: string }>, inherited: boolean): string {
560 const what = [...new Set(blocking.map((b) => `${b.type} (${b.kind.replace('_', ' ')})`))].join(', ')
561 if (inherited) {
562 return (
563 `Latet stopped this request before it left your machine: this conversation already held values in clear ` +
564 `before Latet checked it (${what}). It began before Latet was on, or was resumed from then, and every ` +
565 'request sends that history again. Start a new conversation with /clear: from there everything is masked as ' +
566 'it comes in. Nothing was sent.'
567 )
568 }
569 return (
570 `Latet stopped this request before it left your machine: ${what} would have been sent in clear. ` +
571 'Nothing was sent. Run `latet dashboard` to see where it was, and `latet doctor` to see why it was not masked.'
572 )
573}
574
575/** What the CLAUDE.md files hold that auto mode's reviewer would get unmasked, in one toast. */
576function instructionsWarning(found: Array<{ file: string; line: number; type: string }>): string {
577 const names = [...new Set(found.map((f) => f.file.split(/[\\/]/).slice(-2).join('/')))].join(', ')
578 const lines = found.slice(0, 4).map((f) => f.line).join(', ') + (found.length > 4 ? '…' : '')
579 return (
580 `Latet: ${names} holds secrets or other people's data (lines ${lines}). Claude Code gives auto mode's ` +
581 'reviewer that file unmasked, so its reviews will be stopped: move them to .env. `latet doctor` lists them.'
582 )
583}
584
585export function register(on) {
586 on('session.start', async ($, e, next) => {
587 routeKnown = false
588 bridge = (await $.env.get('LATET_BRIDGE_URL')) || DEFAULT_BRIDGE
589 token = (await $.env.get('LATET_BRIDGE_TOKEN')) || ''
590 session = await $.session.id()
591 cwd = typeof e.cwd === 'string' ? e.cwd : ''
592 const autostart = (await $.env.get('LATET_AUTOSTART')) !== '0'
593 route = null
594 unchecked = null
595 untrusted = null
596 remote = !LOOPBACK.test(bridge)
597 startCause = REMOTE_FAILURE
598 launch = null
599 inspected = true
600 helperSource = undefined
601 helperSetting = undefined
602 selectedBridge = undefined
603 const facts = await routeFacts($, e)
604 untrusted = bridgeProblem(facts.sources.LATET_BRIDGE_URL)
605 if (remote && !untrusted) {
606 try {
607 const effective = await $.env.get('LATET_BRIDGE_HELPER')
608 const source = facts.sources.LATET_BRIDGE_URL
609 if (e.surface !== 'terminal') refused(SURFACE) // T20.8: the helper runs on the interactive terminal alone
610 if (!inspected || selectedBridge !== bridge ||
611 typeof source !== 'string' || !['user', 'flag', 'policy'].includes(source)) refused()
612 if (!helperSource || !['user', 'flag', 'policy'].includes(helperSource) ||
613 effective !== helperSetting || !/^latet_pat_[A-Za-z0-9_-]{43}$/.test(token)) {
614 refused((await onWindows($)) ? BINDING : PLATFORM) // no binding is ever made on Linux or macOS (R5)
615 }
616 try {
617 launch = boundLaunch(helperSetting)
618 } catch { refused(BINDING) }
619 const canonical = remoteSpace(bridge)
620 if (!canonical) refused()
621 bridge = canonical
622 } catch (error) { untrusted = causeOf(error) }
623 }
624 routeKnown = true // from here on: a verdict, or facts the first prompt checks
625 if (untrusted) {
626 route = { ok: false, reason: untrusted }
627 $.ui.toast(`Latet: ${untrusted}. Prompts are stopped.`)
628 return next(e)
629 }
630 const version = await ensureDaemon($, autostart)
631 if (version === null) {
632 unchecked = facts
633 $.ui.toast(
634 remote ? `Latet: ${startCause}.` : autostart
635 ? 'Latet: the local daemon is not running; requests will be withheld until it is.'
636 : 'Latet: the daemon is not answering; start its container (docker start latet). Requests are withheld until then.',
637 )
638 } else {
639 if (!remote && version && version !== PLUGIN_VERSION) {
640 $.ui.toast(`Latet: the plugin is ${PLUGIN_VERSION} and the daemon ${version}; update the older one.`)
641 }
642 try {
643 route = await checkRoute($, facts)
644 } catch {
645 unchecked = facts // the daemon stopped answering: checked at the first prompt
646 }
647 if (remote && route?.ok !== true) return next(e)
648 // A resumed conversation is already in surrogate space: let the daemon scope its values.
649 const messages = await $.session.messages({ as: 'api' })
650 const texts = apiSlots(messages).map((slot) => slot.text)
651 try {
652 if (texts.length > 0) await bridgeCall($, 'scope', { items: texts })
653 } catch (error) {
654 startCause = causeOf(error)
655 throw error
656 }
657 try {
658 const check = await bridgeCall($, 'instructions', {})
659 const found = Array.isArray(check.findings) ? check.findings : []
660 if (found.length > 0) $.ui.toast(instructionsWarning(found))
661 } catch {
662 // An older daemon has no such check: nothing to say.
663 }
664 }
665 return next(e)
666 }).catch(async ($, e, next) => {
667 // Never hold the session back: masking hooks fail closed on their own if the daemon is away.
668 if (!routeKnown) route = { ok: false, reason: 'Latet could not read where Claude Code sends its requests' }
669 $.ui.toast(remote ? `Latet: ${startCause}.` : 'Latet: the local daemon did not answer; requests will be withheld until it does.')
670 return next.called ? undefined : next(e)
671 })
672
673 on('prompt.submit', async ($, e, next) => {
674 try {
675 if (unchecked && !untrusted) {
676 route = await checkRoute($, unchecked)
677 unchecked = null
678 }
679 if (route?.ok === false) return { drop: `Latet did not send this prompt: ${route.reason}.` }
680 const items: Item[] = [{ text: e.text, kind: 'user_text' }]
681 for (const extra of e.context ?? []) items.push({ text: extra, kind: 'attachment' })
682 const masked = await maskItems($, items)
683 const stopped = masked.find((item) => item.blocked)
684 if (stopped) return { drop: `Latet did not send this prompt: ${stopped.blocked}.` }
685 const [text, ...context] = masked.map((item) => item.text)
686 return next(e.context ? { ...e, text, context } : { ...e, text })
687 } catch (error) {
688 if (remote && !next.called) return { drop: `Latet did not send this prompt: ${causeOf(error)}.` }
689 throw error
690 }
691 }).catch(async ($, e, next) =>
692 next.called ? next(e) : {
693 drop: remote
694 ? `Latet did not send this prompt: ${REMOTE_FAILURE}.`
695 : 'Latet could not mask this prompt: the local daemon did not answer, or failed on it (its log says why). Run `latet doctor`.',
696 },
697 )
698
699 on('prompt.section', async ($, e, next) => {
700 const result = await next(e)
701 if (!result.text) return result
702 const [text] = await mask($, [{ text: result.text, kind: 'system' }])
703 return { text }
704 }).catch(async () => ({ text: withheld() }))
705
706 on('prompt.context', async ($, e, next) => {
707 const result = await next(e)
708 const texts = await mask($, result.blocks.map((b) => ({ text: b.text, kind: 'system' })))
709 return { ...result, blocks: result.blocks.map((b, i) => ({ ...b, text: texts[i] })) }
710 }).catch(async ($, e, next) => {
711 const result = next.called ? await next(e) : e
712 return { ...result, blocks: result.blocks.map((b) => ({ ...b, text: b.text ? withheld() : b.text })) }
713 })
714
715 on('prompt.attachment', async ($, e, next) => {
716 const result = await next(e)
717 if (!result.text) return result
718 const [text] = await mask($, [{ text: result.text, kind: 'attachment' }])
719 return { text }
720 }).catch(async () => ({ text: withheld() }))
721
722 on('skill.prompt', async ($, e, next) => {
723 const result = await next(e)
724 if (!result.text) return result
725 const [text] = await mask($, [{ text: result.text, kind: 'system' }])
726 return { text }
727 }).catch(async () => ({ text: withheld() }))
728
729 on('tool.describe', async ($, e, next) => {
730 const result = await next(e)
731 const [description] = await mask($, [{ text: result.description, kind: 'tool_description' }])
732 return { ...result, description }
733 }).catch(async ($, e) => ({
734 // A description that could not be masked is withheld, as every other masking hook does (R17): never sent as it is.
735 description: WITHHELD_DESCRIPTION,
736 ...(e.isDeferred === true ? { isDeferred: true } : {}),
737 }))
738
739 on('session.append', async ($, e, next) => {
740 if (SKIP_DOORS.has(e.door) || !e.message.role) return next(e)
741 const pasted = e.door === 'prompt' ? pastedImages(e.message.content) : null
742 if (pasted !== null) {
743 // An image you pasted cannot be changed, only left out: a masked copy is offered to read instead.
744 const masked = await maskPictures($, pasted.pictures, 'user_text')
745 const offered = await Promise.all(masked.map((m) => (m.image && m.count > 0 ? offerCopy(m.image) : null)))
746 e = { ...e, message: { ...e.message, content: pasted.rebuild(masked, offered) } }
747 }
748 const { texts, ids, rebuild } = rowTexts(e.message.content)
749 if (texts.length === 0) return next(e)
750 const kind = e.door === 'tool-result' ? 'tool_result' : e.door === 'prompt' ? 'user_text' : 'attachment'
751 const masked = await mask(
752 $,
753 texts.map((text, i) => {
754 const tool = kind === 'tool_result' ? toolNames.get(ids[i] ?? '') : undefined
755 const path = kind === 'tool_result' ? toolSources.get(ids[i] ?? '') : undefined
756 return tool ? { text, kind, tool, ...(path ? { path } : {}) } : { text, kind }
757 }),
758 )
759 return next({ ...e, message: { ...e.message, content: rebuild(masked) } })
760 }).catch(async ($, e, next) => {
761 if (next.called) return next(e)
762 const pasted = e.door === 'prompt' ? pastedImages(e.message.content) : null
763 const content = pasted ? pasted.rebuild(pasted.pictures.map(() => ({ image: null, count: 0, withheld: 'the local daemon did not answer' })), []) : e.message.content
764 const { texts, rebuild } = rowTexts(content)
765 return next({ ...e, message: { ...e.message, content: rebuild(texts.map(() => withheld())) } })
766 })
767
768 on('tool.call', async ($, e, next) => {
769 try {
770 if (typeof e.tool_use_id === 'string' && typeof e.tool === 'string') {
771 toolNames.set(e.tool_use_id, e.tool)
772 if (toolNames.size > TOOL_NAMES_LIMIT) toolNames.delete(toolNames.keys().next().value)
773 const source = SOURCE_ARGUMENTS.map((key) => (e as Record<string, unknown>)[key]).find((value) => typeof value === 'string' && value !== '')
774 if (typeof source === 'string') {
775 toolSources.set(e.tool_use_id, source)
776 if (toolSources.size > TOOL_NAMES_LIMIT) toolSources.delete(toolSources.keys().next().value)
777 }
778 }
779 const copy = e.tool === 'Read' ? copies.get(copyId(e.file_path) ?? '') : undefined
780 if (copy) return { result: { type: 'image', file: { base64: copy.data, type: copy.media_type, originalSize: copy.data.length } } }
781 if (KEEP_SURROGATES.test(e.tool)) {
782 if (OUTSIDE.test(e.tool)) {
783 const values = collectStrings(e, PINNED).map((leaf) => leaf.value)
784 const check = values.length > 0 ? await bridgeCall($, 'outbound', { tool: e.tool, values }) : null
785 if (check?.reason) return { deny: check.reason }
786 }
787 return maskToolResult($, e.tool, await next(e))
788 }
789 // The strings of its arguments, and the keys of the objects among them: a key the model wrote with a stand-in in it
790 // gets its real value back too.
791 const leaves = collectStrings(e, PINNED)
792 const keys = collectKeys(e)
793 if (leaves.length === 0 && keys.length === 0) return maskToolResult($, e.tool, await next(e))
794 const restored = await restore($, [...leaves.map((leaf) => leaf.value), ...keys.map((leaf) => leaf.key)], false, true)
795 const values = restored.slice(0, leaves.length)
796 const names = restored.slice(leaves.length)
797 if (values.every((value, i) => value === leaves[i].value) && names.every((name, i) => name === keys[i].key)) {
798 return maskToolResult($, e.tool, await next(e))
799 }
800 return maskToolResult($, e.tool, await next(renameKeys(replaceStrings(e, leaves, values), keys, names)))
801 } catch (error) {
802 if (remote) return { deny: `Latet withheld this tool call: ${causeOf(error)}.` }
803 throw error
804 }
805 }).catch(async ($, e, next) =>
806 remote
807 ? { deny: `Latet withheld this tool call: ${REMOTE_FAILURE}.` }
808 : next.called
809 ? { deny: 'Latet could not mask the images of this tool\'s result: the local daemon did not answer, or failed on it.' }
810 : { deny: 'Latet could not restore the real values for this tool call: the local daemon did not answer, or failed on it.' },
811 )
812
813 on('ui.render', { component: ['AssistantMessage', 'UserMessage'] }, async ($, e, next) => {
814 const text = await restoreForScreen($, e.props.text)
815 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
816 }).catch(async ($, e, next) => (next.called ? undefined : next(e)))
817
818 // What you see carries the real values: the command a tool runs, what it printed, and a folded
819 // group of calls while it runs. The model's own copy keeps the surrogates.
820 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
821 const props = await restoredForScreen($, e.props, ['input', 'output'])
822 return props === e.props ? next(e) : next({ ...e, props })
823 }).catch(async ($, e, next) => (next.called ? undefined : next(e)))
824
825 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
826 const props = await restoredForScreen($, e.props, ['output'])
827 return props === e.props ? next(e) : next({ ...e, props })
828 }).catch(async ($, e, next) => (next.called ? undefined : next(e)))
829
830 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
831 const calls = await Promise.all(e.props.calls.map((call) => restoredForScreen($, call, ['input', 'output'])))
832 return calls.every((call, i) => call === e.props.calls[i]) ? next(e) : next({ ...e, props: { ...e.props, calls } })
833 }).catch(async ($, e, next) => (next.called ? undefined : next(e)))
834
835 on('turn.step', async function* ($, e, next) {
836 try {
837 if (unchecked && !untrusted) {
838 route = await checkRoute($, unchecked)
839 unchecked = null
840 }
841 if (route?.ok === false) {
842 const answer = `Latet did not send this request: ${route.reason}.`
843 return { turnId: e.turnId, index: e.index, answer, toolUses: [], stopReason: null, usage: null }
844 }
845 const agent = e.agentId ?? ''
846 const answer = e.agentId ? await $.session.messages({ as: 'api', agentId: e.agentId }) : await $.session.messages({ as: 'api' })
847 const messages = Array.isArray(answer) ? answer : (answer?.messages ?? [])
848 const from = verified.get(agent) ?? 0
849 const fresh = messages.length >= from ? messages.slice(from) : messages // compaction: check everything
850 // The first check of this conversation here: what came before its last message is the history
851 // Claude Code had when Latet started checking (from before Latet was on, if it holds values in clear).
852 const history = from === 0 && messages.length > 1 ? apiSlots(messages.slice(0, -1)).length : 0
853 const result = await bridgeCall($, 'verify', { slots: apiSlots(fresh), images: apiImages(fresh), history })
854 if (result.blocking.length > 0) {
855 $.ui.toast(
856 result.inherited
857 ? 'Latet: this conversation began before Latet was on. Start a new one with /clear.'
858 : 'Latet blocked a request: a sensitive value would have been sent in clear.',
859 )
860 const answer = blockedAnswer(result.blocking, result.inherited === true)
861 return { turnId: e.turnId, index: e.index, answer, toolUses: [], stopReason: null, usage: null }
862 }
863 verified.set(agent, messages.length)
864 } catch (error) {
865 if (!remote) throw error
866 const answer = `Latet did not send this request: ${causeOf(error)}.`
867 return { turnId: e.turnId, index: e.index, answer, toolUses: [], stopReason: null, usage: null }
868 }
869 return yield* next(e)
870 }).catch(async function* ($, e) {
871 return {
872 turnId: e.turnId,
873 index: e.index,
874 answer: remote
875 ? `Latet did not send this request: ${REMOTE_FAILURE}.`
876 : 'Latet could not check this request (the local daemon did not answer, or failed on it), so it was not sent. Run `latet doctor`.',
877 toolUses: [],
878 stopReason: null,
879 usage: null,
880 }
881 })
882}
883hooks/images.ts 137 lines1// Pure helpers for the images Claude Code sends: where they are in a tool's result, in a prompt you
2// pasted, and in the conversation the final check reads. No mods API ($) here, so they can be
3// unit-tested on their own.
4
5export type Picture = { data: string; media_type: string }
6// What the daemon answers for one image: the image as it may be sent, or none when it was withheld.
7export type MaskedPicture = { image: Picture | null; count: number; withheld?: string; blocked?: string; unread?: boolean }
8
9export const NOT_SENT = (why: string) => `[image withheld by Latet: ${why}]`
10
11type Found = { pictures: Picture[]; rebuild: (masked: MaskedPicture[]) => { result: unknown } | { deny: string } }
12
13type ApiBlock = { type?: string; source?: { type?: string; data?: unknown; media_type?: unknown }; content?: unknown; text?: unknown }
14
15function picture(source: ApiBlock['source']): Picture | null {
16 if (source?.type !== 'base64' || typeof source.data !== 'string' || !source.data) return null
17 return { data: source.data, media_type: typeof source.media_type === 'string' ? source.media_type : 'image/png' }
18}
19
20function why(m: MaskedPicture): string {
21 return m.withheld ?? m.blocked ?? 'it could not be masked'
22}
23
24/** The images of a tool's result: a Read of an image, of PDF pages read as images, or the image
25 * blocks of an MCP tool's result. Null when it has none. */
26export function toolImages(result: unknown): Found | null {
27 if (Array.isArray(result)) {
28 const at = result.flatMap((block: ApiBlock, i) => (block?.type === 'image' && picture(block.source) ? [i] : []))
29 if (at.length === 0) return null
30 return {
31 pictures: at.map((i) => picture(result[i].source) as Picture),
32 rebuild: (masked) => {
33 const out = [...result]
34 at.forEach((i, n) => {
35 const m = masked[n]
36 out[i] = m.image ? { ...out[i], source: { ...out[i].source, data: m.image.data, media_type: m.image.media_type } } : { type: 'text', text: NOT_SENT(why(m)) }
37 })
38 return { result: out }
39 },
40 }
41 }
42 const read = result as { type?: string; file?: Record<string, unknown>; pages?: { base64?: unknown; mediaType?: unknown }[] }
43 if (read?.type === 'image' && typeof read.file?.base64 === 'string') {
44 const media = typeof read.file.type === 'string' ? read.file.type : 'image/png'
45 return {
46 pictures: [{ data: read.file.base64, media_type: media }],
47 rebuild: ([m]) =>
48 m.image
49 ? { result: { ...read, file: { ...read.file, base64: m.image.data, type: m.image.media_type } } }
50 : { deny: `Latet did not let this image through: ${why(m)}.` },
51 }
52 }
53 if (read?.type === 'parts' && Array.isArray(read.pages)) {
54 const at = read.pages.flatMap((page, i) => (typeof page?.base64 === 'string' && page.base64 ? [i] : []))
55 if (at.length === 0) return null
56 const pages = read.pages
57 return {
58 pictures: at.map((i) => ({ data: pages[i].base64 as string, media_type: typeof pages[i].mediaType === 'string' ? (pages[i].mediaType as string) : 'image/png' })),
59 rebuild: (masked) => {
60 const out = [...pages]
61 at.forEach((i, n) => {
62 const m = masked[n]
63 out[i] = m.image ? { ...out[i], base64: m.image.data, mediaType: m.image.media_type } : { ...out[i], base64: '', error: NOT_SENT(why(m)) }
64 })
65 return { result: { ...read, pages: out } }
66 },
67 }
68 }
69 return null
70}
71
72/** The images you pasted in a prompt's row, and the row without them: each masked one is offered as a
73 * copy to read (``copies``: where each is), and a note says what happened. Null when there are none. */
74export function pastedImages(content: unknown): { pictures: Picture[]; rebuild: (masked: MaskedPicture[], copies: (string | null)[]) => unknown[] } | null {
75 if (!Array.isArray(content)) return null
76 const at = content.flatMap((block: ApiBlock, i) => (block?.type === 'image' && picture(block.source) ? [i] : []))
77 if (at.length === 0) return null
78 return {
79 pictures: at.map((i) => picture(content[i].source) as Picture),
80 rebuild: (masked, copies) => {
81 const notes: string[] = []
82 const drop = new Set<number>()
83 at.forEach((i, n) => {
84 const m = masked[n]
85 if (m.image && m.count === 0 && !m.unread) return // nothing to mask in it: it goes as it is
86 if (m.image && m.unread) return // not read here: the final check decides
87 drop.add(i)
88 if (m.image && copies[n]) {
89 notes.push(`[Latet: the image you pasted showed values Latet masks, so it was not sent as it was. A copy with them masked is at ${copies[n]}: open it with the Read tool to see it.]`)
90 } else {
91 notes.push(`[Latet: the image you pasted was not sent: ${why(m)}.]`)
92 }
93 })
94 const kept = content.filter((_, i) => !drop.has(i))
95 if (notes.length === 0) return kept
96 const note = notes.join('\n')
97 const first = kept.findIndex((b: ApiBlock) => b?.type === 'text' && typeof b.text === 'string')
98 if (first < 0) return [...kept, { type: 'text', text: note }]
99 return kept.map((b: ApiBlock, i) => (i === first ? { ...b, text: `${b.text}\n\n${note}` } : b))
100 },
101 }
102}
103
104/** The images a conversation sends, for the final check: in messages, and in tool results. */
105export function apiImages(messages: readonly { content?: unknown }[]): Picture[] {
106 const out: Picture[] = []
107 const walk = (blocks: unknown): void => {
108 if (!Array.isArray(blocks)) return
109 for (const block of blocks as ApiBlock[]) {
110 if (block?.type === 'image') {
111 const found = picture(block.source)
112 if (found) out.push(found)
113 } else if (block?.type === 'tool_result') {
114 walk(block.content)
115 }
116 }
117 }
118 for (const message of messages) walk(message.content)
119 return out
120}
121
122const EXTENSIONS: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }
123
124/** Where a masked copy of a pasted image is offered: a file name Claude Code never finds on disk, which
125 * the plugin answers itself when the Read tool asks for it. */
126export function copyPath(folder: string, id: string, media: string): string {
127 const separator = folder.includes('\\') && !folder.includes('/') ? '\\' : '/'
128 return `${folder.replace(/[\\/]+$/, '')}${separator}.latet${separator}masked-${id}.${EXTENSIONS[media] ?? 'png'}`
129}
130
131/** The id of the masked copy a Read asks for, or null for any other file. */
132export function copyId(path: unknown): string | null {
133 if (typeof path !== 'string') return null
134 const found = /[\\/]\.latet[\\/]masked-([0-9a-f]{12})\.(?:png|jpg|webp|gif)$/.exec(path)
135 return found ? found[1] : null
136}
137hooks/walk.ts 181 lines1// Pure helpers for the Latet hooks module: walking JSON values and Messages API content.
2// No mods API ($) here, so they can be imported by register.ts and unit-tested on their own.
3
4export type PathKey = string | number
5export type StringLeaf = { path: PathKey[]; value: string }
6export type Slot = { text: string; kind: string; from_model: boolean }
7
8/** Every string inside a JSON value, with its path, skipping the given top-level keys. */
9export function collectStrings(value: unknown, skipTopLevel: ReadonlySet<string> = new Set()): StringLeaf[] {
10 const out: StringLeaf[] = []
11 const walk = (v: unknown, path: PathKey[]): void => {
12 if (typeof v === 'string') {
13 out.push({ path, value: v })
14 } else if (Array.isArray(v)) {
15 v.forEach((x, i) => walk(x, [...path, i]))
16 } else if (v !== null && typeof v === 'object') {
17 for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
18 if (path.length === 0 && skipTopLevel.has(k)) continue
19 walk(x, [...path, k])
20 }
21 }
22 }
23 walk(value, [])
24 return out
25}
26
27/** A copy of ``value`` with the strings at ``leaves`` replaced by ``replacements`` (same order). */
28export function replaceStrings<T>(value: T, leaves: StringLeaf[], replacements: string[]): T {
29 const copy = structuredClone(value) as unknown as Record<PathKey, unknown>
30 leaves.forEach((leaf, i) => {
31 if (leaf.path.length === 0) return
32 let node: Record<PathKey, unknown> = copy
33 for (const key of leaf.path.slice(0, -1)) node = node[key] as Record<PathKey, unknown>
34 node[leaf.path[leaf.path.length - 1]] = replacements[i]
35 })
36 return copy as unknown as T
37}
38
39export type KeyLeaf = { path: PathKey[]; key: string }
40
41/**
42 * Every key of the objects inside a JSON value, with the path of its object: a key can hold a value (an address, a
43 * name) that the daemon masks and restores as it does a string. The top level's own keys are not taken: a tool call's
44 * arguments are named by its tool.
45 */
46export function collectKeys(value: unknown): KeyLeaf[] {
47 const out: KeyLeaf[] = []
48 const walk = (v: unknown, path: PathKey[]): void => {
49 if (Array.isArray(v)) {
50 v.forEach((x, i) => walk(x, [...path, i]))
51 } else if (v !== null && typeof v === 'object') {
52 for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
53 if (path.length > 0) out.push({ path, key: k })
54 walk(x, [...path, k])
55 }
56 }
57 }
58 walk(value, [])
59 return out
60}
61
62/**
63 * A copy of ``value`` with the keys at ``keys`` named ``names`` (same order), each object's keys in their order. The
64 * deepest objects go first, so that the path of each object still names its keys as they were. Two keys of an object
65 * that would be named alike (a stand-in beside the value it stands for) each keep their own name: an object holds a
66 * key once, and a value would be lost.
67 */
68export function renameKeys<T>(value: T, keys: KeyLeaf[], names: string[]): T {
69 const copy = structuredClone(value) as unknown as Record<PathKey, unknown>
70 const objects = new Map<string, { path: PathKey[]; names: Map<string, string> }>()
71 keys.forEach((leaf, i) => {
72 if (names[i] === leaf.key) return
73 const id = JSON.stringify(leaf.path)
74 const found = objects.get(id) ?? { path: leaf.path, names: new Map<string, string>() }
75 found.names.set(leaf.key, names[i])
76 objects.set(id, found)
77 })
78 const deepestFirst = [...objects.values()].sort((a, b) => b.path.length - a.path.length)
79 for (const { path, names: given } of deepestFirst) {
80 let parent: Record<PathKey, unknown> = copy
81 for (const key of path.slice(0, -1)) parent = parent[key] as Record<PathKey, unknown>
82 const last = path[path.length - 1]
83 const object = parent[last] as Record<string, unknown>
84 const own = Object.keys(object)
85 let named = own.map((k) => given.get(k) ?? k)
86 while (new Set(named).size !== named.length) {
87 // What one key would be named, another is: those keep their own name.
88 const counts = new Map<string, number>()
89 for (const name of named) counts.set(name, (counts.get(name) ?? 0) + 1)
90 named = named.map((name, i) => ((counts.get(name) ?? 0) > 1 ? own[i] : name))
91 }
92 parent[last] = Object.fromEntries(own.map((k, i) => [named[i], object[k]]))
93 }
94 return copy as unknown as T
95}
96
97type Block = { type?: string; text?: unknown; content?: unknown; input?: unknown; tool_use_id?: unknown }
98type ApiMessage = { role?: string; content?: unknown }
99
100/** The text a Messages API conversation sends: text blocks, tool results and tool inputs. */
101export function apiSlots(messages: readonly ApiMessage[]): Slot[] {
102 const slots: Slot[] = []
103 for (const message of messages) {
104 const fromModel = message.role === 'assistant'
105 const kind = fromModel ? 'assistant_text' : 'user_text'
106 const content = message.content
107 if (typeof content === 'string') {
108 slots.push({ text: content, kind, from_model: fromModel })
109 continue
110 }
111 if (!Array.isArray(content)) continue
112 for (const block of content as Block[]) {
113 if (block.type === 'text' && typeof block.text === 'string') {
114 slots.push({ text: block.text, kind, from_model: fromModel })
115 } else if (block.type === 'tool_result') {
116 if (typeof block.content === 'string') {
117 slots.push({ text: block.content, kind: 'tool_result', from_model: false })
118 } else if (Array.isArray(block.content)) {
119 for (const inner of block.content as Block[]) {
120 if (inner.type === 'text' && typeof inner.text === 'string') {
121 slots.push({ text: inner.text, kind: 'tool_result', from_model: false })
122 }
123 }
124 }
125 } else if (block.type === 'tool_use') {
126 for (const leaf of collectStrings(block.input)) {
127 slots.push({ text: leaf.value, kind: 'tool_input', from_model: true })
128 }
129 }
130 }
131 }
132 return slots
133}
134
135/**
136 * The texts of the maskable blocks of one row's content, the tool_use id each tool result answers
137 * (undefined for other text), and a function putting the texts back.
138 */
139export function rowTexts(content: readonly Block[]): {
140 texts: string[]
141 ids: Array<string | undefined>
142 rebuild: (masked: string[]) => Block[]
143} {
144 const texts: string[] = []
145 const ids: Array<string | undefined> = []
146 const places: Array<{ block: number; inner?: number; whole?: boolean }> = []
147 content.forEach((block, i) => {
148 if (block.type === 'text' && typeof block.text === 'string') {
149 texts.push(block.text)
150 ids.push(undefined)
151 places.push({ block: i })
152 } else if (block.type === 'tool_result') {
153 const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
154 if (typeof block.content === 'string') {
155 texts.push(block.content)
156 ids.push(id)
157 places.push({ block: i, whole: true })
158 } else if (Array.isArray(block.content)) {
159 ;(block.content as Block[]).forEach((inner, j) => {
160 if (inner.type === 'text' && typeof inner.text === 'string') {
161 texts.push(inner.text)
162 ids.push(id)
163 places.push({ block: i, inner: j })
164 }
165 })
166 }
167 }
168 })
169 const rebuild = (masked: string[]): Block[] => {
170 const copy = structuredClone(content) as Block[]
171 places.forEach((place, k) => {
172 const block = copy[place.block]
173 if (place.whole) block.content = masked[k]
174 else if (place.inner !== undefined) (block.content as Block[])[place.inner].text = masked[k]
175 else block.text = masked[k]
176 })
177 return copy
178 }
179 return { texts, ids, rebuild }
180}
181