weakincentives 0.9.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.
Files changed (73) hide show
  1. weakincentives/__init__.py +67 -0
  2. weakincentives/adapters/__init__.py +37 -0
  3. weakincentives/adapters/_names.py +32 -0
  4. weakincentives/adapters/_provider_protocols.py +69 -0
  5. weakincentives/adapters/_tool_messages.py +80 -0
  6. weakincentives/adapters/core.py +102 -0
  7. weakincentives/adapters/litellm.py +254 -0
  8. weakincentives/adapters/openai.py +254 -0
  9. weakincentives/adapters/shared.py +1021 -0
  10. weakincentives/cli/__init__.py +23 -0
  11. weakincentives/cli/wink.py +58 -0
  12. weakincentives/dbc/__init__.py +412 -0
  13. weakincentives/deadlines.py +58 -0
  14. weakincentives/prompt/__init__.py +105 -0
  15. weakincentives/prompt/_generic_params_specializer.py +64 -0
  16. weakincentives/prompt/_normalization.py +48 -0
  17. weakincentives/prompt/_overrides_protocols.py +33 -0
  18. weakincentives/prompt/_types.py +34 -0
  19. weakincentives/prompt/chapter.py +146 -0
  20. weakincentives/prompt/composition.py +281 -0
  21. weakincentives/prompt/errors.py +57 -0
  22. weakincentives/prompt/markdown.py +108 -0
  23. weakincentives/prompt/overrides/__init__.py +59 -0
  24. weakincentives/prompt/overrides/_fs.py +164 -0
  25. weakincentives/prompt/overrides/inspection.py +141 -0
  26. weakincentives/prompt/overrides/local_store.py +275 -0
  27. weakincentives/prompt/overrides/validation.py +534 -0
  28. weakincentives/prompt/overrides/versioning.py +269 -0
  29. weakincentives/prompt/prompt.py +353 -0
  30. weakincentives/prompt/protocols.py +103 -0
  31. weakincentives/prompt/registry.py +375 -0
  32. weakincentives/prompt/rendering.py +288 -0
  33. weakincentives/prompt/response_format.py +60 -0
  34. weakincentives/prompt/section.py +166 -0
  35. weakincentives/prompt/structured_output.py +179 -0
  36. weakincentives/prompt/tool.py +397 -0
  37. weakincentives/prompt/tool_result.py +30 -0
  38. weakincentives/py.typed +0 -0
  39. weakincentives/runtime/__init__.py +82 -0
  40. weakincentives/runtime/events/__init__.py +126 -0
  41. weakincentives/runtime/events/_types.py +110 -0
  42. weakincentives/runtime/logging.py +284 -0
  43. weakincentives/runtime/session/__init__.py +46 -0
  44. weakincentives/runtime/session/_slice_types.py +24 -0
  45. weakincentives/runtime/session/_types.py +55 -0
  46. weakincentives/runtime/session/dataclasses.py +29 -0
  47. weakincentives/runtime/session/protocols.py +34 -0
  48. weakincentives/runtime/session/reducer_context.py +40 -0
  49. weakincentives/runtime/session/reducers.py +82 -0
  50. weakincentives/runtime/session/selectors.py +56 -0
  51. weakincentives/runtime/session/session.py +387 -0
  52. weakincentives/runtime/session/snapshots.py +310 -0
  53. weakincentives/serde/__init__.py +19 -0
  54. weakincentives/serde/_utils.py +240 -0
  55. weakincentives/serde/dataclass_serde.py +55 -0
  56. weakincentives/serde/dump.py +189 -0
  57. weakincentives/serde/parse.py +417 -0
  58. weakincentives/serde/schema.py +260 -0
  59. weakincentives/tools/__init__.py +154 -0
  60. weakincentives/tools/_context.py +38 -0
  61. weakincentives/tools/asteval.py +853 -0
  62. weakincentives/tools/errors.py +26 -0
  63. weakincentives/tools/planning.py +831 -0
  64. weakincentives/tools/podman.py +1655 -0
  65. weakincentives/tools/subagents.py +346 -0
  66. weakincentives/tools/vfs.py +1390 -0
  67. weakincentives/types/__init__.py +35 -0
  68. weakincentives/types/json.py +45 -0
  69. weakincentives-0.9.0.dist-info/METADATA +775 -0
  70. weakincentives-0.9.0.dist-info/RECORD +73 -0
  71. weakincentives-0.9.0.dist-info/WHEEL +4 -0
  72. weakincentives-0.9.0.dist-info/entry_points.txt +2 -0
  73. weakincentives-0.9.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,1655 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS,
