search-with-proof: oracle placeholders for demos; Python apps run under the venv interpreter

Demos may reference ${served_root}, ${database_path}, ${marker_dir} or
${callback_url}; the verifier substitutes them per side, so apps that take a
directory or database argument can be wired to the oracle. Python apps start
under the verifier interpreter (the one pip built the products for): the image
carries a second system Python whose ABI tag broke compiled extensions.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-06 12:43:11 +02:00
co-authored by Claude Fable 5.1
parent 0d3e6df31c
commit 7a91cc1482
6 changed files with 270 additions and 33 deletions
+28 -13
View File
@@ -1,6 +1,6 @@
"""Bounded declarative demonstrations; no executable agent content or private variables.
Only a previously captured application response can define a substitution. Runtime
Start configuration accepts oracle-owned fixture placeholders; steps accept prior response captures. Runtime
paths and build manifests are checkout relative; request payloads remain data (and
may contain traversal or injection strings being tested at the public interface).
"""
@@ -10,6 +10,7 @@ from __future__ import annotations
import json
import os
import re
import sys
import tomllib
from pathlib import Path, PurePosixPath
from typing import Any, cast
@@ -77,7 +78,16 @@ BUILD_RECIPES = {
"maven": ("/usr/bin/mvn", "--offline", "-f"),
"gradle": ("/usr/bin/gradle", "--offline", "--no-daemon", "--project-dir"),
}
RUNTIMES = {"python": ("/usr/bin/python3", "-I"), "node": ("/usr/bin/node",), "php": ("/usr/bin/php",), "java": ("/usr/bin/java", "-jar")}
# Python apps run under the verifier's own interpreter: it is the one pip installed the products for
# (the image carries a second, system Python with a different ABI tag; compiled extensions failed under it).
RUNTIMES = {"python": (sys.executable, "-I"), "node": ("/usr/bin/node",), "php": ("/usr/bin/php",), "java": ("/usr/bin/java", "-jar")}
ORACLE_PLACEHOLDERS = {
"path": {"served_root"},
"sql": {"database_path", "database_url"},
"command": {"marker_dir"},
"ssrf": {"callback_url"},
"xss": set(),
}
_REFERENCE = re.compile(r"\$\{([a-z][a-z0-9_]{0,31})\}")
_PRIVATE = re.compile(r"/(?:fixture|proc|sys|dev|deps|build)(?:/|\b)|(?:PROOF_MARKER|CALLBACK_URL|DATABASE|SERVED_ROOT)", re.I)
@@ -274,11 +284,11 @@ def preparation_command(recipe: str, path: Path, checkout: Path, products: Path)
def _text(value: Any, names: set[str], *, maximum: int = MAX_VALUE_BYTES) -> str:
if not isinstance(value, str) or "\0" in value or len(value.encode()) > maximum:
raise ValueError("invalid or oversized value")
if _PRIVATE.search(value):
if _PRIVATE.search(_REFERENCE.sub(lambda m: "" if m[1] in names else m[0], value)):
raise ValueError("private verifier references are forbidden")
references = _REFERENCE.findall(value)
if any(name not in names for name in references) or "$" in _REFERENCE.sub("", value):
raise ValueError("only earlier response captures may be referenced")
raise ValueError("only allowed oracle placeholders or earlier response captures may be referenced")
return value
@@ -289,7 +299,8 @@ def _arguments(value: Any, names: set[str]) -> None:
_text(arg, names)
def validate_start_config(start: dict[str, Any]) -> None:
def validate_start_config(start: dict[str, Any], names: set[str] | None = None) -> None:
names = names or set()
environment = start.get("environment", {})
if not isinstance(environment, dict) or len(environment) > 32:
raise ValueError("invalid environment map")
@@ -304,7 +315,7 @@ def validate_start_config(start: dict[str, Any]) -> None:
raise ValueError("forbidden environment variable")
if not isinstance(value, str) or len(value) > 4096:
raise ValueError("invalid environment value")
_text(value, set())
_text(value, names)
files = start.get("files", {})
if not isinstance(files, dict) or len(files) > 8:
raise ValueError("invalid data file map")
@@ -312,13 +323,15 @@ def validate_start_config(start: dict[str, Any]) -> None:
checkout_path(name)
if ".." in name or not any(name.endswith(ext) for ext in DATA_EXTENSIONS):
raise ValueError("invalid data file extension or name")
_text(content, set(), maximum=MAX_DATA_BYTES)
_text(content, names, maximum=MAX_DATA_BYTES)
def write_start_files(checkout: Path, start: dict[str, Any]) -> None:
def write_start_files(checkout: Path, start: dict[str, Any], bindings: dict[str, str] | None = None) -> None:
"""Create only new private data files, before any app process is launched."""
validate_start_config(start)
bindings = bindings or {}
validate_start_config(start, set(bindings))
for name, content in start.get("files", {}).items():
content = substitute(content, bindings, maximum=MAX_DATA_BYTES)
path = checkout / ".demo" / name
directory = checkout
for part in path.parent.relative_to(checkout).parts:
@@ -361,7 +374,8 @@ def validate_demo(value: Any, *, checkout: Path | None = None) -> dict[str, Any]
if checkout is not None:
build_project(recipe, checkout / args[0], checkout)
_keys(start, {"runtime", "path", "arguments", "mode"}, {"port", "environment", "files"})
validate_start_config(start)
placeholders = ORACLE_PLACEHOLDERS[value["oracle"]]
validate_start_config(start, placeholders)
ordinary = {**value, "start": {k: v for k, v in start.items() if k not in {"environment", "files"}}}
if len(json.dumps(ordinary, allow_nan=False).encode()) > 65536:
raise ValueError("demo excluding configuration exceeds size limit")
@@ -370,7 +384,7 @@ def validate_demo(value: Any, *, checkout: Path | None = None) -> dict[str, Any]
checkout_path(start["path"])
if start["path"].split("/")[0] == ".demo":
raise ValueError("data files cannot be runtime entrypoints")
_arguments(start["arguments"], set())
_arguments(start["arguments"], placeholders)
if any(
arg.startswith("/") or ".." in arg.split("/") or re.search(r"(?:^|[= :])/(?:etc|home|root|tmp|workspace|scratch)/", arg)
for arg in start["arguments"]
@@ -423,6 +437,7 @@ def validate_demo(value: Any, *, checkout: Path | None = None) -> dict[str, Any]
or not re.fullmatch(r"[a-z][a-z0-9_]{0,31}", name)
or name in names
or name == "canary"
or any(name in allowed for allowed in ORACLE_PLACEHOLDERS.values())
or _PRIVATE.search(name)
):
raise ValueError("invalid capture name")
@@ -458,9 +473,9 @@ def load_demo(path: Path) -> dict[str, Any]:
raise ValueError("demo nesting exceeds limit") from exc
def substitute(value: str, captures: dict[str, str]) -> str:
def substitute(value: str, captures: dict[str, str], *, maximum: int = MAX_VALUE_BYTES) -> str:
result = _REFERENCE.sub(lambda m: captures[m.group(1)], value)
if len(result.encode()) > MAX_VALUE_BYTES or "\0" in result:
if len(result.encode()) > maximum or "\0" in result:
raise ValueError("expanded value exceeds limit")
return result
+7 -1
View File
@@ -39,6 +39,7 @@ class Canary:
class Oracle(Protocol):
namespace_pid: int | None
placeholders: dict[str, str]
def prepare(self, canary: Canary) -> dict[str, str]: ...
def capture(self, output: str) -> None: ...
@@ -52,6 +53,7 @@ class OutputOracle:
def prepare(self, canary: Canary) -> dict[str, str]:
self.canary = canary
self.seen = False
self.placeholders: dict[str, str] = {}
return {}
def capture(self, output: str) -> None:
@@ -70,6 +72,7 @@ class PathOracle(OutputOracle):
(canary.root / "public").mkdir()
(canary.root / "canary").write_text(canary.nonce)
(canary.root / "canary").chmod(0o600)
self.placeholders = {"served_root": "/fixture/public"}
return {"SERVED_ROOT": "/fixture/public"}
@@ -80,6 +83,7 @@ class SQLOracle(OutputOracle):
db.execute("CREATE TABLE records (name TEXT, value TEXT, public INTEGER)")
db.executemany("INSERT INTO records VALUES (?, ?, ?)", [("ordinary", "public value", 1), ("secret", canary.nonce, 0)])
(canary.root / "fixture.db").chmod(0o600)
self.placeholders = {"database_path": "/fixture/fixture.db", "database_url": "sqlite:////fixture/fixture.db"}
return {"DATABASE": "/fixture/fixture.db"}
@@ -88,6 +92,7 @@ class CommandOracle(OutputOracle):
super().prepare(canary)
# Marker is in app-only writable scratch, inaccessible in artefact sandbox.
self.marker = canary.root / "marker"
self.placeholders = {"marker_dir": "/scratch"}
return {}
def observe(self) -> tuple[bool, str]:
@@ -132,7 +137,8 @@ class SSRFOracle(OutputOracle):
self.namespace_pid, port = json.loads(line)
if self.task_boundary:
self.namespace_pid = None
return {"CALLBACK_URL": f"http://127.0.0.1:{port}/{canary.nonce}"} # loopback: oracle listener inside the private sandbox namespace
self.placeholders = {"callback_url": f"http://127.0.0.1:{port}/{canary.nonce}"} # loopback: private oracle listener
return {"CALLBACK_URL": self.placeholders["callback_url"]}
def observe(self) -> tuple[bool, str]:
assert self.process is not None and self.process.stdout is not None
+28 -11
View File
@@ -283,6 +283,7 @@ def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExe
timings = dict(build=0.0, ready=0.0, run=0.0)
app_diagnostics: dict[str, Any] = {}
private_values: list[str] = []
disk_peak = 0
root: Path | None = None
@@ -301,7 +302,7 @@ def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExe
outcome,
tuple(observations),
time.monotonic() - started,
diagnostic(reason, maximum=1500),
diagnostic(reason, maximum=1500, private=tuple(private_values)),
timings["build"],
timings["ready"] + app_diagnostics.get("ready_seconds", 0),
max(0.0, timings["run"] - app_diagnostics.get("ready_seconds", 0)),
@@ -310,8 +311,10 @@ def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExe
chromium_no_sandbox=bool(getattr(browser, "no_sandbox", False)),
phase=phase,
exit_code=result.exit_code if result is not None else None,
stderr=diagnostic(result.stderr if result is not None else app_diagnostics.get("stderr", ""), maximum=1500),
stdout=diagnostic(getattr(result, "stdout", app_diagnostics.get("stdout", "")), maximum=1500),
stderr=diagnostic(
result.stderr if result is not None else app_diagnostics.get("stderr", ""), maximum=1500, private=tuple(private_values)
),
stdout=diagnostic(getattr(result, "stdout", app_diagnostics.get("stdout", "")), maximum=1500, private=tuple(private_values)),
)
try:
@@ -359,12 +362,6 @@ def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExe
return record("could_not_build", "build recipe failed", "build", compiled)
if tuple(_tree(p) for p in immutable) != before:
return record("inconclusive", "build changed immutable inputs")
write_start_files(checkout, schema["start"])
if not task_boundary:
for name in schema["start"].get("files", {}):
_sandbox.writable_directory(checkout / ".demo" / name)
product_hash = _tree(products, generated_links=True)
checkout_hash = _tree(checkout, generated_links=True)
mounts["/build"] = products
start_spec = schema["start"]
entry = checkout / start_spec["path"]
@@ -400,14 +397,34 @@ def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExe
assert oracle is not None
try:
canary = Canary.fresh(root / "fixture")
private_values.extend((canary.nonce, str(root), "/fixture", "/scratch"))
if task_boundary and hasattr(oracle, "task_boundary"):
oracle.task_boundary = True
oracle_env = oracle.prepare(canary)
bindings = oracle.placeholders
file_bindings = bindings
if task_boundary:
# Files are app data, so resolve their paths before writing;
# the task runner only translates argv and environment.
file_bindings = {
key: re.sub(r"/(?:fixture|scratch)(?=/|$)", lambda _, path=str(canary.root): path, value)
for key, value in bindings.items()
}
private_values.extend((*bindings.values(), *file_bindings.values()))
write_start_files(checkout, start_spec, file_bindings)
if not task_boundary:
for name in start_spec.get("files", {}):
_sandbox.writable_directory(checkout / ".demo" / name)
product_hash = _tree(products, generated_links=True)
checkout_hash = _tree(checkout, generated_links=True)
command = tuple(substitute(arg, bindings) for arg in command)
app_env = {
**{
key: "/workspace/" + value if value.startswith(".demo/") else value
key: "/workspace/" + expanded if expanded.startswith(".demo/") else expanded
for key, value in start_spec.get("environment", {}).items()
for expanded in [substitute(value, bindings)]
},
**oracle.prepare(canary),
**oracle_env,
}
if start_spec["runtime"] == "node":
app_env["NODE_PATH"] = "/build/node_modules"
+7 -8
View File
@@ -91,22 +91,21 @@ DEMO_GUIDE = (
"HTTP mode requires port (1024-65535) and steps {type:http,method,path,headers?,body?}; "
"CLI mode forbids port and uses {type:cli,arguments,stdin?}. Supply 1-32 ordered steps. "
"Optional capture {name,source:output|json,path?} defines ${name} only for later steps from app responses. "
"Config via start.environment and small data files in start.files is allowed; no scripts or private canary references. "
"start.environment and start.files allow data, never scripts or raw private references. "
"Only the selected oracle's placeholders expand per side only in start.arguments, environment values and files contents. "
"environment maps up to 32 uppercase names to values (4096 chars); loader/interpreter variables are forbidden. "
"files maps up to 8 relative data filenames to contents (64 KiB each), created under .demo/ mode 0600; "
"point environment values at .demo/name. Allowed: .pem .key .json .yaml .yml .txt .env .ini .toml .db .sqlite .crt."
)
ORACLE_GUIDE = (
"Family oracles: path plants a private nonce file outside the served directory and observes its contents in app output. "
"SQL plants public and secret rows in a fixture database and observes the secret nonce in app output. "
"Command reserves an app-only marker location and observes whether an injected command creates that file. "
"SSRF provides a private loopback listener with a fresh nonce URL and observes a request to it. "
'Path observes a private nonce file outside ${served_root} in app output; e.g. "arguments": ["--dir", "${served_root}"]. '
'SQL observes secret rows in ${database_path} (${database_url} is its SQLite URL); e.g. "arguments": ["--db", "${database_path}"]. '
'Command observes marker creation at marker location ${marker_dir}/marker; e.g. "environment": {"MARKER_DIR": "${marker_dir}"}. '
'SSRF observes the nonce URL at its loopback listener ${callback_url}; e.g. "environment": {"INTERNAL_URL": "${callback_url}"}. '
"XSS installs a verifier-owned alert hook in a browser and observes actual execution of alert('ousast-xss'); "
"reflection alone is insufficient. "
"Set the required oracle field using the allowed choices below; for injection choose sql or command. "
"Other families have no owned oracle; report that gap instead of inventing one. "
+ "Allowed family/oracle choices: "
+ json.dumps(FAMILY_ORACLES)
"Other families have no oracle; report the gap. " + "Allowed family/oracle choices: " + json.dumps(FAMILY_ORACLES)
)
REASON_PROMPT = (
"Your goal is a declarative demo conforming to demo.py's schema that makes the verifier observe the effect "