SLOPSHOPPER

latet

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

newrowsguardtoaststatusprompt
★ 13v0.9.10LicenseRef-Latet-Sustainable-Use-1.0updated 2026-10-25Latet-AI/latet/src/latet/claude_plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · latet
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ latet │ ⏺ Read(src/auth.ts) │ Latet: the local daemon is not running; │ ⎿ Read 6 lines │ requests will be withheld until it is. │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ╭────────────────────────────────────────────╮ ⏺ Read(/work/app/src/auth.ts) │ latet │ ⎿ Denied by latet: Latet could not restore the real values for this │ Latet: Latet could not check where Claude │ │ Code sends its requests (`latet doctor` │ ● Done. refresh now rejects expired claims and logs an audit event. │ says why). Prompts are stopped. │ ╰────────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<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.

CI License: Sustainable Use

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">

What it protects

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 machineFor exampleWhat 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 exportKeys, passwords, connection strings and personal data are masked; files the agent writes get the real values back
Command output and logsdocker logs, a stack trace, kubectl describeTokens, email addresses, public IP addresses and ID numbers in them are masked
Database queriespsql, or an MCP server answering SELECT * FROM customersSecret columns always; with strict data mode every value, with fakes that keep joins and counts working
API responsescurl of an internal API, a webhook payloadMasked by value, and by field: a password or token field always
Web pagesAn admin panel the agent opens with a browser toolThe personal data in the page's text is masked
InstructionsCLAUDE.md, AGENTS.md, the system promptYour name, user name, host name and company domain are masked
HistoryA session resumed ten days laterThe same value gets the same fake, every time
ScreenshotsA screenshot of an invoiceWith 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.

How it protects

  • Detects secrets (API keys, tokens, passwords, .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).
  • Replaces each value with a deterministic, format-preserving fake of the same shape, so code and configuration still make sense to the model.
  • Restores the originals locally, in responses, streaming output and tool-call arguments, so files written by the agent contain the real values.
  • Adapts to each project: a 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).
  • Reads production data by its structure: the secret columns of a query result are always masked, and strict data mode masks every value of the tables, JSON and logs an agent reads; the dashboard proposes the columns that can stay in clear, such as ids, dates and states (production data).
  • Reviews, if you want, everything the model did not write before it leaves, with a language model on your own computer: the names, health details and secrets in prose that no pattern finds (below).
  • Asks you, if you want, before anything new leaves: every prompt, file and tool output waits in the dashboard until you approve it, mask more in it, or stop it (below).
  • Shows exactly what was sent upstream and how it was masked, in a local dashboard (latet dashboard).

Everything runs on your machine. There is no Latet cloud service, no telemetry and no analytics.

Beyond patterns: the local reviewer and the Live Reviewer

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.

  • Everything the model did not write is read before it leaves: your prompts and attachments, the agent's instructions, and above all what its tools fetch, the part nobody types.
  • Nothing leaves without its review. When the reviewer cannot answer, because its server is off or its answer is not to be trusted, the request is stopped. You can choose to send it masked by the detectors alone instead.
  • A text is read once, and its review is remembered across restarts: history the agent sends again with every turn costs nothing more.
  • Your guidance: Latet's own instructions to the reviewer are fixed, and shown in the dashboard as they are sent. After them you can add what only you know of your work (ward names are confidential, PRJ- codes are internal projects), for everything or for one project.
  • How much it finds: on 93 invented cases, counting only what the detectors missed, Qwen3.5-4B found 84% (a prompt in under a second on a graphics card) and Ternary-Bonsai-2-27B 97%. Which local model works best as a reviewer is still open, and measurements from the community are welcome: benchmarks/llm-review/README.md says how these were made, with scripts/llm_review_bench.py, and CONTRIBUTING.md how to send yours.
  • The original texts stay on your machines. The server is on this computer, or on another machine of yours once you confirm it, reached over https (a self-signed certificate is trusted by its fingerprint).

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.

  • Approve it, mask more, or stop it. Select a value in the original and choose what it is; it leaves as a fake. A text you stop is never sent.
  • You can only add masking. A value already masked cannot be put back in clear, and a value you mask stays masked from then on, everywhere.
  • In Claude Code it waits inside the plugin, before Claude Code builds the request, piece by piece as prompts and tool outputs arrive; for the other agents, in the daemon, on the whole request. Nothing has a time limit unless you set one.
  • It suits the moments that matter: a session on a customer's data, an agent you leave alone on a production system, the first days with a new project. Approve all waiting and letting texts with nothing found go by themselves keep it quick.

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.

Agents and services

AgentHow it goes through LatetSigned in with
Claude Code: CLI, VS Code, JetBrainsClient mode: the Latet plugin, and the daemon checking each requestA Pro or Max subscription, or an API key
Claude Desktop's Code tab (local sessions)Client mode, plugin only: the app ignores ANTHROPIC_BASE_URLA subscription
Codex CLI and Codex DesktopGateway: openai_base_url in ~/.codex/config.tomlChatGPT, or an API key
OpenCodeGateway: each provider's baseURLAPI keys (Anthropic, OpenAI, OpenRouter)
Gemini CLIGateway: latet gemini sets GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URLA Gemini API key, or Vertex AI
GitHub Copilot CLIGateway: latet copilot points COPILOT_PROVIDER_BASE_URL at Latet; signed in with GitHub, on a Copilot plan's models, it is not coveredA provider of your own: OpenAI, Anthropic or OpenRouter
Hermes Agent, OpenClaw, Goose, OpenHands, and any agent that takes an OpenAI-compatible base URLGateway: 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 LatetAPI keys
GitHub Copilot (VS Code chat), JetBrains Junie CLI and AI Assistant, Zed, Cline, Kilo Code, Continue, Qwen Code, Crush, Prime AgentGateway: the tool's own base URL or custom endpoint set to Latet (other tools); Copilot's inline suggestions are not coveredAPI 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).

  • Client mode, for Claude Code: the plugin masks inside Claude Code, before the request is built. The daemon checks each request and forwards it unchanged; it stops one that would carry a sensitive value in clear.
  • Gateway mode, for every other agent: the daemon on 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.

Claude Pro and Max subscriptions

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:

  • Claude Code is not modified. Latet extends it with a plugin, through Claude Code's own plugin system, and points it at the local daemon with ANTHROPIC_BASE_URL, the setting Anthropic documents for gateways, a subscription login included.
  • Your sign-in stays Anthropic's. You log in with Claude Code's own login. Latet never asks for your credentials, never reads them for itself and never stores them.
  • Each request is the one Claude Code built. The plugin masks inside Claude Code, before the request is built. The daemon then forwards it byte for byte, headers included, as Anthropic's gateway guide asks a gateway to do: it inspects without modifying. Your token passes through it and is never saved or logged.
  • No one else uses your subscription. The other agents go through the gateway with their own API keys.

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.

Run your agent through Latet

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.

Install

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.

Quickstart

  1. See it at work. New to Latet? 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.
  2. Try it, changing nothing: in a scratch folder, put a made-up key in a .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.
  3. Work with it: keep starting your agent with 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.
  4. Turn it on for good, once it fits: `la
Source 3 files
hooks/register.ts 883 lines
1// 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}
883
hooks/images.ts 137 lines
1// 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}
137
hooks/walk.ts 181 lines
1// 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