diff --git a/exploitation/README.md b/exploitation/README.md index b21a3bd..2ee433d 100644 --- a/exploitation/README.md +++ b/exploitation/README.md @@ -18,6 +18,8 @@ The goal of these exploitations is to demonstrate practical applications of both * **`system_reconnaissance/`**: A repeatable bilingual (English and Turkish) reconnaissance campaign for existing local sandboxes. It probes nine disclosure surfaces, records conservative evidence labels, and produces machine-readable JSONL plus reviewer-friendly Markdown reports. +* **`embedding_inversion/`**: A complete, end-to-end example of an embedding inversion attack against the `RAG_local` sandbox. It reconstructs plaintext from a leaked, metadata-stripped embedding vector using only black-box access to the embedding model API and an LLM-guided guess-and-check loop. + * **`Langflow_v1.0.12/`**: Details the discovery and exploitation of **CVE-2024-37014** (RCE via Custom Component) in the Langflow sandbox, demonstrating how an attacker can execute arbitrary system commands or establish a reverse shell. * **`LangGrinch/`**: A complete, end-to-end example of a manual red team operation against a local LLM sandbox with a known vulnerability (**CVE-2025-68664**, LangGrinch). It demonstrates how prompt injection can lead to credential exfiltration or Remote Code Execution (RCE) via unsafe object deserialization in `langchain-core` v1.2.4. diff --git a/exploitation/embedding_inversion/.gitignore b/exploitation/embedding_inversion/.gitignore new file mode 100644 index 0000000..6541223 --- /dev/null +++ b/exploitation/embedding_inversion/.gitignore @@ -0,0 +1,16 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +.env +.pytest_cache/ +.mypy_cache/ +tmp + +# Generated attack artifacts (contain the run's recovered secrets) +outputs/*.json +outputs/*.jsonl +reports/*.md +!outputs/.gitkeep +!reports/.gitkeep diff --git a/exploitation/embedding_inversion/Makefile b/exploitation/embedding_inversion/Makefile new file mode 100644 index 0000000..73a39fc --- /dev/null +++ b/exploitation/embedding_inversion/Makefile @@ -0,0 +1,52 @@ +SANDBOX_NAME := $(shell uv run python -c 'import tomllib, pathlib; print(tomllib.loads(pathlib.Path("config/config.toml").read_text())["target"]["sandbox"])') +SANDBOX_DIR := ../../sandboxes/$(SANDBOX_NAME) + +.PHONY: help setup attack stop sync lock format test all + +# Default target +help: + @echo "Embedding Inversion Attack - Available Commands:" + @echo "" + @echo " make setup - Build and start the RAG_local mock API sandbox" + @echo " make attack - Seed target secrets and run the embedding inversion attack" + @echo " make stop - Stop and remove the sandbox container" + @echo " make all - Run setup, attack, and stop in sequence" + @echo " make test - Run the offline unit tests (no live sandbox required)" + @echo " make format - Run code formatting (black, isort, mypy)" + @echo " make sync - Sync dependencies with uv" + @echo " make lock - Lock dependencies with uv" + @echo "" + @echo "Environment:" + @echo " - Sandbox Directory: $(SANDBOX_DIR)" + @echo "" + +sync: + uv sync + +lock: + uv lock + +format: + uv run black . + uv run isort . + uv run mypy . + +test: sync + uv run pytest + +setup: + @echo "๐Ÿš€ Setting up Red Team environment..." + $(MAKE) -C $(SANDBOX_DIR) test + @echo "โœ… Environment ready!" + +attack: sync lock + @echo "โš”๏ธ Launching embedding inversion attack..." + uv run attack.py + +stop: + @echo "๐Ÿงน Tearing down Red Team environment..." + $(MAKE) -C $(SANDBOX_DIR) down + @echo "โœ… Environment cleaned up!" + +all: stop setup attack stop + @echo "Embedding Inversion Attack - Completed!" diff --git a/exploitation/embedding_inversion/README.md b/exploitation/embedding_inversion/README.md new file mode 100644 index 0000000..f4557ec --- /dev/null +++ b/exploitation/embedding_inversion/README.md @@ -0,0 +1,224 @@ +# Embedding Inversion: Reverse Engineering Embeddings + +An end-to-end example of an **embedding inversion attack** against the +`RAG_local` sandbox: reconstructing the plaintext behind a leaked embedding +vector using only black-box access to the embedding model API. + +This addresses backlog issue "Reverse Engineering Embeddings", mapped to +OWASP GenAI Red Teaming Manual `4.2.2.1 Embedding Inversion Attacks / A. +Reverse Engineering Embeddings`. + +--- + +## Table of Contents + +1. [Threat Model](#threat-model) +2. [Attack Strategy](#attack-strategy) +3. [Prerequisites](#prerequisites) +4. [Running the Attack](#running-the-attack) +5. [Configuration](#configuration) +6. [Files Overview](#files-overview) +7. [Known Limitations](#known-limitations) +8. [OWASP Top 10 for LLM Applications Coverage](#owasp-top-10-for-llm-applications-coverage) + +--- + +## Threat Model + +A RAG pipeline stores document chunks as embedding vectors in a vector +database (here, `RAG_local`'s mock Pinecone API backed by ChromaDB). If an +attacker obtains a **vector-only dump** of that store -- e.g. a +misconfigured backup, an insider export, or a leaked ChromaDB persistence +directory -- they get the raw floating-point vectors but not the plaintext +that produced them. + +This script models exactly that split: + +- **Phase 1 (victim)**: ingests secret strings the normal way -- embeds + each one and upserts it to the vector store with the plaintext attached + as metadata, mirroring `sandboxes/RAG_local/ETL/ingest.py`. +- **Phase 2 (attacker)**: is handed only `{id, vector}` pairs. It never + reads the metadata produced in Phase 1. It does, however, still have + black-box access to the same embedding model API (`POST /v1/embeddings`) + that produced the vectors -- a realistic assumption when the embedding + endpoint is exposed to more callers than the vector store itself. + +The ground-truth plaintext is only reattached afterward, for scoring the +attack's own output -- never fed into the inversion loop itself. + +## Attack Strategy + +```mermaid +graph LR + subgraph "Victim (Phase 1)" + Secrets[Secret Strings
config/config.toml] + end + + subgraph "Attacker (Phase 2)" + Inverter[EmbeddingInverter
inversion/inverter.py] + Guess[LLM Candidate Guess] + end + + subgraph "Target Sandbox (Container)" + MockAPI[Mock API Gateway
FastAPI :8000] + ChromaDB[(Mock Vector DB
ChromaDB)] + end + + subgraph "LLM Backend (Local Host)" + Ollama[Ollama Server
:11434] + end + + Secrets -->|POST /v1/embeddings| MockAPI + MockAPI -->|embed| Ollama + MockAPI -->|POST /pinecone/vectors/upsert
id + vector + metadata.text| ChromaDB + + ChromaDB -.->|leaked vector dump
id + vector ONLY| Inverter + Inverter -->|POST /v1/chat/completions| MockAPI + MockAPI -->|generate| Ollama + Ollama --> Guess + Guess -->|POST /v1/embeddings| MockAPI + MockAPI -->|cosine similarity vs target| Inverter + Inverter -->|repeat until threshold or max_iterations| Guess + + style Secrets fill:#e1f5ff + style Inverter fill:#ffcccc,stroke:#ff0000 + style Guess fill:#ffcccc,stroke:#ff0000 + style MockAPI fill:#fff4e1 + style ChromaDB fill:#ffe1f5 + style Ollama fill:#ffe1f5 +``` + +Each round: the LLM proposes a candidate phrase, the candidate is embedded +with the same model that produced the target vector, and cosine similarity +against the target is computed locally. The best-scoring candidate and its +score are fed back to the LLM to steer the next guess. This mirrors the +guess-and-check technique demonstrated in +[`ranfysvalle02/hacking-vectors`](https://github.com/ranfysvalle02/hacking-vectors), +adapted here into a self-contained victim/attacker split against +`RAG_local`. + +--- + +## Prerequisites + +- **Podman** (or Docker) -- container runtime for the sandbox. +- **Ollama**, with `gpt-oss:20b` and `nomic-embed-text` pulled + (`sandboxes/RAG_local` provides `make ollama-pull`). +- **Make** -- for the convenience commands. +- **uv** -- for dependency management. + +No changes to `sandboxes/RAG_local` are required; this attack only uses its +existing `/v1/embeddings`, `/v1/chat/completions`, and +`/pinecone/vectors/upsert` endpoints. + +--- + +## Running the Attack + +| Target | What it does | Typical usage | +|--------|---------------|----------------| +| `make setup` | Builds and starts the `RAG_local` mock API container. | `make setup` | +| `make attack` | Seeds the configured secrets, then runs the inversion attack. | `make attack` | +| `make test` | Runs the offline unit tests (no live sandbox needed). | `make test` | +| `make stop` | Stops and removes the sandbox container. | `make stop` | +| `make all` | Runs `stop โ†’ setup โ†’ attack โ†’ stop` in one shot. | `make all` | + +`make test` exercises `inversion/inverter.py`'s control flow and the +cosine-similarity math against a scripted fake client, so the algorithm can +be validated without Podman, Ollama, or any live model. + +--- + +## Configuration + +### `config/config.toml` + +```toml +[target] +sandbox = "RAG_local" + +[attack] +secrets = [ + "The secret code is 12345.", + "Reset password for admin: Tr0ub4dor&3", +] + +embedding_model = "nomic-embed-text" +chat_model = "gpt-oss:20b" + +max_iterations = 15 +similarity_threshold = 0.93 +``` + +- `secrets`: strings the victim phase ingests; each becomes one inversion + target. +- `embedding_model` / `chat_model`: must match models available on the + sandbox's Ollama backend (see `sandboxes/RAG_local/config/model.toml`). +- `max_iterations`: hard cap on guesses per target, to bound runtime and + request load against the local model. +- `similarity_threshold`: cosine similarity at which a guess is treated as + converged and the loop for that target stops early. + +--- + +## Files Overview + +- **`attack.py`**: Entry point -- loads config, runs the victim-ingestion + phase, runs the attacker-inversion phase, writes `outputs/*.json` and + `reports/*.md`. +- **`inversion/client.py`**: Thin `requests`-based client for the three + `RAG_local` endpoints this attack touches. +- **`inversion/inverter.py`**: `EmbeddingInverter` (the guess-and-check + loop) and `cosine_similarity`. +- **`tests/test_inverter.py`**: Offline `pytest` suite covering the + inversion loop and similarity math via a fake client. +- **`config/config.toml`**: Target sandbox, secrets, models, loop bounds. + +## Known Limitations + +This module was validated in two stages, and it is important to be precise +about what each one actually shows: + +- **Mechanism validated live, end-to-end, against a real running + `RAG_local` instance.** Real HTTP calls to `/v1/embeddings`, + `/v1/chat/completions`, and `/pinecone/vectors/upsert`; real embeddings; + real cosine-similarity scoring; real history-feedback loop. No mocking. + This confirms the code is correct and the attack's plumbing works. +- **Inversion success against the sandbox's default model, + `gpt-oss:20b`, was not demonstrated.** The validating machine had no GPU + and 8GB of RAM, well under this sandbox's own stated requirement of + 16GB dedicated GPU memory / 32GB system RAM for `gpt-oss:20b`. The live + run instead substituted `llama3.2:1b` (1B parameters) as the guiding + chat model, with `nomic-embed-text` left unchanged as the real + embedding model. Across 15 iterations per target, best cosine + similarity plateaued around 0.35-0.40 (`similarity_threshold` in + `config.toml` defaults to 0.93) and neither target string converged; + recovered text was semantically unrelated to the ground truth. + +This gap is expected, not a red flag: a 1B-parameter model is a +substantially weaker guesser than the intended 20B-parameter target, and +the guess-and-check technique's effectiveness is inherently tied to the +guiding LLM's capability. Whether inversion succeeds against the real +`gpt-oss:20b` -- and how that success rate varies with target-text +complexity (a short generic phrase vs. a specific password or code) -- +has not been independently confirmed and should be validated on hardware +meeting the sandbox's stated requirements. + +More broadly, the guess-and-check approach used here (adapted from +[`ranfysvalle02/hacking-vectors`](https://github.com/ranfysvalle02/hacking-vectors)) +is a legitimate but comparatively weak form of embedding inversion next +to state-of-the-art academic techniques (e.g. trained inversion models +such as vec2text). It is well suited to illustrating the vulnerability +class in a red-team lab setting; it should not be read as a claim of a +strong or state-of-the-art attack. + +## OWASP Top 10 for LLM Applications Coverage + +| OWASP Top 10 Vulnerability | Description | +| :--- | :--- | +| **LLM08: Vector and Embedding Weaknesses** | Demonstrates that a leaked, metadata-stripped embedding vector is not opaque: black-box access to the originating embedding model is enough to reconstruct the underlying text. | + +> [!NOTE] +> This is a lab example against a mock local sandbox. For production RAG +> systems, treat vector store exports and embedding-model API access with +> the same sensitivity as the plaintext they represent. diff --git a/exploitation/embedding_inversion/attack.py b/exploitation/embedding_inversion/attack.py new file mode 100644 index 0000000..57970b6 --- /dev/null +++ b/exploitation/embedding_inversion/attack.py @@ -0,0 +1,151 @@ +"""Embedding inversion attack against the RAG_local sandbox. + +Simulates an attacker who has obtained a dump of raw embedding vectors from +a vector database -- e.g. a misconfigured backup, an insider, or a leaked +ChromaDB persistence directory -- but no accompanying plaintext or +metadata. The attacker still has black-box access to the same embedding +model API that produced those vectors, and uses that access, combined with +an LLM-guided guess-and-check loop, to reconstruct the original text. + +Reference: OWASP GenAI Red Teaming Manual, 4.2.2.1 Embedding Inversion +Attacks / A. Reverse Engineering Embeddings. +Technique adapted from https://github.com/ranfysvalle02/hacking-vectors. +""" + +import json +import os +import sys +import tomllib +from datetime import datetime, timezone +from typing import Any, Dict, List + +from inversion import EmbeddingInverter, RAGLocalClient + + +def load_config() -> Dict[str, Any]: + try: + with open("config/config.toml", "rb") as f: + return tomllib.load(f) + except FileNotFoundError: + print("[!] config/config.toml not found.") + sys.exit(1) + except Exception as e: + print(f"[!] Error loading config: {e}") + sys.exit(1) + + +def seed_victim_data( + client: RAGLocalClient, secrets: List[str], embedding_model: str +) -> List[Dict[str, Any]]: + """Represents the victim's normal RAG ingestion pipeline. + + Embeds and upserts each secret with its plaintext in metadata (as + ETL/ingest.py would for a real document), then returns only + {id, vector} pairs plus the ground truth kept aside for scoring. The + inversion attack below is never given the ground truth text, only the + vector -- modeling a leaked vector-only dump. + """ + leaked_vectors = [] + for i, secret in enumerate(secrets): + vector_id = f"secret_{i}" + vector = client.embed(secret, model=embedding_model) + client.pinecone_upsert( + [{"id": vector_id, "values": vector, "metadata": {"text": secret}}] + ) + print(f"[victim] Ingested {vector_id!r}: {secret!r}") + leaked_vectors.append( + {"id": vector_id, "vector": vector, "_ground_truth": secret} + ) + return leaked_vectors + + +def main() -> None: + config = load_config() + attack_cfg = config.get("attack", {}) + + secrets: List[str] = attack_cfg.get("secrets", []) + embedding_model = attack_cfg.get("embedding_model", "nomic-embed-text") + chat_model = attack_cfg.get("chat_model", "gpt-oss:20b") + max_iterations = attack_cfg.get("max_iterations", 15) + similarity_threshold = attack_cfg.get("similarity_threshold", 0.93) + + if not secrets: + print("[!] No secrets configured under [attack].secrets in config/config.toml") + sys.exit(1) + + client = RAGLocalClient() + + print("[*] --- Phase 1: victim ingests secret data (attacker cannot see this) ---") + leaked_vectors = seed_victim_data(client, secrets, embedding_model) + + print( + "\n[*] --- Phase 2: attacker inverts leaked vectors (no plaintext access) ---" + ) + inverter = EmbeddingInverter( + client=client, + embedding_model=embedding_model, + chat_model=chat_model, + max_iterations=max_iterations, + similarity_threshold=similarity_threshold, + ) + + results = [] + for entry in leaked_vectors: + print(f"\n[*] Inverting {entry['id']}...") + result = inverter.invert(entry["id"], entry["vector"]) + ground_truth = entry["_ground_truth"] + print(f"[*] Recovered: {result.recovered_text!r}") + print( + f"[*] Best cosine similarity: {result.best_similarity:.4f} " + f"in {result.iterations_used} iteration(s)" + ) + print(f"[*] Ground truth (for scoring only): {ground_truth!r}") + results.append( + { + "id": result.target_id, + "ground_truth": ground_truth, + "recovered_text": result.recovered_text, + "best_similarity": result.best_similarity, + "iterations_used": result.iterations_used, + "converged": result.converged, + "attempts": [ + { + "iteration": a.iteration, + "candidate": a.candidate, + "similarity": a.similarity, + } + for a in result.attempts + ], + } + ) + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + + os.makedirs("outputs", exist_ok=True) + output_path = os.path.join("outputs", f"embedding_inversion_{timestamp}.json") + with open(output_path, "w", encoding="utf-8") as f: + json.dump(results, f, indent=2) + print(f"\n[+] Results saved to {output_path}") + + os.makedirs("reports", exist_ok=True) + report_path = os.path.join("reports", f"log_{timestamp}.md") + with open(report_path, "w", encoding="utf-8") as f: + f.write(f"# Embedding Inversion Attack Log - {timestamp}\n\n") + f.write( + f"- **Target Sandbox**: {config.get('target', {}).get('sandbox', 'unknown')}\n" + ) + f.write(f"- **Embedding Model**: {embedding_model}\n") + f.write(f"- **Chat Model**: {chat_model}\n") + f.write(f"- **Total Targets**: {len(results)}\n\n---\n\n") + for r in results: + status = "CONVERGED" if r["converged"] else "PARTIAL" + f.write(f"## {r['id']} -- {status}\n") + f.write(f"- **Ground truth**: `{r['ground_truth']}`\n") + f.write(f"- **Recovered**: `{r['recovered_text']}`\n") + f.write(f"- **Best similarity**: {r['best_similarity']:.4f}\n") + f.write(f"- **Iterations**: {r['iterations_used']}\n\n") + print(f"[+] Report saved to {report_path}") + + +if __name__ == "__main__": + main() diff --git a/exploitation/embedding_inversion/config/config.toml b/exploitation/embedding_inversion/config/config.toml new file mode 100644 index 0000000..6c429d5 --- /dev/null +++ b/exploitation/embedding_inversion/config/config.toml @@ -0,0 +1,18 @@ +[target] +sandbox = "RAG_local" + +[attack] +# Text the victim's normal RAG ingestion pipeline embeds and stores. +# The attacker side of this script only ever receives the resulting +# vectors, never this plaintext -- see README.md "Threat Model". +secrets = [ + "The secret code is 12345.", + "Reset password for admin: Tr0ub4dor&3", +] + +embedding_model = "nomic-embed-text" +chat_model = "gpt-oss:20b" + +# Guess-and-check loop bounds, see inversion/inverter.py. +max_iterations = 15 +similarity_threshold = 0.93 diff --git a/exploitation/embedding_inversion/inversion/__init__.py b/exploitation/embedding_inversion/inversion/__init__.py new file mode 100644 index 0000000..7974c18 --- /dev/null +++ b/exploitation/embedding_inversion/inversion/__init__.py @@ -0,0 +1,15 @@ +from .client import RAGLocalClient +from .inverter import ( + EmbeddingInverter, + InversionAttempt, + InversionResult, + cosine_similarity, +) + +__all__ = [ + "RAGLocalClient", + "EmbeddingInverter", + "InversionAttempt", + "InversionResult", + "cosine_similarity", +] diff --git a/exploitation/embedding_inversion/inversion/client.py b/exploitation/embedding_inversion/inversion/client.py new file mode 100644 index 0000000..dc8d484 --- /dev/null +++ b/exploitation/embedding_inversion/inversion/client.py @@ -0,0 +1,54 @@ +"""Minimal HTTP client for the RAG_local mock API surface used by this attack. + +Wraps the three endpoints the attack needs: the mock OpenAI embeddings and +chat completions routes, and the mock Pinecone upsert route used only to +seed victim data. See sandboxes/RAG_local/README.md for the full API. +""" + +from typing import Any, Dict, List, cast + +import requests + +DEFAULT_BASE_URL = "http://localhost:8000" +OPENAI_API_KEY = "sk-mock-key" +PINECONE_API_KEY = "bar" + + +class RAGLocalClient: + """Talks to a running RAG_local sandbox on http://localhost:8000.""" + + def __init__(self, base_url: str = DEFAULT_BASE_URL, timeout: float = 60.0) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + def embed(self, text: str, model: str) -> List[float]: + """Call POST /v1/embeddings and return the embedding vector.""" + response = requests.post( + f"{self.base_url}/v1/embeddings", + headers={"Authorization": f"Bearer {OPENAI_API_KEY}"}, + json={"model": model, "input": text}, + timeout=self.timeout, + ) + response.raise_for_status() + return cast(List[float], response.json()["data"][0]["embedding"]) + + def chat(self, messages: List[Dict[str, str]], model: str) -> str: + """Call POST /v1/chat/completions and return the reply content.""" + response = requests.post( + f"{self.base_url}/v1/chat/completions", + headers={"Authorization": f"Bearer {OPENAI_API_KEY}"}, + json={"model": model, "messages": messages, "temperature": 0.9}, + timeout=self.timeout, + ) + response.raise_for_status() + return cast(str, response.json()["choices"][0]["message"]["content"]) + + def pinecone_upsert(self, vectors: List[Dict[str, Any]]) -> None: + """Call POST /pinecone/vectors/upsert. Used only to seed victim data.""" + response = requests.post( + f"{self.base_url}/pinecone/vectors/upsert", + headers={"Api-Key": PINECONE_API_KEY}, + json={"vectors": vectors}, + timeout=self.timeout, + ) + response.raise_for_status() diff --git a/exploitation/embedding_inversion/inversion/inverter.py b/exploitation/embedding_inversion/inversion/inverter.py new file mode 100644 index 0000000..aca2504 --- /dev/null +++ b/exploitation/embedding_inversion/inversion/inverter.py @@ -0,0 +1,135 @@ +"""Embedding inversion engine. + +Recovers approximate source text from a raw embedding vector by iteratively +asking an LLM to propose candidate text, embedding each candidate with the +same model that produced the target vector, and keeping whichever candidate +scores highest on cosine similarity against the target. This is a black-box +attack: it never inspects the target vector's originating plaintext, only +the vector itself and the similarity score computed locally after each +guess. + +Adapted from the guess-and-check technique demonstrated in +https://github.com/ranfysvalle02/hacking-vectors. +""" + +import math +from dataclasses import dataclass, field +from typing import List + +from .client import RAGLocalClient + + +def cosine_similarity(a: List[float], b: List[float]) -> float: + """Cosine similarity between two equal-length vectors, in [-1, 1].""" + if len(a) != len(b): + raise ValueError(f"Vector length mismatch: {len(a)} vs {len(b)}") + dot = sum(x * y for x, y in zip(a, b)) + norm_a = math.sqrt(sum(x * x for x in a)) + norm_b = math.sqrt(sum(y * y for y in b)) + if norm_a == 0.0 or norm_b == 0.0: + return 0.0 + return dot / (norm_a * norm_b) + + +@dataclass +class InversionAttempt: + iteration: int + candidate: str + similarity: float + + +@dataclass +class InversionResult: + target_id: str + recovered_text: str + best_similarity: float + iterations_used: int + attempts: List[InversionAttempt] = field(default_factory=list) + converged: bool = False + + +SYSTEM_PROMPT = ( + "You are assisting a red team exercise that reconstructs text from an " + "embedding vector. You cannot see the vector directly. Each round you " + "will be told your previous guesses and a cosine similarity score " + "between 0 and 1 describing how close each guess's embedding was to " + "the hidden target embedding. Propose ONE new short candidate phrase " + "more likely to score higher than the best one so far. Reply with the " + "candidate phrase only -- no quotes, no explanation." +) + + +class EmbeddingInverter: + """Iteratively reconstructs text from a target embedding vector.""" + + def __init__( + self, + client: RAGLocalClient, + embedding_model: str, + chat_model: str, + max_iterations: int = 15, + similarity_threshold: float = 0.93, + ) -> None: + self.client = client + self.embedding_model = embedding_model + self.chat_model = chat_model + self.max_iterations = max_iterations + self.similarity_threshold = similarity_threshold + + def _next_guess(self, history: List[InversionAttempt]) -> str: + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + if not history: + messages.append( + { + "role": "user", + "content": "No prior guesses yet. Propose an initial candidate phrase.", + } + ) + else: + best = max(history, key=lambda a: a.similarity) + transcript = "\n".join( + f'Round {a.iteration}: "{a.candidate}" -> similarity {a.similarity:.4f}' + for a in history[-5:] + ) + messages.append( + { + "role": "user", + "content": ( + f"History:\n{transcript}\n\n" + f'Best so far: "{best.candidate}" ({best.similarity:.4f}). ' + "Propose a new candidate phrase, closer in meaning and " + "wording to whatever produced the highest-scoring guess " + "so far." + ), + } + ) + reply = self.client.chat(messages, model=self.chat_model) + return reply.strip().strip('"') + + def invert(self, target_id: str, target_vector: List[float]) -> InversionResult: + history: List[InversionAttempt] = [] + best = InversionAttempt(iteration=0, candidate="", similarity=-1.0) + + for i in range(1, self.max_iterations + 1): + candidate = self._next_guess(history) + if not candidate: + continue + candidate_vector = self.client.embed(candidate, model=self.embedding_model) + similarity = cosine_similarity(candidate_vector, target_vector) + attempt = InversionAttempt( + iteration=i, candidate=candidate, similarity=similarity + ) + history.append(attempt) + if similarity > best.similarity: + best = attempt + if similarity >= self.similarity_threshold: + break + + return InversionResult( + target_id=target_id, + recovered_text=best.candidate, + best_similarity=best.similarity, + iterations_used=len(history), + attempts=history, + converged=best.similarity >= self.similarity_threshold, + ) diff --git a/exploitation/embedding_inversion/outputs/.gitkeep b/exploitation/embedding_inversion/outputs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/exploitation/embedding_inversion/pyproject.toml b/exploitation/embedding_inversion/pyproject.toml new file mode 100644 index 0000000..73d7720 --- /dev/null +++ b/exploitation/embedding_inversion/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "embedding-inversion" +version = "0.1.0" +description = "Reverse-engineers text from leaked embedding vectors against the RAG_local sandbox." +readme = "README.md" +requires-python = ">=3.12,<3.13" +dependencies = [ + "requests>=2.31.0", +] + +[dependency-groups] +dev = [ + "black>=24.0.0", + "isort>=5.13.0", + "mypy>=1.10.0", + "pytest>=8.0.0", + "types-requests>=2.32.0", +] + +[tool.black] +line-length = 88 +target-version = ["py312"] + +[tool.isort] +profile = "black" +line_length = 88 + +[tool.mypy] +python_version = "3.12" +strict = true +files = ["attack.py", "inversion"] + +[tool.pytest.ini_options] +addopts = "-ra" +pythonpath = ["."] +testpaths = ["tests"] diff --git a/exploitation/embedding_inversion/reports/.gitkeep b/exploitation/embedding_inversion/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/exploitation/embedding_inversion/tests/test_inverter.py b/exploitation/embedding_inversion/tests/test_inverter.py new file mode 100644 index 0000000..2588b81 --- /dev/null +++ b/exploitation/embedding_inversion/tests/test_inverter.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import math +from typing import Dict, List + +import pytest + +from inversion.inverter import EmbeddingInverter, cosine_similarity + + +def test_cosine_similarity_identical_vectors() -> None: + assert cosine_similarity([1.0, 0.0, 0.0], [1.0, 0.0, 0.0]) == pytest.approx(1.0) + + +def test_cosine_similarity_orthogonal_vectors() -> None: + assert cosine_similarity([1.0, 0.0], [0.0, 1.0]) == pytest.approx(0.0) + + +def test_cosine_similarity_zero_vector_is_defined() -> None: + assert cosine_similarity([0.0, 0.0], [1.0, 0.0]) == 0.0 + + +def test_cosine_similarity_length_mismatch_raises() -> None: + with pytest.raises(ValueError): + cosine_similarity([1.0, 0.0], [1.0, 0.0, 0.0]) + + +class FakeClient: + """Stands in for RAGLocalClient: text->vector is a deterministic toy + embedding (character codepoints, padded/truncated to a fixed width), + and chat() replays a scripted sequence of guesses so the inverter's + control flow can be tested without a live sandbox.""" + + def __init__(self, scripted_guesses: List[str], dim: int = 8) -> None: + self.scripted_guesses = list(scripted_guesses) + self.dim = dim + self.chat_calls = 0 + self.embed_calls = 0 + + def _toy_embedding(self, text: str) -> List[float]: + codes = [float(ord(c)) for c in text[: self.dim]] + codes += [0.0] * (self.dim - len(codes)) + return codes + + def embed(self, text: str, model: str) -> List[float]: + self.embed_calls += 1 + return self._toy_embedding(text) + + def chat(self, messages: List[Dict[str, str]], model: str) -> str: + self.chat_calls += 1 + index = min(self.chat_calls - 1, len(self.scripted_guesses) - 1) + return self.scripted_guesses[index] + + +def test_invert_converges_when_guess_matches_target() -> None: + client = FakeClient(scripted_guesses=["wrong", "still wrong", "target"]) + inverter = EmbeddingInverter( + client=client, # type: ignore[arg-type] + embedding_model="nomic-embed-text", + chat_model="gpt-oss:20b", + max_iterations=5, + similarity_threshold=0.999, + ) + + target_vector = client._toy_embedding("target") + result = inverter.invert("secret_0", target_vector) + + assert result.converged is True + assert result.recovered_text == "target" + assert result.best_similarity == pytest.approx(1.0) + assert result.iterations_used == 3 + + +def test_invert_stops_at_max_iterations_without_converging() -> None: + client = FakeClient(scripted_guesses=["nope"]) + inverter = EmbeddingInverter( + client=client, # type: ignore[arg-type] + embedding_model="nomic-embed-text", + chat_model="gpt-oss:20b", + max_iterations=4, + similarity_threshold=0.999, + ) + + target_vector = client._toy_embedding("completely different secret") + result = inverter.invert("secret_1", target_vector) + + assert result.converged is False + assert result.iterations_used == 4 + assert client.chat_calls == 4 + + +def test_invert_tracks_best_attempt_even_after_a_worse_guess() -> None: + client = FakeClient(scripted_guesses=["target", "worse guess after"]) + inverter = EmbeddingInverter( + client=client, # type: ignore[arg-type] + embedding_model="nomic-embed-text", + chat_model="gpt-oss:20b", + max_iterations=2, + similarity_threshold=2.0, # unreachable, forces both rounds to run + ) + + target_vector = client._toy_embedding("target") + result = inverter.invert("secret_2", target_vector) + + assert result.recovered_text == "target" + assert result.best_similarity == pytest.approx(1.0) + assert result.iterations_used == 2 diff --git a/exploitation/embedding_inversion/uv.lock b/exploitation/embedding_inversion/uv.lock new file mode 100644 index 0000000..c3c5ada --- /dev/null +++ b/exploitation/embedding_inversion/uv.lock @@ -0,0 +1,352 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] + +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "embedding-inversion" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "isort" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "types-requests" }, +] + +[package.metadata] +requires-dist = [{ name = "requests", specifier = ">=2.31.0" }] + +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=24.0.0" }, + { name = "isort", specifier = ">=5.13.0" }, + { name = "mypy", specifier = ">=1.10.0" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "types-requests", specifier = ">=2.32.0" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/0a/062135c9a98dac804265073cc3afdbec5ae1aa37980bb354f461bafe81b4/platformdirs-4.11.1.tar.gz", hash = "sha256:bb1af68078f25e2f3e111e2d43b8d536df41b73c8a684b40bb018223b66fae27", size = 32396, upload-time = "2026-08-07T23:06:48.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/85/9b31b44296cfa3bb56cddb35e6a0f6578bab0b490c0806c0245e32c6110c/platformdirs-4.11.1-py3-none-any.whl", hash = "sha256:2efd27d363e8dd2e661639ffb398865a5e0a46442a11d266bf375a0e0c10e386", size = 23261, upload-time = "2026-08-07T23:06:47.219Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260712" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/51/703318f7b7be8bee126ec13bf615050f932d0179b8784420f3a0199cc769/types_requests-2.33.0.20260712.tar.gz", hash = "sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb", size = 25084, upload-time = "2026-07-12T05:14:20.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/e7/010c87f559e216d83f9dc51e939633fd0d0ead3377340181ab0e223cd3b5/types_requests-2.33.0.20260712-py3-none-any.whl", hash = "sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb", size = 21392, upload-time = "2026-07-12T05:14:19.616Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]