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.
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