Skip to content

Commit 872de34

Browse files
authored
fix(p0-7): run_validation default-safe output + symlink-resistant containment (#433)
* test(run-validation): add failing scaffold for default-no-delete output (F009 S1) * fix(run-validation): default output uses run-<ts> subdir, no delete (F009 S2) * feat(run-validation): add --clean-output opt-in for delete (F009 S3) * fix(run-validation): containment check restricts --clean-output to repo reports/ (F009 S4) * fix(run-validation): double-resolve symlinks in containment check (F009 S5) * style(run-validation): format output guard tests (F009) * fix(test-e2b-output): mark E2B stub modules as packages with __path__ * fix(papers): align per-source-timeout source loop with F006a delegate refactor
1 parent ef940c1 commit 872de34

3 files changed

Lines changed: 307 additions & 25 deletions

File tree

autosearch/skills/channels/papers/methods/via_paper_search.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import asyncio
55
import os
6+
from concurrent.futures import ThreadPoolExecutor
67
from datetime import UTC, datetime
78

89
import structlog
@@ -41,14 +42,31 @@ async def search(
4142
return []
4243
fetched_at = datetime.now(UTC)
4344
source_items = list(active_sources.items())
44-
tasks = [
45-
asyncio.wait_for(
46-
asyncio.to_thread(_search_source, searcher_cls, query.text, max_results_per_source),
47-
timeout=per_source_timeout_seconds,
48-
)
49-
for _, searcher_cls in source_items
50-
]
51-
results = await asyncio.gather(*tasks, return_exceptions=True)
45+
if not source_items:
46+
return []
47+
48+
loop = asyncio.get_running_loop()
49+
executor = ThreadPoolExecutor(
50+
max_workers=len(source_items),
51+
thread_name_prefix="papers-source",
52+
)
53+
try:
54+
tasks = [
55+
asyncio.wait_for(
56+
loop.run_in_executor(
57+
executor,
58+
_search_source,
59+
searcher_cls,
60+
query.text,
61+
max_results_per_source,
62+
),
63+
timeout=per_source_timeout_seconds,
64+
)
65+
for _, searcher_cls in source_items
66+
]
67+
results = await asyncio.gather(*tasks, return_exceptions=True)
68+
finally:
69+
executor.shutdown(wait=False, cancel_futures=True)
5270

5371
evidence_by_source: dict[str, list[Evidence]] = {}
5472
for (source_name, _), result in zip(source_items, results, strict=True):

scripts/e2b/run_validation.py

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ def parse_args() -> argparse.Namespace:
4646
"--source-dir", help="Pack this directory to a temp tarball and use it as --tarball"
4747
)
4848
parser.add_argument("--phase", help="Optional CSV filter for phases to run")
49+
parser.add_argument(
50+
"--clean-output",
51+
action="store_true",
52+
help="Delete the base output directory before creating this run's report directory",
53+
)
4954
return parser.parse_args()
5055

5156

@@ -389,23 +394,50 @@ def execute_phase(
389394
return phase_summary
390395

391396

397+
def get_reports_root() -> Path:
398+
return Path(__file__).resolve().parents[2] / "reports"
399+
400+
401+
def _path_is_inside(child: Path, parent: Path) -> bool:
402+
return child != parent and child.is_relative_to(parent)
403+
404+
392405
def clean_output_dir(output_dir: Path, console: Console) -> None:
393-
resolved = output_dir.expanduser().resolve()
394-
dangerous_paths = {
395-
Path("/"),
396-
Path.home().resolve(),
397-
Path.home().resolve().parent,
398-
Path.cwd().resolve(),
399-
}
400-
if resolved in dangerous_paths:
401-
raise ValueError(f"Refusing to wipe dangerous path: {resolved}")
402-
if len(resolved.parts) < 3:
403-
raise ValueError(f"Output dir must be at least 2 levels deep: {resolved}")
406+
reports_root = get_reports_root().expanduser().resolve()
407+
clean_target = output_dir.expanduser().resolve()
408+
if not _path_is_inside(clean_target, reports_root):
409+
raise ValueError(
410+
f"Refusing to clean output outside repo reports/: {clean_target} "
411+
f"is not inside {reports_root}"
412+
)
404413

405-
if output_dir.exists():
406-
console.print(f"[yellow]WARN[/] wiping existing output directory {resolved}")
407-
shutil.rmtree(resolved)
408-
resolved.mkdir(parents=True, exist_ok=True)
414+
if clean_target.exists():
415+
console.print(f"[yellow]WARN[/] wiping existing output directory {clean_target}")
416+
shutil.rmtree(clean_target)
417+
clean_target.mkdir(parents=True, exist_ok=True)
418+
419+
420+
def create_run_output_dir(
421+
base_output_dir: Path,
422+
*,
423+
clean_output: bool,
424+
console: Console,
425+
) -> Path:
426+
if clean_output:
427+
clean_output_dir(base_output_dir, console)
428+
else:
429+
base_output_dir.mkdir(parents=True, exist_ok=True)
430+
431+
for _attempt in range(100):
432+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
433+
run_output_dir = base_output_dir / f"run-{timestamp}"
434+
try:
435+
run_output_dir.mkdir()
436+
except FileExistsError:
437+
time.sleep(0.001)
438+
continue
439+
return run_output_dir
440+
raise ValueError(f"Unable to create a unique run output directory under {base_output_dir}")
409441

410442

411443
def select_phases(all_phases: list[PhaseSpec], phase_filter: str | None) -> list[PhaseSpec]:
@@ -456,7 +488,7 @@ def main() -> int:
456488
args = parse_args()
457489
matrix_path = Path(args.matrix).expanduser().resolve()
458490
secrets_path = Path(args.secrets).expanduser()
459-
output_dir = Path(args.output).expanduser()
491+
base_output_dir = Path(args.output).expanduser()
460492
tarball = Path(args.tarball).expanduser().resolve() if args.tarball else None
461493
source_dir = Path(args.source_dir).expanduser().resolve() if args.source_dir else None
462494

@@ -471,7 +503,11 @@ def main() -> int:
471503
if "E2B_API_KEY" in secrets and "E2B_API_KEY" not in os.environ:
472504
os.environ["E2B_API_KEY"] = secrets["E2B_API_KEY"]
473505

474-
clean_output_dir(output_dir, stderr_console)
506+
output_dir = create_run_output_dir(
507+
base_output_dir,
508+
clean_output=args.clean_output,
509+
console=stderr_console,
510+
)
475511

476512
started_at = datetime.now(timezone.utc).isoformat()
477513
with Progress(
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
from __future__ import annotations
2+
3+
import importlib
4+
import sys
5+
from pathlib import Path
6+
from types import ModuleType, SimpleNamespace
7+
8+
9+
ROOT = Path(__file__).resolve().parents[2]
10+
E2B_SCRIPT_DIR = ROOT / "scripts" / "e2b"
11+
12+
13+
def _install_e2b_stubs(monkeypatch) -> None:
14+
e2b_module = ModuleType("e2b")
15+
sandbox_module = ModuleType("e2b.sandbox")
16+
commands_module = ModuleType("e2b.sandbox.commands")
17+
command_handle_module = ModuleType("e2b.sandbox.commands.command_handle")
18+
interpreter_module = ModuleType("e2b_code_interpreter")
19+
e2b_module.__path__ = []
20+
sandbox_module.__path__ = []
21+
commands_module.__path__ = []
22+
23+
class CommandExitException(Exception):
24+
exit_code = 1
25+
stdout = ""
26+
stderr = ""
27+
error = "stubbed command failure"
28+
29+
class Sandbox:
30+
@classmethod
31+
def create(cls, **_kwargs):
32+
raise AssertionError("Sandbox.create should be mocked by these tests")
33+
34+
command_handle_module.CommandExitException = CommandExitException
35+
interpreter_module.Sandbox = Sandbox
36+
37+
monkeypatch.setitem(sys.modules, "e2b", e2b_module)
38+
monkeypatch.setitem(sys.modules, "e2b.sandbox", sandbox_module)
39+
monkeypatch.setitem(sys.modules, "e2b.sandbox.commands", commands_module)
40+
monkeypatch.setitem(
41+
sys.modules,
42+
"e2b.sandbox.commands.command_handle",
43+
command_handle_module,
44+
)
45+
monkeypatch.setitem(sys.modules, "e2b_code_interpreter", interpreter_module)
46+
47+
48+
def test_e2b_stubs_support_nested_command_handle_import(monkeypatch) -> None:
49+
_install_e2b_stubs(monkeypatch)
50+
51+
command_handle = importlib.import_module("e2b.sandbox.commands.command_handle")
52+
53+
assert sys.modules["e2b"].__path__ == []
54+
assert sys.modules["e2b.sandbox"].__path__ == []
55+
assert sys.modules["e2b.sandbox.commands"].__path__ == []
56+
assert command_handle.CommandExitException.__name__ == "CommandExitException"
57+
58+
59+
def _load_run_validation(monkeypatch):
60+
_install_e2b_stubs(monkeypatch)
61+
monkeypatch.syspath_prepend(str(E2B_SCRIPT_DIR))
62+
sys.modules.pop("run_validation", None)
63+
return importlib.import_module("run_validation")
64+
65+
66+
def _patch_successful_run(monkeypatch, run_validation):
67+
phase = run_validation.PhaseSpec(
68+
id="smoke",
69+
timeout=1,
70+
parallel=1,
71+
tasks=[run_validation.TaskSpec(id="noop", cmd="true")],
72+
)
73+
output_dirs: list[Path] = []
74+
75+
def fake_execute_phase(**kwargs):
76+
output_dirs.append(kwargs["output_dir"])
77+
return {
78+
"phase": kwargs["phase"].id,
79+
"parallel": 1,
80+
"timeout": 1,
81+
"template": "default",
82+
"wall_seconds": 0.0,
83+
"passed": 1,
84+
"failed": 0,
85+
"sandboxes": [],
86+
}
87+
88+
monkeypatch.setattr(
89+
run_validation, "load_matrix", lambda _path: SimpleNamespace(phases=[phase])
90+
)
91+
monkeypatch.setattr(run_validation, "load_secrets", lambda _path: {})
92+
monkeypatch.setattr(run_validation, "execute_phase", fake_execute_phase)
93+
return output_dirs
94+
95+
96+
def _run_main(monkeypatch, run_validation, matrix_path: Path, output_dir: Path, *extra: str) -> int:
97+
monkeypatch.setattr(
98+
sys,
99+
"argv",
100+
[
101+
"run_validation.py",
102+
"--project",
103+
"autosearch-test",
104+
"--matrix",
105+
str(matrix_path),
106+
"--secrets",
107+
str(matrix_path.parent / "secrets.env"),
108+
"--output",
109+
str(output_dir),
110+
"--parallel",
111+
"1",
112+
*extra,
113+
],
114+
)
115+
return run_validation.main()
116+
117+
118+
def _matrix_path(tmp_path: Path) -> Path:
119+
matrix_path = tmp_path / "matrix.yaml"
120+
matrix_path.write_text("phases: {}\n", encoding="utf-8")
121+
return matrix_path
122+
123+
124+
def test_default_does_not_delete_existing(tmp_path, monkeypatch) -> None:
125+
run_validation = _load_run_validation(monkeypatch)
126+
output_dir = tmp_path / "validation-output"
127+
output_dir.mkdir()
128+
marker = output_dir / "marker.txt"
129+
marker.write_text("keep me", encoding="utf-8")
130+
matrix_path = _matrix_path(tmp_path)
131+
output_dirs = _patch_successful_run(monkeypatch, run_validation)
132+
133+
assert _run_main(monkeypatch, run_validation, matrix_path, output_dir) == 0
134+
135+
assert marker.read_text(encoding="utf-8") == "keep me"
136+
assert len(output_dirs) == 1
137+
assert output_dirs[0].parent == output_dir
138+
assert output_dirs[0].name.startswith("run-")
139+
140+
141+
def test_clean_output_flag_required_for_delete(tmp_path, monkeypatch) -> None:
142+
run_validation = _load_run_validation(monkeypatch)
143+
reports_root = tmp_path / "reports"
144+
output_dir = reports_root / "validation-output"
145+
output_dir.mkdir(parents=True)
146+
marker = output_dir / "marker.txt"
147+
marker.write_text("keep me", encoding="utf-8")
148+
matrix_path = _matrix_path(tmp_path)
149+
output_dirs = _patch_successful_run(monkeypatch, run_validation)
150+
monkeypatch.setattr(run_validation, "get_reports_root", lambda: reports_root, raising=False)
151+
152+
assert _run_main(monkeypatch, run_validation, matrix_path, output_dir) == 0
153+
assert marker.read_text(encoding="utf-8") == "keep me"
154+
155+
marker.write_text("delete me", encoding="utf-8")
156+
assert _run_main(monkeypatch, run_validation, matrix_path, output_dir, "--clean-output") == 0
157+
158+
assert not marker.exists()
159+
assert output_dir.exists()
160+
assert len(output_dirs) == 2
161+
assert output_dirs[-1].parent == output_dir
162+
assert output_dirs[-1].name.startswith("run-")
163+
164+
165+
def test_output_outside_reports_root_rejected(tmp_path, monkeypatch, capsys) -> None:
166+
run_validation = _load_run_validation(monkeypatch)
167+
reports_root = tmp_path / "reports"
168+
reports_root.mkdir()
169+
output_dir = tmp_path / "outside"
170+
output_dir.mkdir()
171+
marker = output_dir / "marker.txt"
172+
marker.write_text("keep me", encoding="utf-8")
173+
matrix_path = _matrix_path(tmp_path)
174+
output_dirs = _patch_successful_run(monkeypatch, run_validation)
175+
monkeypatch.setattr(run_validation, "get_reports_root", lambda: reports_root, raising=False)
176+
177+
assert _run_main(monkeypatch, run_validation, matrix_path, output_dir, "--clean-output") == 2
178+
179+
assert marker.read_text(encoding="utf-8") == "keep me"
180+
assert output_dirs == []
181+
assert "outside repo reports" in capsys.readouterr().err
182+
183+
184+
def test_symlink_escape_rejected(tmp_path, monkeypatch, capsys) -> None:
185+
run_validation = _load_run_validation(monkeypatch)
186+
reports_root = tmp_path / "reports"
187+
reports_root.mkdir()
188+
outside = tmp_path / "outside"
189+
outside.mkdir()
190+
marker = outside / "marker.txt"
191+
marker.write_text("keep me", encoding="utf-8")
192+
output_link = reports_root / "escape"
193+
output_link.symlink_to(outside, target_is_directory=True)
194+
matrix_path = _matrix_path(tmp_path)
195+
output_dirs = _patch_successful_run(monkeypatch, run_validation)
196+
monkeypatch.setattr(run_validation, "get_reports_root", lambda: reports_root, raising=False)
197+
198+
assert _run_main(monkeypatch, run_validation, matrix_path, output_link, "--clean-output") == 2
199+
200+
assert marker.read_text(encoding="utf-8") == "keep me"
201+
assert output_dirs == []
202+
assert "outside repo reports" in capsys.readouterr().err
203+
204+
205+
def test_reports_root_symlink_resolved_consistently(tmp_path, monkeypatch) -> None:
206+
run_validation = _load_run_validation(monkeypatch)
207+
real_reports_root = tmp_path / "real-reports"
208+
real_reports_root.mkdir()
209+
reports_root_link = tmp_path / "reports-link"
210+
reports_root_link.symlink_to(real_reports_root, target_is_directory=True)
211+
output_dir = reports_root_link / "validation-output"
212+
output_dir.mkdir()
213+
marker = output_dir / "marker.txt"
214+
marker.write_text("delete me", encoding="utf-8")
215+
matrix_path = _matrix_path(tmp_path)
216+
output_dirs = _patch_successful_run(monkeypatch, run_validation)
217+
monkeypatch.setattr(
218+
run_validation,
219+
"get_reports_root",
220+
lambda: reports_root_link,
221+
raising=False,
222+
)
223+
224+
assert _run_main(monkeypatch, run_validation, matrix_path, output_dir, "--clean-output") == 0
225+
226+
assert not marker.exists()
227+
assert len(output_dirs) == 1
228+
assert output_dirs[0].resolve().is_relative_to(real_reports_root.resolve())

0 commit comments

Comments
 (0)