9
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ # See the License for the specific language governing permissions and
11
+ # limitations under the License.
12
+
13
+ """Podman-backed tool surface exposing the ``shell_execute`` command."""
14
+
15
+ from __future__ import annotations
16
+
17
+ import fnmatch
18
+ import json
19
+ import os
20
+ import posixpath
21
+ import re
22
+ import shutil
23
+ import subprocess # nosec: B404
24
+ import tempfile
25
+ import threading
26
+ import time
27
+ import weakref
28
+ from collections.abc import Callable, Iterator, Mapping, Sequence
29
+ from contextlib import suppress
30
+ from dataclasses import dataclass, field, replace
31
+ from datetime import UTC, datetime
32
+ from pathlib import Path
33
+ from typing import Any, Final, Protocol, cast, runtime_checkable
34
+
35
+ from ..prompt.markdown import MarkdownSection
36
+ from ..prompt.tool import Tool, ToolContext, ToolResult
37
+ from ..runtime.logging import StructuredLogger, get_logger
38
+ from ..runtime.session import Session, replace_latest, select_latest
39
+ from . import vfs as vfs_module
40
+ from ._context import ensure_context_uses_session
41
+ from .asteval import (
42
+ EvalParams,
43
+ EvalResult,
44
+ make_eval_result_reducer,
45
+ )
46
+ from .errors import ToolValidationError
47
+ from .vfs import (
48
+ DeleteEntry,
49
+ EditFileParams,
50
+ FileInfo,
51
+ GlobMatch,
52
+ GlobParams,
53
+ GrepMatch,
54
+ GrepParams,
55
+ HostMount,
56
+ ListDirectoryParams,
57
+ ReadFileParams,
58
+ ReadFileResult,
59
+ RemoveParams,
60
+ VfsPath,
61
+ VirtualFileSystem,
62
+ WriteFile,
63
+ WriteFileParams,
64
+ )
65
+
66
+ _LOGGER: StructuredLogger = get_logger(__name__, context={"component": "tools.podman"})
67
+
68
+ _DEFAULT_IMAGE: Final[str] = "python:3.12-bookworm"
69
+ _DEFAULT_WORKDIR: Final[str] = "/workspace"
70
+ _DEFAULT_USER: Final[str] = "65534:65534"
71
+ _TMPFS_SIZE: Final[int] = 268_435_456
72
+ _TMPFS_TARGET: Final[str] = tempfile.gettempdir()
73
+ _MAX_STDIO_CHARS: Final[int] = 32 * 1024
74
+ _MAX_COMMAND_LENGTH: Final[int] = 4_096
75
+ _MAX_ENV_LENGTH: Final[int] = 512
76
+ _MAX_ENV_VARS: Final[int] = 64
77
+ _MAX_TIMEOUT: Final[float] = 120.0
78
+ _MIN_TIMEOUT: Final[float] = 1.0
79
+ _DEFAULT_TIMEOUT: Final[float] = 30.0
80
+ _EVAL_TIMEOUT_SECONDS: Final[float] = 5.0
81
+ _MAX_PATH_DEPTH: Final[int] = 16
82
+ _MAX_PATH_SEGMENT: Final[int] = 80
83
+ _EVAL_MAX_STREAM_LENGTH: Final[int] = 4_096
84
+ _ASCII: Final[str] = "ascii"
85
+ _CAPTURE_DISABLED: Final[str] = "capture disabled"
86
+ _CACHE_ENV: Final[str] = "WEAKINCENTIVES_CACHE"
87
+ _PODMAN_BASE_URL_ENV: Final[str] = "PODMAN_BASE_URL"
88
+ _PODMAN_IDENTITY_ENV: Final[str] = "PODMAN_IDENTITY"
89
+ _PODMAN_CONNECTION_ENV: Final[str] = "PODMAN_CONNECTION"
90
+ _CPU_PERIOD: Final[int] = 100_000
91
+ _CPU_QUOTA: Final[int] = 100_000
92
+ _MEMORY_LIMIT: Final[str] = "1g"
93
+ _MISSING_DEPENDENCY_MESSAGE: Final[str] = (
94
+ "Install weakincentives[podman] to enable the Podman tool suite."
95
+ )
96
+ _MAX_MATCH_RESULTS: Final[int] = 2_000
97
+ _REMOVE_PATH_SCRIPT: Final[str] = """
98
+ import shutil
99
+ import sys
100
+ from pathlib import Path
101
+
102
+ target = Path(sys.argv[1])
103
+ if not target.exists():
104
+ raise SystemExit(3)
105
+ if target.is_symlink():
106
+ target.unlink()
107
+ elif target.is_dir():
108
+ shutil.rmtree(target)
109
+ else:
110
+ target.unlink()
111
+ """
112
+ _PODMAN_TEMPLATE: Final[str] = """\
113
+ Podman Workspace
114
+ ----------------
115
+ You have access to an isolated Linux container powered by Podman. The `ls`,
116
+ `read_file`, `write_file`, `glob`, `grep`, and `rm` tools mirror the virtual
117
+ filesystem interface but operate on `/workspace` inside the container. The
118
+ `evaluate_python` tool is a thin wrapper around `python3 -c` (≤5 seconds); read
119
+ and edit files directly from the workspace. `shell_execute` runs short commands
120
+ (≤120 seconds) in the shared environment. No network access or privileged
121
+ operations are available. Do not assume files outside `/workspace` exist."""
122
+
123
+
124
+ @runtime_checkable
125
+ class _PodmanClient(Protocol):
126
+ """Subset of :class:`podman.PodmanClient` used by the section."""
127
+
128
+ containers: Any
129
+ images: Any
130
+
131
+ def close(self) -> None: ...
132
+
133
+
134
+ type _ClientFactory = Callable[[], _PodmanClient]
135
+
136
+
137
+ @runtime_checkable
138
+ class _ExecRunner(Protocol):
139
+ def __call__(
140
+ self,
141
+ cmd: list[str],
142
+ *,
143
+ input: str | None = None, # noqa: A002 - matches subprocess API
144
+ text: bool | None = None,
145
+ capture_output: bool | None = None,
146
+ timeout: float | None = None,
147
+ ) -> subprocess.CompletedProcess[str]: ...
148
+
149
+
150
+ @dataclass(slots=True, frozen=True)
151
+ class _PodmanSectionParams:
152
+ image: str = _DEFAULT_IMAGE
153
+ workspace_root: str = _DEFAULT_WORKDIR
154
+
155
+
156
+ @dataclass(slots=True, frozen=True)
157
+ class PodmanShellParams:
158
+ """Parameter payload accepted by the ``shell_execute`` tool."""
159
+
160
+ command: tuple[str, ...]
161
+ cwd: str | None = None
162
+ env: Mapping[str, str] = field(default_factory=lambda: dict[str, str]())
163
+ stdin: str | None = None
164
+ timeout_seconds: float = _DEFAULT_TIMEOUT
165
+ capture_output: bool = True
166
+
167
+
168
+ @dataclass(slots=True, frozen=True)
169
+ class PodmanShellResult:
170
+ """Structured command summary returned by the ``shell_execute`` tool."""
171
+
172
+ command: tuple[str, ...]
173
+ cwd: str
174
+ exit_code: int
175
+ stdout: str
176
+ stderr: str
177
+ duration_ms: int
178
+ timed_out: bool
179
+
180
+
181
+ @dataclass(slots=True, frozen=True)
182
+ class PodmanWorkspace:
183
+ """Active Podman container backing the session."""
184
+
185
+ container_id: str
186
+ container_name: str
187
+ image: str
188
+ workdir: str
189
+ overlay_path: str
190
+ env: tuple[tuple[str, str], ...]
191
+ started_at: datetime
192
+ last_used_at: datetime
193
+
194
+
195
+ @dataclass(slots=True)
196
+ class _WorkspaceHandle:
197
+ descriptor: PodmanWorkspace
198
+ overlay_path: Path
199
+
200
+
201
+ @dataclass(slots=True, frozen=True)
202
+ class _PodmanConnectionInfo:
203
+ base_url: str | None
204
+ identity: str | None
205
+ connection_name: str | None
206
+
207
+
208
+ @dataclass(slots=True, frozen=True)
209
+ class _ResolvedHostMount:
210
+ source_label: str
211
+ resolved_host: Path
212
+ mount_path: VfsPath
213
+ include_glob: tuple[str, ...]
214
+ exclude_glob: tuple[str, ...]
215
+ max_bytes: int | None
216
+ follow_symlinks: bool
217
+ preview: vfs_module.HostMountPreview
218
+
219
+
220
+ def _default_cache_root() -> Path:
221
+ override = os.environ.get(_CACHE_ENV)
222
+ if override:
223
+ return Path(override).expanduser()
224
+ return Path.home() / ".cache" / "weakincentives" / "podman"
225
+
226
+
227
+ def _resolve_podman_connection(
228
+ *,
229
+ preferred_name: str | None = None,
230
+ ) -> _PodmanConnectionInfo | None:
231
+ env_base_url = os.environ.get(_PODMAN_BASE_URL_ENV)
232
+ env_identity = os.environ.get(_PODMAN_IDENTITY_ENV)
233
+ env_connection = os.environ.get(_PODMAN_CONNECTION_ENV)
234
+ if env_base_url or env_identity:
235
+ return _PodmanConnectionInfo(
236
+ base_url=env_base_url,
237
+ identity=env_identity,
238
+ connection_name=preferred_name or env_connection,
239
+ )
240
+ resolved_name = preferred_name or env_connection
241
+ return _connection_from_cli(resolved_name)
242
+
243
+
244
+ def _connection_from_cli(
245
+ connection_name: str | None,
246
+ ) -> _PodmanConnectionInfo | None:
247
+ try:
248
+ result = subprocess.run( # nosec B603 B607
249
+ ["podman", "system", "connection", "list", "--format", "json"],
250
+ capture_output=True,
251
+ text=True,
252
+ check=True,
253
+ )
254
+ except (OSError, subprocess.CalledProcessError):
255
+ return None
256
+ try:
257
+ connections = json.loads(result.stdout)
258
+ except json.JSONDecodeError:
259
+ return None
260
+ candidate: dict[str, Any] | None = None
261
+ if connection_name:
262
+ for entry in connections:
263
+ if entry.get("Name") == connection_name:
264
+ candidate = entry
265
+ break
266
+ else:
267
+ for entry in connections:
268
+ if entry.get("Default"):
269
+ candidate = entry
270
+ break
271
+ if candidate is None and connections:
272
+ candidate = connections[0]
273
+ if candidate is None:
274
+ return None
275
+ return _PodmanConnectionInfo(
276
+ base_url=candidate.get("URI"),
277
+ identity=candidate.get("Identity"),
278
+ connection_name=candidate.get("Name"),
279
+ )
280
+
281
+
282
+ def _resolve_podman_host_mounts(
283
+ mounts: Sequence[HostMount],
284
+ allowed_roots: Sequence[Path],
285
+ ) -> tuple[tuple[_ResolvedHostMount, ...], tuple[vfs_module.HostMountPreview, ...]]:
286
+ if not mounts:
287
+ return (), ()
288
+ resolved: list[_ResolvedHostMount] = []
289
+ previews: list[vfs_module.HostMountPreview] = []
290
+ for mount in mounts:
291
+ spec = _resolve_single_host_mount(mount, allowed_roots)
292
+ resolved.append(spec)
293
+ previews.append(spec.preview)
294
+ return tuple(resolved), tuple(previews)
295
+
296
+
297
+ def _resolve_single_host_mount(
298
+ mount: HostMount,
299
+ allowed_roots: Sequence[Path],
300
+ ) -> _ResolvedHostMount:
301
+ host_path = mount.host_path.strip()
302
+ if not host_path:
303
+ raise ToolValidationError("Host mount path must not be empty.")
304
+ vfs_module.ensure_ascii(host_path, "host path")
305
+ resolved_host = _resolve_host_path(host_path, allowed_roots)
306
+ include_glob = _normalize_mount_globs(mount.include_glob, "include_glob")
307
+ exclude_glob = _normalize_mount_globs(mount.exclude_glob, "exclude_glob")
308
+ mount_path = (
309
+ vfs_module.normalize_path(mount.mount_path)
310
+ if mount.mount_path is not None
311
+ else VfsPath(())
312
+ )
313
+ preview_entries = _preview_mount_entries(resolved_host)
314
+ preview = vfs_module.HostMountPreview(
315
+ host_path=host_path,
316
+ resolved_host=resolved_host,
317
+ mount_path=mount_path,
318
+ entries=preview_entries,
319
+ is_directory=resolved_host.is_dir(),
320
+ )
321
+ return _ResolvedHostMount(
322
+ source_label=host_path,
323
+ resolved_host=resolved_host,
324
+ mount_path=mount_path,
325
+ include_glob=include_glob,
326
+ exclude_glob=exclude_glob,
327
+ max_bytes=mount.max_bytes,
328
+ follow_symlinks=mount.follow_symlinks,
329
+ preview=preview,
330
+ )
331
+
332
+
333
+ def _resolve_host_path(host_path: str, allowed_roots: Sequence[Path]) -> Path:
334
+ if not allowed_roots:
335
+ raise ToolValidationError("No allowed host roots configured for mounts.")
336
+ for root in allowed_roots:
337
+ candidate = (root / host_path).expanduser().resolve()
338
+ try:
339
+ _ = candidate.relative_to(root)
340
+ except ValueError:
341
+ continue
342
+ if candidate.exists():
343
+ return candidate
344
+ raise ToolValidationError("Host path is outside the allowed roots or missing.")
345
+
346
+
347
+ def _normalize_mount_globs(patterns: Sequence[str], field: str) -> tuple[str, ...]:
348
+ normalized: list[str] = []
349
+ for pattern in patterns:
350
+ stripped = pattern.strip()
351
+ if not stripped:
352
+ continue
353
+ vfs_module.ensure_ascii(stripped, field)
354
+ normalized.append(stripped)
355
+ return tuple(normalized)
356
+
357
+
358
+ def _preview_mount_entries(root: Path) -> tuple[str, ...]:
359
+ if root.is_file():
360
+ return (root.name,)
361
+ try:
362
+ children = sorted(root.iterdir(), key=lambda path: path.name.lower())
363
+ except OSError as error:
364
+ raise ToolValidationError(f"Failed to inspect host mount {root}.") from error
365
+ labels: list[str] = []
366
+ for child in children:
367
+ suffix = "/" if child.is_dir() else ""
368
+ labels.append(f"{child.name}{suffix}")
369
+ return tuple(labels)
370
+
371
+
372
+ def _iter_host_mount_files(root: Path, follow_symlinks: bool) -> Iterator[Path]:
373
+ if root.is_file():
374
+ yield root
375
+ return
376
+ for dirpath, _dirnames, filenames in os.walk(root, followlinks=follow_symlinks):
377
+ current = Path(dirpath)
378
+ for name in filenames:
379
+ yield current / name
380
+
381
+
382
+ def _default_exec_runner(
383
+ cmd: list[str],
384
+ *,
385
+ input: str | None = None, # noqa: A002 - matches subprocess API
386
+ text: bool | None = None,
387
+ capture_output: bool | None = None,
388
+ timeout: float | None = None,
389
+ ) -> subprocess.CompletedProcess[str]:
390
+ completed = subprocess.run( # nosec: B603
391
+ cmd,
392
+ input=input,
393
+ text=True if text is None else text,
394
+ capture_output=True if capture_output is None else capture_output,
395
+ timeout=timeout,
396
+ )
397
+ return cast(subprocess.CompletedProcess[str], completed)
398
+
399
+
400
+ def _build_client_factory(
401
+ *,
402
+ base_url: str | None,
403
+ identity: str | None,
404
+ ) -> _ClientFactory:
405
+ def _factory() -> _PodmanClient:
406
+ try:
407
+ from podman import PodmanClient
408
+ except ModuleNotFoundError as error: # pragma: no cover - configuration guard
409
+ raise RuntimeError(_MISSING_DEPENDENCY_MESSAGE) from error
410
+
411
+ kwargs: dict[str, str] = {}
412
+ if base_url is not None:
413
+ kwargs["base_url"] = base_url
414
+ if identity is not None:
415
+ kwargs["identity"] = identity
416
+ return PodmanClient(**kwargs)
417
+
418
+ return _factory
419
+
420
+
421
+ def _ensure_ascii(value: str, *, field: str) -> str:
422
+ try:
423
+ _ = value.encode(_ASCII)
424
+ except UnicodeEncodeError as error:
425
+ raise ToolValidationError(f"{field} must be ASCII.") from error
426
+ return value
427
+
428
+
429
+ def _normalize_command(command: tuple[str, ...]) -> tuple[str, ...]:
430
+ if not command:
431
+ raise ToolValidationError("command must contain at least one entry.")
432
+ total_length = 0
433
+ normalized: list[str] = []
434
+ for index, entry in enumerate(command):
435
+ if not entry:
436
+ raise ToolValidationError(f"command[{index}] must not be empty.")
437
+ normalized_entry = _ensure_ascii(entry, field="command")
438
+ total_length += len(normalized_entry)
439
+ if total_length > _MAX_COMMAND_LENGTH:
440
+ raise ToolValidationError("command is too long (limit 4,096 characters).")
441
+ normalized.append(normalized_entry)
442
+ return tuple(normalized)
443
+
444
+
445
+ def _normalize_env(env: Mapping[str, str]) -> dict[str, str]:
446
+ if len(env) > _MAX_ENV_VARS:
447
+ raise ToolValidationError("env contains too many entries (max 64).")
448
+ normalized: dict[str, str] = {}
449
+ for key, value in env.items():
450
+ normalized_key = _ensure_ascii(key, field="env key").upper()
451
+ if not normalized_key:
452
+ raise ToolValidationError("env keys must not be empty.")
453
+ if len(normalized_key) > _MAX_PATH_SEGMENT:
454
+ raise ToolValidationError(
455
+ f"env key {normalized_key!r} is longer than {_MAX_PATH_SEGMENT} characters."
456
+ )
457
+ normalized_value = _ensure_ascii(value, field="env value")
458
+ if len(normalized_value) > _MAX_ENV_LENGTH:
459
+ raise ToolValidationError(
460
+ f"env value for {normalized_key!r} exceeds {_MAX_ENV_LENGTH} characters."
461
+ )
462
+ normalized[normalized_key] = normalized_value
463
+ return normalized
464
+
465
+
466
+ def _normalize_timeout(timeout_seconds: float) -> float:
467
+ if timeout_seconds != timeout_seconds: # NaN guard
468
+ raise ToolValidationError("timeout_seconds must be a real number.")
469
+ return max(_MIN_TIMEOUT, min(_MAX_TIMEOUT, timeout_seconds))
470
+
471
+
472
+ def _normalize_cwd(path: str | None) -> str:
473
+ if path is None or path == "":
474
+ return _DEFAULT_WORKDIR
475
+ stripped = path.strip()
476
+ if stripped.startswith("/"):
477
+ raise ToolValidationError("cwd must be relative to /workspace.")
478
+ parts = [segment for segment in stripped.split("/") if segment]
479
+ if len(parts) > _MAX_PATH_DEPTH:
480
+ raise ToolValidationError("cwd exceeds maximum depth of 16 segments.")
481
+ normalized_segments: list[str] = []
482
+ for segment in parts:
483
+ if segment in {".", ".."}:
484
+ raise ToolValidationError("cwd must not contain '.' or '..' segments.")
485
+ if len(segment) > _MAX_PATH_SEGMENT:
486
+ raise ToolValidationError(
487
+ f"cwd segment {segment!r} exceeds {_MAX_PATH_SEGMENT} characters."
488
+ )
489
+ normalized_segment = _ensure_ascii(segment, field="cwd")
490
+ normalized_segments.append(normalized_segment)
491
+ if not normalized_segments:
492
+ return _DEFAULT_WORKDIR
493
+ return posixpath.join(_DEFAULT_WORKDIR, *normalized_segments)
494
+
495
+
496
+ def _truncate_stream(value: str) -> str:
497
+ if len(value) <= _MAX_STDIO_CHARS:
498
+ return value
499
+ truncated = value[: _MAX_STDIO_CHARS - len("[truncated]")]
500
+ return f"{truncated}[truncated]"
501
+
502
+
503
+ def _truncate_eval_stream(value: str) -> str:
504
+ if len(value) <= _EVAL_MAX_STREAM_LENGTH:
505
+ return value
506
+ suffix = "..."
507
+ keep = _EVAL_MAX_STREAM_LENGTH - len(suffix)
508
+ return f"{value[:keep]}{suffix}"
509
+
510
+
511
+ def _normalize_podman_eval_code(code: str) -> str:
512
+ for char in code:
513
+ code_point = ord(char)
514
+ if code_point < 32 and char not in {"\n", "\t"}:
515
+ raise ToolValidationError("Code contains unsupported control characters.")
516
+ return code
517
+
518
+
519
+ class PodmanSandboxSection(MarkdownSection[_PodmanSectionParams]):
520
+ """Prompt section exposing the Podman ``shell_execute`` tool."""
521
+
522
+ def __init__(
523
+ self,
524
+ *,
525
+ session: Session,
526
+ image: str = _DEFAULT_IMAGE,
527
+ mounts: Sequence[HostMount] = (),
528
+ allowed_host_roots: Sequence[os.PathLike[str] | str] = (),
529
+ base_url: str | None = None,
530
+ identity: str | os.PathLike[str] | None = None,
531
+ base_environment: Mapping[str, str] | None = None,
532
+ cache_dir: os.PathLike[str] | str | None = None,
533
+ client_factory: _ClientFactory | None = None,
534
+ clock: Callable[[], datetime] | None = None,
535
+ connection_name: str | None = None,
536
+ exec_runner: _ExecRunner | None = None,
537
+ accepts_overrides: bool = False,
538
+ ) -> None:
539
+ self._session = session
540
+ self._image = image
541
+ env_connection = os.environ.get(_PODMAN_CONNECTION_ENV)
542
+ preferred_connection = connection_name or env_connection
543
+ resolved_connection: _PodmanConnectionInfo | None = None
544
+ if base_url is None or identity is None:
545
+ resolved_connection = _resolve_podman_connection(
546
+ preferred_name=preferred_connection
547
+ )
548
+ if base_url is None and resolved_connection is not None:
549
+ base_url = resolved_connection.base_url
550
+ if identity is None and resolved_connection is not None:
551
+ identity = resolved_connection.identity
552
+ if connection_name is None:
553
+ if resolved_connection is not None:
554
+ connection_name = resolved_connection.connection_name
555
+ else:
556
+ connection_name = env_connection
557
+ if base_url is None:
558
+ message = (
559
+ "Podman connection could not be resolved. Configure `podman system connection` {}"
560
+ ).format("or set PODMAN_BASE_URL/PODMAN_IDENTITY.")
561
+ raise ToolValidationError(message)
562
+ identity_str = str(identity) if identity is not None else None
563
+ self._client_factory = client_factory or _build_client_factory(
564
+ base_url=base_url,
565
+ identity=identity_str,
566
+ )
567
+ self._base_env = tuple(
568
+ sorted((base_environment or {}).items(), key=lambda item: item[0])
569
+ )
570
+ self._overlay_root = (
571
+ Path(cache_dir).expanduser()
572
+ if cache_dir is not None
573
+ else _default_cache_root()
574
+ )
575
+ self._overlay_root.mkdir(parents=True, exist_ok=True)
576
+ allowed_roots = tuple(
577
+ vfs_module.normalize_host_root(path) for path in allowed_host_roots
578
+ )
579
+ (
580
+ self._resolved_mounts,
581
+ self._mount_previews,
582
+ ) = _resolve_podman_host_mounts(mounts, allowed_roots)
583
+ self._mount_snapshot = VirtualFileSystem()
584
+ self._clock = clock or (lambda: datetime.now(UTC))
585
+ self._workspace_handle: _WorkspaceHandle | None = None
586
+ self._lock = threading.RLock()
587
+ self._connection_name = connection_name
588
+ self._exec_runner: _ExecRunner = exec_runner or _default_exec_runner
589
+ self._finalizer = weakref.finalize(
590
+ self, PodmanSandboxSection._cleanup_from_finalizer, weakref.ref(self)
591
+ )
592
+
593
+ session.register_reducer(PodmanWorkspace, replace_latest)
594
+ self._initialize_vfs_state(session)
595
+ session.register_reducer(
596
+ EvalResult,
597
+ make_eval_result_reducer(),
598
+ slice_type=VirtualFileSystem,
599
+ )
600
+
601
+ self._vfs_suite = _PodmanVfsSuite(section=self)
602
+ self._shell_suite = _PodmanShellSuite(section=self)
603
+ self._eval_suite = _PodmanEvalSuite(section=self)
604
+ tools = (
605
+ Tool[ListDirectoryParams, tuple[FileInfo, ...]](
606
+ name="ls",
607
+ description="List directory entries under a relative path.",
608
+ handler=self._vfs_suite.list_directory,
609
+ accepts_overrides=accepts_overrides,
610
+ ),
611
+ Tool[ReadFileParams, ReadFileResult](
612
+ name="read_file",
613
+ description="Read UTF-8 file contents with pagination support.",
614
+ handler=self._vfs_suite.read_file,
615
+ accepts_overrides=accepts_overrides,
616
+ ),
617
+ Tool[WriteFileParams, WriteFile](
618
+ name="write_file",
619
+ description="Create a new UTF-8 text file.",
620
+ handler=self._vfs_suite.write_file,
621
+ accepts_overrides=accepts_overrides,
622
+ ),
623
+ Tool[EditFileParams, WriteFile](
624
+ name="edit_file",
625
+ description="Replace occurrences of a string within a file.",
626
+ handler=self._vfs_suite.edit_file,
627
+ accepts_overrides=accepts_overrides,
628
+ ),
629
+ Tool[GlobParams, tuple[GlobMatch, ...]](
630
+ name="glob",
631
+ description="Match files beneath a directory using shell patterns.",
632
+ handler=self._vfs_suite.glob,
633
+ accepts_overrides=accepts_overrides,
634
+ ),
635
+ Tool[GrepParams, tuple[GrepMatch, ...]](
636
+ name="grep",
637
+ description="Search files for a regular expression pattern.",
638
+ handler=self._vfs_suite.grep,
639
+ accepts_overrides=accepts_overrides,
640
+ ),
641
+ Tool[RemoveParams, DeleteEntry](
642
+ name="rm",
643
+ description="Remove files or directories recursively.",
644
+ handler=self._vfs_suite.remove,
645
+ accepts_overrides=accepts_overrides,
646
+ ),
647
+ Tool[PodmanShellParams, PodmanShellResult](
648
+ name="shell_execute",
649
+ description="Run a short command inside the Podman workspace.",
650
+ handler=self._shell_suite.run_shell,
651
+ accepts_overrides=accepts_overrides,
652
+ ),
653
+ Tool[EvalParams, EvalResult](
654
+ name="evaluate_python",
655
+ description=(
656
+ "Run a short Python script via `python3 -c` inside the Podman workspace. "
657
+ "Captures stdout/stderr and reports the exit code."
658
+ ),
659
+ handler=self._eval_suite.evaluate_python,
660
+ accepts_overrides=accepts_overrides,
661
+ ),
662
+ )
663
+ template = _PODMAN_TEMPLATE
664
+ mounts_block = vfs_module.render_host_mounts_block(self._mount_previews)
665
+ if mounts_block:
666
+ template = f"{_PODMAN_TEMPLATE}\n\n{mounts_block}"
667
+ super().__init__(
668
+ title="Podman Workspace",
669
+ key="podman.shell",
670
+ template=template,
671
+ default_params=_PodmanSectionParams(
672
+ image=image, workspace_root=_DEFAULT_WORKDIR
673
+ ),
674
+ tools=tools,
675
+ accepts_overrides=accepts_overrides,
676
+ )
677
+
678
+ @property
679
+ def session(self) -> Session:
680
+ return self._session
681
+
682
+ def _initialize_vfs_state(self, session: Session) -> None:
683
+ session.register_reducer(VirtualFileSystem, replace_latest)
684
+ session.seed_slice(VirtualFileSystem, (self._mount_snapshot,))
685
+ session.register_reducer(
686
+ WriteFile,
687
+ vfs_module.make_write_reducer(),
688
+ slice_type=VirtualFileSystem,
689
+ )
690
+ session.register_reducer(
691
+ DeleteEntry,
692
+ vfs_module.make_delete_reducer(),
693
+ slice_type=VirtualFileSystem,
694
+ )
695
+
696
+ def latest_snapshot(self) -> VirtualFileSystem:
697
+ snapshot = select_latest(self._session, VirtualFileSystem)
698
+ return snapshot or self._mount_snapshot
699
+
700
+ @staticmethod
701
+ def resolve_connection(
702
+ connection_name: str | None = None,
703
+ ) -> dict[str, str | None] | None:
704
+ resolved = _resolve_podman_connection(preferred_name=connection_name)
705
+ if resolved is None:
706
+ return None
707
+ return {
708
+ "base_url": resolved.base_url,
709
+ "identity": resolved.identity,
710
+ "connection_name": resolved.connection_name,
711
+ }
712
+
713
+ def _ensure_workspace(self) -> _WorkspaceHandle:
714
+ with self._lock:
715
+ if self._workspace_handle is not None:
716
+ return self._workspace_handle
717
+ handle = self._create_workspace()
718
+ self._workspace_handle = handle
719
+ self._session.seed_slice(PodmanWorkspace, (handle.descriptor,))
720
+ return handle
721
+
722
+ def _create_workspace(self) -> _WorkspaceHandle:
723
+ client = self._client_factory()
724
+ overlay = self._workspace_overlay_path()
725
+ overlay.mkdir(parents=True, exist_ok=True)
726
+ self._hydrate_overlay_mounts(overlay)
727
+ _LOGGER.info(
728
+ "Creating Podman workspace",
729
+ event="podman.workspace.create",
730
+ context={"overlay": str(overlay), "image": self._image},
731
+ )
732
+ _ = client.images.pull(self._image)
733
+ env = _normalize_env(dict(self._base_env))
734
+ name = f"wink-{self._session.session_id}"
735
+ container = client.containers.create(
736
+ image=self._image,
737
+ command=["sleep", "infinity"],
738
+ name=name,
739
+ workdir=_DEFAULT_WORKDIR,
740
+ user=_DEFAULT_USER,
741
+ mem_limit=_MEMORY_LIMIT,
742
+ memswap_limit=_MEMORY_LIMIT,
743
+ cpu_period=_CPU_PERIOD,
744
+ cpu_quota=_CPU_QUOTA,
745
+ environment=env,
746
+ mounts=[
747
+ {
748
+ "Target": _TMPFS_TARGET,
749
+ "Type": "tmpfs",
750
+ "TmpfsOptions": {"SizeBytes": _TMPFS_SIZE},
751
+ },
752
+ {
753
+ "Target": _DEFAULT_WORKDIR,
754
+ "Source": str(overlay),
755
+ "Type": "bind",
756
+ "Options": ["rbind", "rw"],
757
+ },
758
+ ],
759
+ remove=True,
760
+ )
761
+ container.start()
762
+ exit_code, _output = container.exec_run(
763
+ ["test", "-d", _DEFAULT_WORKDIR],
764
+ stdout=True,
765
+ stderr=True,
766
+ demux=True,
767
+ )
768
+ if exit_code != 0:
769
+ raise ToolValidationError("Podman workspace failed readiness checks.")
770
+ now = self._clock().astimezone(UTC)
771
+ descriptor = PodmanWorkspace(
772
+ container_id=container.id,
773
+ container_name=name,
774
+ image=self._image,
775
+ workdir=_DEFAULT_WORKDIR,
776
+ overlay_path=str(overlay),
777
+ env=tuple(sorted(env.items())),
778
+ started_at=now,
779
+ last_used_at=now,
780
+ )
781
+ return _WorkspaceHandle(descriptor=descriptor, overlay_path=overlay)
782
+
783
+ def _workspace_overlay_path(self) -> Path:
784
+ return self._overlay_root / str(self._session.session_id)
785
+
786
+ def _hydrate_overlay_mounts(self, overlay: Path) -> None:
787
+ if not self._resolved_mounts:
788
+ return
789
+ iterator = overlay.iterdir()
790
+ try:
791
+ _ = next(iterator)
792
+ except StopIteration:
793
+ pass
794
+ else:
795
+ return
796
+ for mount in self._resolved_mounts:
797
+ self._copy_mount_into_overlay(overlay=overlay, mount=mount)
798
+
799
+ def _workspace_env(self) -> dict[str, str]:
800
+ return (
801
+ dict(self._workspace_handle.descriptor.env)
802
+ if self._workspace_handle
803
+ else dict(self._base_env)
804
+ )
805
+
806
+ def _copy_mount_into_overlay(
807
+ self,
808
+ *,
809
+ overlay: Path,
810
+ mount: _ResolvedHostMount,
811
+ ) -> None:
812
+ base_target = _host_path_for(overlay, mount.mount_path)
813
+ consumed_bytes = 0
814
+ source = mount.resolved_host
815
+ for file_path in _iter_host_mount_files(source, mount.follow_symlinks):
816
+ relative = (
817
+ Path(file_path.name)
818
+ if source.is_file()
819
+ else file_path.relative_to(source)
820
+ )
821
+ relative_label = relative.as_posix()
822
+ if mount.include_glob and not any(
823
+ fnmatch.fnmatchcase(relative_label, pattern)
824
+ for pattern in mount.include_glob
825
+ ):
826
+ continue
827
+ if any(
828
+ fnmatch.fnmatchcase(relative_label, pattern)
829
+ for pattern in mount.exclude_glob
830
+ ):
831
+ continue
832
+ target = base_target / relative
833
+ _assert_within_overlay(overlay, target)
834
+ target.parent.mkdir(parents=True, exist_ok=True)
835
+ try:
836
+ size = file_path.stat().st_size
837
+ except OSError as error:
838
+ raise ToolValidationError(
839
+ f"Failed to stat mounted file {file_path}."
840
+ ) from error
841
+ if mount.max_bytes is not None and consumed_bytes + size > mount.max_bytes:
842
+ raise ToolValidationError(
843
+ "Host mount exceeded the configured byte budget."
844
+ )
845
+ consumed_bytes += size
846
+ try:
847
+ _ = shutil.copy2(file_path, target)
848
+ except OSError as error:
849
+ raise ToolValidationError(
850
+ "Failed to materialize host mounts inside the Podman workspace."
851
+ ) from error
852
+
853
+ def _touch_workspace(self) -> None:
854
+ with self._lock:
855
+ handle = self._workspace_handle
856
+ if handle is None:
857
+ return
858
+ now = self._clock().astimezone(UTC)
859
+ updated_descriptor = replace(handle.descriptor, last_used_at=now)
860
+ self._workspace_handle = _WorkspaceHandle(
861
+ descriptor=updated_descriptor,
862
+ overlay_path=handle.overlay_path,
863
+ )
864
+ self._session.seed_slice(PodmanWorkspace, (updated_descriptor,))
865
+
866
+ def _teardown_workspace(self) -> None:
867
+ with self._lock:
868
+ handle = self._workspace_handle
869
+ self._workspace_handle = None
870
+ if handle is None:
871
+ return
872
+ try:
873
+ client = self._client_factory()
874
+ except Exception:
875
+ return
876
+ try:
877
+ try:
878
+ container = client.containers.get(handle.descriptor.container_id)
879
+ except Exception:
880
+ return
881
+ with suppress(Exception):
882
+ container.stop(timeout=1)
883
+ with suppress(Exception):
884
+ container.remove(force=True)
885
+ finally:
886
+ with suppress(Exception):
887
+ client.close()
888
+
889
+ def ensure_workspace(self) -> _WorkspaceHandle:
890
+ return self._ensure_workspace()
891
+
892
+ def workspace_environment(self) -> dict[str, str]:
893
+ return self._workspace_env()
894
+
895
+ def touch_workspace(self) -> None:
896
+ self._touch_workspace()
897
+
898
+ def run_cli_exec(
899
+ self,
900
+ *,
901
+ command: Sequence[str],
902
+ stdin: str | None = None,
903
+ cwd: str | None = None,
904
+ environment: Mapping[str, str] | None = None,
905
+ timeout: float | None = None,
906
+ capture_output: bool = True,
907
+ ) -> subprocess.CompletedProcess[str]:
908
+ handle = self.ensure_workspace()
909
+ env = self.workspace_environment()
910
+ if environment:
911
+ env.update(environment)
912
+
913
+ exec_cmd: list[str] = ["podman"]
914
+ if self._connection_name:
915
+ exec_cmd.extend(["--connection", self._connection_name])
916
+ exec_cmd.append("exec")
917
+ if stdin is not None:
918
+ exec_cmd.append("--interactive")
919
+ exec_cmd.extend(["--workdir", cwd or _DEFAULT_WORKDIR])
920
+ for key, value in env.items():
921
+ exec_cmd.extend(["--env", f"{key}={value}"])
922
+ exec_cmd.append(handle.descriptor.container_name)
923
+ exec_cmd.extend(command)
924
+ runner = self._exec_runner
925
+ return runner(
926
+ exec_cmd,
927
+ input=stdin,
928
+ text=True,
929
+ capture_output=capture_output,
930
+ timeout=timeout,
931
+ )
932
+
933
+ def run_cli_cp(
934
+ self,
935
+ *,
936
+ source: str,
937
+ destination: str,
938
+ timeout: float | None = None,
939
+ ) -> subprocess.CompletedProcess[str]:
940
+ cmd: list[str] = ["podman"]
941
+ if self._connection_name:
942
+ cmd.extend(["--connection", self._connection_name])
943
+ cmd.extend(["cp", source, destination])
944
+ runner = self._exec_runner
945
+ return runner(
946
+ cmd,
947
+ text=True,
948
+ capture_output=True,
949
+ timeout=timeout,
950
+ )
951
+
952
+ def run_python_script(
953
+ self,
954
+ *,
955
+ script: str,
956
+ args: Sequence[str],
957
+ timeout: float | None = None,
958
+ ) -> subprocess.CompletedProcess[str]:
959
+ return self.run_cli_exec(
960
+ command=["python3", "-c", script, *args],
961
+ timeout=timeout,
962
+ )
963
+
964
+ def write_via_container(
965
+ self,
966
+ *,
967
+ path: VfsPath,
968
+ content: str,
969
+ mode: str,
970
+ ) -> None:
971
+ handle = self.ensure_workspace()
972
+ host_path = _host_path_for(handle.overlay_path, path)
973
+ payload = content
974
+ if mode == "append" and host_path.exists():
975
+ try:
976
+ existing = host_path.read_text(encoding="utf-8")
977
+ except UnicodeDecodeError as error:
978
+ raise ToolValidationError("File is not valid UTF-8.") from error
979
+ except OSError as error:
980
+ raise ToolValidationError("Failed to read existing file.") from error
981
+ payload = f"{existing}{content}"
982
+ container_path = _container_path_for(path)
983
+ parent = posixpath.dirname(container_path)
984
+ if parent and parent != "/":
985
+ mkdir_result = self.run_cli_exec(
986
+ command=["mkdir", "-p", parent],
987
+ cwd="/",
988
+ )
989
+ if mkdir_result.returncode != 0:
990
+ message = (
991
+ mkdir_result.stderr.strip()
992
+ or mkdir_result.stdout.strip()
993
+ or "Failed to create target directory."
994
+ )
995
+ raise ToolValidationError(message)
996
+ with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as tmp:
997
+ _ = tmp.write(payload)
998
+ temp_path = tmp.name
999
+ destination = f"{handle.descriptor.container_name}:{container_path}"
1000
+ try:
1001
+ completed = self.run_cli_cp(
1002
+ source=temp_path,
1003
+ destination=destination,
1004
+ )
1005
+ except FileNotFoundError as error:
1006
+ raise ToolValidationError(
1007
+ "Podman CLI is required to execute filesystem commands."
1008
+ ) from error
1009
+ finally:
1010
+ with suppress(OSError):
1011
+ Path(temp_path).unlink()
1012
+ if completed.returncode != 0:
1013
+ message = (
1014
+ completed.stderr.strip() or completed.stdout.strip() or "Write failed."
1015
+ )
1016
+ raise ToolValidationError(message)
1017
+
1018
+ def new_client(self) -> _PodmanClient:
1019
+ return self._client_factory()
1020
+
1021
+ @property
1022
+ def connection_name(self) -> str | None:
1023
+ return self._connection_name
1024
+
1025
+ @property
1026
+ def exec_runner(self) -> _ExecRunner:
1027
+ return self._exec_runner
1028
+
1029
+ def close(self) -> None:
1030
+ finalizer = self._finalizer
1031
+ if finalizer.alive:
1032
+ _ = finalizer()
1033
+
1034
+ @staticmethod
1035
+ def _cleanup_from_finalizer(
1036
+ section_ref: weakref.ReferenceType[PodmanSandboxSection],
1037
+ ) -> None:
1038
+ section = section_ref()
1039
+ if section is not None:
1040
+ section._teardown_workspace()
1041
+
1042
+
1043
+ def _host_path_for(root: Path, path: VfsPath) -> Path:
1044
+ host = root
1045
+ for segment in path.segments:
1046
+ host = host / segment
1047
+ return host
1048
+
1049
+
1050
+ def _container_path_for(path: VfsPath) -> str:
1051
+ if not path.segments:
1052
+ return _DEFAULT_WORKDIR
1053
+ return posixpath.join(_DEFAULT_WORKDIR, *path.segments)
1054
+
1055
+
1056
+ def _assert_within_overlay(root: Path, candidate: Path) -> None:
1057
+ try:
1058
+ resolved = candidate.resolve()
1059
+ except FileNotFoundError:
1060
+ try:
1061
+ resolved = candidate.parent.resolve()
1062
+ except FileNotFoundError as error: # pragma: no cover - defensive guard
1063
+ raise ToolValidationError("Workspace path is unavailable.") from error
1064
+ try:
1065
+ _ = resolved.relative_to(root)
1066
+ except ValueError as error:
1067
+ raise ToolValidationError("Path escapes the workspace boundary.") from error
1068
+
1069
+
1070
+ def _compose_child_path(base: VfsPath, name: str) -> VfsPath | None:
1071
+ candidate = VfsPath((*base.segments, name))
1072
+ try:
1073
+ return vfs_module.normalize_path(candidate)
1074
+ except ToolValidationError:
1075
+ return None
1076
+
1077
+
1078
+ def _compose_relative_path(base: VfsPath, relative: Path) -> VfsPath | None:
1079
+ segments = (*base.segments, *relative.parts)
1080
+ candidate = VfsPath(segments)
1081
+ try:
1082
+ return vfs_module.normalize_path(candidate)
1083
+ except ToolValidationError:
1084
+ return None
1085
+
1086
+
1087
+ def _iter_workspace_files(base: Path) -> Iterator[Path]:
1088
+ if not base.exists():
1089
+ return
1090
+ for dirpath, _, filenames in os.walk(base, followlinks=False):
1091
+ current = Path(dirpath)
1092
+ for name in filenames:
1093
+ yield current / name
1094
+
1095
+
1096
+ def _stat_file(path: Path) -> tuple[int, datetime]:
1097
+ try:
1098
+ stat_result = path.stat()
1099
+ except OSError as error: # pragma: no cover - defensive guard
1100
+ raise ToolValidationError("Failed to stat workspace file.") from error
1101
+ size = stat_result.st_size
1102
+ updated_at = datetime.fromtimestamp(stat_result.st_mtime, tz=UTC)
1103
+ return size, updated_at
1104
+
1105
+
1106
+ def _format_remove_message(path: VfsPath, count: int) -> str:
1107
+ path_label = "/".join(path.segments) or "/"
1108
+ label = "entry" if count == 1 else "entries"
1109
+ return f"Deleted {count} {label} under {path_label}."
1110
+
1111
+
1112
+ def _format_read_message(path: VfsPath, start: int, end: int) -> str:
1113
+ path_label = "/".join(path.segments) or "/"
1114
+ if start == end:
1115
+ return f"Read file {path_label} (no lines returned)."
1116
+ return f"Read file {path_label} (lines {start + 1}-{end})."
1117
+
1118
+
1119
+ class _PodmanVfsSuite:
1120
+ """Filesystem tool handlers bound to a :class:`PodmanSandboxSection`."""
1121
+
1122
+ def __init__(self, *, section: PodmanSandboxSection) -> None:
1123
+ super().__init__()
1124
+ self._section = section
1125
+
1126
+ def list_directory(
1127
+ self, params: ListDirectoryParams, *, context: ToolContext
1128
+ ) -> ToolResult[tuple[FileInfo, ...]]:
1129
+ ensure_context_uses_session(context=context, session=self._section.session)
1130
+ del context
1131
+ path = vfs_module.normalize_string_path(
1132
+ params.path, allow_empty=True, field="path"
1133
+ )
1134
+ handle = self._section.ensure_workspace()
1135
+ host_path = _host_path_for(handle.overlay_path, path)
1136
+ _assert_within_overlay(handle.overlay_path, host_path)
1137
+ if host_path.exists() and host_path.is_file():
1138
+ raise ToolValidationError("Cannot list a file path; provide a directory.")
1139
+ snapshot = self._section.latest_snapshot()
1140
+ entries = self._build_directory_entries(
1141
+ base=path,
1142
+ host_path=host_path,
1143
+ snapshot=snapshot,
1144
+ overlay_root=handle.overlay_path,
1145
+ )
1146
+ message = vfs_module.format_directory_message(path, entries)
1147
+ self._section.touch_workspace()
1148
+ return ToolResult(message=message, value=tuple(entries))
1149
+
1150
+ def read_file(
1151
+ self, params: ReadFileParams, *, context: ToolContext
1152
+ ) -> ToolResult[ReadFileResult]:
1153
+ ensure_context_uses_session(context=context, session=self._section.session)
1154
+ del context
1155
+ path = vfs_module.normalize_string_path(params.file_path, field="file_path")
1156
+ offset = vfs_module.normalize_offset(params.offset)
1157
+ limit = vfs_module.normalize_limit(params.limit)
1158
+ handle = self._section.ensure_workspace()
1159
+ host_path = _host_path_for(handle.overlay_path, path)
1160
+ if not host_path.exists() or not host_path.is_file():
1161
+ raise ToolValidationError("File does not exist in the workspace.")
1162
+ _assert_within_overlay(handle.overlay_path, host_path)
1163
+ try:
1164
+ content = host_path.read_text(encoding="utf-8")
1165
+ except UnicodeDecodeError as error:
1166
+ raise ToolValidationError("File is not valid UTF-8.") from error
1167
+ except OSError as error: # pragma: no cover - defensive guard
1168
+ raise ToolValidationError("Failed to read file contents.") from error
1169
+ lines = content.splitlines()
1170
+ total_lines = len(lines)
1171
+ start = min(offset, total_lines)
1172
+ end = min(start + limit, total_lines)
1173
+ numbered = [
1174
+ f"{index + 1:>4} | {line}"
1175
+ for index, line in enumerate(lines[start:end], start=start)
1176
+ ]
1177
+ formatted = "\n".join(numbered)
1178
+ message = _format_read_message(path, start, end)
1179
+ self._section.touch_workspace()
1180
+ return ToolResult(
1181
+ message=message,
1182
+ value=ReadFileResult(
1183
+ path=path,
1184
+ content=formatted,
1185
+ offset=start,
1186
+ limit=end - start,
1187
+ total_lines=total_lines,
1188
+ ),
1189
+ )
1190
+
1191
+ def write_file(
1192
+ self, params: WriteFileParams, *, context: ToolContext
1193
+ ) -> ToolResult[WriteFile]:
1194
+ ensure_context_uses_session(context=context, session=self._section.session)
1195
+ del context
1196
+ path = vfs_module.normalize_string_path(params.file_path, field="file_path")
1197
+ content = vfs_module.normalize_content(params.content)
1198
+ handle = self._section.ensure_workspace()
1199
+ host_path = _host_path_for(handle.overlay_path, path)
1200
+ if host_path.exists():
1201
+ raise ToolValidationError(
1202
+ "File already exists; use edit_file to modify existing content."
1203
+ )
1204
+ _assert_within_overlay(handle.overlay_path, host_path)
1205
+ self._section.write_via_container(path=path, content=content, mode="create")
1206
+ self._section.touch_workspace()
1207
+ message = vfs_module.format_write_file_message(path, content, "create")
1208
+ return ToolResult(
1209
+ message=message,
1210
+ value=WriteFile(path=path, content=content, mode="create"),
1211
+ )
1212
+
1213
+ def edit_file(
1214
+ self, params: EditFileParams, *, context: ToolContext
1215
+ ) -> ToolResult[WriteFile]:
1216
+ ensure_context_uses_session(context=context, session=self._section.session)
1217
+ del context
1218
+ path = vfs_module.normalize_string_path(params.file_path, field="file_path")
1219
+ if len(params.old_string) > vfs_module.MAX_WRITE_LENGTH:
1220
+ raise ToolValidationError("old_string exceeds the 48,000 character limit.")
1221
+ if len(params.new_string) > vfs_module.MAX_WRITE_LENGTH:
1222
+ raise ToolValidationError("new_string exceeds the 48,000 character limit.")
1223
+ handle = self._section.ensure_workspace()
1224
+ host_path = _host_path_for(handle.overlay_path, path)
1225
+ if not host_path.exists() or not host_path.is_file():
1226
+ raise ToolValidationError("File does not exist in the workspace.")
1227
+ _assert_within_overlay(handle.overlay_path, host_path)
1228
+ try:
1229
+ existing = host_path.read_text(encoding="utf-8")
1230
+ except UnicodeDecodeError as error:
1231
+ raise ToolValidationError("File is not valid UTF-8.") from error
1232
+ occurrences = existing.count(params.old_string)
1233
+ if occurrences == 0:
1234
+ raise ToolValidationError("old_string not found in the target file.")
1235
+ if not params.replace_all and occurrences != 1:
1236
+ raise ToolValidationError(
1237
+ "old_string must match exactly once unless replace_all is true."
1238
+ )
1239
+ if params.replace_all:
1240
+ replacements = occurrences
1241
+ updated = existing.replace(params.old_string, params.new_string)
1242
+ else:
1243
+ replacements = 1
1244
+ updated = existing.replace(params.old_string, params.new_string, 1)
1245
+ normalized = vfs_module.normalize_content(updated)
1246
+ self._section.write_via_container(
1247
+ path=path, content=normalized, mode="overwrite"
1248
+ )
1249
+ self._section.touch_workspace()
1250
+ message = vfs_module.format_edit_message(path, replacements)
1251
+ return ToolResult(
1252
+ message=message,
1253
+ value=WriteFile(path=path, content=normalized, mode="overwrite"),
1254
+ )
1255
+
1256
+ def glob(
1257
+ self, params: GlobParams, *, context: ToolContext
1258
+ ) -> ToolResult[tuple[GlobMatch, ...]]:
1259
+ ensure_context_uses_session(context=context, session=self._section.session)
1260
+ del context
1261
+ base = vfs_module.normalize_string_path(
1262
+ params.path, allow_empty=True, field="path"
1263
+ )
1264
+ pattern = params.pattern.strip()
1265
+ if not pattern:
1266
+ raise ToolValidationError("Pattern must not be empty.")
1267
+ _ = vfs_module.ensure_ascii(pattern, "pattern")
1268
+ handle = self._section.ensure_workspace()
1269
+ host_base = _host_path_for(handle.overlay_path, base)
1270
+ _assert_within_overlay(handle.overlay_path, host_base)
1271
+ matches: list[GlobMatch] = []
1272
+ snapshot = self._section.latest_snapshot()
1273
+ for file_path in _iter_workspace_files(host_base):
1274
+ try:
1275
+ relative = file_path.relative_to(host_base)
1276
+ except ValueError:
1277
+ continue
1278
+ candidate_path = _compose_relative_path(base, relative)
1279
+ if candidate_path is None:
1280
+ continue
1281
+ relative_label = relative.as_posix()
1282
+ if not fnmatch.fnmatchcase(relative_label, pattern):
1283
+ continue
1284
+ try:
1285
+ match = self._build_glob_match(
1286
+ target=candidate_path,
1287
+ host_path=file_path,
1288
+ snapshot=snapshot,
1289
+ overlay_root=handle.overlay_path,
1290
+ )
1291
+ except ToolValidationError:
1292
+ continue
1293
+ matches.append(match)
1294
+ if len(matches) >= _MAX_MATCH_RESULTS:
1295
+ break
1296
+ matches.sort(key=lambda match: match.path.segments)
1297
+ message = vfs_module.format_glob_message(base, pattern, matches)
1298
+ self._section.touch_workspace()
1299
+ return ToolResult(message=message, value=tuple(matches))
1300
+
1301
+ def grep(
1302
+ self, params: GrepParams, *, context: ToolContext
1303
+ ) -> ToolResult[tuple[GrepMatch, ...]]:
1304
+ ensure_context_uses_session(context=context, session=self._section.session)
1305
+ del context
1306
+ try:
1307
+ pattern = re.compile(params.pattern)
1308
+ except re.error as error:
1309
+ return ToolResult(
1310
+ message=f"Invalid regular expression: {error}",
1311
+ value=None,
1312
+ success=False,
1313
+ )
1314
+ base_path: VfsPath | None = None
1315
+ if params.path is not None:
1316
+ base_path = vfs_module.normalize_string_path(
1317
+ params.path, allow_empty=True, field="path"
1318
+ )
1319
+ glob_pattern = params.glob.strip() if params.glob is not None else None
1320
+ if glob_pattern:
1321
+ _ = vfs_module.ensure_ascii(glob_pattern, "glob")
1322
+ handle = self._section.ensure_workspace()
1323
+ host_base = _host_path_for(handle.overlay_path, base_path or VfsPath(()))
1324
+ _assert_within_overlay(handle.overlay_path, host_base)
1325
+ matches: list[GrepMatch] = []
1326
+ for file_path in _iter_workspace_files(host_base):
1327
+ try:
1328
+ relative = file_path.relative_to(host_base)
1329
+ except ValueError:
1330
+ continue
1331
+ relative_label = relative.as_posix()
1332
+ if glob_pattern and not fnmatch.fnmatchcase(relative_label, glob_pattern):
1333
+ continue
1334
+ target_path = _compose_relative_path(base_path or VfsPath(()), relative)
1335
+ if target_path is None:
1336
+ continue
1337
+ try:
1338
+ _assert_within_overlay(handle.overlay_path, file_path)
1339
+ except ToolValidationError:
1340
+ continue
1341
+ try:
1342
+ content = file_path.read_text(encoding="utf-8")
1343
+ except UnicodeDecodeError:
1344
+ continue
1345
+ except OSError:
1346
+ continue
1347
+ for index, line in enumerate(content.splitlines(), start=1):
1348
+ if pattern.search(line):
1349
+ matches.append(
1350
+ GrepMatch(
1351
+ path=target_path,
1352
+ line_number=index,
1353
+ line=line,
1354
+ )
1355
+ )
1356
+ if len(matches) >= _MAX_MATCH_RESULTS:
1357
+ break
1358
+ if len(matches) >= _MAX_MATCH_RESULTS:
1359
+ break
1360
+ message = vfs_module.format_grep_message(params.pattern, matches)
1361
+ self._section.touch_workspace()
1362
+ return ToolResult(message=message, value=tuple(matches))
1363
+
1364
+ def remove(
1365
+ self, params: RemoveParams, *, context: ToolContext
1366
+ ) -> ToolResult[DeleteEntry]:
1367
+ ensure_context_uses_session(context=context, session=self._section.session)
1368
+ del context
1369
+ path = vfs_module.normalize_string_path(params.path, field="path")
1370
+ if not path.segments:
1371
+ raise ToolValidationError("Cannot remove the workspace root.")
1372
+ handle = self._section.ensure_workspace()
1373
+ host_path = _host_path_for(handle.overlay_path, path)
1374
+ if not host_path.exists():
1375
+ raise ToolValidationError("No files matched the provided path.")
1376
+ _assert_within_overlay(handle.overlay_path, host_path)
1377
+ removed_entries = sum(1 for _ in _iter_workspace_files(host_path))
1378
+ removed_entries = 1 if host_path.is_file() else max(removed_entries, 1)
1379
+ args = (_container_path_for(path),)
1380
+ try:
1381
+ completed = self._section.run_python_script(
1382
+ script=_REMOVE_PATH_SCRIPT,
1383
+ args=args,
1384
+ )
1385
+ except FileNotFoundError as error:
1386
+ raise ToolValidationError(
1387
+ "Podman CLI is required to execute filesystem commands."
1388
+ ) from error
1389
+ if completed.returncode != 0:
1390
+ message = (
1391
+ completed.stderr.strip()
1392
+ or completed.stdout.strip()
1393
+ or "Removal failed."
1394
+ )
1395
+ raise ToolValidationError(message)
1396
+ self._section.touch_workspace()
1397
+ message = _format_remove_message(path, removed_entries)
1398
+ return ToolResult(
1399
+ message=message,
1400
+ value=DeleteEntry(path=path),
1401
+ )
1402
+
1403
+ def _build_directory_entries(
1404
+ self,
1405
+ *,
1406
+ base: VfsPath,
1407
+ host_path: Path,
1408
+ snapshot: VirtualFileSystem,
1409
+ overlay_root: Path,
1410
+ ) -> list[FileInfo]:
1411
+ entries: list[FileInfo] = []
1412
+ if not host_path.exists():
1413
+ return entries
1414
+ try:
1415
+ children = sorted(host_path.iterdir(), key=lambda child: child.name.lower())
1416
+ except OSError as error:
1417
+ raise ToolValidationError(
1418
+ "Failed to inspect directory contents."
1419
+ ) from error
1420
+ for child in children:
1421
+ entry_path = _compose_child_path(base, child.name)
1422
+ if entry_path is None:
1423
+ continue
1424
+ if child.is_dir() and not child.is_symlink():
1425
+ entries.append(
1426
+ FileInfo(
1427
+ path=entry_path,
1428
+ kind="directory",
1429
+ size_bytes=None,
1430
+ version=None,
1431
+ updated_at=None,
1432
+ )
1433
+ )
1434
+ continue
1435
+ try:
1436
+ info = self._build_file_info(
1437
+ path=entry_path,
1438
+ host_file=child,
1439
+ snapshot=snapshot,
1440
+ overlay_root=overlay_root,
1441
+ )
1442
+ except ToolValidationError:
1443
+ continue
1444
+ entries.append(info)
1445
+ entries.sort(key=lambda entry: entry.path.segments)
1446
+ return entries[:_MAX_MATCH_RESULTS]
1447
+
1448
+ def _build_file_info(
1449
+ self,
1450
+ *,
1451
+ path: VfsPath,
1452
+ host_file: Path,
1453
+ snapshot: VirtualFileSystem,
1454
+ overlay_root: Path,
1455
+ ) -> FileInfo:
1456
+ _assert_within_overlay(overlay_root, host_file)
1457
+ snapshot_entry = vfs_module.find_file(snapshot.files, path)
1458
+ size_bytes, updated_at = _stat_file(host_file)
1459
+ version = snapshot_entry.version if snapshot_entry else None
1460
+ updated = snapshot_entry.updated_at if snapshot_entry else updated_at
1461
+ return FileInfo(
1462
+ path=path,
1463
+ kind="file",
1464
+ size_bytes=size_bytes,
1465
+ version=version,
1466
+ updated_at=updated,
1467
+ )
1468
+
1469
+ def _build_glob_match(
1470
+ self,
1471
+ *,
1472
+ target: VfsPath,
1473
+ host_path: Path,
1474
+ snapshot: VirtualFileSystem,
1475
+ overlay_root: Path,
1476
+ ) -> GlobMatch:
1477
+ _assert_within_overlay(overlay_root, host_path)
1478
+ snapshot_entry = vfs_module.find_file(snapshot.files, target)
1479
+ size_bytes, updated_at = _stat_file(host_path)
1480
+ if snapshot_entry is None:
1481
+ return GlobMatch(
1482
+ path=target,
1483
+ size_bytes=size_bytes,
1484
+ version=1,
1485
+ updated_at=updated_at,
1486
+ )
1487
+ return GlobMatch(
1488
+ path=target,
1489
+ size_bytes=size_bytes,
1490
+ version=snapshot_entry.version,
1491
+ updated_at=snapshot_entry.updated_at,
1492
+ )
1493
+
1494
+
1495
+ class _PodmanEvalSuite:
1496
+ def __init__(self, *, section: PodmanSandboxSection) -> None:
1497
+ super().__init__()
1498
+ self._section = section
1499
+
1500
+ def evaluate_python(
1501
+ self, params: EvalParams, *, context: ToolContext
1502
+ ) -> ToolResult[EvalResult]:
1503
+ ensure_context_uses_session(context=context, session=self._section.session)
1504
+ del context
1505
+ self._ensure_passthrough_payload_is_empty(params)
1506
+ code = _normalize_podman_eval_code(params.code)
1507
+ _ = self._section.ensure_workspace()
1508
+ try:
1509
+ completed = self._section.run_python_script(
1510
+ script=code,
1511
+ args=(),
1512
+ timeout=_EVAL_TIMEOUT_SECONDS,
1513
+ )
1514
+ except subprocess.TimeoutExpired:
1515
+ return self._timeout_result()
1516
+ except FileNotFoundError as error:
1517
+ raise ToolValidationError(
1518
+ "Podman CLI is required to execute evaluation commands."
1519
+ ) from error
1520
+
1521
+ stdout = _truncate_eval_stream(str(completed.stdout or ""))
1522
+ stderr = _truncate_eval_stream(str(completed.stderr or ""))
1523
+ success = completed.returncode == 0
1524
+ if success:
1525
+ message = f"Evaluation succeeded (exit code {completed.returncode})."
1526
+ else:
1527
+ message = f"Evaluation failed (exit code {completed.returncode})."
1528
+ result = EvalResult(
1529
+ value_repr=None,
1530
+ stdout=stdout,
1531
+ stderr=stderr,
1532
+ globals={},
1533
+ reads=(),
1534
+ writes=(),
1535
+ )
1536
+ self._section.touch_workspace()
1537
+ return ToolResult(message=message, value=result, success=success)
1538
+
1539
+ def _ensure_passthrough_payload_is_empty(self, params: EvalParams) -> None:
1540
+ if params.reads:
1541
+ raise ToolValidationError(
1542
+ "Podman evaluate_python reads are not supported; access the workspace directly."
1543
+ )
1544
+ if params.writes:
1545
+ raise ToolValidationError(
1546
+ "Podman evaluate_python writes are not supported; use the write_file tool."
1547
+ )
1548
+ if params.globals:
1549
+ raise ToolValidationError(
1550
+ "Podman evaluate_python globals are not supported."
1551
+ )
1552
+
1553
+ def _timeout_result(self) -> ToolResult[EvalResult]:
1554
+ result = EvalResult(
1555
+ value_repr=None,
1556
+ stdout="",
1557
+ stderr="Execution timed out.",
1558
+ globals={},
1559
+ reads=(),
1560
+ writes=(),
1561
+ )
1562
+ return ToolResult(message="Evaluation timed out.", value=result, success=False)
1563
+
1564
+
1565
+ class _PodmanShellSuite:
1566
+ """Handler collection bound to a :class:`PodmanSandboxSection`."""
1567
+
1568
+ def __init__(self, *, section: PodmanSandboxSection) -> None:
1569
+ super().__init__()
1570
+ self._section = section
1571
+
1572
+ def run_shell(
1573
+ self, params: PodmanShellParams, *, context: ToolContext
1574
+ ) -> ToolResult[PodmanShellResult]:
1575
+ ensure_context_uses_session(context=context, session=self._section.session)
1576
+ command = _normalize_command(params.command)
1577
+ cwd = _normalize_cwd(params.cwd)
1578
+ env_overrides = _normalize_env(params.env)
1579
+ timeout_seconds = _normalize_timeout(params.timeout_seconds)
1580
+ if params.stdin:
1581
+ _ = _ensure_ascii(params.stdin, field="stdin")
1582
+ _ = self._section.ensure_workspace()
1583
+
1584
+ return self._run_shell_via_cli(
1585
+ params=params,
1586
+ command=command,
1587
+ cwd=cwd,
1588
+ environment=env_overrides,
1589
+ timeout_seconds=timeout_seconds,
1590
+ )
1591
+
1592
+ def _run_shell_via_cli(
1593
+ self,
1594
+ *,
1595
+ params: PodmanShellParams,
1596
+ command: tuple[str, ...],
1597
+ cwd: str,
1598
+ environment: Mapping[str, str],
1599
+ timeout_seconds: float,
1600
+ ) -> ToolResult[PodmanShellResult]:
1601
+ exec_cmd = list(command)
1602
+ start = time.perf_counter()
1603
+ try:
1604
+ completed = self._section.run_cli_exec(
1605
+ command=exec_cmd,
1606
+ stdin=params.stdin if params.stdin else None,
1607
+ cwd=cwd,
1608
+ environment=environment,
1609
+ timeout=timeout_seconds,
1610
+ capture_output=params.capture_output,
1611
+ )
1612
+ timed_out = False
1613
+ exit_code = completed.returncode
1614
+ stdout_text = completed.stdout
1615
+ stderr_text = completed.stderr
1616
+ except subprocess.TimeoutExpired as error:
1617
+ timed_out = True
1618
+ exit_code = 124
1619
+ stdout_text = str(error.stdout or "")
1620
+ stderr_text = str(error.stderr or "")
1621
+ except FileNotFoundError as error:
1622
+ raise ToolValidationError(
1623
+ "Podman CLI is required to execute commands over SSH connections."
1624
+ ) from error
1625
+ duration_ms = int((time.perf_counter() - start) * 1_000)
1626
+ self._section.touch_workspace()
1627
+ stdout_text_clean = str(stdout_text or "").rstrip()
1628
+ stderr_text_clean = str(stderr_text or "").rstrip()
1629
+ if not params.capture_output:
1630
+ stdout_text_final = _CAPTURE_DISABLED
1631
+ stderr_text_final = _CAPTURE_DISABLED
1632
+ else:
1633
+ stdout_text_final = _truncate_stream(stdout_text_clean)
1634
+ stderr_text_final = _truncate_stream(stderr_text_clean)
1635
+ result = PodmanShellResult(
1636
+ command=command,
1637
+ cwd=cwd,
1638
+ exit_code=exit_code,
1639
+ stdout=stdout_text_final,
1640
+ stderr=stderr_text_final,
1641
+ duration_ms=duration_ms,
1642
+ timed_out=timed_out,
1643
+ )
1644
+ message = f"`shell_execute` exited with {exit_code}."
1645
+ if timed_out:
1646
+ message = "`shell_execute` exceeded the configured timeout."
1647
+ return ToolResult(message=message, value=result)
1648
+
1649
+
1650
+ __all__ = [
1651
+ "PodmanSandboxSection",
1652
+ "PodmanShellParams",
1653
+ "PodmanShellResult",
1654
+ "PodmanWorkspace",
1655
+ ]