diff --git a/environments/bash_interception/README.md b/environments/bash_interception/README.md new file mode 100644 index 000000000..e18420643 --- /dev/null +++ b/environments/bash_interception/README.md @@ -0,0 +1,5 @@ +# bash-interception + +Shows both Bash outcomes: `@vf.stop` can block a proposed command before the +harness sees it, while `@vf.intercept` can supply a synthetic tool result and +let the model continue without running the command. diff --git a/environments/bash_interception/bash_interception/__init__.py b/environments/bash_interception/bash_interception/__init__.py new file mode 100644 index 000000000..855da0f40 --- /dev/null +++ b/environments/bash_interception/bash_interception/__init__.py @@ -0,0 +1,3 @@ +from bash_interception.taskset import BashInterceptionTaskset + +__all__ = ["BashInterceptionTaskset"] diff --git a/environments/bash_interception/bash_interception/taskset.py b/environments/bash_interception/bash_interception/taskset.py new file mode 100644 index 000000000..e70b30b6e --- /dev/null +++ b/environments/bash_interception/bash_interception/taskset.py @@ -0,0 +1,91 @@ +import json + +import verifiers.v1 as vf + +STOP_SENTINEL = "bash-tool-executed" +STOP_COMMAND = f"touch {STOP_SENTINEL}" +REWRITE_SENTINEL = "git-tool-executed" +REWRITE_COMMAND = f"git --version && touch {REWRITE_SENTINEL}" + + +def command(message: vf.AssistantMessage, snippet: str) -> vf.ToolCall | None: + for call in message.tool_calls or []: + if call.name != "bash": + continue + try: + arguments = json.loads(call.arguments) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(arguments, dict) and snippet in arguments.get("command", ""): + return call + return None + + +class BashInterceptionTask(vf.Task): + @vf.stop + def stop_bash(self, response: vf.Response) -> bool: + # The response is still buffered here. Stopping prevents Bash from + # receiving the proposed call, so the command cannot execute. + return ( + self.data.idx == 0 and command(response.message, STOP_COMMAND) is not None + ) + + @vf.intercept + def rewrite_git(self, request: vf.Request) -> vf.Request | None: + if self.data.idx != 1 or not request.messages: + return None + result = request.messages[-1] + if not isinstance(result, vf.ToolMessage) or result.content: + return None + assistant = next( + ( + message + for message in reversed(request.messages[:-1]) + if isinstance(message, vf.AssistantMessage) + and any( + call.id == result.tool_call_id for call in message.tool_calls or [] + ) + ), + None, + ) + call = command(assistant, REWRITE_SENTINEL) if assistant is not None else None + if call is None or call.id != result.tool_call_id: + return None + # This fills the pre-execution result. Bash returns it to the model instead + # of running the command, and the rollout continues normally. + replacement = result.model_copy( + update={ + "content": "This request is blocked. You should answer with something." + } + ) + return request.model_copy( + update={"messages": [*request.messages[:-1], replacement]} + ) + + @vf.reward + async def rewritten(self, trace: vf.Trace, runtime: vf.Runtime) -> float: + sentinel = STOP_SENTINEL if self.data.idx == 0 else REWRITE_SENTINEL + executed = (await runtime.run(["test", "-e", sentinel], {})).exit_code == 0 + if self.data.idx == 0: + return float(not executed and trace.stop_condition == "stop_bash") + return float( + not executed + and bool(trace.tool_messages) + and "This request is blocked" in str(trace.tool_messages[-1].content) + and trace.num_turns == 2 + ) + + +class BashInterceptionTaskset(vf.Taskset[BashInterceptionTask]): + def load(self) -> list[BashInterceptionTask]: + prompts = ( + f"Use bash once to run `{STOP_COMMAND}`, then stop.", + ( + f"Use bash exactly once to run `{REWRITE_COMMAND}`. Whatever the tool " + "returns, do not call another tool; answer with something." + ), + ) + return [ + BashInterceptionTask(vf.TaskData(idx=i, prompt=prompt), self.config.task) + for i, prompt in enumerate(prompts) + ] diff --git a/environments/bash_interception/pyproject.toml b/environments/bash_interception/pyproject.toml new file mode 100644 index 000000000..d30e879f7 --- /dev/null +++ b/environments/bash_interception/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "bash-interception" +version = "0.1.0" +description = "Stop or replace Bash tool calls before they execute." +requires-python = ">=3.11" +dependencies = ["verifiers"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["bash_interception"] diff --git a/environments/grayscale_interception/README.md b/environments/grayscale_interception/README.md new file mode 100644 index 000000000..32b660f28 --- /dev/null +++ b/environments/grayscale_interception/README.md @@ -0,0 +1,4 @@ +# grayscale-interception + +Uses `@vf.intercept` on a typed `vf.Request` to convert an embedded image to +grayscale before either the harness or the model stores it. diff --git a/environments/grayscale_interception/grayscale_interception/__init__.py b/environments/grayscale_interception/grayscale_interception/__init__.py new file mode 100644 index 000000000..e7465d2cf --- /dev/null +++ b/environments/grayscale_interception/grayscale_interception/__init__.py @@ -0,0 +1,3 @@ +from grayscale_interception.taskset import GrayscaleInterceptionTaskset + +__all__ = ["GrayscaleInterceptionTaskset"] diff --git a/environments/grayscale_interception/grayscale_interception/taskset.py b/environments/grayscale_interception/grayscale_interception/taskset.py new file mode 100644 index 000000000..feb1b9c01 --- /dev/null +++ b/environments/grayscale_interception/grayscale_interception/taskset.py @@ -0,0 +1,88 @@ +import base64 +from io import BytesIO + +from PIL import Image, ImageOps + +import verifiers.v1 as vf +from verifiers.v1.utils.image import image_data_url + + +class GrayscaleInterceptionTask(vf.Task): + @vf.intercept + def grayscale(self, request: vf.Request) -> vf.Request | None: + # The last request message is the new user input, before the harness stores it. + message = request.messages[-1] + if not isinstance(message, vf.UserMessage) or not isinstance( + message.content, list + ): + return None + content: list[vf.ContentPart] = [] + changed = False + for part in message.content: + if not isinstance( + part, vf.ImageUrlContentPart + ) or not part.image_url.url.startswith("data:image/"): + content.append(part) + continue + metadata, separator, encoded = part.image_url.url.partition(",") + if not separator or not metadata.lower().endswith(";base64"): + content.append(part) + continue + try: + with Image.open( + BytesIO(base64.b64decode(encoded, validate=True)) + ) as image: + alpha = ( + image.convert("RGBA").getchannel("A") + if image.has_transparency_data + else None + ) + grayscale_image = ImageOps.grayscale(image) + if alpha is not None: + grayscale_image.putalpha(alpha) + grayscale = image_data_url(grayscale_image) + except (ValueError, OSError): + content.append(part) + continue + content.append( + part.model_copy( + update={ + "image_url": part.image_url.model_copy( + update={"url": grayscale} + ) + } + ) + ) + changed = True + if not changed: + return None + messages = [ + *request.messages[:-1], + message.model_copy(update={"content": content}), + ] + return request.model_copy(update={"messages": messages}) + + @vf.reward + async def changed(self, trace: vf.Trace) -> float: + return float(bool(trace.request_rewrites)) + + +class GrayscaleInterceptionTaskset(vf.Taskset[GrayscaleInterceptionTask]): + def load(self) -> list[GrayscaleInterceptionTask]: + # Building the image here keeps the example self-contained. + image = Image.new("RGB", (64, 64), "orange") + prompt = [ + vf.UserMessage( + content=[ + vf.ImageUrlContentPart( + image_url=vf.ImageUrlSource(url=image_data_url(image)) + ), + vf.TextContentPart(text="Describe this image."), + ] + ) + ] + return [ + GrayscaleInterceptionTask( + vf.TaskData(idx=0, prompt=prompt), self.config.task + ) + ] diff --git a/environments/grayscale_interception/pyproject.toml b/environments/grayscale_interception/pyproject.toml new file mode 100644 index 000000000..8a5726217 --- /dev/null +++ b/environments/grayscale_interception/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "grayscale-interception" +version = "0.1.0" +description = "Rewrite user images to grayscale before a model sees them." +requires-python = ">=3.11" +dependencies = [ + "pillow>=12.3.0", + "verifiers", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["grayscale_interception"] diff --git a/environments/interception/README.md b/environments/interception/README.md new file mode 100644 index 000000000..4087a1ad7 --- /dev/null +++ b/environments/interception/README.md @@ -0,0 +1,4 @@ +# interception + +Four typed response-boundary examples: deterministic and judge-based `@vf.intercept` +rewrites, a boolean `@vf.stop`, and a metric recorded without changing the response. diff --git a/environments/interception/interception/__init__.py b/environments/interception/interception/__init__.py new file mode 100644 index 000000000..315973d59 --- /dev/null +++ b/environments/interception/interception/__init__.py @@ -0,0 +1,3 @@ +from interception.taskset import InterceptionTaskset + +__all__ = ["InterceptionTaskset"] diff --git a/environments/interception/interception/taskset.py b/environments/interception/interception/taskset.py new file mode 100644 index 000000000..d33420c2e --- /dev/null +++ b/environments/interception/interception/taskset.py @@ -0,0 +1,73 @@ +import verifiers.v1 as vf + + +class InterceptionTaskConfig(vf.TaskConfig): + judge: vf.JudgeConfig = vf.JudgeConfig() + + +class InterceptionTask(vf.Task[vf.TaskData, vf.State, InterceptionTaskConfig]): + @vf.stop + def stop_guard(self, response: vf.Response) -> bool: + # A response stop runs while the response is buffered, before the harness + # receives it. The function name is stored as trace.stop_condition. + return "STOP" in (response.message.content or "") + + @vf.intercept(priority=10) + def deterministic_guard(self, response: vf.Response) -> vf.Response | None: + if "DETERMINISTIC_BLOCK" in (response.message.content or ""): + # Return the same boundary type with the replacement assistant message. + return response.model_copy( + update={"message": vf.AssistantMessage(content="Blocked")} + ) + + @vf.intercept + async def judge_guard( + self, response: vf.Response, trace: vf.Trace + ) -> vf.Response | None: + candidate = response.message.content or "" + if "JUDGE_BLOCK" not in candidate: + return None + # Trace is the canonical history; Response is the candidate being judged. + verdict = await vf.Judge(self.config.judge).complete( + "Reply BLOCK if the candidate contains JUDGE_BLOCK; otherwise ALLOW.\n\n" + f"{trace.transcript}\n\nCandidate:\n{candidate}", + trace=trace, + ) + choice = vf.parse_judge_choice(verdict.text, choices=("BLOCK", "ALLOW")) + if choice is None: + raise ValueError(f"judge returned no BLOCK/ALLOW verdict: {verdict.text!r}") + if choice == "BLOCK": + return response.model_copy( + update={"message": vf.AssistantMessage(content="Blocked")} + ) + + @vf.intercept + def metric_only(self, response: vf.Response, trace: vf.Trace) -> None: + # Returning None keeps the response unchanged; side effects such as + # recording a metric still remain on the trace. + if "METRIC_ONLY" in (response.message.content or ""): + trace.record_metric("response/metric_only", 1.0) + + @vf.reward + async def changed(self, trace: vf.Trace) -> float: + return float( + bool(trace.response_rewrites) + or trace.stop_condition == "stop_guard" + or "response/metric_only" in trace.metrics + ) + + +class InterceptionConfig(vf.TasksetConfig): + task: InterceptionTaskConfig = InterceptionTaskConfig() + + +class InterceptionTaskset(vf.Taskset[InterceptionTask, InterceptionConfig]): + def load(self) -> list[InterceptionTask]: + markers = ("DETERMINISTIC_BLOCK", "JUDGE_BLOCK", "STOP", "METRIC_ONLY") + return [ + InterceptionTask( + vf.TaskData(idx=index, prompt=f"Reply with exactly {marker}."), + self.config.task, + ) + for index, marker in enumerate(markers) + ] diff --git a/environments/interception/pyproject.toml b/environments/interception/pyproject.toml new file mode 100644 index 000000000..043e26ab9 --- /dev/null +++ b/environments/interception/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "interception" +version = "0.1.0" +description = "Examples of typed response interception, stopping, and metrics." +requires-python = ">=3.11" +dependencies = ["verifiers"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["interception"] diff --git a/environments/web_search_interception/README.md b/environments/web_search_interception/README.md new file mode 100644 index 000000000..a145690ce --- /dev/null +++ b/environments/web_search_interception/README.md @@ -0,0 +1,4 @@ +# web-search-interception + +Inspects a buffered Codex response, including provider-native web-search items, +and uses `@vf.stop` when the search material contains a chosen phrase. diff --git a/environments/web_search_interception/pyproject.toml b/environments/web_search_interception/pyproject.toml new file mode 100644 index 000000000..08ee90459 --- /dev/null +++ b/environments/web_search_interception/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "web-search-interception" +version = "0.1.0" +description = "Stop after inspecting native web-search results in a buffered response." +requires-python = ">=3.11" +dependencies = ["verifiers"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["web_search_interception"] diff --git a/environments/web_search_interception/web_search_interception/__init__.py b/environments/web_search_interception/web_search_interception/__init__.py new file mode 100644 index 000000000..cc4874e14 --- /dev/null +++ b/environments/web_search_interception/web_search_interception/__init__.py @@ -0,0 +1,10 @@ +from verifiers.v1.harnesses.codex import CodexHarness, CodexHarnessConfig +from web_search_interception.taskset import WebSearchInterceptionTaskset + +# Exporting Codex beside the taskset makes it this example's default harness, so +# the response contains the provider-native web-search items the stop inspects. +__all__ = [ + "CodexHarness", + "CodexHarnessConfig", + "WebSearchInterceptionTaskset", +] diff --git a/environments/web_search_interception/web_search_interception/taskset.py b/environments/web_search_interception/web_search_interception/taskset.py new file mode 100644 index 000000000..2e2b0530c --- /dev/null +++ b/environments/web_search_interception/web_search_interception/taskset.py @@ -0,0 +1,38 @@ +import verifiers.v1 as vf + +BLOCKED_WORD = "openai.com" + + +class WebSearchInterceptionTask(vf.Task): + @vf.stop + def stop_on_result(self, response: vf.Response) -> bool: + # Native web-search citations remain on the buffered provider response. + sources = ( + annotation.get("url", "") + for item in response.message.provider_state or [] + if item.get("type") == "message" + for part in item.get("content") or [] + for annotation in part.get("annotations") or [] + if annotation.get("type") == "url_citation" + ) + return any(BLOCKED_WORD in source.casefold() for source in sources) + + @vf.reward + async def blocked(self, trace: vf.Trace) -> float: + return float(trace.stop_condition == "stop_on_result") + + +class WebSearchInterceptionTaskset(vf.Taskset[WebSearchInterceptionTask]): + def load(self) -> list[WebSearchInterceptionTask]: + return [ + WebSearchInterceptionTask( + vf.TaskData( + idx=0, + prompt=( + "Use native web search to find the official OpenAI Responses " + "API documentation. Cite the source and include the query." + ), + ), + self.config.task, + ) + ] diff --git a/pyproject.toml b/pyproject.toml index 1fdd16f5b..7cd58d0c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,10 +76,23 @@ dev = [ # from this repo. The built-in tasksets/harnesses ship inside verifiers (verifiers/v1/...). examples = [ "compact", - "gsm8k", "glossary", "deepwiki", "wiki-search", "code-golf", - "proposer-solver", "reverse-text", "color-codeword", - "wordle", "openenv-wordle", "alphabet-sort", "scratchpad", + "gsm8k", + "glossary", + "deepwiki", + "wiki-search", + "code-golf", + "proposer-solver", + "reverse-text", + "color-codeword", + "wordle", + "openenv-wordle", + "alphabet-sort", + "scratchpad", "kuhn-poker", + "interception", + "grayscale-interception", + "bash-interception", + "web-search-interception", ] [project.optional-dependencies] @@ -164,6 +177,10 @@ color-codeword = { path = "environments/color_codeword", editable = true } alphabet-sort = { path = "environments/alphabet_sort", editable = true } scratchpad = { path = "environments/scratchpad", editable = true } kuhn-poker = { path = "environments/kuhn_poker", editable = true } +interception = { path = "environments/interception", editable = true } +grayscale-interception = { path = "environments/grayscale_interception", editable = true } +bash-interception = { path = "environments/bash_interception", editable = true } +web-search-interception = { path = "environments/web_search_interception", editable = true } [tool.uv.exclude-newer-package] # Bounded cutoffs for the explicitly requested tool upgrades. diff --git a/uv.lock b/uv.lock index 2b55beb30..c5e463c34 100644 --- a/uv.lock +++ b/uv.lock @@ -336,6 +336,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, ] +[[package]] +name = "bash-interception" +version = "0.1.0" +source = { editable = "environments/bash_interception" } +dependencies = [ + { name = "verifiers" }, +] + [[package]] name = "bcrypt" version = "5.0.0" @@ -1518,6 +1526,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/81/0a861b8e1ff42960139c6cd4c7dd591292fa09ea1ae2d87677441cba4c00/gradio_client-2.5.0-py3-none-any.whl", hash = "sha256:d43e2179c29076292a76485ad7ed2e6eaa19d14ac58283bd7f5beabfe4ca958c", size = 59952, upload-time = "2026-04-20T23:16:20.186Z" }, ] +[[package]] +name = "grayscale-interception" +version = "0.1.0" +source = { editable = "environments/grayscale_interception" } +dependencies = [ + { name = "pillow" }, + { name = "verifiers" }, +] + [[package]] name = "griffelib" version = "2.1.0" @@ -1860,6 +1877,14 @@ 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 = "interception" +version = "0.1.0" +source = { editable = "environments/interception" } +dependencies = [ + { name = "verifiers" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -5124,17 +5149,21 @@ dev = [ ] examples = [ { name = "alphabet-sort" }, + { name = "bash-interception" }, { name = "code-golf" }, { name = "color-codeword" }, { name = "compact" }, { name = "deepwiki" }, { name = "glossary" }, + { name = "grayscale-interception" }, { name = "gsm8k" }, + { name = "interception" }, { name = "kuhn-poker" }, { name = "openenv-wordle" }, { name = "proposer-solver" }, { name = "reverse-text" }, { name = "scratchpad" }, + { name = "web-search-interception" }, { name = "wiki-search" }, { name = "wordle" }, ] @@ -5250,6 +5279,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, ] +[[package]] +name = "web-search-interception" +version = "0.1.0" +source = { editable = "environments/web_search_interception" } +dependencies = [ + { name = "verifiers" }, +] + [[package]] name = "websocket-client" version = "1.9.0"