python-yama 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_yama-0.1.0.dist-info/METADATA +98 -0
- python_yama-0.1.0.dist-info/RECORD +27 -0
- python_yama-0.1.0.dist-info/WHEEL +4 -0
- python_yama-0.1.0.dist-info/entry_points.txt +2 -0
- python_yama-0.1.0.dist-info/licenses/LICENSE +21 -0
- yama/__init__.py +31 -0
- yama/__main__.py +3 -0
- yama/assertions.py +577 -0
- yama/cli.py +318 -0
- yama/llm.py +365 -0
- yama/loaders/__init__.py +6 -0
- yama/loaders/case.py +378 -0
- yama/loaders/common.py +29 -0
- yama/loaders/tool_loader.py +173 -0
- yama/mocks.py +383 -0
- yama/models.py +159 -0
- yama/report.py +854 -0
- yama/runner.py +531 -0
- yama/tools/__init__.py +0 -0
- yama/tools/base.py +19 -0
- yama/tools/bash/__init__.py +0 -0
- yama/tools/bash/_cli_shim.py +71 -0
- yama/tools/bash/bash.py +285 -0
- yama/tools/bash/fs.py +530 -0
- yama/tools/skill/__init__.py +0 -0
- yama/tools/skill/skill.py +57 -0
- yama/workspace.py +115 -0
yama/tools/bash/fs.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
from typing import Any, Mapping
|
|
13
|
+
|
|
14
|
+
from ...mocks import MockConfigurationError, MockInvocationError, ToolMockContext, ToolMockError
|
|
15
|
+
from ...models import sanitize_case_key
|
|
16
|
+
|
|
17
|
+
_FS_FIELDS = {"cwd", "network", "backend", "files", "bin", "env", "limits"}
|
|
18
|
+
_CONTENT_FIELDS = {"content", "file", "mode"}
|
|
19
|
+
_LIMIT_FIELDS = {"cpu_seconds", "memory_mb", "max_processes", "timeout_seconds"}
|
|
20
|
+
_BACKENDS = {"auto", "macos-sandbox-exec", "none"}
|
|
21
|
+
_ENV_ALLOWLIST = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "USER", "SHELL")
|
|
22
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
23
|
+
|
|
24
|
+
# Live persistent-shell sessions, keyed by id(context.state) -- the same run's
|
|
25
|
+
# ToolMockContext.state dict is reused (mutated in place, never replaced) for
|
|
26
|
+
# the whole run, so its identity is a stable per-run key. The session itself
|
|
27
|
+
# holds a real subprocess handle, which cannot go into context.state: that
|
|
28
|
+
# dict must stay JSON serializable (mocks.py clones it after every call).
|
|
29
|
+
_SESSIONS: dict[int, "ShellSession"] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_backend(requested: str) -> str:
|
|
33
|
+
if requested not in _BACKENDS:
|
|
34
|
+
raise MockConfigurationError(
|
|
35
|
+
f"mocks.fs.backend must be one of {sorted(_BACKENDS)}: {requested!r}"
|
|
36
|
+
)
|
|
37
|
+
if requested == "macos-sandbox-exec" and sys.platform != "darwin":
|
|
38
|
+
raise MockConfigurationError(
|
|
39
|
+
"mocks.fs.backend: macos-sandbox-exec requires macOS"
|
|
40
|
+
)
|
|
41
|
+
if requested == "auto":
|
|
42
|
+
if sys.platform == "darwin":
|
|
43
|
+
return "macos-sandbox-exec"
|
|
44
|
+
raise MockConfigurationError(
|
|
45
|
+
"mocks.fs has no sandboxing backend on this platform; set "
|
|
46
|
+
"mocks.fs.backend: none to run without OS-level isolation"
|
|
47
|
+
)
|
|
48
|
+
return requested
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _safe_relpath(label: str, value: Any) -> str:
|
|
52
|
+
if not isinstance(value, str) or not value.strip():
|
|
53
|
+
raise MockConfigurationError(f"{label} path must be a non-empty string")
|
|
54
|
+
pure = PurePosixPath(value)
|
|
55
|
+
if pure.is_absolute():
|
|
56
|
+
raise MockConfigurationError(f"{label} path must be relative: {value}")
|
|
57
|
+
if any(part == ".." for part in pure.parts):
|
|
58
|
+
raise MockConfigurationError(f"{label} path cannot contain '..': {value}")
|
|
59
|
+
return pure.as_posix()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_content(
|
|
63
|
+
label: str,
|
|
64
|
+
value: Any,
|
|
65
|
+
*,
|
|
66
|
+
base_dir: Path | None,
|
|
67
|
+
root: Path | None,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
if isinstance(value, str):
|
|
70
|
+
return {"content": value, "mode": None}
|
|
71
|
+
if not isinstance(value, dict):
|
|
72
|
+
raise MockConfigurationError(f"{label} must be a string or an object")
|
|
73
|
+
unknown = set(value) - _CONTENT_FIELDS
|
|
74
|
+
if unknown:
|
|
75
|
+
raise MockConfigurationError(f"unknown {label} fields: {sorted(unknown)}")
|
|
76
|
+
if ("content" in value) == ("file" in value):
|
|
77
|
+
raise MockConfigurationError(f"{label} must set exactly one of content or file")
|
|
78
|
+
mode = value.get("mode")
|
|
79
|
+
if mode is not None and not isinstance(mode, str):
|
|
80
|
+
raise MockConfigurationError(f"{label} mode must be a string")
|
|
81
|
+
if "content" in value:
|
|
82
|
+
content = value["content"]
|
|
83
|
+
if not isinstance(content, str):
|
|
84
|
+
raise MockConfigurationError(f"{label} content must be a string")
|
|
85
|
+
return {"content": content, "mode": mode}
|
|
86
|
+
reference = value["file"]
|
|
87
|
+
if not isinstance(reference, str) or not reference.strip():
|
|
88
|
+
raise MockConfigurationError(f"{label} file must be a relative path")
|
|
89
|
+
if base_dir is None:
|
|
90
|
+
raise MockConfigurationError(f"{label} file references require an evals directory")
|
|
91
|
+
path = (base_dir / reference).resolve()
|
|
92
|
+
boundary = Path(root or base_dir).resolve()
|
|
93
|
+
if not path.is_relative_to(boundary):
|
|
94
|
+
raise MockConfigurationError(f"{label} file must be inside the plugin: {path}")
|
|
95
|
+
if not path.is_file():
|
|
96
|
+
raise MockConfigurationError(f"{label} file does not exist: {path}")
|
|
97
|
+
try:
|
|
98
|
+
content = path.read_text(encoding="utf-8")
|
|
99
|
+
except OSError as exc:
|
|
100
|
+
raise MockConfigurationError(f"cannot read {label} file {path}: {exc}") from exc
|
|
101
|
+
return {"content": content, "mode": mode}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def normalize_fs_config(
|
|
105
|
+
fs: Any,
|
|
106
|
+
*,
|
|
107
|
+
base_dir: Path | None = None,
|
|
108
|
+
root: Path | None = None,
|
|
109
|
+
) -> dict[str, Any]:
|
|
110
|
+
if not isinstance(fs, dict):
|
|
111
|
+
raise MockConfigurationError("mocks.fs must be an object")
|
|
112
|
+
unknown = set(fs) - _FS_FIELDS
|
|
113
|
+
if unknown:
|
|
114
|
+
raise MockConfigurationError(f"unknown mocks.fs fields: {sorted(unknown)}")
|
|
115
|
+
|
|
116
|
+
cwd_raw = fs.get("cwd", "")
|
|
117
|
+
cwd = _safe_relpath("mocks.fs.cwd", cwd_raw) if cwd_raw else ""
|
|
118
|
+
|
|
119
|
+
network = fs.get("network", False)
|
|
120
|
+
if not isinstance(network, bool):
|
|
121
|
+
raise MockConfigurationError("mocks.fs.network must be a boolean")
|
|
122
|
+
|
|
123
|
+
backend = _resolve_backend(fs.get("backend", "auto"))
|
|
124
|
+
|
|
125
|
+
files_raw = fs.get("files", {})
|
|
126
|
+
if not isinstance(files_raw, dict):
|
|
127
|
+
raise MockConfigurationError("mocks.fs.files must be an object")
|
|
128
|
+
files: dict[str, dict[str, Any]] = {}
|
|
129
|
+
for key, value in files_raw.items():
|
|
130
|
+
relpath = _safe_relpath("mocks.fs.files", key)
|
|
131
|
+
files[relpath] = _resolve_content(
|
|
132
|
+
f"mocks.fs.files.{key}", value, base_dir=base_dir, root=root
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
bin_raw = fs.get("bin", {})
|
|
136
|
+
if not isinstance(bin_raw, dict):
|
|
137
|
+
raise MockConfigurationError("mocks.fs.bin must be an object")
|
|
138
|
+
bin_files: dict[str, dict[str, Any]] = {}
|
|
139
|
+
for key, value in bin_raw.items():
|
|
140
|
+
if not isinstance(key, str) or not key.strip() or "/" in key:
|
|
141
|
+
raise MockConfigurationError(
|
|
142
|
+
f"mocks.fs.bin command names must be single path segments: {key!r}"
|
|
143
|
+
)
|
|
144
|
+
bin_files[key] = _resolve_content(
|
|
145
|
+
f"mocks.fs.bin.{key}", value, base_dir=base_dir, root=root
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
env_raw = fs.get("env", {})
|
|
149
|
+
if not isinstance(env_raw, dict) or not all(
|
|
150
|
+
isinstance(k, str) and isinstance(v, str) for k, v in env_raw.items()
|
|
151
|
+
):
|
|
152
|
+
raise MockConfigurationError("mocks.fs.env must be an object of string values")
|
|
153
|
+
|
|
154
|
+
limits_raw = fs.get("limits", {})
|
|
155
|
+
if not isinstance(limits_raw, dict):
|
|
156
|
+
raise MockConfigurationError("mocks.fs.limits must be an object")
|
|
157
|
+
unknown_limits = set(limits_raw) - _LIMIT_FIELDS
|
|
158
|
+
if unknown_limits:
|
|
159
|
+
raise MockConfigurationError(
|
|
160
|
+
f"unknown mocks.fs.limits fields: {sorted(unknown_limits)}"
|
|
161
|
+
)
|
|
162
|
+
limits: dict[str, float] = {}
|
|
163
|
+
for field_name in _LIMIT_FIELDS:
|
|
164
|
+
if field_name not in limits_raw:
|
|
165
|
+
continue
|
|
166
|
+
value = limits_raw[field_name]
|
|
167
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
|
168
|
+
raise MockConfigurationError(
|
|
169
|
+
f"mocks.fs.limits.{field_name} must be a positive number"
|
|
170
|
+
)
|
|
171
|
+
limits[field_name] = float(value)
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
"cwd": cwd,
|
|
175
|
+
"network": network,
|
|
176
|
+
"backend": backend,
|
|
177
|
+
"files": files,
|
|
178
|
+
"bin": bin_files,
|
|
179
|
+
"env": dict(env_raw),
|
|
180
|
+
"limits": limits,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def compile_cli_config(cli: Mapping[str, Any]) -> dict[str, Any]:
|
|
185
|
+
"""Compile a normalized mocks.cli tree into a synthetic fs_config.
|
|
186
|
+
|
|
187
|
+
Each mocked top-level command becomes a real, PATH-shadowed shell script
|
|
188
|
+
(materialized the same way as mocks.fs.bin) that delegates matching to
|
|
189
|
+
yama.tools.bash._cli_shim, which reuses the exact same rule-tree walk and _matches()
|
|
190
|
+
semantics the old in-process interpreter used -- so behavior (including
|
|
191
|
+
regex fidelity) is unchanged even though execution now goes through a
|
|
192
|
+
real shell instead of a hand-rolled tokenizer.
|
|
193
|
+
"""
|
|
194
|
+
files: dict[str, dict[str, Any]] = {}
|
|
195
|
+
bin_files: dict[str, dict[str, Any]] = {}
|
|
196
|
+
for name, node in cli.items():
|
|
197
|
+
if name in {"passthrough", "passthrough_timeout_seconds"}:
|
|
198
|
+
continue
|
|
199
|
+
rules_relpath = f".mockbin-rules/{name}.json"
|
|
200
|
+
files[rules_relpath] = {
|
|
201
|
+
"content": json.dumps({"name": name, "node": node}, ensure_ascii=False),
|
|
202
|
+
"mode": None,
|
|
203
|
+
}
|
|
204
|
+
bin_files[name] = {
|
|
205
|
+
"content": (
|
|
206
|
+
"#!/bin/sh\n"
|
|
207
|
+
f'exec "$YAMA_PYTHON" -m yama.tools.bash._cli_shim '
|
|
208
|
+
f'"$YAMA_WORKSPACE/{rules_relpath}" "$@"\n'
|
|
209
|
+
),
|
|
210
|
+
"mode": None,
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
"cwd": "",
|
|
214
|
+
"network": False,
|
|
215
|
+
"backend": "none",
|
|
216
|
+
"files": files,
|
|
217
|
+
"bin": bin_files,
|
|
218
|
+
"env": {},
|
|
219
|
+
"limits": {},
|
|
220
|
+
"include_system_path": bool(cli.get("passthrough", False)),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def materialize_workspace(workspace_dir: Path, fs_config: Mapping[str, Any]) -> None:
|
|
225
|
+
workspace_dir.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
for relpath, entry in fs_config["files"].items():
|
|
227
|
+
target = workspace_dir / relpath
|
|
228
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
target.write_text(entry["content"], encoding="utf-8")
|
|
230
|
+
if entry.get("mode") is not None:
|
|
231
|
+
target.chmod(int(entry["mode"], 8))
|
|
232
|
+
bin_files = fs_config["bin"]
|
|
233
|
+
if bin_files:
|
|
234
|
+
bin_dir = workspace_dir / ".mockbin"
|
|
235
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
for name, entry in bin_files.items():
|
|
237
|
+
target = bin_dir / name
|
|
238
|
+
target.write_text(entry["content"], encoding="utf-8")
|
|
239
|
+
target.chmod(0o755)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def build_macos_profile(workspace_dir: Path, *, network: bool) -> str:
|
|
243
|
+
home = Path.home()
|
|
244
|
+
sensitive = (".ssh", ".aws", ".config/gcloud", ".gnupg", "Library/Keychains")
|
|
245
|
+
deny_reads = "\n".join(
|
|
246
|
+
f'(deny file-read* (subpath "{home / path}"))' for path in sensitive
|
|
247
|
+
)
|
|
248
|
+
network_rule = "" if network else '(deny network*)\n'
|
|
249
|
+
return f"""(version 1)
|
|
250
|
+
(deny default)
|
|
251
|
+
|
|
252
|
+
(allow process-fork)
|
|
253
|
+
(allow process-exec)
|
|
254
|
+
(allow signal (target self))
|
|
255
|
+
(allow sysctl-read)
|
|
256
|
+
(allow mach-lookup)
|
|
257
|
+
(allow iokit-open)
|
|
258
|
+
|
|
259
|
+
(allow file-read*)
|
|
260
|
+
(allow file-write* (subpath "{workspace_dir}"))
|
|
261
|
+
(allow file-write-data (literal "/dev/null") (literal "/dev/tty") (literal "/dev/dtracehelper"))
|
|
262
|
+
|
|
263
|
+
{deny_reads}
|
|
264
|
+
|
|
265
|
+
{network_rule}"""
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _make_preexec(limits: Mapping[str, float]):
|
|
269
|
+
def _apply() -> None:
|
|
270
|
+
os.setsid()
|
|
271
|
+
try:
|
|
272
|
+
import resource
|
|
273
|
+
|
|
274
|
+
if limits.get("cpu_seconds"):
|
|
275
|
+
value = int(limits["cpu_seconds"])
|
|
276
|
+
resource.setrlimit(resource.RLIMIT_CPU, (value, value))
|
|
277
|
+
if limits.get("memory_mb"):
|
|
278
|
+
value = int(limits["memory_mb"]) * 1024 * 1024
|
|
279
|
+
resource.setrlimit(resource.RLIMIT_AS, (value, value))
|
|
280
|
+
if limits.get("max_processes"):
|
|
281
|
+
value = int(limits["max_processes"])
|
|
282
|
+
resource.setrlimit(resource.RLIMIT_NPROC, (value, value))
|
|
283
|
+
except (ValueError, OSError):
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
return _apply
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _build_env(workspace_dir: Path, fs_config: Mapping[str, Any]) -> dict[str, str]:
|
|
290
|
+
real_path = os.environ.get("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
|
291
|
+
bin_dir = workspace_dir / ".mockbin"
|
|
292
|
+
env = {key: os.environ[key] for key in _ENV_ALLOWLIST if key in os.environ}
|
|
293
|
+
include_system_path = fs_config.get("include_system_path", True)
|
|
294
|
+
env["PATH"] = f"{bin_dir}:{real_path}" if include_system_path else str(bin_dir)
|
|
295
|
+
env.setdefault("HOME", os.environ.get("HOME", str(workspace_dir)))
|
|
296
|
+
# Exposed so generated .mockbin scripts (mocks.fs.bin as well as compiled
|
|
297
|
+
# mocks.cli commands) can locate the interpreter running yama, the
|
|
298
|
+
# workspace root, and -- for rule/node-level `passthrough: true` -- the
|
|
299
|
+
# real PATH to exec the underlying binary without looping back into their
|
|
300
|
+
# own shadow script.
|
|
301
|
+
env["YAMA_WORKSPACE"] = str(workspace_dir)
|
|
302
|
+
env["YAMA_PYTHON"] = sys.executable
|
|
303
|
+
env["YAMA_REAL_PATH"] = real_path
|
|
304
|
+
env.update(fs_config.get("env", {}))
|
|
305
|
+
return env
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def _read_until_marker(
|
|
309
|
+
stream: asyncio.StreamReader, marker: str
|
|
310
|
+
) -> tuple[str, int | None]:
|
|
311
|
+
"""Scan raw bytes for `<marker> <exit code>\\n`, not readline().
|
|
312
|
+
|
|
313
|
+
A command's own output does not always end with a newline (`printf -n`,
|
|
314
|
+
`echo -n`, ...); if it doesn't, our own marker printf lands glued onto
|
|
315
|
+
the same line and a readline()-based, "does this line start with the
|
|
316
|
+
marker" check would never match -- the read then blocks until timeout.
|
|
317
|
+
Scanning the accumulated buffer for the marker pattern anywhere in it
|
|
318
|
+
(not just at a line start) makes this correct regardless of what the
|
|
319
|
+
command's own output looks like.
|
|
320
|
+
"""
|
|
321
|
+
pattern = re.compile(re.escape(marker).encode("ascii") + rb" (-?\d+)\n")
|
|
322
|
+
buffer = b""
|
|
323
|
+
while True:
|
|
324
|
+
match = pattern.search(buffer)
|
|
325
|
+
if match is not None:
|
|
326
|
+
output = buffer[: match.start()]
|
|
327
|
+
try:
|
|
328
|
+
code = int(match.group(1))
|
|
329
|
+
except ValueError:
|
|
330
|
+
code = None
|
|
331
|
+
return output.decode("utf-8", errors="replace"), code
|
|
332
|
+
chunk = await stream.read(4096)
|
|
333
|
+
if not chunk:
|
|
334
|
+
return buffer.decode("utf-8", errors="replace"), None
|
|
335
|
+
buffer += chunk
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class ShellSession:
|
|
339
|
+
def __init__(self, workspace_dir: Path, fs_config: Mapping[str, Any]) -> None:
|
|
340
|
+
self.workspace_dir = workspace_dir
|
|
341
|
+
self.fs_config = fs_config
|
|
342
|
+
self.process: asyncio.subprocess.Process | None = None
|
|
343
|
+
self.closed = False
|
|
344
|
+
|
|
345
|
+
def _command_argv(self) -> tuple[list[str], str]:
|
|
346
|
+
# Resolved against the host's real PATH, not the (possibly
|
|
347
|
+
# restricted) env we build for the session below -- otherwise a
|
|
348
|
+
# mocks.cli session with no system PATH in its own env couldn't even
|
|
349
|
+
# find the `bash` binary to launch in the first place. Passed via
|
|
350
|
+
# `executable=` rather than as argv[0] itself, so bash's own
|
|
351
|
+
# self-reported name in messages like "bash: line 3: ..." stays the
|
|
352
|
+
# short, familiar "bash" instead of an absolute path.
|
|
353
|
+
bash_path = shutil.which("bash") or "/bin/bash"
|
|
354
|
+
backend = self.fs_config["backend"]
|
|
355
|
+
if backend == "macos-sandbox-exec":
|
|
356
|
+
sandbox_exec_path = shutil.which("sandbox-exec") or "/usr/bin/sandbox-exec"
|
|
357
|
+
profile_path = self.workspace_dir.parent / "sandbox.sb"
|
|
358
|
+
profile_path.write_text(
|
|
359
|
+
build_macos_profile(
|
|
360
|
+
self.workspace_dir, network=self.fs_config["network"]
|
|
361
|
+
),
|
|
362
|
+
encoding="utf-8",
|
|
363
|
+
)
|
|
364
|
+
return (
|
|
365
|
+
[
|
|
366
|
+
"sandbox-exec",
|
|
367
|
+
"-f",
|
|
368
|
+
str(profile_path),
|
|
369
|
+
bash_path,
|
|
370
|
+
"--noprofile",
|
|
371
|
+
"--norc",
|
|
372
|
+
],
|
|
373
|
+
sandbox_exec_path,
|
|
374
|
+
)
|
|
375
|
+
return (["bash", "--noprofile", "--norc"], bash_path)
|
|
376
|
+
|
|
377
|
+
async def start(self) -> None:
|
|
378
|
+
cwd = self.workspace_dir
|
|
379
|
+
if self.fs_config["cwd"]:
|
|
380
|
+
cwd = cwd / self.fs_config["cwd"]
|
|
381
|
+
cwd.mkdir(parents=True, exist_ok=True)
|
|
382
|
+
env = _build_env(self.workspace_dir, self.fs_config)
|
|
383
|
+
argv, executable = self._command_argv()
|
|
384
|
+
self.process = await asyncio.create_subprocess_exec(
|
|
385
|
+
*argv,
|
|
386
|
+
executable=executable,
|
|
387
|
+
cwd=str(cwd),
|
|
388
|
+
env=env,
|
|
389
|
+
stdin=asyncio.subprocess.PIPE,
|
|
390
|
+
stdout=asyncio.subprocess.PIPE,
|
|
391
|
+
stderr=asyncio.subprocess.PIPE,
|
|
392
|
+
preexec_fn=_make_preexec(self.fs_config.get("limits", {})),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
async def run(self, command: str, timeout: float) -> dict[str, Any]:
|
|
396
|
+
assert self.process is not None
|
|
397
|
+
marker = f"__yama_fs_{uuid.uuid4().hex}__"
|
|
398
|
+
payload = (
|
|
399
|
+
"{\n"
|
|
400
|
+
f"{command}\n"
|
|
401
|
+
"} < /dev/null\n"
|
|
402
|
+
"__yama_ec__=$?\n"
|
|
403
|
+
f"printf '%s %d\\n' '{marker}' \"$__yama_ec__\"\n"
|
|
404
|
+
f"printf '%s %d\\n' '{marker}' \"$__yama_ec__\" 1>&2\n"
|
|
405
|
+
)
|
|
406
|
+
try:
|
|
407
|
+
assert self.process.stdin is not None
|
|
408
|
+
self.process.stdin.write(payload.encode("utf-8"))
|
|
409
|
+
await self.process.stdin.drain()
|
|
410
|
+
assert self.process.stdout is not None
|
|
411
|
+
assert self.process.stderr is not None
|
|
412
|
+
(out, out_code), (err, err_code) = await asyncio.wait_for(
|
|
413
|
+
asyncio.gather(
|
|
414
|
+
_read_until_marker(self.process.stdout, marker),
|
|
415
|
+
_read_until_marker(self.process.stderr, marker),
|
|
416
|
+
),
|
|
417
|
+
timeout=timeout,
|
|
418
|
+
)
|
|
419
|
+
except asyncio.TimeoutError:
|
|
420
|
+
await self.stop()
|
|
421
|
+
raise ToolMockError(
|
|
422
|
+
"BASH_TIMEOUT", f"sandboxed command timed out after {timeout}s"
|
|
423
|
+
)
|
|
424
|
+
except (BrokenPipeError, ConnectionResetError) as exc:
|
|
425
|
+
await self.stop()
|
|
426
|
+
raise ToolMockError(
|
|
427
|
+
"BASH_SESSION_DIED", f"sandbox shell session died: {exc}"
|
|
428
|
+
)
|
|
429
|
+
if out_code is not None:
|
|
430
|
+
exit_code = out_code
|
|
431
|
+
elif err_code is not None:
|
|
432
|
+
exit_code = err_code
|
|
433
|
+
elif self.process.returncode is not None:
|
|
434
|
+
# The shell process itself exited before it got to print either
|
|
435
|
+
# marker -- e.g. the model's command was malformed enough that
|
|
436
|
+
# bash raised its own fatal parse error and quit. What it did
|
|
437
|
+
# manage to write (its own error message) is still real, useful
|
|
438
|
+
# output; fall back to the real process exit code instead of a
|
|
439
|
+
# made-up one.
|
|
440
|
+
exit_code = self.process.returncode
|
|
441
|
+
else:
|
|
442
|
+
exit_code = 1
|
|
443
|
+
if self.process.returncode is not None:
|
|
444
|
+
# The session is gone either way (see above, or a `exit`/`kill`
|
|
445
|
+
# inside the command itself) -- mark it closed so the next call
|
|
446
|
+
# spawns a fresh shell instead of writing to a dead process.
|
|
447
|
+
await self.stop()
|
|
448
|
+
return {"stdout": out, "stderr": err, "exit_code": exit_code}
|
|
449
|
+
|
|
450
|
+
async def stop(self) -> None:
|
|
451
|
+
if self.closed:
|
|
452
|
+
return
|
|
453
|
+
self.closed = True
|
|
454
|
+
process = self.process
|
|
455
|
+
if process is not None and process.returncode is None:
|
|
456
|
+
try:
|
|
457
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
458
|
+
except (ProcessLookupError, PermissionError):
|
|
459
|
+
pass
|
|
460
|
+
try:
|
|
461
|
+
await asyncio.wait_for(process.wait(), timeout=5)
|
|
462
|
+
except asyncio.TimeoutError:
|
|
463
|
+
pass
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def workspace_dir(context: ToolMockContext) -> Path:
|
|
467
|
+
"""Bash-sandbox workspace directory of the current run.
|
|
468
|
+
|
|
469
|
+
Public helper for python mock handlers: the path is a pure function of
|
|
470
|
+
plugin_root/case_key/run_index, so it can be computed before the run's
|
|
471
|
+
first `bash` call actually materializes the directory.
|
|
472
|
+
"""
|
|
473
|
+
return (
|
|
474
|
+
context.plugin_root
|
|
475
|
+
/ ".yama"
|
|
476
|
+
/ "fs-runs"
|
|
477
|
+
/ sanitize_case_key(context.case_key)
|
|
478
|
+
/ f"run-{context.run_index:03d}"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
async def _get_session(
|
|
483
|
+
context: ToolMockContext, fs_config: Mapping[str, Any]
|
|
484
|
+
) -> ShellSession:
|
|
485
|
+
session = _SESSIONS.get(id(context.state))
|
|
486
|
+
if session is None or session.closed:
|
|
487
|
+
workspace = workspace_dir(context)
|
|
488
|
+
if not workspace.exists():
|
|
489
|
+
materialize_workspace(workspace, fs_config)
|
|
490
|
+
session = ShellSession(workspace, fs_config)
|
|
491
|
+
await session.start()
|
|
492
|
+
_SESSIONS[id(context.state)] = session
|
|
493
|
+
context.state["__fs__"] = {
|
|
494
|
+
"workspace_dir": str(workspace),
|
|
495
|
+
"backend": fs_config["backend"],
|
|
496
|
+
}
|
|
497
|
+
return session
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
async def run_bash_fs(
|
|
501
|
+
arguments: Mapping[str, Any],
|
|
502
|
+
context: ToolMockContext | None,
|
|
503
|
+
fs_config: Mapping[str, Any],
|
|
504
|
+
) -> dict[str, Any]:
|
|
505
|
+
if context is None:
|
|
506
|
+
raise MockInvocationError("mocks.fs requires a ToolMockContext")
|
|
507
|
+
command = arguments["command"]
|
|
508
|
+
session = await _get_session(context, fs_config)
|
|
509
|
+
timeout = fs_config.get("limits", {}).get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
|
|
510
|
+
return await session.run(command, timeout=timeout)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
async def run_bash_cli(
|
|
514
|
+
arguments: Mapping[str, Any],
|
|
515
|
+
context: ToolMockContext | None,
|
|
516
|
+
cli_config: Mapping[str, Any],
|
|
517
|
+
) -> dict[str, Any]:
|
|
518
|
+
if context is None:
|
|
519
|
+
raise MockInvocationError("mocks.cli requires a ToolMockContext")
|
|
520
|
+
command = arguments["command"]
|
|
521
|
+
fs_config = compile_cli_config(cli_config)
|
|
522
|
+
session = await _get_session(context, fs_config)
|
|
523
|
+
timeout = cli_config.get("passthrough_timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
|
|
524
|
+
return await session.run(command, timeout=timeout)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
async def cleanup_session(state: dict[str, Any]) -> None:
|
|
528
|
+
session = _SESSIONS.pop(id(state), None)
|
|
529
|
+
if session is not None:
|
|
530
|
+
await session.stop()
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
from ..base import Tool
|
|
7
|
+
from ...mocks import ToolMockContext, ToolMockError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SkillTool(Tool):
|
|
11
|
+
def run(
|
|
12
|
+
self,
|
|
13
|
+
arguments: Mapping[str, Any],
|
|
14
|
+
context: ToolMockContext,
|
|
15
|
+
config: Mapping[str, Any],
|
|
16
|
+
) -> str:
|
|
17
|
+
name = arguments.get("name")
|
|
18
|
+
enabled = config.get("enabled", [])
|
|
19
|
+
if not isinstance(name, str) or name not in enabled:
|
|
20
|
+
raise ToolMockError(
|
|
21
|
+
"SKILL_NOT_ENABLED",
|
|
22
|
+
f"skill {name!r} is not enabled for this case",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
skills_dir = str(config.get("skills_dir", "skills"))
|
|
26
|
+
entry = str(config.get("skill_entry", "SKILL.md"))
|
|
27
|
+
skill_root = (context.plugin_root / skills_dir / name).resolve()
|
|
28
|
+
requested_file = arguments.get("file", entry)
|
|
29
|
+
if not isinstance(requested_file, str) or not requested_file.strip():
|
|
30
|
+
raise ToolMockError(
|
|
31
|
+
"INVALID_SKILL_FILE", "file must be a non-empty relative path"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
relative = Path(requested_file)
|
|
35
|
+
if relative.is_absolute():
|
|
36
|
+
raise ToolMockError(
|
|
37
|
+
"INVALID_SKILL_FILE", "file must be relative to the Skill directory"
|
|
38
|
+
)
|
|
39
|
+
target = (skill_root / relative).resolve()
|
|
40
|
+
if not target.is_relative_to(skill_root):
|
|
41
|
+
raise ToolMockError(
|
|
42
|
+
"INVALID_SKILL_FILE", "file escapes the selected Skill directory"
|
|
43
|
+
)
|
|
44
|
+
if not target.is_file():
|
|
45
|
+
raise ToolMockError(
|
|
46
|
+
"SKILL_FILE_NOT_FOUND", f"skill file does not exist: {requested_file}"
|
|
47
|
+
)
|
|
48
|
+
try:
|
|
49
|
+
return target.read_text(encoding="utf-8")
|
|
50
|
+
except UnicodeDecodeError as exc:
|
|
51
|
+
raise ToolMockError(
|
|
52
|
+
"SKILL_FILE_NOT_TEXT",
|
|
53
|
+
f"skill file is not UTF-8 text: {requested_file}",
|
|
54
|
+
) from exc
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
read_skill = SkillTool().run
|